From 7b3712e2bbfad75bf42c8ce6b7310d40e5cf9672 Mon Sep 17 00:00:00 2001 From: SelinBayramoglu Date: Thu, 14 Sep 2023 01:17:23 -0400 Subject: [PATCH] Add the modified Ecole library --- .gitignore | 3 - ecole/AUTHORS | 4 + ecole/CMakeLists.txt | 55 + ecole/LICENSE | 29 + ecole/README.rst | 124 + ecole/VERSION | 10 + ecole/cmake/CompilerWarnings.cmake | 128 + ecole/cmake/Conda.cmake | 45 + ecole/cmake/Coverage.cmake | 14 + ecole/cmake/CreateVersionFile.cmake | 43 + ecole/cmake/DefaultSettings.cmake | 69 + ecole/cmake/DependenciesResolver.cmake | 95 + ecole/cmake/InterproceduralOptimization.cmake | 13 + ecole/cmake/Python.cmake | 17 + ecole/cmake/Sanitizers.cmake | 42 + ecole/cmake/Version.cmake | 15 + ecole/dev/Dockerfile.src | 51 + ecole/dev/conda.yaml | 45 + ecole/dev/hooks/build | 34 + ecole/dev/hooks/push | 12 + ecole/dev/run.sh | 602 +++ ecole/dev/singularity.def | 26 + ecole/docs/_static/css/custom.css | 170 + ecole/docs/_static/favicon.ico | Bin 0 -> 22382 bytes ecole/docs/_static/images/ecole-logo-bare.png | Bin 0 -> 57378 bytes ecole/docs/_static/images/ecole-logo.svg | 909 ++++ ecole/docs/_templates/layout.html | 8 + ecole/docs/conf.py | 111 + ecole/docs/contributing.rst | 255 + ecole/docs/developers/example-observation.rst | 65 + ecole/docs/discussion/gym-differences.rst | 88 + ecole/docs/discussion/seeding.rst | 85 + ecole/docs/discussion/theory.rst | 208 + ecole/docs/howto/create-environments.rst | 230 + ecole/docs/howto/create-functions.rst | 160 + ecole/docs/howto/instances.rst | 171 + ecole/docs/howto/observation-functions.rst | 92 + ecole/docs/howto/reward-functions.rst | 120 + ecole/docs/images/mdp.png | Bin 0 -> 61218 bytes ecole/docs/index.rst | 106 + ecole/docs/installation.rst | 71 + ecole/docs/reference/environments.rst | 27 + ecole/docs/reference/information.rst | 17 + ecole/docs/reference/instances.rst | 34 + ecole/docs/reference/observations.rst | 45 + ecole/docs/reference/rewards.rst | 65 + ecole/docs/reference/scip-interface.rst | 34 + ecole/docs/reference/utilities.rst | 8 + ecole/docs/using-environments.rst | 175 + .../conda-requirements.yaml | 13 + .../branching-imitation/example.ipynb | 1498 ++++++ .../conda-requirements.yaml | 13 + .../configuring-bandits/example.ipynb | 370 ++ ecole/examples/libecole/src/branching.cpp | 30 + ecole/libecole/CMakeLists.txt | 205 + ecole/libecole/EcoleConfig.cmake.in | 17 + ecole/libecole/README.md | 3 + ecole/libecole/benchmarks/CMakeLists.txt | 31 + .../benchmarks/dependencies/private.cmake | 8 + .../benchmarks/src/bench-branching.cpp | 77 + .../benchmarks/src/bench-branching.hpp | 23 + ecole/libecole/benchmarks/src/benchmark.cpp | 56 + ecole/libecole/benchmarks/src/benchmark.hpp | 34 + .../src/branching/index-branchrule.hpp | 41 + .../src/branching/lambda-branchrule.hpp | 46 + ecole/libecole/benchmarks/src/csv.hpp | 18 + ecole/libecole/benchmarks/src/main.cpp | 94 + ecole/libecole/dependencies/private.cmake | 30 + ecole/libecole/dependencies/public.cmake | 29 + .../libecole/include/ecole/data/abstract.hpp | 5 + .../libecole/include/ecole/data/constant.hpp | 22 + ecole/libecole/include/ecole/data/dynamic.hpp | 84 + ecole/libecole/include/ecole/data/map.hpp | 42 + .../libecole/include/ecole/data/multiary.hpp | 60 + ecole/libecole/include/ecole/data/none.hpp | 15 + ecole/libecole/include/ecole/data/parser.hpp | 66 + ecole/libecole/include/ecole/data/timed.hpp | 48 + ecole/libecole/include/ecole/data/tuple.hpp | 36 + ecole/libecole/include/ecole/data/vector.hpp | 45 + ecole/libecole/include/ecole/default.hpp | 30 + .../include/ecole/dynamics/branching.hpp | 31 + .../include/ecole/dynamics/configuring.hpp | 30 + .../libecole/include/ecole/dynamics/parts.hpp | 20 + .../include/ecole/dynamics/primal-search.hpp | 43 + .../include/ecole/environment/branching.hpp | 17 + .../include/ecole/environment/configuring.hpp | 18 + .../include/ecole/environment/environment.hpp | 222 + .../ecole/environment/primal-search.hpp | 18 + ecole/libecole/include/ecole/exception.hpp | 28 + .../include/ecole/information/abstract.hpp | 12 + .../include/ecole/information/nothing.hpp | 21 + ecole/libecole/include/ecole/none.hpp | 24 + .../include/ecole/observation/abstract.hpp | 3 + .../include/ecole/observation/hutter-2011.hpp | 76 + .../include/ecole/observation/khalil-2016.hpp | 123 + .../ecole/observation/milp-bipartite.hpp | 51 + .../observation/node-bipartite-candidate.hpp | 84 + .../ecole/observation/node-bipartite.hpp | 77 + .../include/ecole/observation/nothing.hpp | 9 + .../include/ecole/observation/pseudocosts.hpp | 19 + .../observation/strong-branching-scores.hpp | 26 + ecole/libecole/include/ecole/random.hpp | 41 + .../include/ecole/reward/abstract.hpp | 9 + .../include/ecole/reward/bound-integral.hpp | 35 + .../include/ecole/reward/constant.hpp | 10 + .../libecole/include/ecole/reward/is-done.hpp | 14 + .../include/ecole/reward/lp-iterations.hpp | 19 + .../libecole/include/ecole/reward/n-nodes.hpp | 19 + .../include/ecole/reward/solving-time.hpp | 22 + .../libecole/include/ecole/scip/callback.hpp | 83 + ecole/libecole/include/ecole/scip/col.hpp | 13 + ecole/libecole/include/ecole/scip/cons.hpp | 74 + .../libecole/include/ecole/scip/exception.hpp | 25 + ecole/libecole/include/ecole/scip/model.hpp | 305 ++ ecole/libecole/include/ecole/scip/row.hpp | 21 + ecole/libecole/include/ecole/scip/scimpl.hpp | 46 + ecole/libecole/include/ecole/scip/seed.hpp | 9 + ecole/libecole/include/ecole/scip/type.hpp | 41 + ecole/libecole/include/ecole/scip/utils.hpp | 17 + ecole/libecole/include/ecole/scip/var.hpp | 39 + ecole/libecole/include/ecole/traits.hpp | 180 + ecole/libecole/include/ecole/tweak/range.hpp | 16 + .../libecole/include/ecole/utility/chrono.hpp | 29 + .../include/ecole/utility/coroutine.hpp | 357 ++ .../include/ecole/utility/function-traits.hpp | 93 + .../include/ecole/utility/numeric.hpp | 39 + .../libecole/include/ecole/utility/random.hpp | 65 + .../include/ecole/utility/sparse-matrix.hpp | 57 + .../include/ecole/utility/type-traits.hpp | 33 + .../include/ecole/utility/unreachable.hpp | 12 + .../libecole/include/ecole/utility/vector.hpp | 16 + ecole/libecole/include/ecole/version.hpp.in | 78 + ecole/libecole/src/dynamics/branching.cpp | 79 + ecole/libecole/src/dynamics/configuring.cpp | 19 + ecole/libecole/src/dynamics/parts.cpp | 17 + ecole/libecole/src/dynamics/primal-search.cpp | 153 + ecole/libecole/src/exception.cpp | 9 + .../libecole/src/observation/hutter-2011.cpp | 393 ++ .../libecole/src/observation/khalil-2016.cpp | 730 +++ .../src/observation/milp-bipartite.cpp | 153 + .../observation/node-bipartite-candidate.cpp | 479 ++ .../src/observation/node-bipartite.cpp | 404 ++ .../libecole/src/observation/pseudocosts.cpp | 54 + .../observation/strong-branching-scores.cpp | 82 + ecole/libecole/src/random.cpp | 84 + ecole/libecole/src/reward/bound-integral.cpp | 352 ++ ecole/libecole/src/reward/is-done.cpp | 9 + ecole/libecole/src/reward/lp-iterations.cpp | 33 + ecole/libecole/src/reward/n-nodes.cpp | 17 + ecole/libecole/src/reward/solving-time.cpp | 31 + ecole/libecole/src/scip/col.cpp | 23 + ecole/libecole/src/scip/cons.cpp | 349 ++ ecole/libecole/src/scip/exception.cpp | 194 + ecole/libecole/src/scip/model.cpp | 310 ++ ecole/libecole/src/scip/row.cpp | 53 + ecole/libecole/src/scip/scimpl.cpp | 265 + ecole/libecole/src/scip/var.cpp | 16 + ecole/libecole/src/utility/chrono.cpp | 28 + ecole/libecole/src/utility/graph.cpp | 198 + ecole/libecole/src/utility/graph.hpp | 92 + ecole/libecole/src/utility/math.hpp | 93 + ecole/libecole/src/version.cpp | 55 + ecole/libecole/tests/CMakeLists.txt | 90 + ecole/libecole/tests/data/bppc8-02.mps | 4709 +++++++++++++++++ ecole/libecole/tests/data/enlight8.mps | 618 +++ .../libecole/tests/dependencies/private.cmake | 6 + ecole/libecole/tests/src/conftest.cpp | 35 + ecole/libecole/tests/src/conftest.hpp | 22 + .../libecole/tests/src/data/mock-function.hpp | 20 + .../libecole/tests/src/data/test-constant.cpp | 27 + .../libecole/tests/src/data/test-dynamic.cpp | 36 + ecole/libecole/tests/src/data/test-map.cpp | 28 + .../libecole/tests/src/data/test-multiary.cpp | 41 + ecole/libecole/tests/src/data/test-none.cpp | 22 + ecole/libecole/tests/src/data/test-parser.cpp | 39 + ecole/libecole/tests/src/data/test-timed.cpp | 25 + ecole/libecole/tests/src/data/test-tuple.cpp | 27 + ecole/libecole/tests/src/data/test-vector.cpp | 27 + ecole/libecole/tests/src/data/unit-tests.hpp | 44 + .../tests/src/dynamics/test-branching.cpp | 88 + .../tests/src/dynamics/test-configuring.cpp | 53 + .../tests/src/dynamics/test-parts.cpp | 27 + .../tests/src/dynamics/test-primal-search.cpp | 173 + .../tests/src/dynamics/unit-tests.hpp | 56 + .../src/environment/test-environment.cpp | 113 + ecole/libecole/tests/src/main.cpp | 3 + .../src/observation/test-hutter-2011.cpp | 129 + .../src/observation/test-khalil-2016.cpp | 165 + .../src/observation/test-milp-bipartite.cpp | 56 + .../src/observation/test-node-bipartite.cpp | 55 + .../src/observation/test-pseudocosts.cpp | 35 + .../test-strong-branching-scores.cpp | 31 + .../tests/src/observation/unit-tests.hpp | 13 + .../tests/src/reward/test-bound-integral.cpp | 50 + .../tests/src/reward/test-is-done.cpp | 26 + .../tests/src/reward/test-lp-iterations.cpp | 58 + .../tests/src/reward/test-n-nodes.cpp | 50 + .../tests/src/reward/test-solving-time.cpp | 30 + .../libecole/tests/src/reward/unit-tests.hpp | 22 + ecole/libecole/tests/src/scip/test-model.cpp | 230 + ecole/libecole/tests/src/scip/test-scimpl.cpp | 16 + ecole/libecole/tests/src/test-random.cpp | 34 + ecole/libecole/tests/src/test-traits.cpp | 170 + .../tests/src/test-utility/tmp-folder.cpp | 38 + .../tests/src/test-utility/tmp-folder.hpp | 31 + .../tests/src/utility/test-chrono.cpp | 11 + .../tests/src/utility/test-coroutine.cpp | 88 + .../libecole/tests/src/utility/test-graph.cpp | 136 + .../tests/src/utility/test-random.cpp | 46 + .../tests/src/utility/test-sparse-matrix.cpp | 27 + .../tests/src/utility/test-vector.cpp | 17 + ecole/pyproject.toml | 14 + ecole/python/.gitignore | 0 ecole/python/ecole/CMakeLists.txt | 97 + ecole/python/ecole/README.md | 4 + ecole/python/ecole/src/ecole/__init__.py | 15 + ecole/python/ecole/src/ecole/core/caster.hpp | 80 + ecole/python/ecole/src/ecole/core/core.cpp | 95 + ecole/python/ecole/src/ecole/core/core.hpp | 41 + ecole/python/ecole/src/ecole/core/data.cpp | 114 + .../python/ecole/src/ecole/core/dynamics.cpp | 307 ++ .../ecole/src/ecole/core/information.cpp | 25 + .../python/ecole/src/ecole/core/instance.cpp | 455 ++ .../ecole/src/ecole/core/observation.cpp | 537 ++ ecole/python/ecole/src/ecole/core/reward.cpp | 416 ++ ecole/python/ecole/src/ecole/core/scip.cpp | 201 + ecole/python/ecole/src/ecole/core/version.cpp | 30 + ecole/python/ecole/src/ecole/data.py | 39 + ecole/python/ecole/src/ecole/doctor.py | 28 + ecole/python/ecole/src/ecole/dynamics.py | 1 + ecole/python/ecole/src/ecole/environment.py | 222 + ecole/python/ecole/src/ecole/information.py | 1 + ecole/python/ecole/src/ecole/instance.py | 1 + ecole/python/ecole/src/ecole/observation.py | 1 + ecole/python/ecole/src/ecole/py.typed | 0 ecole/python/ecole/src/ecole/reward.py | 1 + ecole/python/ecole/src/ecole/scip.py | 1 + ecole/python/ecole/src/ecole/typing.py | 301 ++ ecole/python/ecole/src/ecole/version.py | 1 + ecole/python/ecole/tests/conftest.py | 72 + ecole/python/ecole/tests/test_data.py | 140 + ecole/python/ecole/tests/test_dynamics.py | 129 + ecole/python/ecole/tests/test_environment.py | 57 + ecole/python/ecole/tests/test_information.py | 55 + ecole/python/ecole/tests/test_instance.py | 117 + ecole/python/ecole/tests/test_observation.py | 145 + ecole/python/ecole/tests/test_random.py | 60 + ecole/python/ecole/tests/test_reward.py | 143 + ecole/python/ecole/tests/test_scip.py | 175 + ecole/python/ecole/tests/test_version.py | 42 + ecole/python/extension-helper/CMakeLists.txt | 74 + .../EcoleExtensionHelperConfig.cmake.in | 17 + .../dependencies/public.cmake | 21 + .../include/ecole/python/auto-class.hpp | 108 + ecole/setup.py | 100 + 255 files changed, 27735 insertions(+), 3 deletions(-) create mode 100644 ecole/AUTHORS create mode 100644 ecole/CMakeLists.txt create mode 100644 ecole/LICENSE create mode 100644 ecole/README.rst create mode 100644 ecole/VERSION create mode 100644 ecole/cmake/CompilerWarnings.cmake create mode 100644 ecole/cmake/Conda.cmake create mode 100644 ecole/cmake/Coverage.cmake create mode 100644 ecole/cmake/CreateVersionFile.cmake create mode 100644 ecole/cmake/DefaultSettings.cmake create mode 100644 ecole/cmake/DependenciesResolver.cmake create mode 100644 ecole/cmake/InterproceduralOptimization.cmake create mode 100644 ecole/cmake/Python.cmake create mode 100644 ecole/cmake/Sanitizers.cmake create mode 100644 ecole/cmake/Version.cmake create mode 100644 ecole/dev/Dockerfile.src create mode 100644 ecole/dev/conda.yaml create mode 100644 ecole/dev/hooks/build create mode 100644 ecole/dev/hooks/push create mode 100755 ecole/dev/run.sh create mode 100644 ecole/dev/singularity.def create mode 100644 ecole/docs/_static/css/custom.css create mode 100644 ecole/docs/_static/favicon.ico create mode 100644 ecole/docs/_static/images/ecole-logo-bare.png create mode 100644 ecole/docs/_static/images/ecole-logo.svg create mode 100644 ecole/docs/_templates/layout.html create mode 100644 ecole/docs/conf.py create mode 100644 ecole/docs/contributing.rst create mode 100644 ecole/docs/developers/example-observation.rst create mode 100644 ecole/docs/discussion/gym-differences.rst create mode 100644 ecole/docs/discussion/seeding.rst create mode 100644 ecole/docs/discussion/theory.rst create mode 100644 ecole/docs/howto/create-environments.rst create mode 100644 ecole/docs/howto/create-functions.rst create mode 100644 ecole/docs/howto/instances.rst create mode 100644 ecole/docs/howto/observation-functions.rst create mode 100644 ecole/docs/howto/reward-functions.rst create mode 100644 ecole/docs/images/mdp.png create mode 100644 ecole/docs/index.rst create mode 100644 ecole/docs/installation.rst create mode 100644 ecole/docs/reference/environments.rst create mode 100644 ecole/docs/reference/information.rst create mode 100644 ecole/docs/reference/instances.rst create mode 100644 ecole/docs/reference/observations.rst create mode 100644 ecole/docs/reference/rewards.rst create mode 100644 ecole/docs/reference/scip-interface.rst create mode 100644 ecole/docs/reference/utilities.rst create mode 100644 ecole/docs/using-environments.rst create mode 100644 ecole/examples/branching-imitation/conda-requirements.yaml create mode 100644 ecole/examples/branching-imitation/example.ipynb create mode 100644 ecole/examples/configuring-bandits/conda-requirements.yaml create mode 100644 ecole/examples/configuring-bandits/example.ipynb create mode 100644 ecole/examples/libecole/src/branching.cpp create mode 100644 ecole/libecole/CMakeLists.txt create mode 100644 ecole/libecole/EcoleConfig.cmake.in create mode 100644 ecole/libecole/README.md create mode 100644 ecole/libecole/benchmarks/CMakeLists.txt create mode 100644 ecole/libecole/benchmarks/dependencies/private.cmake create mode 100644 ecole/libecole/benchmarks/src/bench-branching.cpp create mode 100644 ecole/libecole/benchmarks/src/bench-branching.hpp create mode 100644 ecole/libecole/benchmarks/src/benchmark.cpp create mode 100644 ecole/libecole/benchmarks/src/benchmark.hpp create mode 100644 ecole/libecole/benchmarks/src/branching/index-branchrule.hpp create mode 100644 ecole/libecole/benchmarks/src/branching/lambda-branchrule.hpp create mode 100644 ecole/libecole/benchmarks/src/csv.hpp create mode 100644 ecole/libecole/benchmarks/src/main.cpp create mode 100644 ecole/libecole/dependencies/private.cmake create mode 100644 ecole/libecole/dependencies/public.cmake create mode 100644 ecole/libecole/include/ecole/data/abstract.hpp create mode 100644 ecole/libecole/include/ecole/data/constant.hpp create mode 100644 ecole/libecole/include/ecole/data/dynamic.hpp create mode 100644 ecole/libecole/include/ecole/data/map.hpp create mode 100644 ecole/libecole/include/ecole/data/multiary.hpp create mode 100644 ecole/libecole/include/ecole/data/none.hpp create mode 100644 ecole/libecole/include/ecole/data/parser.hpp create mode 100644 ecole/libecole/include/ecole/data/timed.hpp create mode 100644 ecole/libecole/include/ecole/data/tuple.hpp create mode 100644 ecole/libecole/include/ecole/data/vector.hpp create mode 100644 ecole/libecole/include/ecole/default.hpp create mode 100644 ecole/libecole/include/ecole/dynamics/branching.hpp create mode 100644 ecole/libecole/include/ecole/dynamics/configuring.hpp create mode 100644 ecole/libecole/include/ecole/dynamics/parts.hpp create mode 100644 ecole/libecole/include/ecole/dynamics/primal-search.hpp create mode 100644 ecole/libecole/include/ecole/environment/branching.hpp create mode 100644 ecole/libecole/include/ecole/environment/configuring.hpp create mode 100644 ecole/libecole/include/ecole/environment/environment.hpp create mode 100644 ecole/libecole/include/ecole/environment/primal-search.hpp create mode 100644 ecole/libecole/include/ecole/exception.hpp create mode 100644 ecole/libecole/include/ecole/information/abstract.hpp create mode 100644 ecole/libecole/include/ecole/information/nothing.hpp create mode 100644 ecole/libecole/include/ecole/none.hpp create mode 100644 ecole/libecole/include/ecole/observation/abstract.hpp create mode 100644 ecole/libecole/include/ecole/observation/hutter-2011.hpp create mode 100644 ecole/libecole/include/ecole/observation/khalil-2016.hpp create mode 100644 ecole/libecole/include/ecole/observation/milp-bipartite.hpp create mode 100644 ecole/libecole/include/ecole/observation/node-bipartite-candidate.hpp create mode 100644 ecole/libecole/include/ecole/observation/node-bipartite.hpp create mode 100644 ecole/libecole/include/ecole/observation/nothing.hpp create mode 100644 ecole/libecole/include/ecole/observation/pseudocosts.hpp create mode 100644 ecole/libecole/include/ecole/observation/strong-branching-scores.hpp create mode 100644 ecole/libecole/include/ecole/random.hpp create mode 100644 ecole/libecole/include/ecole/reward/abstract.hpp create mode 100644 ecole/libecole/include/ecole/reward/bound-integral.hpp create mode 100644 ecole/libecole/include/ecole/reward/constant.hpp create mode 100644 ecole/libecole/include/ecole/reward/is-done.hpp create mode 100644 ecole/libecole/include/ecole/reward/lp-iterations.hpp create mode 100644 ecole/libecole/include/ecole/reward/n-nodes.hpp create mode 100644 ecole/libecole/include/ecole/reward/solving-time.hpp create mode 100644 ecole/libecole/include/ecole/scip/callback.hpp create mode 100644 ecole/libecole/include/ecole/scip/col.hpp create mode 100644 ecole/libecole/include/ecole/scip/cons.hpp create mode 100644 ecole/libecole/include/ecole/scip/exception.hpp create mode 100644 ecole/libecole/include/ecole/scip/model.hpp create mode 100644 ecole/libecole/include/ecole/scip/row.hpp create mode 100644 ecole/libecole/include/ecole/scip/scimpl.hpp create mode 100644 ecole/libecole/include/ecole/scip/seed.hpp create mode 100644 ecole/libecole/include/ecole/scip/type.hpp create mode 100644 ecole/libecole/include/ecole/scip/utils.hpp create mode 100644 ecole/libecole/include/ecole/scip/var.hpp create mode 100644 ecole/libecole/include/ecole/traits.hpp create mode 100644 ecole/libecole/include/ecole/tweak/range.hpp create mode 100644 ecole/libecole/include/ecole/utility/chrono.hpp create mode 100644 ecole/libecole/include/ecole/utility/coroutine.hpp create mode 100644 ecole/libecole/include/ecole/utility/function-traits.hpp create mode 100644 ecole/libecole/include/ecole/utility/numeric.hpp create mode 100644 ecole/libecole/include/ecole/utility/random.hpp create mode 100644 ecole/libecole/include/ecole/utility/sparse-matrix.hpp create mode 100644 ecole/libecole/include/ecole/utility/type-traits.hpp create mode 100644 ecole/libecole/include/ecole/utility/unreachable.hpp create mode 100644 ecole/libecole/include/ecole/utility/vector.hpp create mode 100644 ecole/libecole/include/ecole/version.hpp.in create mode 100644 ecole/libecole/src/dynamics/branching.cpp create mode 100644 ecole/libecole/src/dynamics/configuring.cpp create mode 100644 ecole/libecole/src/dynamics/parts.cpp create mode 100644 ecole/libecole/src/dynamics/primal-search.cpp create mode 100644 ecole/libecole/src/exception.cpp create mode 100644 ecole/libecole/src/observation/hutter-2011.cpp create mode 100644 ecole/libecole/src/observation/khalil-2016.cpp create mode 100644 ecole/libecole/src/observation/milp-bipartite.cpp create mode 100644 ecole/libecole/src/observation/node-bipartite-candidate.cpp create mode 100644 ecole/libecole/src/observation/node-bipartite.cpp create mode 100644 ecole/libecole/src/observation/pseudocosts.cpp create mode 100644 ecole/libecole/src/observation/strong-branching-scores.cpp create mode 100644 ecole/libecole/src/random.cpp create mode 100644 ecole/libecole/src/reward/bound-integral.cpp create mode 100644 ecole/libecole/src/reward/is-done.cpp create mode 100644 ecole/libecole/src/reward/lp-iterations.cpp create mode 100644 ecole/libecole/src/reward/n-nodes.cpp create mode 100644 ecole/libecole/src/reward/solving-time.cpp create mode 100644 ecole/libecole/src/scip/col.cpp create mode 100644 ecole/libecole/src/scip/cons.cpp create mode 100644 ecole/libecole/src/scip/exception.cpp create mode 100644 ecole/libecole/src/scip/model.cpp create mode 100644 ecole/libecole/src/scip/row.cpp create mode 100644 ecole/libecole/src/scip/scimpl.cpp create mode 100644 ecole/libecole/src/scip/var.cpp create mode 100644 ecole/libecole/src/utility/chrono.cpp create mode 100644 ecole/libecole/src/utility/graph.cpp create mode 100644 ecole/libecole/src/utility/graph.hpp create mode 100644 ecole/libecole/src/utility/math.hpp create mode 100644 ecole/libecole/src/version.cpp create mode 100644 ecole/libecole/tests/CMakeLists.txt create mode 100644 ecole/libecole/tests/data/bppc8-02.mps create mode 100644 ecole/libecole/tests/data/enlight8.mps create mode 100644 ecole/libecole/tests/dependencies/private.cmake create mode 100644 ecole/libecole/tests/src/conftest.cpp create mode 100644 ecole/libecole/tests/src/conftest.hpp create mode 100644 ecole/libecole/tests/src/data/mock-function.hpp create mode 100644 ecole/libecole/tests/src/data/test-constant.cpp create mode 100644 ecole/libecole/tests/src/data/test-dynamic.cpp create mode 100644 ecole/libecole/tests/src/data/test-map.cpp create mode 100644 ecole/libecole/tests/src/data/test-multiary.cpp create mode 100644 ecole/libecole/tests/src/data/test-none.cpp create mode 100644 ecole/libecole/tests/src/data/test-parser.cpp create mode 100644 ecole/libecole/tests/src/data/test-timed.cpp create mode 100644 ecole/libecole/tests/src/data/test-tuple.cpp create mode 100644 ecole/libecole/tests/src/data/test-vector.cpp create mode 100644 ecole/libecole/tests/src/data/unit-tests.hpp create mode 100644 ecole/libecole/tests/src/dynamics/test-branching.cpp create mode 100644 ecole/libecole/tests/src/dynamics/test-configuring.cpp create mode 100644 ecole/libecole/tests/src/dynamics/test-parts.cpp create mode 100644 ecole/libecole/tests/src/dynamics/test-primal-search.cpp create mode 100644 ecole/libecole/tests/src/dynamics/unit-tests.hpp create mode 100644 ecole/libecole/tests/src/environment/test-environment.cpp create mode 100644 ecole/libecole/tests/src/main.cpp create mode 100644 ecole/libecole/tests/src/observation/test-hutter-2011.cpp create mode 100644 ecole/libecole/tests/src/observation/test-khalil-2016.cpp create mode 100644 ecole/libecole/tests/src/observation/test-milp-bipartite.cpp create mode 100644 ecole/libecole/tests/src/observation/test-node-bipartite.cpp create mode 100644 ecole/libecole/tests/src/observation/test-pseudocosts.cpp create mode 100644 ecole/libecole/tests/src/observation/test-strong-branching-scores.cpp create mode 100644 ecole/libecole/tests/src/observation/unit-tests.hpp create mode 100644 ecole/libecole/tests/src/reward/test-bound-integral.cpp create mode 100644 ecole/libecole/tests/src/reward/test-is-done.cpp create mode 100644 ecole/libecole/tests/src/reward/test-lp-iterations.cpp create mode 100644 ecole/libecole/tests/src/reward/test-n-nodes.cpp create mode 100644 ecole/libecole/tests/src/reward/test-solving-time.cpp create mode 100644 ecole/libecole/tests/src/reward/unit-tests.hpp create mode 100644 ecole/libecole/tests/src/scip/test-model.cpp create mode 100644 ecole/libecole/tests/src/scip/test-scimpl.cpp create mode 100644 ecole/libecole/tests/src/test-random.cpp create mode 100644 ecole/libecole/tests/src/test-traits.cpp create mode 100644 ecole/libecole/tests/src/test-utility/tmp-folder.cpp create mode 100644 ecole/libecole/tests/src/test-utility/tmp-folder.hpp create mode 100644 ecole/libecole/tests/src/utility/test-chrono.cpp create mode 100644 ecole/libecole/tests/src/utility/test-coroutine.cpp create mode 100644 ecole/libecole/tests/src/utility/test-graph.cpp create mode 100644 ecole/libecole/tests/src/utility/test-random.cpp create mode 100644 ecole/libecole/tests/src/utility/test-sparse-matrix.cpp create mode 100644 ecole/libecole/tests/src/utility/test-vector.cpp create mode 100644 ecole/pyproject.toml create mode 100644 ecole/python/.gitignore create mode 100644 ecole/python/ecole/CMakeLists.txt create mode 100644 ecole/python/ecole/README.md create mode 100644 ecole/python/ecole/src/ecole/__init__.py create mode 100644 ecole/python/ecole/src/ecole/core/caster.hpp create mode 100644 ecole/python/ecole/src/ecole/core/core.cpp create mode 100644 ecole/python/ecole/src/ecole/core/core.hpp create mode 100644 ecole/python/ecole/src/ecole/core/data.cpp create mode 100644 ecole/python/ecole/src/ecole/core/dynamics.cpp create mode 100644 ecole/python/ecole/src/ecole/core/information.cpp create mode 100644 ecole/python/ecole/src/ecole/core/instance.cpp create mode 100644 ecole/python/ecole/src/ecole/core/observation.cpp create mode 100644 ecole/python/ecole/src/ecole/core/reward.cpp create mode 100644 ecole/python/ecole/src/ecole/core/scip.cpp create mode 100644 ecole/python/ecole/src/ecole/core/version.cpp create mode 100644 ecole/python/ecole/src/ecole/data.py create mode 100644 ecole/python/ecole/src/ecole/doctor.py create mode 100644 ecole/python/ecole/src/ecole/dynamics.py create mode 100644 ecole/python/ecole/src/ecole/environment.py create mode 100644 ecole/python/ecole/src/ecole/information.py create mode 100644 ecole/python/ecole/src/ecole/instance.py create mode 100644 ecole/python/ecole/src/ecole/observation.py create mode 100644 ecole/python/ecole/src/ecole/py.typed create mode 100644 ecole/python/ecole/src/ecole/reward.py create mode 100644 ecole/python/ecole/src/ecole/scip.py create mode 100644 ecole/python/ecole/src/ecole/typing.py create mode 100644 ecole/python/ecole/src/ecole/version.py create mode 100644 ecole/python/ecole/tests/conftest.py create mode 100644 ecole/python/ecole/tests/test_data.py create mode 100644 ecole/python/ecole/tests/test_dynamics.py create mode 100644 ecole/python/ecole/tests/test_environment.py create mode 100644 ecole/python/ecole/tests/test_information.py create mode 100644 ecole/python/ecole/tests/test_instance.py create mode 100644 ecole/python/ecole/tests/test_observation.py create mode 100644 ecole/python/ecole/tests/test_random.py create mode 100644 ecole/python/ecole/tests/test_reward.py create mode 100644 ecole/python/ecole/tests/test_scip.py create mode 100644 ecole/python/ecole/tests/test_version.py create mode 100644 ecole/python/extension-helper/CMakeLists.txt create mode 100644 ecole/python/extension-helper/EcoleExtensionHelperConfig.cmake.in create mode 100644 ecole/python/extension-helper/dependencies/public.cmake create mode 100644 ecole/python/extension-helper/include/ecole/python/auto-class.hpp create mode 100644 ecole/setup.py diff --git a/.gitignore b/.gitignore index b49e4dd..64b07fd 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,3 @@ ENV/ # In-tree generated files */_version.py - -# Do not track ecole for now -ecole/ diff --git a/ecole/AUTHORS b/ecole/AUTHORS new file mode 100644 index 0000000..0388718 --- /dev/null +++ b/ecole/AUTHORS @@ -0,0 +1,4 @@ +Antoine Prouvost +Maxime Gasse +Didier Chételat +Justin Dumouchelle diff --git a/ecole/CMakeLists.txt b/ecole/CMakeLists.txt new file mode 100644 index 0000000..8319d48 --- /dev/null +++ b/ecole/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.16) + +# Adapt compiler flags if using Conda compiler packages. Before project so they are not modified. +include(cmake/Conda.cmake) + +# Read the version from file +include(cmake/Version.cmake) +read_version("VERSION" Ecole_VERSION) + +# Set default parameters. Assumes Ecole user, +include(cmake/DefaultSettings.cmake) + +project( + Ecole + VERSION "${Ecole_VERSION}" + LANGUAGES CXX + DESCRIPTION "Extensible Combinatorial Optimization Learning Environments" +) + +# Add option to enable interprocedural optimization +include(cmake/InterproceduralOptimization.cmake) + +# Define a target Ecole::warnings with all compiler warnings. +include(cmake/CompilerWarnings.cmake) + +# Define a target Ecole::sanitizers with enabled sanitizers. +include(cmake/Sanitizers.cmake) + +# Define a target Ecole::coverage with coverage options. +include(cmake/Coverage.cmake) + +# Utilities to automatically download missing dependencies +include(cmake/DependenciesResolver.cmake) + +# Adapt which Python is found +include(cmake/Python.cmake) + +# Enable CTest for registering tests +include(CTest) + +# Ecole library +if(ECOLE_BUILD_LIB) + # Build the Ecole library + add_subdirectory(libecole) +else() + # Find the Ecole library of same version already installed + option(ECOLE_DOWNLOAD_DEPENDENCIES "Download the static and header libraries used in Ecole public interface" ON) + find_package(Ecole ${Ecole_VERSION} EXACT REQUIRED) +endif() + +# Ecole Python extension +if(ECOLE_BUILD_PY_EXT) + add_subdirectory(python/extension-helper) + add_subdirectory(python/ecole) +endif() diff --git a/ecole/LICENSE b/ecole/LICENSE new file mode 100644 index 0000000..23fe966 --- /dev/null +++ b/ecole/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2019, Antoine Prouvost +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/ecole/README.rst b/ecole/README.rst new file mode 100644 index 0000000..9699bc7 --- /dev/null +++ b/ecole/README.rst @@ -0,0 +1,124 @@ +⚠️ **Warning** ⚠️ + +*Ecole is looking for a new home.* +*It is not being actively developed, only critical issues will be investigated.* + + +.. image:: https://raw.githubusercontent.com/ds4dm/ecole/master/docs/_static/images/ecole-logo.svg + :target: https://www.ecole.ai + :alt: Ecole logo + :width: 30 % + :align: right + +Ecole +===== + +.. image:: https://github.com/ds4dm/ecole/actions/workflows/continuous-testing.yml/badge.svg + :target: https://github.com/ds4dm/ecole/actions/workflows/continuous-testing.yml + :alt: Test and deploy on Github Actions + +Ecole (pronounced [ekɔl]) stands for *Extensible Combinatorial Optimization Learning +Environments* and aims to expose a number of control problems arising in combinatorial +optimization solvers as Markov +Decision Processes (*i.e.*, Reinforcement Learning environments). +Rather than trying to predict solutions to combinatorial optimization problems directly, the +philosophy behind Ecole is to work +in cooperation with a state-of-the-art Mixed Integer Linear Programming solver +that acts as a controllable algorithm. + +The underlying solver used is `SCIP `_, and the user facing API is +meant to mimic the `OpenAI Gym `_ API (as much as possible). + +.. code-block:: python + + import ecole + + env = ecole.environment.Branching( + reward_function=-1.5 * ecole.reward.LpIterations() ** 2, + observation_function=ecole.observation.NodeBipartite(), + ) + instances = ecole.instance.SetCoverGenerator() + + for _ in range(10): + obs, action_set, reward_offset, done, info = env.reset(next(instances)) + while not done: + obs, action_set, reward, done, info = env.step(action_set[0]) + + +Documentation +------------- +Consult the `user Documentation `_ for tutorials, examples, and library reference. + +Discussions and help +-------------------- +Head to `Github Discussions `_ for interaction with the community: give +and recieve help, discuss intresting envirnoment, rewards function, and instances generators. + +Installation +------------ +Conda +^^^^^ + +.. image:: https://img.shields.io/conda/vn/conda-forge/ecole?label=version&logo=conda-forge + :target: https://anaconda.org/conda-forge/ecole + :alt: Conda-Forge version +.. image:: https://img.shields.io/conda/pn/conda-forge/ecole?logo=conda-forge + :target: https://anaconda.org/conda-forge/ecole + :alt: Conda-Forge platforms + +.. code-block:: bash + + conda install -c conda-forge ecole + +All dependencies are resolved by conda, no compiler is required. + +Pip wheel (binary) +^^^^^^^^^^^^^^^^^^ +Currently unavailable. + +Pip source +^^^^^^^^^^^ +.. image:: https://img.shields.io/pypi/v/ecole?logo=python + :target: https://pypi.org/project/ecole/ + :alt: PyPI version + +Building from source requires: + - A `C++17 compiler `_, + - A `SCIP `__ installation. + +.. code-block:: bash + + pip install ecole + +Other Options +^^^^^^^^^^^^^ +Checkout the `installation instructions `_ in the +documentation for more installation options. + +Related Projects +---------------- + +* `OR-Gym `_ is a gym-like library providing gym-like environments to produce feasible solutions + directly, without the need for an MILP solver; +* `MIPLearn `_ for learning to configure solvers. + +Use It, Cite It +--------------- + +.. image:: https://img.shields.io/badge/arxiv-2011.06069-red + :target: https://arxiv.org/abs/2011.06069 + :alt: Ecole publication on Arxiv + + +If you use Ecole in a scientific publication, please cite the Ecole publication + +.. code-block:: text + + @inproceedings{ + prouvost2020ecole, + title={Ecole: A Gym-like Library for Machine Learning in Combinatorial Optimization Solvers}, + author={Antoine Prouvost and Justin Dumouchelle and Lara Scavuzzo and Maxime Gasse and Didier Ch{\'e}telat and Andrea Lodi}, + booktitle={Learning Meets Combinatorial Algorithms at NeurIPS2020}, + year={2020}, + url={https://openreview.net/forum?id=IVc9hqgibyB} + } diff --git a/ecole/VERSION b/ecole/VERSION new file mode 100644 index 0000000..b0841b7 --- /dev/null +++ b/ecole/VERSION @@ -0,0 +1,10 @@ +# See PEP 440 for valid version specification +# The following should include only numbers +VERSION_MAJOR 0 +VERSION_MINOR 8 +VERSION_PATCH 1 +# The following should include their whole string and can be combined. +# They must be numbered, starting from 0. +VERSION_PRE # Pre release without leading dot, e.g. `a0` (alpha), `b0` (beta), or `rc0` (release candidate) +VERSION_POST # Post release with leading dot, e.g. `.post0` +VERSION_DEV # Dev release with leading dot, e.g. `.dev0` diff --git a/ecole/cmake/CompilerWarnings.cmake b/ecole/cmake/CompilerWarnings.cmake new file mode 100644 index 0000000..c025401 --- /dev/null +++ b/ecole/cmake/CompilerWarnings.cmake @@ -0,0 +1,128 @@ +# Module to set default compiler warnings. +# +# File adapted from Jason Turner's cpp_starter_project +# https://github.com/lefticus/cpp_starter_project/blob/master/cmake/CompilerWarnings.cmake +# Using INTERFACE targets is not so desirable as they need to be installed when building +# static libraries. + +function(ecole_target_add_compile_warnings target) + option(WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) + + set(msvc_warnings + # Baseline reasonable warnings + /W4 + # "identfier": conversion from "type1" to "type1", possible loss of data + /w14242 + # "operator": conversion from "type1:field_bits" to "type2:field_bits", possible + # loss of data + /w14254 + # "function": member function does not override any base class virtual member + # function + /w14263 + # "classname": class has virtual functions, but destructor is not virtual instances + # of this class may not be destructed correctly + /w14265 + # "operator": unsigned/negative constant mismatch + /w14287 + # Nonstandard extension used: "variable": loop control variable declared in the + # for-loop is used outside the for-loop scope + /we4289 + # "operator": expression is always "boolean_value" + /w14296 + # "variable": pointer truncation from "type1" to "type2" + /w14311 + # Expression before comma evaluates to a function which is missing an argument list + /w14545 + # Function call before comma missing argument list + /w14546 + # "operator": operator before comma has no effect; expected operator with side-effect + /w14547 + # "operator": operator before comma has no effect; did you intend "operator"? + /w14549 + # Expression has no effect; expected expression with side- effect + /w14555 + # Pragma warning: there is no warning number "number" + /w14619 + # Enable warning on thread un-safe static member initialization + /w14640 + # Conversion from "type1" to "type_2" is sign-extended. This may cause unexpected + # runtime behavior. + /w14826 + # Wide string literal cast to "LPSTR" + /w14905 + # String literal cast to "LPWSTR" + /w14906 + # Illegal copy-initialization; more than one user-defined conversion has been + # implicitly applied + /w14928 + ) + + set(clang_warnings + # Some default set of warnings + -Wall + # Reasonable and standard + -Wextra + # Warn the user if a variable declaration shadows one from a parent context + -Wshadow + # Warn the user if a class with virtual functions has a non-virtual destructor. + # This helps catch hard to track down memory errors + -Wnon-virtual-dtor + # Warn for c-style casts + -Wold-style-cast + # Warn for potential performance problem casts + -Wcast-align + # Warn on anything being unused + -Wunused + # Warn if you overload (not override) a virtual function + -Woverloaded-virtual + # Warn if non-standard C++ is used + -Wpedantic + # Warn on type conversions that may lose data + -Wconversion + # Warn on sign conversions + -Wsign-conversion + # Warn if a null dereference is detected + -Wnull-dereference + # Warn if float is implicit promoted to double + -Wdouble-promotion + # Warn on security issues around functions that format output (ie printf) + -Wformat=2 + # Warn on code that cannot be executed + -Wunreachable-code + # Warn if a variable is used before being initialized + -Wuninitialized + ) + + if (WARNINGS_AS_ERRORS) + set(clang_warnings ${clang_warnings} -Werror) + set(msvc_warnings ${msvc_warnings} /WX) + endif() + + set(gcc_warnings + ${clang_warnings} + # FIXME currently not adding more warning for GCC because they fail on clang-tidy + # warn if identation implies blocks where blocks do not exist + # -Wmisleading-indentation + # warn if if / else chain has duplicated conditions + # -Wduplicated-cond + # warn if if / else branches have duplicated code + # -Wduplicated-branches + # warn about logical operations being used where bitwise were probably wanted + # -Wlogical-op + # warn if you perform a cast to the same type + # -Wuseless-cast + ) + + if(MSVC) + set(warnings ${msvc_warnings}) + elseif(CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") + set(warnings ${clang_warnings}) + elseif(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(warnings ${clang_warnings}) + else() + set(warnings ${gcc_warnings}) + endif() + + target_compile_options("${target}" PRIVATE ${warnings}) + +endfunction() diff --git a/ecole/cmake/Conda.cmake b/ecole/cmake/Conda.cmake new file mode 100644 index 0000000..aba98f7 --- /dev/null +++ b/ecole/cmake/Conda.cmake @@ -0,0 +1,45 @@ +# This file processes the flags set by conda in the compiler packages. +# +# Conda has a separate set of debug flags defined, which are not picked up by CMake. +# Similarily the regular flags set by Conda are not adapted to debug builds. +# +# This file adds to build type: +# - CondaDebug for the the DEBUG_XXXFLAGS set by Conda, +# - CondaRelease for the XXXFLAGS set by Conda. +# +# Note: the `LDFLAGS` environment variable is not processed into CMAKE_EXE_LINKER_FLAGS_, +# CMAKE_SHARED_LINKER_FLAGS_ and CMAKE_MODULE_LINKER_FLAGS_ as they contain +# information to find the library dependencies. + +# If we are building a recipe or not using the compiler packages then do nothing. +if(DEFINED ENV{CONDA_BUILD} OR NOT DEFINED ENV{CONDA_BUILD_SYSROOT}) + return() +endif() + +# Utility to set the language and linker flags. +function(set_flags LANG FLAGS) +set(BUILD "${ARGV2}") # Optional build type + set( + CMAKE_${LANG}_FLAGS_${BUILD} "${FLAGS}" + CACHE STRING "Flags used by ${LANG} during ${BUILD} builds." + ) + MARK_AS_ADVANCED(CMAKE_${LANG}_FLAGS_${BUILD}) +endfunction() + +# Define the CondaDebug build type +set_flags(Fortran "$ENV{DEBUG_FFLAGS}" CONDADEBUG) +set_flags(C "$ENV{DEBUG_CFLAGS} $ENV{DEBUG_CPPFLAGS}" CONDADEBUG) +set_flags(CXX "$ENV{DEBUG_CXXFLAGS} $ENV{DEBUG_CPPFLAGS}" CONDADEBUG) +# Unset the environment flags in order to prevent CMake from reading them +set(ENV{DEBUG_FFLAGS} "") +set(ENV{DEBUG_CFLAGS} "") +set(ENV{DEBUG_CXXFLAGS} "") + +# Define the CondaRelease build type +set_flags(Fortran "$ENV{FFLAGS}" CONDARELEASE) +set_flags(C "$ENV{CFLAGS} $ENV{CPPFLAGS}" CONDARELEASE) +set_flags(CXX "$ENV{CXXFLAGS} $ENV{CPPFLAGS}" CONDARELEASE) +# Unset the environment flags in order to prevent CMake from reading them +set(ENV{FFLAGS} "") +set(ENV{CFLAGS} "") +set(ENV{CXXFLAGS} "") diff --git a/ecole/cmake/Coverage.cmake b/ecole/cmake/Coverage.cmake new file mode 100644 index 0000000..786cac3 --- /dev/null +++ b/ecole/cmake/Coverage.cmake @@ -0,0 +1,14 @@ +# Module to enable code coverage + +function(ecole_target_add_coverage target) + + set(supported_compilers "GNU" "Clang" "AppleClang") + if(CMAKE_CXX_COMPILER_ID IN_LIST supported_compilers) + option(COVERAGE "Enable coverage reporting for gcc/clang" FALSE) + if(COVERAGE) + target_compile_options("${target}" PRIVATE --coverage -O0 -g) + target_link_libraries("${target}" PRIVATE --coverage) + endif() + endif() + +endfunction() diff --git a/ecole/cmake/CreateVersionFile.cmake b/ecole/cmake/CreateVersionFile.cmake new file mode 100644 index 0000000..fffaf38 --- /dev/null +++ b/ecole/cmake/CreateVersionFile.cmake @@ -0,0 +1,43 @@ +# Script to configure version files at build time. +# +# This is not meant to be included directly in CMakeLists.txt but to be called with `cmake -P` +# script mode. +# This way, one can build a target that regenerate the version file at every compilation. +# It avoids getting an outdated Git revision. +# All other variable defined before running the script can also be used for templating the +# versio file. + +# Default working directory +if(NOT WORKING_DIR) + get_filename_component(WORKING_DIR "${SOURCE_FILE}" DIRECTORY) +endif() + +if(NOT Ecole_VERSION_MAJOR) + set(Ecole_VERSION_MAJOR 0) +endif() +if(NOT Ecole_VERSION_MINOR) + set(Ecole_VERSION_MINOR 0) +endif() +if(NOT Ecole_VERSION_PATCH) + set(Ecole_VERSION_PATCH 0) +endif() + +if(NOT Ecole_VERSION_REVISION) + message(STATUS "Resolving Git Version") + set(Ecole_VERSION_REVISION "unknown") + find_package(Git) + if(GIT_FOUND) + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --verify HEAD + WORKING_DIRECTORY "${WORKING_DIR}" + OUTPUT_VARIABLE Ecole_VERSION_REVISION + ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + message(STATUS "Git revision: ${Ecole_VERSION_REVISION}") + else() + message(STATUS "Git not found") + endif() +endif() + +configure_file("${SOURCE_FILE}" "${TARGET_FILE}" @ONLY) diff --git a/ecole/cmake/DefaultSettings.cmake b/ecole/cmake/DefaultSettings.cmake new file mode 100644 index 0000000..de84550 --- /dev/null +++ b/ecole/cmake/DefaultSettings.cmake @@ -0,0 +1,69 @@ +# Set default parameters depending if user or developer. + + +# Set the default build type to the given value if no build type was specified +function(set_default_build_type DEFAULT_BUILD_TYPE) + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + message(STATUS "Setting build type to ${DEFAULT_BUILD_TYPE} as none was specified") + set( + CMAKE_BUILD_TYPE ${DEFAULT_BUILD_TYPE} + CACHE STRING "Choose the type of build" FORCE + ) + # Set the possible values of build type for cmake-gui, ccmake + set_property( + CACHE CMAKE_BUILD_TYPE + PROPERTY STRINGS "Debug" "Release" "MinSizeRel" "RelWithDebInfo" + ) + endif() +endfunction() + +# Set default common for all cases +macro(set_common_defaults) + option(CMAKE_EXPORT_COMPILE_COMMANDS "Generate compile_commands.json for tools" ON) + option(CMAKE_POSITION_INDEPENDENT_CODE "Position Independent Code for building shared libraries." ON) + option(CMAKE_VISIBILITY_INLINES_HIDDEN "Hidden symbol visibility for inline functions in shared libraries" ON) + set(CMAKE_CXX_VISIBILITY_PRESET hidden CACHE STRING "Hidden visibility of symbols in shared libraries.") + option(ECOLE_BUILD_LIB "Build Ecole library, find already installed otherwise" ON) + option(ECOLE_BUILD_PY_EXT "Build Ecole Python Extension" ON) +endmacro() + + +# Set of defaults for Ecole users +macro(set_user_defaults) + set_common_defaults() + set_default_build_type(RelWithDebInfo) + option(ENABLE_IPO "Enable Interprocedural Optimization, aka Link Time Optimization (LTO)" ON) + option(ECOLE_BUILD_BENCHMARKS "Build Ecole benchmarks" OFF) + option(ECOLE_BUILD_TESTS "Build Ecole tests" OFF) +endmacro() + + +# Set of defaults for Ecole developers (anyone contributing) +macro(set_developer_defaults) + set_common_defaults() + set_default_build_type(Debug) + + option(ECOLE_BUILD_BENCHMARKS "Build Ecole benchmarks" ON) + option(ECOLE_BUILD_TESTS "Build Ecole tests" ON) + + # Enable compiler cache if found + find_program(CCACHE ccache) + if(CCACHE) + message(STATUS "Using ccache") + set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE} CACHE FILEPATH "Compiler launching tool") + else() + message(STATUS "Cannot find requirement ccache") + endif() +endmacro() + + +macro(set_defaults) + if(ECOLE_DEVELOPER) + set_developer_defaults() + else() + set_user_defaults() + endif() +endmacro() + + +set_defaults() diff --git a/ecole/cmake/DependenciesResolver.cmake b/ecole/cmake/DependenciesResolver.cmake new file mode 100644 index 0000000..326c710 --- /dev/null +++ b/ecole/cmake/DependenciesResolver.cmake @@ -0,0 +1,95 @@ +# Find or download dependencies. +# +# Utility to try to find a package, or downloaad it, configure it, and install it inside +# the build tree. +# Based and fetch content, it avoids using `add_subdirectory` which exposes other project +# targets and errors as part of this project. + +# Avoid warning about DOWNLOAD_EXTRACT_TIMESTAMP in CMake 3.24: +if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.24.0") + cmake_policy(SET CMP0135 NEW) +endif() + +include(FetchContent) + + +# Where downloaded dependencies will be installed (in the build tree by default). +set(FETCHCONTENT_INSTALL_DIR "${FETCHCONTENT_BASE_DIR}/local") +option(ECOLE_FORCE_DOWNLOAD "Don't look for dependencies locally, rather always download them." OFF) + + +# Execute command at comfigure time and handle errors and output. +function(execute_process_handle_output) + execute_process( + ${ARGV} + RESULT_VARIABLE ERROR + OUTPUT_VARIABLE STD_OUT + ERROR_VARIABLE STD_ERR + ) + if(ERROR) + message(FATAL_ERROR "${STD_OUT} ${STD_ERR}") + else() + message(DEBUG "${STD_OUT}") + endif() +endfunction() + + +# Configure, build and install the a CMake project +# +# The source of the project must have been made available prior to calling this function. +function(build_package) + set(options) + set(oneValueArgs SOURCE_DIR BUILD_DIR INSTALL_DIR) + set(multiValueArgs CONFIGURE_ARGS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + message(DEBUG "${CMAKE_COMMAND}" -S "${ARG_SOURCE_DIR}" -B "${ARG_BUILD_DIR}" ${ARG_CONFIGURE_ARGS}) + execute_process_handle_output( + COMMAND "${CMAKE_COMMAND}" -S "${ARG_SOURCE_DIR}" -B "${ARG_BUILD_DIR}" -G "${CMAKE_GENERATOR}" ${ARG_CONFIGURE_ARGS} + ) + execute_process_handle_output(COMMAND "${CMAKE_COMMAND}" --build "${ARG_BUILD_DIR}" --parallel) + execute_process_handle_output(COMMAND "${CMAKE_COMMAND}" --install "${ARG_BUILD_DIR}" --prefix "${ARG_INSTALL_DIR}") +endfunction() + + +# Try to find a package or downloads it at configure time. +# +# Use FetchContent to download a package if it was not found and build it inside the build tree. +# This is a macro so that find_package can export variables in the parent scope. +macro(find_or_download_package) + set(options) + set(oneValueArgs NAME URL URL_HASH) + set(multiValueArgs CONFIGURE_ARGS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT ${ARG_NAME}_FOUND) + if(NOT ECOLE_FORCE_DOWNLOAD) + find_package(${ARG_NAME} QUIET) + endif() + + if(${ARG_NAME}_FOUND) + message(STATUS "Found ${ARG_NAME}") + else() + FetchContent_Declare( + ${ARG_NAME} + URL ${ARG_URL} + URL_HASH ${ARG_URL_HASH} + ) + FetchContent_GetProperties(${ARG_NAME}) + if(NOT ${ARG_NAME}_POPULATED) + message(STATUS "Downloading ${ARG_NAME}") + FetchContent_Populate(${ARG_NAME}) + message(STATUS "Building ${ARG_NAME}") + # FetchContent_Populate uses lower case name of FetchContent_Declare for directories + string(TOLOWER "${ARG_NAME}" ARG_NAME_LOWER) + build_package( + CONFIGURE_ARGS ${ARG_CONFIGURE_ARGS} -D "CMAKE_PREFIX_PATH=${FETCHCONTENT_INSTALL_DIR}" + SOURCE_DIR "${${ARG_NAME_LOWER}_SOURCE_DIR}" + BUILD_DIR "${${ARG_NAME_LOWER}_BINARY_DIR}" + INSTALL_DIR "${FETCHCONTENT_INSTALL_DIR}" + ) + find_package(${ARG_NAME} PATHS "${FETCHCONTENT_INSTALL_DIR}" NO_DEFAULT_PATH QUIET) + endif() + endif() + endif() +endmacro() diff --git a/ecole/cmake/InterproceduralOptimization.cmake b/ecole/cmake/InterproceduralOptimization.cmake new file mode 100644 index 0000000..f65ec3b --- /dev/null +++ b/ecole/cmake/InterproceduralOptimization.cmake @@ -0,0 +1,13 @@ +option(ENABLE_IPO "Enable Interprocedural Optimization, aka Link Time Optimization (LTO)" OFF) + +if(ENABLE_IPO) + include(CheckIPOSupported) + check_ipo_supported(RESULT result OUTPUT output) + if(result) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) + message(STATUS "IPO enabled") + else() + message(STATUS "IPO is not supported") + message(DEBUG "${output}") + endif() +endif() diff --git a/ecole/cmake/Python.cmake b/ecole/cmake/Python.cmake new file mode 100644 index 0000000..545d18f --- /dev/null +++ b/ecole/cmake/Python.cmake @@ -0,0 +1,17 @@ +# Set some variables to find the proper Python version + +if(SKBUILD) + # If scikit-build is compiling, let if define the interpreter + set(Python_EXECUTABLE "${PYTHON_EXECUTABLE}") + set(Python_INCLUDE_DIR "${PYTHON_INCLUDE_DIR}") + set(Python_LIBRARY "${PYTHON_LIBRARY}") + set(DUMMY "${PYTHON_VERSION_STRING}") # Not needed, silences a warning + +elseif(NOT DEFINED Python_EXECUTABLE) + # Find Python interpreter from the path and don't resolve symlinks + execute_process( + COMMAND "python3" "-c" "import sys; print(sys.executable)" + OUTPUT_VARIABLE Python_EXECUTABLE + OUTPUT_STRIP_TRAILING_WHITESPACE + ) +endif() diff --git a/ecole/cmake/Sanitizers.cmake b/ecole/cmake/Sanitizers.cmake new file mode 100644 index 0000000..76198f2 --- /dev/null +++ b/ecole/cmake/Sanitizers.cmake @@ -0,0 +1,42 @@ +# Module to enable compiler runtime checks. +# +# File adapted from Jason Turner's cpp_starter_project +# https://github.com/lefticus/cpp_starter_project/blob/master/cmake/Sanitizers.cmake +# Using INTERFACE targets is not so desirable as they need to be installed when building +# static libraries. + +function(ecole_target_add_sanitizers target) + + set(supported_compilers "GNU" "Clang" "AppleClang") + if(CMAKE_CXX_COMPILER_ID IN_LIST supported_compilers) + set(sanitizers "") + + option(SANITIZE_ADDRESS "Enable address sanitizer" FALSE) + if(SANITIZE_ADDRESS) + list(APPEND sanitizers "address") + endif() + + option(SANITIZE_MEMORY "Enable memory sanitizer" FALSE) + if(SANITIZE_MEMORY) + list(APPEND sanitizers "memory") + endif() + + option(SANITIZE_UNDEFINED_BEHAVIOR "Enable undefined behavior sanitizer" FALSE) + if(SANITIZE_UNDEFINED_BEHAVIOR) + list(APPEND sanitizers "undefined") + endif() + + option(SANITIZE_THREAD "Enable thread sanitizer" FALSE) + if(SANITIZE_THREAD) + list(APPEND sanitizers "thread") + endif() + + list(JOIN sanitizers "," list_of_sanitizers) + if(NOT "${list_of_sanitizers}" STREQUAL "") + target_compile_options("${target}" PRIVATE -fsanitize=${list_of_sanitizers}) + target_link_libraries("${target}" PRIVATE -fsanitize=${list_of_sanitizers}) + endif() + + endif() + +endfunction() diff --git a/ecole/cmake/Version.cmake b/ecole/cmake/Version.cmake new file mode 100644 index 0000000..bd30de5 --- /dev/null +++ b/ecole/cmake/Version.cmake @@ -0,0 +1,15 @@ +function(read_version file version_var) + file(READ "${file}" version_text) + + string(REGEX MATCH "VERSION_MAJOR ([0-9]+)" _ "${version_text}") + set(version_major "${CMAKE_MATCH_1}") + + string(REGEX MATCH "VERSION_MINOR ([0-9]+)" _ "${version_text}") + set(version_minor "${CMAKE_MATCH_1}") + + string(REGEX MATCH "VERSION_PATCH ([0-9]+)" _ "${version_text}") + set(version_patch "${CMAKE_MATCH_1}") + + set("${version_var}" "${version_major}.${version_minor}.${version_patch}" PARENT_SCOPE) + message(STATUS "Ecole version ${version_major}.${version_minor}.${version_patch}") +endfunction() diff --git a/ecole/dev/Dockerfile.src b/ecole/dev/Dockerfile.src new file mode 100644 index 0000000..d280386 --- /dev/null +++ b/ecole/dev/Dockerfile.src @@ -0,0 +1,51 @@ +# Matrix built with different compilers (e.g. gcc9, clang10) and python versions. +ARG compiler=clang10 + +FROM conanio/${compiler} +USER root + +ARG CXXFLAGS="" +ARG LDFLAGS="" +ENV CXXFLAGS="$CXXFLAGS" +ENV LDFLAGS="$LDFLAGS" + +# Install minimal dependencies for a CircleCI image. +RUN apt-get update && \ + apt-get install -y --no-install-recommends wget git openssh-client tar gzip ca-certificates && \ + apt-get clean + +# Install SCIP from source. +# We do not need a very "complete" scip, just the bare minimum. +ARG scip_version=8.0.0 +RUN wget --no-verbose https://scip.zib.de/download/release/scipoptsuite-${scip_version}.tgz && \ + tar -xzf scipoptsuite-${scip_version}.tgz && \ + cmake -B build/ -S scipoptsuite-${scip_version} \ + -D CMAKE_BUILD_TYPE=Release \ + -D PARASCIP=ON \ + -D PAPILO=OFF \ + -D GCG=OFF \ + -D ZIMPL=OFF \ + -D GMP=OFF \ + -D IPOPT=OFF \ + -D BOOST=OFF && \ + cmake --build build && \ + cmake --install build && \ + rm -rf build/ scipoptsuite-${scip_version} scipoptsuite-${scip_version}.tgz + + +# Install Python and NumPy +# Pyenv needs a full version (e.g 3.7.10) so we search for the latest bug fix release +ARG python_version=3.7 +# This system script uses /usr/bin/python3 which get hijacked by pyenv so we hard code it to system python3.7. +# https://askubuntu.com/q/965043 +# Does not happen on all images. +RUN sed --in-place '1s:^#!/usr/bin/python3:#!/usr/bin/python3.7:' "$(which lsb_release)" || true +RUN version_regex='^[[:blank:]]*'"$(echo ${python_version} | sed 's/\./\\./')"'\.[[:digit:]]+[[:blank:]]*$' && \ + python_version_fix=$(pyenv install --list | grep -E "${version_regex}" | cut -d'.' -f 3 | sort -n | tail -1) && \ + python_full_version=${python_version}.${python_version_fix} && \ + pyenv install ${python_full_version} && \ + pyenv global ${python_full_version} && \ + python -m pip install --no-cache-dir --upgrade pip && \ + python -m pip install --no-cache-dir cmake numpy pytest pytest-helpers-namespace pyscipopt + +WORKDIR /app diff --git a/ecole/dev/conda.yaml b/ecole/dev/conda.yaml new file mode 100644 index 0000000..ea5d694 --- /dev/null +++ b/ecole/dev/conda.yaml @@ -0,0 +1,45 @@ +channels: + - conda-forge + - defaults + +dependencies: + # C++ build tools + - make + - cmake>=3.15 + # C++ build time dependencies + - xtensor + - cxx-compiler + - xsimd + - fmt + # C++ runtime dependencies + - scip=8 + # Build time Python dependencies + - python>=3.6 + - pybind11>=2.7 + - numpy>=1.4 + - xtensor-python + - scikit-build + - build + + # Documentation + - doxygen + - sphinx = 4.4 + - breathe>=4.15 + - sphinx_rtd_theme + + # General dev tools + - pre-commit + # C++ dev tools + - clang-tools=11 + - ccache + - catch2 < 3.0 + - cli11 + # Python dev tools + - pip + - pytest + - pytest-helpers-namespace + - black + - ipython + - pyscipopt >= 3.0.1 # optional + - twine + - papermill diff --git a/ecole/dev/hooks/build b/ecole/dev/hooks/build new file mode 100644 index 0000000..a87381b --- /dev/null +++ b/ecole/dev/hooks/build @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -o errexit # Fail script on errors +set -o nounset # Fail on empty variables +set -o pipefail # Error if error in pipe + +# Directory of this file +__DIR__="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# St on Dockerhub but this and the CWD seem to be unreliable +DOCKERFILE_DIR="${__DIR__}/.." +# Set on DockerHub but set default value to use script locally +DOCKER_REPO="${DOCKER_REPO:-ecoleai/ci}" + +for python_version in "3.7" "3.8" "3.9" "3.10"; do + + # Source images with given compiler + for compiler in "gcc9" "clang10" ; do + extra_args=() + # If using clang, compile with LLVM libc++ because the given libstd++ does not fully support C++17. + # FIXME libstdc++ should just be updated (because libc++ does not fully support C+=17), but + # somehow the add-apt-repository hangs . + if [[ "${compiler}" = clang* ]]; then + extra_args+=(--build-arg CXXFLAGS="-stdlib=libc++" --build-arg LDFLAGS="-lc++abi") + fi; + docker build \ + --file "${DOCKERFILE_DIR}/Dockerfile.src" \ + --build-arg python_version="${python_version}" \ + --build-arg compiler="${compiler}" \ + "${extra_args[@]+"${extra_args[@]}"}" \ + --tag "${DOCKER_REPO}-linux-src-${compiler}-py${python_version}:${DOCKER_TAG:-latest}" "${DOCKERFILE_DIR}" + done + +done diff --git a/ecole/dev/hooks/push b/ecole/dev/hooks/push new file mode 100644 index 0000000..fcdbf96 --- /dev/null +++ b/ecole/dev/hooks/push @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -o errexit # Fail script on errors +set -o nounset # Fail on empty variables +set -o pipefail # Error if error in pipe + +# Set on DockerHub but set default value to use script locally +DOCKER_REPO="${DOCKER_REPO:-index.docker.io/ecoleai/ci}" + +for image_name in $(docker images "${DOCKER_REPO#index.docker.io/}-*" --format "{{.Repository}}"); do + docker push "${image_name}" +done diff --git a/ecole/dev/run.sh b/ecole/dev/run.sh new file mode 100755 index 0000000..a0a8cac --- /dev/null +++ b/ecole/dev/run.sh @@ -0,0 +1,602 @@ +#!/usr/bin/env bash + + +# Directory of this file +readonly __DIR__="$(cd "$(dirname "${BASH_SOURCE[0]:?}")" && pwd)" + +# Top of the repository in which this file is +readonly __ECOLE_DIR__="$(cd "${__DIR__:?}/.." && pwd)" + +# If CI is defined then "true", otherwise "false" (string, not bools). +readonly __CI__="$([ -z "${CI+x}" ] && printf "false" || printf "true")" + +# Number of spaces to shift the commands outputs by. +readonly __SHIFT__=4 + + +# Print in yellow with a new line. +function echo_yellow { + local -r yellow="\033[1;33m" + local -r nc="\033[0m" + printf "${yellow}$*${nc}\n" +} + + +# Logging entry point +function log { + echo_yellow "$@" +} + +# Read each character of stdin, indenting each line. +# Not using sed as explained in this SO post https://stackoverflow.com/a/46495830/5862073 +function interactive_indent { + local -r spaces="$(printf "%${__SHIFT__}s")" + echo -n "$spaces" + while IFS= read -r -d '' -n1 chr; do + [[ $chr == $'\n' ]] && chr="\\n\\r$spaces" + [[ $chr == $'\r' ]] && chr="\\r$spaces" + echo -ne "$chr" + done + echo -ne '\r' +} + +# Execute a command, indenting its output while preserving colors. +function execute_shift_output { + # Number of columns to use is reduced by the indentation + local -r columns=$((${COLUMNS:-$(tput -T "${TERM:-xterm}" cols)} - ${__SHIFT__})) + # `script` cannot run builtin command like `export` + if [ "$(type -t "$1")" = "builtin" ]; then + COLUMNS=${columns} "$@" + else + # Usage of `script` for MacOS + if [[ "$(uname -s)" = Darwin* ]]; then + COLUMNS=${columns} script -q /dev/null "$@" | interactive_indent + # Usage of `script` for Linux + else + # Quote-expand command to avoid space splitting words https://stackoverflow.com/a/12985540/5862073 + local -r command="$(printf "'%s' " "$@")" + script -feqc "COLUMNS=${columns} ${command}" /dev/null | interactive_indent + fi + fi +} + +# Wrap calls to manage verbosity, dry-run, ... +function execute { + log "$@" + if [ "${dry_run}" = "false" ]; then + ## Run the command. Indent both stdout and stderr but preserve them. + execute_shift_output "$@" + fi +} + + +# Wrap call and set PYTHONPATH +function execute_pythonpath { + if [ "${fix_pythonpath}" = "true" ]; then + execute export PYTHONPATH="${cmake_build_dir}/python/ecole${PYTHONPATH+:}${PYTHONPATH:-}" + execute "$@" + execute unset PYTHONPATH + else + execute "$@" + fi +} + + +function configure { + local extra_args=("$@") + if [ "${cmake_warnings}" = "true" ]; then + extra_args+=("-Wdev") + fi + if [ "${warnings_as_errors}" = "true" ]; then + extra_args+=("-Werror=dev" "-D" "WARNINGS_AS_ERRORS=ON") + fi + execute cmake -S "${source_dir}" -B "${cmake_build_dir}" -D ECOLE_BUILD_TESTS=ON -D ECOLE_BUILD_BENCHMARKS=ON ${extra_args[@]+"${extra_args[@]}"} + execute ln -nfs "${cmake_build_dir}/compile_commands.json" +} + + +function build_all { + # List all functions in that file. + local all_funcs + mapfile -t all_funcs < <(declare -F) + all_funcs=("${all_funcs[@]#declare -f }") + # Run functions that start with test_ + local func + for func in "${all_funcs[@]}"; do + if [[ "${func}" = build_* && "${func}" != "build_all" ]]; then + "${func}" + fi + done +} + + +function cmake_build { + execute cmake --build "${cmake_build_dir}" --parallel --target "${1-all}" "${@:2}" +} + + +function build_lib { + cmake_build ecole-lib "$@" +} + + +function build_lib_test { + cmake_build ecole-lib-test "$@" +} + + +function build_py { + cmake_build ecole-py-ext "$@" +} + + +# Execute a command if rebuild is true. +function if_rebuild_then { + if [ "${rebuild}" = "true" ]; then + "${@}" + fi +} + + +function build_doc { + if_rebuild_then build_py + if [ "${warnings_as_errors}" = "true" ]; then + local sphinx_args+=("-W") + fi + execute_pythonpath python -m sphinx ${sphinx_args[@]+"${sphinx_args[@]}"} -b html "${source_doc_dir}" "${build_doc_dir}" "$@" +} + + +function test_all { + # List all functions in that file. + local all_funcs + mapfile -t all_funcs < <(declare -F) + all_funcs=("${all_funcs[@]#declare -f }") + # Run functions that start with test_ + local func + for func in "${all_funcs[@]}"; do + if [[ "${func}" = test_* && "${func}" != "test_all" ]]; then + "${func}" + fi + done +} + + +# Return false (1) when `diff` is set and given files pattern have modifications since `rev`. +function files_have_changed { + if [ "${diff}" = "true" ]; then + cd "${__ECOLE_DIR__}" && git diff --name-only --exit-code "${rev}" -- "${@}" > /dev/null && return 1 || return 0 + fi +} + + +function test_lib { + if files_have_changed 'CMakeLists.txt' 'libecole'; then + if_rebuild_then build_lib_test + local extra_args=("$@") + if [ "${fail_fast}" = "true" ]; then + extra_args+=("--abort") + fi + execute "${cmake_build_dir}/libecole/tests/ecole-lib-test" ${extra_args[@]+"${extra_args[@]}"} + else + log "Skipping ${FUNCNAME[0]} as unchanged since ${rev}." + fi +} + + +# CTest runner runs test individually, reducing mem consumption +function ctest_lib { + if files_have_changed 'CMakeLists.txt' 'libecole'; then + if_rebuild_then build_lib_test + local extra_args=("$@") + if [ "${fail_fast}" = "true" ]; then + extra_args+=("--stop-on-failure ") + fi + # Possible option --parallel + cmake_build test -- ARGS="${extra_args[@]+"${extra_args[@]}"}" + else + log "Skipping ${FUNCNAME[0]} as unchanged since ${rev}." + fi +} + + +function test_py { + local -r relevant_files=('CMakeLists.txt' 'libecole/CMakeLists.txt' 'libecole/src' 'libecole/include' 'python') + if files_have_changed "${relevant_files[@]}"; then + if_rebuild_then build_py + local extra_args=("$@") + if [ "${fail_fast}" = "true" ]; then + extra_args+=("--exitfirst") + fi + execute_pythonpath python -m pytest ${extra_args[@]+"${extra_args[@]}"} + else + log "Skipping ${FUNCNAME[0]} as unchanged since ${rev}." + fi +} + + +function test_doc { + if files_have_changed 'doc' 'python'; then + if_rebuild_then build_doc + local extra_args=("$@") + if [ "${warnings_as_errors}" = "true" ]; then + extra_args+=("-W") + fi + execute python -m sphinx ${extra_args[@]+"${extra_args[@]}"} -b linkcheck "${source_doc_dir}" "${build_doc_dir}" + execute_pythonpath python -m sphinx ${extra_args[@]+"${extra_args[@]}"} -b doctest "${source_doc_dir}" "${build_doc_dir}" + else + log "Skipping ${FUNCNAME[0]} as unchanged since ${rev}." + fi +} + + +function file_version { + local -r version_text="$(cat "${source_dir}/VERSION")" + + function find_version { + local -r version="${1}" + local -r regex="${version}[[:space:]]+(\.?[[:alnum:]]+)" + if [[ "${version_text}" =~ $regex ]]; then + echo "${BASH_REMATCH[1]}" + fi + } + + local -r file_major="$(find_version 'VERSION_MAJOR')" + local -r file_minor="$(find_version 'VERSION_MINOR')" + local -r file_patch="$(find_version 'VERSION_PATCH')" + local -r file_pre="$(find_version 'VERSION_PRE')" + local -r file_post="$(find_version 'VERSION_POST')" + local -r file_dev="$(find_version 'VERSION_DEV')" + local version="${file_major:?}.${file_minor:?}.${file_patch:?}" + version+="${file_pre}${file_post}${file_dev}" + echo "${version}" +} + + +# Check that a string is version and print it without the leading 'v'. +function is_version { + local -r candidate="${1-"$(git describe --tags --exact-match 2> /dev/null)"}" + ( printf "${candidate}" | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?$' | sed 's/^v//' ) || return 1 +} + + +function sort_versions { + local -r sort_versions=( + 'import sys, pkg_resources;' + 'lines = [pkg_resources.parse_version(l) for l in sys.stdin.readlines()];' + 'versions = sorted(lines);' + 'print(" ".join(str(v) for v in versions));' + ) + python -c "${sort_versions[*]}" +} + + +function git_version { + local -r rev="${1-HEAD}" + # All possible git tags. + mapfile -t all_tags < <(git tag) + # Find tags that are ancestor of rev and match a version. + local prev_versions + local tag + for tag in "${all_tags[@]}"; do + if git merge-base --is-ancestor "${tag}" "${rev}"; then + if version=$(is_version "${tag}"); then + prev_versions+=("${version}") + fi + fi + done + # Sort using proper version comparison. + mapfile -t sorted_versions < <(echo "${prev_versions[@]}" | xargs -n1 | sort_versions | xargs -n1) + # Take the lastest version. + local -r latest_version="${sorted_versions[${#sorted_versions[@]}-1]}" + echo "${latest_version}" +} + + +# Test that the git version matches the version in the source code. +function test_version { + # Without args, use the version from git + if [ -z "${1+x}" ]; then + local -r version="$(git_version)" + # Otherwise use the arg + else + local -r version=$(is_version "${1}") + fi + [ "$(file_version)" = "${version}" ] +} + + +# These differential checks are the ones used in CI, for per-commit diff, install the pre-commit hooks with +# pre-commit install +function check_code { + if_rebuild_then cmake_build ecole-lib-version + local extra_args=("$@") + if [ "${diff}" = "true" ]; then + extra_args+=("--from-ref" "${rev}" "--to-ref" "HEAD") + else + extra_args+=("--all-files") + fi + execute pre-commit run "${extra_args[@]}" +} + + +# Install libecole in the given folder. +function install_lib { + if_rebuild_then cmake_build ecole-lib + execute cmake --install "${cmake_build_dir}" --prefix "${1-${build_dir}/local}" "${@:2}" +} + + +# Test the intallation of libecole withe the cmake example. +function test_example_libecole { + local -r install_dir="${1-${build_dir}/local}" + if_rebuild_then install_lib "${install_dir}" + local -r ecole_dir="$(find "${install_dir}" -name "EcoleConfig.cmake" | head -1 | xargs dirname | xargs realpath)" + local -r example_build_dir="${build_dir}/examples" + execute cmake -B "${example_build_dir}" -S "${source_dir}/examples/libecole" -D Ecole_DIR="${ecole_dir}" + execute cmake --build "${example_build_dir}" + execute "${example_build_dir}/branching" +} + + +# Test the configuring example with easy parameters +function test_example_configuring { + if_rebuild_then build_py + local -r in_nb="${source_dir}/examples/configuring-bandits/example.ipynb" + local -r out_nb="${build_dir}/examples/configuring-bandits/example.ipynb" + execute mkdir -p "$(dirname "${out_nb}")" + execute_pythonpath python -m papermill.cli --no-progress-bar "${in_nb}" "${out_nb}" \ + -p train_n_items 100 -p train_n_bids 100 \ + -p optim_n_iters 2 -p optim_n_burnins 1 \ + -p test_n_evals 2 -p test_n_items 100 -p test_n_bids 100 \ + "$@" +} + +# Test the branching example with easy parameters +function test_example_branching { + if_rebuild_then build_py + local -r in_nb="${source_dir}/examples/branching-imitation/example.ipynb" + local -r out_nb="${build_dir}/examples/branching-imitation/example.ipynb" + execute mkdir -p "$(dirname "${out_nb}")" + execute_pythonpath python -m papermill.cli --no-progress-bar "${in_nb}" "${out_nb}" \ + -p DATA_MAX_SAMPLES 3 -p NB_EPOCHS 2 -p NB_EVAL_INSTANCES 2 "$@" +} + + +# Install documentation to a local folder depending on the branch/tag +# FIXME the Github Action could be moved here to a deploy_doc function +# FIXME this is not used in Github Action for now +function deploy_doc_locally { + # Try getting from exact tag. + local -r tag=$(cd "${source_dir}" && git describe --tags --exact-match HEAD 2> /dev/null) + local -r branch="$(cd "${source_dir}" && git rev-parse --abbrev-ref HEAD)" + + local -r install_dir="${1}" + if_rebuild_then build_doc + + # Install master to latest + if printf ${branch} | grep -E '(master|main)' &> /dev/null; then + local -r dir="${install_dir}/latest" + # Only create the parent so that source dir is not created in target + execute mkdir -p "$(dirname "${dir}")" + execute rm -rf "${dir}" + execute cp -R "${build_doc_dir}/" "${dir}" + fi + + # Install versions to v.x.x + if version=$(is_version "${tag}"); then + local -r version_major_minor="$(printf "${tag}" | grep -E -o '[0-9]+\.[0-9]+')" + local -r dir="${install_dir}/v${version_major_minor}" + # Only create the parent so that source dir is not created in target + execute mkdir -p "$(dirname "${dir}")" + execute rm -rf "${dir}" + execute cp -R "${build_doc_dir}/" "${dir}" + fi + + # Install stable + if [[ ! -z "${dir-}" && "$(git_version origin/master)" = "${version-false}" ]]; then + execute ln -s -f "${dir}" "${install_dir}/stable" + fi +} + + +# Build Python source distribution and wheel (from the sdist). +# FIXME wheel is missing MacOS version. +function build_dist { + local -r dist_dir="${1:-"${build_dir}/dist"}" + execute python -m build --outdir="${dist_dir}" "${@:2}" +} + + +# Install wheel into a virtual environment. +function test_dist { + local -r dist_dir="${build_dir}/dist" + if_rebuild_then build_dist "${dist_dir}" + local -r venv="${build_dir}/venv" + execute python -m venv --upgrade-deps "${venv}" + # FIXME should install wheel but it is missing MacOS version + local -r sdist=("${dist_dir}"/ecole-*.tar.gz) + execute "${venv}/bin/python" -m pip install --ignore-installed "${sdist[0]}" + execute "${venv}/bin/python" -m ecole.doctor +} + + +# Deploy sdist to PyPI. Set TWINE_USERNAME and TWINE_PASSWORD environment variables or pass them as arguments. +function deploy_sdist { + local -r dist_dir="${build_dir}/dist" + if_rebuild_then build_dist "${dist_dir}" --sdist + local -r strict="$([ "${warnings_as_errors}" = "true" ] && echo -n '--strict')" + local -r sdists=("${dist_dir}"/ecole-*.tar.gz) + execute python -m twine check "${strict}" "${sdists[@]}" + execute python -m twine upload --non-interactive "$@" "${sdists[@]}" +} + + +# The usage of this script. +function help { + echo "${BASH_SOURCE[0]} [--options...] [...] [-- [...]]..." + echo "" + echo "Options:" + echo " --dry-run|--no-dry-run (${dry_run})" + echo " --source-dir= (${source_dir})" + echo " --build-dir= (${build_dir})" + echo " --cmake-build-dir= (${cmake_build_dir})" + echo " --source-doc-dir= (${source_doc_dir})" + echo " --build-doc-dir= (${build_doc_dir})" + echo " --warnings-as-errors|--no-warnings-as-errors (${warnings_as_errors})" + echo " --cmake-warnings|--no-cmake-warnings (${cmake_warnings})" + echo " --fail-fast|--no-fail-fast (${fail_fast})" + echo " --fix-pythonpath|--no-fix-pythonpath (${fix_pythonpath})" + echo " --rebuild|--no-rebuild (${rebuild})" + echo " --diff|--no-diff (${diff})" + echo " --rev= (${rev})" + echo "" + echo "Commands:" + echo " help, configure," + echo " build-lib, build-lib-test, build-py, build-doc, build-all" + echo " test-lib, test-py, test-doc, test-version," + echo " test-example-libecole, test-example-configuring, test-all" + echo " check-code" + echo " build-dist, test-dist, deploy-sdist" + echo "" + echo "Example:" + echo " ${BASH_SOURCE[0]} --warnings-as-errors configure -D ECOLE_DEVELOPER=ON -- test-lib -- test-py --no-slow" +} + + +# Update variable if it exists or throw an error. +function set_option { + local -r key="${1}" + local -r val="${2}" + # If variable referenced in key is not set throw error + if [ -z "${!key+x}" ]; then + echo "Invalid option ${key}." 1>&2 + return 1 + # Otherwise update it's value + else + printf -v "${key}" "%s" "${val}" + fi +} + + +# Parse command line parameters into variables. +# +# Parsing is done as follows. The output variables must be previously defined to avoid errors. +# --some-key=val -> some_key="val" +# --some-key -> some_key="true" +# --no-some-key -> some_key="false" +# As soon as one of this case does not match, all the remaining parameters are put unchanged in +# a `positional` array. +function parse_argv { + while [[ $# -gt 0 ]]; do + local arg="${1}" + case "${arg}" in + --*=*) + local key="${arg%=*}" + local key="${key#--}" + local key="${key//-/_}" + set_option "${key}" "${arg#*=}" + shift + ;; + --no-*) + local key="${arg#--no-}" + local key="${key//-/_}" + set_option "${key}" "false" + shift + ;; + --*) + local key="${arg#--}" + local key="${key//-/_}" + set_option "${key}" "true" + shift + ;; + *) + positional=("$@") + return 0 + ;; + esac + done +} + + +# Parse the positional arguments and run the commands +# configure -D ECOLE_DEVELOPER=ON -- test-lib -- test-py --pdb +# Will execute +# configure -D ECOLE_DEVELOPER=ON +# test_lib +# test_py --pdb +function parse_and_run_commands { + if [ $# = 0 ]; then + return 0 + fi + local -r args=("$@") # Somehow we need to syntax this to use the following syntax. + # First item in -- separated list is the name of the function where we replace - > _. + local last_cmd_idx=0 + local func="${args[$last_cmd_idx]//-/_}" + + for idx in ${!args[@]}; do + # -- is the delimitor that end the parameters for the current function + if [ "${args[$idx]}" = "--" ]; then + # Run current function with its args + ${func} "${args[@]:$last_cmd_idx+1:$idx-$last_cmd_idx-1}" + # Next fucntion start at the position after -- + last_cmd_idx=$(($idx + 1)) + func="${args[$last_cmd_idx]//-/_}" + fi + done + # Run the last function that does not terminate with a -- separator + ${func} "${args[@]:$last_cmd_idx+1}" +} + + +function run_main { + # Only print the commands that would be executed. + local dry_run="false" + # Where the top-level CMakeLists.txt is. + local source_dir="${__ECOLE_DIR__:?}" + # A top level folder for all build artifacts + local build_dir="build" + # Where is the CMake build folder with the test. + local cmake_build_dir="${build_dir}/cmake" + # Where to find sphinx conf.py. + local source_doc_dir="${__ECOLE_DIR__}/docs" + # Where to output the doc. + local build_doc_dir="${build_dir}/docs/html" + # Fail if there are warnings. + local warnings_as_errors="${__CI__}" + # Warning for CMake itself (not compiler). + local cmake_warnings="${__CI__}" + # Stop on first failure + local fail_fast="${__CI__}" + # Add build tree to PYTHONPATH. + local fix_pythonpath="$([ "${__CI__}" = "true" ] && printf "false" || printf "true")" + # Automaticaly rebuild libraries for tests and doc. + local rebuild="true" + # Test only if relevant differences have been made since the revision branch + local rev="origin/master" + local diff="$([ "${__CI__}" = "true" ] && printf "false" || printf "true")" + + # Parse all command line arguments. + parse_argv "$@" + + # Functions to execute are positional arguments with - replaced by _. + parse_and_run_commands "${positional[@]}" + # local -r commands_and_extta args=("${positional[@]//-/_}") + + # for cmd in "${commands[@]}"; do + # "${cmd}" + # done +} + + +# Run the main when script is not being sourced +if [[ "${BASH_SOURCE[0]}" = "${0}" ]] ; then + + # Fail fast + set -o errexit + set -o pipefail + set -o nounset + + run_main "$@" + +fi diff --git a/ecole/dev/singularity.def b/ecole/dev/singularity.def new file mode 100644 index 0000000..bd2e8bb --- /dev/null +++ b/ecole/dev/singularity.def @@ -0,0 +1,26 @@ +Bootstrap: docker +From: continuumio/miniconda3 + + +%help + This image provides a complete developement environemnt for Ecole. + Use as `singularity run ...` as `singularity shell ...` will not initialize conda properly. + +%files + conda.yaml + +%post + /opt/conda/bin/conda update --name base --channel defaults conda + /opt/conda/bin/conda create --name ecole --channel conda-forge cxx-compiler + /opt/conda/bin/conda env update --name ecole --file conda.yaml + /opt/conda/bin/conda clean --all + rm conda.yaml + + # Singularity does all the environment sourcing as shell (only latter calls bash), + # which conda does not support. + # We put the content in a file, manually call bash, and source it. + echo "source /opt/conda/etc/profile.d/conda.sh" >> /conda_init.sh + echo "conda activate ecole" >> /conda_init.sh + +%runscript + exec /bin/bash --rcfile /conda_init.sh "$@" diff --git a/ecole/docs/_static/css/custom.css b/ecole/docs/_static/css/custom.css new file mode 100644 index 0000000..69d5a83 --- /dev/null +++ b/ecole/docs/_static/css/custom.css @@ -0,0 +1,170 @@ +@import url("theme.css"); + +/* Style the top search bar and logo, or top bar on mobile */ +.wy-side-nav-search, .wy-nav-top { + background-color: #F58A1F; +} + +.wy-side-nav-search input[type="text"] { + border-color: #D97A1B; +} + +.wy-side-nav-search > a img.logo, .wy-side-nav-search .wy-dropdown > a img.logo { + height: 10em; +} + +/* Style the table of content */ +.wy-menu-vertical header, .wy-menu-vertical p.caption { + color: #77D1F6;; +} + +/* Style warning and notes */ +.wy-alert.wy-alert-warning, +.rst-content .wy-alert-warning.note, +.rst-content .attention, +.rst-content .caution, +.rst-content .wy-alert-warning.danger, +.rst-content .wy-alert-warning.error, +.rst-content .wy-alert-warning.hint, +.rst-content .wy-alert-warning.important, +.rst-content .wy-alert-warning.tip, +.rst-content .warning, +.rst-content .wy-alert-warning.seealso, +.rst-content .admonition-todo, +.rst-content .wy-alert-warning.admonition { + background: #fef2e7; +} + +.wy-alert.wy-alert-warning .wy-alert-title, +.rst-content .wy-alert-warning.note .wy-alert-title, +.rst-content .attention .wy-alert-title, +.rst-content .caution .wy-alert-title, +.rst-content .wy-alert-warning.danger .wy-alert-title, +.rst-content .wy-alert-warning.error .wy-alert-title, +.rst-content .wy-alert-warning.hint .wy-alert-title, +.rst-content .wy-alert-warning.important .wy-alert-title, +.rst-content .wy-alert-warning.tip .wy-alert-title, +.rst-content .warning .wy-alert-title, +.rst-content .wy-alert-warning.seealso .wy-alert-title, +.rst-content .admonition-todo .wy-alert-title, +.rst-content .wy-alert-warning.admonition .wy-alert-title, +.wy-alert.wy-alert-warning .rst-content .admonition-title, +.rst-content .wy-alert.wy-alert-warning .admonition-title, +.rst-content .wy-alert-warning.note .admonition-title, +.rst-content .attention .admonition-title, +.rst-content .caution .admonition-title, +.rst-content .wy-alert-warning.danger .admonition-title, +.rst-content .wy-alert-warning.error .admonition-title, +.rst-content .wy-alert-warning.hint .admonition-title, +.rst-content .wy-alert-warning.important .admonition-title, +.rst-content .wy-alert-warning.tip .admonition-title, +.rst-content .warning .admonition-title, +.rst-content .wy-alert-warning.seealso .admonition-title, +.rst-content .admonition-todo .admonition-title, +.rst-content .wy-alert-warning.admonition .admonition-title { + background: #f8a254; +} + +.wy-alert.wy-alert-info, +.rst-content .note, +.rst-content .wy-alert-info.attention, +.rst-content .wy-alert-info.caution, +.rst-content .wy-alert-info.danger, +.rst-content .wy-alert-info.error, +.rst-content .wy-alert-info.hint, +.rst-content .wy-alert-info.important, +.rst-content .wy-alert-info.tip, +.rst-content .wy-alert-info.warning, +.rst-content .seealso, +.rst-content .wy-alert-info.admonition-todo, +.rst-content .wy-alert-info.admonition { + background: #e7f7fd; +} + +.wy-alert.wy-alert-info .wy-alert-title, +.rst-content .note .wy-alert-title, +.rst-content .wy-alert-info.attention .wy-alert-title, +.rst-content .wy-alert-info.caution .wy-alert-title, +.rst-content .wy-alert-info.danger .wy-alert-title, +.rst-content .wy-alert-info.error .wy-alert-title, +.rst-content .wy-alert-info.hint .wy-alert-title, +.rst-content .wy-alert-info.important .wy-alert-title, +.rst-content .wy-alert-info.tip .wy-alert-title, +.rst-content .wy-alert-info.warning .wy-alert-title, +.rst-content .seealso .wy-alert-title, +.rst-content .wy-alert-info.admonition-todo .wy-alert-title, +.rst-content .wy-alert-info.admonition .wy-alert-title, +.wy-alert.wy-alert-info .rst-content .admonition-title, +.rst-content .wy-alert.wy-alert-info .admonition-title, +.rst-content .note .admonition-title, +.rst-content .wy-alert-info.attention .admonition-title, +.rst-content .wy-alert-info.caution .admonition-title, +.rst-content .wy-alert-info.danger .admonition-title, +.rst-content .wy-alert-info.error .admonition-title, +.rst-content .wy-alert-info.hint .admonition-title, +.rst-content .wy-alert-info.important .admonition-title, +.rst-content .wy-alert-info.tip .admonition-title, +.rst-content .wy-alert-info.warning .admonition-title, +.rst-content .seealso .admonition-title, +.rst-content .wy-alert-info.admonition-todo .admonition-title, +.rst-content .wy-alert-info.admonition .admonition-title { + background: #58c6f4; +} + +/* Override Pygment style */ +.highlight { /* Match side bar */ + background: rgb(52, 49, 49) !important; + color: rgb(217, 217, 217) !important; +} + +.highlight .k { + color: #77D1F6 !important; +} + +/* CSS to fix Mathjax equation numbers displaying above. + * + * Credit to @hagenw https://github.com/readthedocs/sphinx_rtd_theme/pull/383 + */ +div.math { + position: relative; + padding-right: 2.5em; +} +.eqno { + height: 100%; + position: absolute; + right: 0; + padding-left: 5px; + padding-bottom: 5px; + /* Fix for mouse over in Firefox */ + padding-right: 1px; +} +.eqno:before { + /* Force vertical alignment of number */ + display: inline-block; + height: 100%; + vertical-align: middle; + content: ""; +} +.eqno .headerlink { + display: none; + visibility: hidden; + font-size: 14px; +} +.eqno:hover .headerlink { + display: inline-block; + visibility: hidden; +} +.eqno .headerlink:after { + display: inline-block; + visibility: visible; + content: "\f0c1"; + font-family: FontAwesome; + margin-left: -.6em; +} + +/* Make responsive */ +.MathJax_Display { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; +} diff --git a/ecole/docs/_static/favicon.ico b/ecole/docs/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0442d9f9b0a7296a9fffb5f9269d5412d23876df GIT binary patch literal 22382 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 + + + + + image/svg+xml + + ECOLE LOGOS_FINAL + + + + + + + + ECOLE LOGOS_FINAL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ecole/docs/_templates/layout.html b/ecole/docs/_templates/layout.html new file mode 100644 index 0000000..74b8643 --- /dev/null +++ b/ecole/docs/_templates/layout.html @@ -0,0 +1,8 @@ +{% extends '!layout.html' %} + +{% block extrahead %} + + +{% endblock %} diff --git a/ecole/docs/conf.py b/ecole/docs/conf.py new file mode 100644 index 0000000..3f842be --- /dev/null +++ b/ecole/docs/conf.py @@ -0,0 +1,111 @@ +from typing import List, Tuple +import pathlib +import re + + +CURRENT_FILE = pathlib.Path(__file__).resolve() +CURRENT_DIR = CURRENT_FILE.parent +PROJECT_DIR = CURRENT_DIR.parent + + +def read_authors(file: pathlib.Path) -> List[str]: + with open(file) as f: + return [l.strip() for l in f.readlines()] + + +def read_version(file: pathlib.Path) -> Tuple[int, int, int]: + with open(file) as f: + text = f.read() + major = re.search("VERSION_MAJOR (\d+)", text).group(1) + minor = re.search("VERSION_MINOR (\d+)", text).group(1) + patch = re.search("VERSION_PATCH (\d+)", text).group(1) + return major, minor, patch + + +project = "Ecole" +author = ", ".join(read_authors(PROJECT_DIR / "AUTHORS")) +copyright = author +version_major, version_minor, version_patch = read_version(PROJECT_DIR / "VERSION") +version = f"{version_major}.{version_minor}" +release = f"{version_major}.{version_minor}.{version_patch}" + +extensions = [] + +# Show [source] link to source code +extensions += ["sphinx.ext.viewcode"] + +# Test code sample in documentation +extensions += ["sphinx.ext.doctest"] +# Patching ecole.scip.Model.from_file and write_problem globally to be able to put fake paths +# Also try import pyscipopt for disable test if it is not available +doctest_global_setup = """ +import unittest.mock +import ecole + +_generator = ecole.instance.SetCoverGenerator(n_rows=100, n_cols=200) +_read_patcher = unittest.mock.patch("ecole.core.scip.Model.from_file", side_effect=_generator) +_read_patcher.start() +_write_patcher = unittest.mock.patch("ecole.core.scip.Model.write_problem") +_write_patcher.start() + +try: + import pyscipopt +except ImportError: + pyscipopt = None +""" + +# Math setting +extensions += ["sphinx.ext.mathjax"] + +# Code style +pygments_style = "monokai" + +# Theme +extensions += ["sphinx_rtd_theme"] +html_theme = "sphinx_rtd_theme" +html_context = { + "display_github": True, + "github_user": "ds4dm", + "github_repo": "ecole", + "github_version": "master", # For the edit on Github link + "conf_py_path": "/docs/", # For the edit on Github link +} +html_theme_options = { + "logo_only": True, +} +html_logo = "_static/images/ecole-logo-bare.png" +html_favicon = "_static/favicon.ico" +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] +html_style = "css/custom.css" + +# Custom footer +templates_path = ["_templates"] + +# Autodoc to read Python docstrings +extensions += ["sphinx.ext.autodoc"] +autodoc_default_options = { + "members": True, # Document all members + "special-members": "__init__", # Document these dunder methods + "undoc-members": True, +} + +# Napoleon write docstrings in Numpy style +extensions += ["sphinx.ext.napoleon"] +napoleon_google_docstring = False +napoleon_numpy_docstring = True + + +# Preprocess docstring to remove "core" from type name +def preprocess_signature(app, what, name, obj, options, signature, return_annotation): + if signature is not None: + signature = signature.replace(".core", "") + if return_annotation is not None: + return_annotation = return_annotation.replace(".core", "") + return signature, return_annotation + + +def setup(app): + app.connect("autodoc-process-signature", preprocess_signature) diff --git a/ecole/docs/contributing.rst b/ecole/docs/contributing.rst new file mode 100644 index 0000000..ede6224 --- /dev/null +++ b/ecole/docs/contributing.rst @@ -0,0 +1,255 @@ +.. _contributing-reference: + +Contribution Guidelines +======================= + +Thank you for your interest in contributing to Ecole! 🌟 +Contributions are more diverse than contributing new features. +Improving the documentation, reporting and reproducing bugs, discussing the direction of Ecole in +the discussions, helping others use Ecole. + + +Contribution acceptance +----------------------- +Not all code contributions are relevant for Ecole. +It does not mean that the idea is not good. +We try to balance added value with maintenance and complexity of a growing codebase. +For that reason, it is a good idea to communicate with the developpers first to be sure that we agree on +what should be added/modified. + +.. important:: + + Be sure to open an issue before sending a pull request. + + +Tour of the codebase +-------------------- +- ``libecole`` is the Ecole C++ library. + Ecole is mostly written in C++ so this is where you will find most features, rewards, observations... +- ``python`` contains some Python and C++ code to create bindings to Python. + Ecole uses `PyBind `_ to create the binding, these are all the C++ files + in this directory. + Sometimes, you may find Python code as well. + This is either because a feature is more naturally implemented in Python, or because we have accepted an early contribution + that is not yet ported to C++. +- ``docs`` is the `Sphinx `_ documentation written in reStructuredText. +- ``examples`` are practical examples showcasing how to use Ecole for certain tasks. + + +Dependencies with Conda +----------------------- +All dependencies required for building Ecole (including SCIP) can be resolved using a +`conda `_ environment. +Install everything in a development environment (named ``ecole``) using + +.. code-block:: bash + + conda env create -n ecole -f dev/conda.yaml + +.. code-block:: bash + + conda activate ecole + conda config --append channels conda-forge + conda config --set channel_priority flexible + +.. note:: + + This environment contains tools to build ecole and scip, format code, test, + generate documentation etc. These are more than the dependencies to only use Ecole. + + +Development script +------------------ +To ease the burden or remembering the relation between commands, their default values *etc*., we +provide a script, ``./dev/run.sh`` to run all commands. +Contributors are still free to use the script commands manually. + +Full usage and options can be found + +.. code-block:: bash + + ./dev/run.sh help + + +.. important:: + + This script is meant for development and does not optimize Ecole for speed. + To install Ecole (including from source) see the :ref:`installation instructions`. + +Configure with CMake +^^^^^^^^^^^^^^^^^^^^ +`CMake `_ is a meta-build tool, used for configuring other build tools +(*e.g.* Make) or IDE's. +The whole build of Ecole can be done with CMake. +A one-time configuration is necessary for CMake to find dependencies, detect system +information, *etc*. +CMake is made available in the ``ecole`` environment created earlier. +For the following, this environment always needs to be activated. + +In the Ecole source repository, configure using + +.. code-block:: bash + + ./dev/run.sh configure -D ECOLE_DEVELOPER=ON + +.. note:: + + This is the time to pass optional build options, such as the build type and compiler + choice. For instance ``-D CMAKE_BUILD_TYPE=Debug`` can be added to compile with debug + information. + +The definition ``-D ECOLE_DEVELOPER=ON`` changes the default settings (such as the build +type, *etc.*) for added convenience. +Only the default settings are changed, this mode does not override any explicit setting. + +Building (Optional) +^^^^^^^^^^^^^^^^^^^ + +Ecole can be build with the following commands, although tests will (re)build Ecole automatically. + +.. code-block:: bash + + ./dev/run.sh build-lib -- build-py + +.. important:: + + Be sure to eliminate all warnings. They will be considered as errors in the PR. + +Running the tests +^^^^^^^^^^^^^^^^^ + +The C++ tests are build with `Catch2 `_. + +.. code-block:: bash + + ./dev/run.sh test-lib + +Python tests are build with `PyTest `_. +By default, this will find Ecole inside the devlopement build tree. + +.. code-block:: bash + + ./dev/run.sh test-py + + +Documentation +^^^^^^^^^^^^^ +The documentation is build with `Sphinx `_. +It reads the docstrings from the Ecole package. + +.. code-block:: bash + + ./dev/run.sh build-doc + +Additional test on the documentation can be run with + +.. code-block:: bash + + ./dev/run.sh test-doc + +The generated HTML files are located under ``build/doc/html``. +In particular, ``build/doc/html/index.html`` can be opened in your browser to visualize the +documentation. + + +Using the Ecole Python package +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +To use the Ecole Python package generated by the development script with + +.. code-block:: bash + + ./dev/run.sh build-py + +one can set the `PYTHONPATH `_ environment variable, +as it is done for running the tests. For instance: + +.. code-block:: bash + + PYTHONPATH="${PWD}/build/cmake/python/ecole" python -m IPython + +This is useful to debug in `IPython `_ or `Jupyter `_, but anything more +should rely on an :ref:`installation `. + + +Coding standards +---------------- +The quality and conventions of the code are enforced automatically with various tools, for instance +to format the layout of the code and fix some C++ error-prone patterns. + +Compilation database +^^^^^^^^^^^^^^^^^^^^ +Some C++ tools need access to a *compilation database*. +This is a file called ``compile_commands.json`` that is created automatically by CMake and +symlinked when configuring with ``./dev/run.sh configure``. +Otherwise, you would need to manually symlink it to the root of the project. + +.. code-block:: bash + + ln -s build/compile_commands.json + +.. tip:: + + This file is also read by `clangd `_, a C++ language server (already + installed in the conda environment). + To get code completion, compile errors, go-to-definition and more, you can install a language + server protocol plugin for your editor. + +Pre-commit +^^^^^^^^^^ +The tools are configured to run with `pre-commit `_, that is they can be +added to run automatically when making a commit, pushing, or on demand. +To have the tools run automatically, install the pre-commit hooks using + +.. code-block:: bash + + pre-commit install + +The tools are configured to run light tests only on the files that were changed during the commit, +so they should not run for long. +Installing the pre-commit hooks to run the tools is recommended. +Similar tests will be run online and pull requests *will* fail if the tools have not been run. + +With ``pre-commit`` hooks, commits will be rejected by ``git`` if the tests ran by the tools fail. +If the tools can fix the issue for you, you will find some modifications that you can add to +your commit. + +Sometimes when working locally, it can be useful not to run the tools. +You can tell ``git`` to ignore the ``pre-commit`` hooks by passing the ``--no-verify`` to any +``git`` command making commit, including ``commit``, ``merge``, ``rebase``, ``push``... + +.. code-block:: bash + + git commit --no-verify + +Pre-commit can also be run manually using + +.. code-block:: bash + + ./dev/run.sh check-code + + +Compiler issues +--------------- +If you encounter problems with your compiler (because it is too old for instance), +you can use the ones from ananconda. + +.. code-block:: bash + + conda install -c conda-forge cxx-compiler + +And start again the configuration of Ecole. + +.. code-block:: bash + + rm -rf build/ && ./dev/run.sh configure -D ECOLE_DEVELOPER=ON + + +When things fail +---------------- +If you cannot eliminate some warnings, code checks, errors, do not hesistate to ask questions in the +`Github Discussions `_. + +.. important:: + + When you cannot figure things out, it's OK to send a failing pull request. + We wish to grow as a community, and help others improve, not exclude and belittle. 🌈 diff --git a/ecole/docs/developers/example-observation.rst b/ecole/docs/developers/example-observation.rst new file mode 100644 index 0000000..5b695d7 --- /dev/null +++ b/ecole/docs/developers/example-observation.rst @@ -0,0 +1,65 @@ +Example: How to Contribute an Observation Function +================================================== + +To contribute an observation (or reward) function, there are a few files to modify. +For the purpose of example, let us call our observation `Cookie`. +We recommend looking, at every step, to other observation functions as examples. + +.. note:: + Be sure to read the :ref:`contribution guidelines ` to figure out how to get started and + running the tests. + +Create the Observation +---------------------- +The C++ code is typically separated into `headers `_ +and source files. + +Headers care not compiled and should only contains the public +`declaration `_ +or classes/functions signature (except for tempalted code). +They should ``#include`` the minimal headers to be self contained. + + - Create the header file ``libecole/include/ecole/observation/cookie.hpp``, and add the observation function declaration. + +Source files contain the definition of the functions, _i.e._ their implementation. + + - Create the source file ``libecole/src/observation/cookie.cpp``, + - Add the inclusion of your header ``#include "ecole/observation/cookie.hpp`` + - Add the definition of your observation function (you can also add helper functions/classes here), + - Explicitly add the source file in CMake, in ``libecole/CMakeLists.txt``. + +Test Your Code +-------------- +Tests are not part of a library, so they only need a source file. + + - Create the test file ``libecole/tests/src/observation/test-cookie.cpp``, + - Add unit tests to ensure the observation function abides to the required interface, + - Add functional tests to ensure the observation function is correct, + - Explicitly add the test file in CMake, in ``libecole/tests/CMakeLists.txt``. + + +Bind Code to Python +------------------- +To expose the code in Python, we are using `PyBind `_ directly from C++. + + - Edit ``python/src/ecole/core/observation.cpp``, and bind the class using ``py::class_``, + - Add the docstring. + +.. warning:: + Due to some discrepencies between C++ and Python, not all bindings are straightforward. + More complex types need to be handled on a case-by-case basis. + +Test the Bindings +----------------- +We need to make sure nothing is forgotten or raises runtime errors when used from Python. + + - Edit ``python/tests/test_observation.py``, test the interface, and the return types. + +Reference the Observation in the Documentation +---------------------------------------------- +Documentation from docstring is automatically read by Sphinx, so we only need to tell it where to display it. + + - Add the observation function in the list in ``docs/reference/observation.rst``. + +.. note:: + Remember to run the tests and code checks before pushing. diff --git a/ecole/docs/discussion/gym-differences.rst b/ecole/docs/discussion/gym-differences.rst new file mode 100644 index 0000000..fe20d32 --- /dev/null +++ b/ecole/docs/discussion/gym-differences.rst @@ -0,0 +1,88 @@ +Differences with OpenAI Gym +=========================== + +Changing reward and observations +-------------------------------- +Contrarily to `OpenAI Gym `_ where learning tasks are predefined, +Ecole gives the user the tools to easily extend and customize environments. +This is because the objective with Ecole is not only to provide a collection of challenges +for machine learning, but really to solve combinatorial optimization problems more +efficiently. +If different data or tweaking the control task delivers better performance, it is an improvement! +This is why Ecole let users change the environment reward and observation using +:py:class:`~ecole.typing.RewardFunction` and :py:class:`~ecole.typing.ObservationFunction`. + +Parameter to reset +------------------ +In OpenAI Gym, ``reset`` does not take parameters, whereas Ecole +:py:meth:`~ecole.environment.Environment.reset` takes a problem instance as a mandatory +input. +This is because when doing machine learning for optimization, there is no practical interest in +solving the same problem over and over again. +What is important is that the machine learning model is able to generalize to unseen problems. +This is typically done by training on mutliple problem instances. + +This setting is similar to multi-task reinforcement learning, where each problem instance is a task +and one aims to generalize to unseen tasks. +An alternative way to implement this is found in `MetaWorld `_, +where instead of passing the task as a parameter to ``reset``, an supplementary ``set_task`` method +is defined in the environment. + +Done on reset +------------- +In Ecole, :py:meth:`~ecole.environment.Environment.reset` returns the same ``done`` flag as +in :py:meth:`~ecole.environment.Environment.step`. +This is because nothing prevents an initial state from also being a terminal one. +It is not only a theoretical consideration: for instance, in :py:class:`~ecole.environment.Branching`, +the initial state would typically be on the root node, prior to making the first branching decision. +However, modern solvers have powerful presolvers, and it is not uncommon that the solution to the +problem is found without needing to branch on any variable. + +Action set +---------- +Ecole defines an action set at every transition of the environment, while OpenAI Gym defines an +``action_space`` as a static variable of the environment. +Ecole environments are more complex: for instance in :py:class:`~ecole.environment.Branching` +the set of valid actions changes, not only with every episode, but also with every transition! +The ``action_set`` is required to make the next call to +:py:meth:`~ecole.environment.Environment.step`. +We chose to add it as a return type to :py:meth:`~ecole.environment.Environment.step` and +:py:meth:`~ecole.environment.Environment.reset` to emphasize this difference. + +Reward offset +------------- +In :py:meth:`~ecole.environment.Environment.reset` a ``reward_offset`` is returned. +This is not only a difference with OpenAI Gym, but also with the MDP formulation. +Its purpose is not to provide additional input to the learning algorithms, but rather to help +researchers better benchmark the resulting performance. +Indeed, :py:class:`~ecole.typing.RewardFunction` are often designed so that their cumulative sum match a +metric on the terminal state, such as solving time or number of LP iterations: this is because final metrics +are often all that matter. +However, for learning, a single reward on the terminal state is hard to learn from. +It is then divided over all intermediate transitions in the episode. + +Rather than providing a different mean of evaluating such metrics, we chose to reuse the +environments to compute the cummulative sum, and therfore need the ``reward_offset`` to exactly +match the metric. + +No observation on terminal states +--------------------------------- +On terminal states, in OpenAI Gym as in Ecole, no further action can be taken and the environment +needs to be :py:meth:`~ecole.environment.Environment.reset`. In Ecole, when an episode is over (that is, when +the ``done`` flag is ``True``), environments always return ``None`` as the observation. This is in contrast with OpenAI Gym, +where some environments do return observations on terminal states. + + +This can be explained as follows: most of the time, a terminal state in Ecole is a solved problem. +This means that some complex observations cannot be extracted because they require information that +simply does not exist. +For instance, the :py:class:`~ecole.observation.NodeBipartite` observation function extracts some +information about the LP solution of the current branch-and-bound node. +When the problem is solved, for example on a terminal state of the +:py:class:`~ecole.environment.Branching` environment, there might not be a current node, or a linear +relaxation problem, from which this information can be extracted. For these reasons, one would find a +``None`` instead of an observation on terminal states. + +In any case, one might note that in reinforcement learning, the observation of a terminal state is usually not very useful. +It is not given to a policy to take the next action (because there are not any), and hence never +used for learning either, so not returning a final observation has no impact in practice. diff --git a/ecole/docs/discussion/seeding.rst b/ecole/docs/discussion/seeding.rst new file mode 100644 index 0000000..9985aae --- /dev/null +++ b/ecole/docs/discussion/seeding.rst @@ -0,0 +1,85 @@ +.. _seeding-discussion: + +Seeding +======= +Ecole empowers researchers to learn reliable machine learning models, and that means not overfitting +on insignificant behaviours of the solver. +One such aspect is the solver randomness, which is controlled by its random seed. + +This means that, by default, Ecole environment will generate different episodes (and in +particular different initial states) after each new call to +:py:meth:`~ecole.environment.Environment.reset`. +To do so, the environment keeps a :py:class:`~ecole.RandomGenerator` (random state) +between episodes, and start a new episode by calling +:py:meth:`~ecole.typing.Dynamics.set_dynamics_random_state` on the underlying +:py:class:`~ecole.typing.Dynamics`. +The latter set random elements of the state including, but not necessary limited to, the +:py:class:`~ecole.scip.Model` random seed, by consuming random numbers from the +:py:class:`~ecole.environment.RandomeGenerator`. +That way, the :py:class:`~ecole.environment.Environment` can avoid generating identical +episodes while letting :py:class:`~ecole.typing.Dynamics` decide what random parameters need to +be set. + +The :py:meth:`~ecole.environment.Environment.seed` method is really one of the environment, +because it seeds the :py:class:`~ecole.RandomGenerator`, not direclty the episode for +the :py:class:`~ecole.typing.Dynamics`. + +When not explicitly seeded, :py:class:`~ecole.typing.Environment` use a :py:class:`~ecole.RandomGenerator` derived +from Ecole's global source of randomness by invoking :py:func:`ecole.spawn_random_generator`. +By default this source is truly random, but it can be controlled with :py:func:`ecole.seed`. + +Similarily, an :py:class:`~ecole.typing.InstanceGenerator` default random generator derived from Ecole global source of +randomness. + +As examples, we provide the following snippets. + +Reproducible program +-------------------- +Running this program again will give the same outcome. + +.. testcode:: + + import ecole + + ecole.seed(754) + + env = ecole.environment.Branching() + + for _ in range(10): + observation, action_set, reward_offset, done, info = env.reset("path/to/problem") + while not done: + obs, action_set, reward, done, info = env.step(action_set[0]) + + +Reproducible environments +------------------------- +Creating this envionment with same seed anywhere else will give the same outcome. + +.. testcode:: + + import ecole + + env = ecole.environment.Branching() + env.seed(8462) + + for _ in range(10): + observation, action_set, reward_offset, done, info = env.reset("path/to/problem") + while not done: + obs, action_set, reward, done, info = env.step(action_set[0]) + + +Reproducible episode +-------------------- +All episodes run in this snippet are identical. + +.. testcode:: + + import ecole + + env = ecole.environment.Branching() + + for _ in range(10): + env.seed(81) + observation, action_set, reward_offset, done, info = env.reset("path/to/problem") + while not done: + obs, action_set, reward, done, info = env.step(action_set[0]) diff --git a/ecole/docs/discussion/theory.rst b/ecole/docs/discussion/theory.rst new file mode 100644 index 0000000..cc0eb20 --- /dev/null +++ b/ecole/docs/discussion/theory.rst @@ -0,0 +1,208 @@ +.. _theory: + +Ecole Theoretical Model +======================= + +The Ecole elements directly correspond to the different elements of +an episodic `partially-observable Markov decision process `_ +(PO-MDP). + +Markov Decision Process +----------------------- +Consider a regular Markov decision process +:math:`(\mathcal{S}, \mathcal{A}, p_\textit{init}, p_\textit{trans}, R)`, +whose components are + +* a state space :math:`\mathcal{S}` +* an action space :math:`\mathcal{A}` +* an initial state distribution :math:`p_\textit{init}: \mathcal{S} \to \mathbb{R}_{\geq 0}` +* a state transition distribution + :math:`p_\textit{trans}: \mathcal{S} \times \mathcal{A} \times \mathcal{S} \to \mathbb{R}_{\geq 0}` +* a reward function :math:`R: \mathcal{S} \to \mathbb{R}`. + +.. note:: + + Having deterministic rewards :math:`r_t = R(s_t)` is an arbitrary choice + here, in order to best fit the Ecole library. It is not restrictive though, + as any MDP with stochastic rewards + :math:`r_t \sim p_\textit{reward}(r_t|s_{t-1},a_{t-1},s_{t})` + can be converted into an equivalent MDP with deterministic ones, + by considering the reward as part of the state. + +Together with an action policy + +.. math:: + + \pi: \mathcal{A} \times \mathcal{S} \to \mathbb{R}_{\geq 0} + +such that :math:`a_t \sim \pi(a_t|s_t)`, an MDP can be unrolled to produce +state-action trajectories + +.. math:: + + \tau=(s_0,a_0,s_1,\dots) + +that obey the following joint distribution + +.. math:: + + \tau \sim \underbrace{p_\textit{init}(s_0)}_{\text{initial state}} + \prod_{t=0}^\infty \underbrace{\pi(a_t | s_t)}_{\text{next action}} + \underbrace{p_\textit{trans}(s_{t+1} | a_t, s_t)}_{\text{next state}} + \text{.} + +The MDP Control Problem +^^^^^^^^^^^^^^^^^^^^^^^ +We define the MDP control problem as that of finding a policy +:math:`\pi^\star` which is optimal with respect to the expected total +reward, + +.. math:: + :label: mdp_control + + \pi^\star = \underset{\pi}{\operatorname{arg\,max}} + \lim_{T \to \infty} \mathbb{E}_\tau\left[\sum_{t=0}^{T} r_t\right] + \text{,} + +where :math:`r_t := R(s_t)`. + +.. note:: + + In the general case this quantity may not be bounded, for example for MDPs + corresponding to *continuing* tasks where episode length may be infinite. + In Ecole, we guarantee that all environments correspond to *episodic* + tasks, that is, each episode is guaranteed to end in a terminal state. + This can be modeled by introducing a null state :math:`s_\textit{null}`, + such that + + * :math:`s_\textit{null}` is absorbing, i.e., :math:`p_\textit{trans}(s_{t+1}|a_t,s_t=s_\textit{null}) := \delta_{s_\textit{null}}(s_{t+1})` + * :math:`s_\textit{null}` yields no reward, i.e., :math:`R(s_\textit{null}) := 0` + * a state :math:`s` is terminal :math:`\iff` it transitions + into the null state with probability one, i.e., :math:`p_\textit{trans}(s_{t+1}|a_t,s_t=s) := \delta_{s_\textit{null}}(s_{t+1})` + + As such, all actions and states encountered after a terminal state + can be safely ignored in the MDP control problem. + +Partially-Observable Markov Decision Process +-------------------------------------------- +In the PO-MDP setting, complete information about the current MDP state +is not necessarily available to the decision-maker. Instead, +at each step only a partial observation :math:`o \in \Omega` +is made available, which can be seen as the result of applying an observation +function :math:`O: \mathcal{S} \to \Omega` to the current state. As such, a +PO-MDP consists of a tuple +:math:`(\mathcal{S}, \mathcal{A}, p_\textit{init}, p_\textit{trans}, R, O)`. + +.. note:: + + Similarly to having deterministic rewards, having deterministic + observations is an arbitrary choice here, but is not restrictive. + +As a result, PO-MDP trajectories take the form + +.. math:: + + \tau=(o_0,r_0,a_0,o_1\dots) + \text{,} + +where :math:`o_t:= O(s_t)` and :math:`r_t:=R(s_t)` are respectively the +observation and the reward collected at time step :math:`t`. + +Let us now introduce a convenience variable +:math:`h_t:=(o_0,r_0,a_0,\dots,o_t,r_t)\in\mathcal{H}` that represents the +PO-MDP history at time step :math:`t`. Due to the non-Markovian nature of +the trajectories, that is, + +.. math:: + + o_{t+1},r_{t+1} \mathop{\rlap{\perp}\mkern2mu{\not\perp}} h_{t-1} \mid o_t,r_t,a_t + \text{,} + +the decision-maker must take into account the whole history of observations, +rewards and actions in order to decide on an optimal action at current time +step :math:`t`. PO-MDP policies then take the form + +.. math:: + + \pi:\mathcal{A} \times \mathcal{H} \to \mathbb{R}_{\geq 0} + +such that :math:`a_t \sim \pi(a_t|h_t)`. + +The PO-MDP Control Problem +^^^^^^^^^^^^^^^^^^^^^^^^^^ +The PO-MDP control problem can then be written identically to the MDP one, + +.. math:: + :label: pomdp_control + + \pi^\star = \underset{\pi}{\operatorname{arg\,max}} \lim_{T \to \infty} + \mathbb{E}_\tau\left[\sum_{t=0}^{T} r_t\right] + \text{.} + +Ecole as PO-MDP Elements +------------------------ + +The following Ecole elements directly translate into PO-MDP elements from +the aforementioned formulation: + +* :py:class:`~ecole.typing.RewardFunction` <=> :math:`R` +* :py:class:`~ecole.typing.ObservationFunction` <=> :math:`O` +* :py:meth:`~ecole.typing.Dynamics.reset_dynamics` <=> + :math:`p_\textit{init}(s_0)` +* :py:meth:`~ecole.typing.Dynamics.step_dynamics` <=> + :math:`p_\textit{trans}(s_{t+1}|s_t,a_t)` + +The state space :math:`\mathcal{S}` can be considered to be the whole computer +memory occupied by the environment, which includes the state of the underlying +SCIP solver instance. The action space :math:`\mathcal{A}` is specific to each +environment. + +.. note:: + + In practice, both :py:class:`~ecole.typing.RewardFunction` and + :py:class:`~ecole.typing.ObservationFunction` are implemented as stateful + classes, and therefore should be considered part of the MDP state + :math:`s`. This *extended* state is not meant to take part in the MDP + dynamics per se, but nonetheless has to be considered as the actual + PO-MDP state, in order to allow for a strict interpretation of Ecole + environments as PO-MDPs. + +The :py:class:`~ecole.environment.Environment` class wraps all of +those components together to form the actual PO-MDP. Its API can be +interpreted as follows: + +* :py:meth:`~ecole.environment.Environment.reset` <=> + :math:`s_0 \sim p_\textit{init}(s_0), r_0=R(s_0), o_0=O(s_0)` +* :py:meth:`~ecole.environment.Environment.step` <=> + :math:`s_{t+1} \sim p_\textit{trans}(s_{t+1}|a_t,s_t), r_t=R(s_t), o_t=O(s_t)` +* ``done == True`` <=> the current state :math:`s_{t}` is terminal. As such, + the episode ends now. + +.. note:: + + In Ecole we allow environments to optionally specify a set of valid + actions at each time step :math:`t`. To this end, both the + :py:meth:`~ecole.environment.Environment.reset` and + :py:meth:`~ecole.environment.Environment.step` methods return + the valid ``action_set`` for the next transition, in addition to the + current observation and reward. This action set is optional, and + environments in which the action set is implicit may simply return + ``action_set == None``. + +Implementation of both the PO-MDP policy :math:`\pi(a_t|h_t)` and a method +to solve the resulting control problem :eq:`pomdp_control` is left to the +user. + +.. note:: + + As can be seen from :eq:`mdp_control` and :eq:`pomdp_control`, the initial + reward :math:`r_0` returned by + :py:meth:`~ecole.environment.Environment.reset` + does not affect the control problem. In Ecole we + nevertheless chose to preserve this initial reward, in order to obtain + meaningful cumulated episode rewards, such as the total running time + (which must include the time spend in + :py:meth:`~ecole.environment.Environment.reset`), or the total + number of branch-and-bound nodes in a + :py:class:`~ecole.environment.Branching` environment (which must include + the root node). diff --git a/ecole/docs/howto/create-environments.rst b/ecole/docs/howto/create-environments.rst new file mode 100644 index 0000000..5377ae1 --- /dev/null +++ b/ecole/docs/howto/create-environments.rst @@ -0,0 +1,230 @@ +.. _create-new-environment: + +Create New Environments +======================= + +Environment Structure +--------------------- +In Ecole, it is possible to customize the :ref:`reward` or +:ref:`observation` returned by the environment. These components are structured in +:py:class:`~ecole.typing.RewardFunction` and :py:class:`~ecole.typing.ObservationFunction` classes that are +independent from the rest of the environment. We call what is left, that is, the environment without rewards +or observations, the environment's :py:class:`~ecole.typing.Dynamics`. +In other words, the dynamics define the bare bone transitions of the Markov Decision Process. + +Dynamics have an interface similar to environments, but with different input parameters and return +types. +In fact environments are wrappers around dynamics classes that drive the following orchestration: + +* Environments store the state as a :py:class:`~ecole.scip.Model`; +* Then, they forward the :py:class:`~ecole.scip.Model` to the :py:class:`~ecole.typing.Dynamics` to start a new + episode or transition to receive an action set; +* Next, they forward the :py:class:`~ecole.scip.Model` to the :py:class:`~ecole.typing.RewardFunction` and + :py:class:`~ecole.typing.ObservationFunction` to receive an observation and reward; +* Finally, return everything to the user. + +One susbtantial difference between the environment and the dynamics is the seeding behavior. +Given that this is not an easy topic, it is discussed in :ref:`seeding-discussion`. + +Creating Dynamics +----------------- + +Reset and Step +^^^^^^^^^^^^^^ +Creating dynamics is very similar to :ref:`creating reward and observation functions`. +It can be done from scratch or by inheriting an existing one. +The following examples show how we can inherit a :py:class:`~ecole.dynamics.BranchingDynamics` class to +deactivate cutting planes and presolving in SCIP. + +.. note:: + + One can also more directly deactivate SCIP parameters through the + :ref:`environment constructor`. + +Given that there is a large number of parameters to change, we want to use one of SCIP default's modes +by calling ``SCIPsetPresolving`` and ``SCIPsetSeparating`` through PyScipOpt +(`SCIP doc `_). + +We will do so by overriding :py:meth:`~ecole.dynamics.BranchingDynamics.reset_dynamics`, which +gets called by :py:meth:`~ecole.environment.Environment.reset`. +The similar method :py:meth:`~ecole.dynamics.BranchingDynamics.step_dynamics`, which is called +by :py:meth:`~ecole.environment.Environment.step`, does not need to be changed in this +example, so we do not override it. + +.. testcode:: + :skipif: pyscipopt is None + + import ecole + from pyscipopt.scip import PY_SCIP_PARAMSETTING + + + class SimpleBranchingDynamics(ecole.dynamics.BranchingDynamics): + def reset_dynamics(self, model): + # Share memory with Ecole model + pyscipopt_model = model.as_pyscipopt() + + pyscipopt_model.setPresolve(PY_SCIP_PARAMSETTING.OFF) + pyscipopt_model.setSeparating(PY_SCIP_PARAMSETTING.OFF) + + # Let the parent class get the model at the root node and return + # the done flag / action_set + return super().reset_dynamics(model) + + +With our ``SimpleBranchingDynamics`` class we have defined what we want the solver to do. +Now, to use it as a full environment that can manage observations and rewards, we wrap it in an +:py:class:`~ecole.environment.Environment`. + + +.. testcode:: + :skipif: pyscipopt is None + + class SimpleBranching(ecole.environment.Environment): + __Dynamics__ = SimpleBranchingDynamics + + +The resulting ``SimpleBranching`` class is then an environment as valid as any other in Ecole. + +Passing parameters +^^^^^^^^^^^^^^^^^^ +We can make the previous example more flexible by deciding what we want to disable. +To do so, we will take parameters in the constructor. + +.. testcode:: + :skipif: pyscipopt is None + + class SimpleBranchingDynamics(ecole.dynamics.BranchingDynamics): + def __init__(self, disable_presolve=True, disable_cuts=True, *args, **kwargs): + super().__init__(*args, **kwargs) + self.disable_presolve = disable_presolve + self.disable_cuts = disable_cuts + + def reset_dynamics(self, model): + # Share memory with Ecole model + pyscipopt_model = model.as_pyscipopt() + + if self.disable_presolve: + pyscipopt_model.setPresolve(PY_SCIP_PARAMSETTING.OFF) + if self.disable_cuts: + pyscipopt_model.setSeparating(PY_SCIP_PARAMSETTING.OFF) + + # Let the parent class get the model at the root node and return + # the done flag / action_set + return super().reset_dynamics(model) + + + class SimpleBranching(ecole.environment.Environment): + __Dynamics__ = SimpleBranchingDynamics + + +The constructor arguments are forwarded from the :py:meth:`~ecole.environment.Environment.__init__` constructor: + +.. testcode:: + :skipif: pyscipopt is None + + env = SimpleBranching(observation_function=None, disable_cuts=False) + +Similarily, extra arguments given to the environemnt :py:meth:`~ecole.environment.Environment.reset` and +:py:meth:`~ecole.environment.Environment.step` are forwarded to the associated +:py:class:`~ecole.typing.Dynamics` methods. + +Using Control Inversion +----------------------- +When using a traditional SCIP callback, the user has to add the callback to SCIP, call ``SCIPsolve``, and wait for the +solving process to terminate. +We say that *SCIP has the control*. +This has some downsides, such a having to forward all the data the agent will use to the callback, making it harder to +stop the solving process, and reduce interactivity. +For instance when using a callback in a notebook, if the user forgot to fetch some data, then they have to re-execute +the whole solving process. + +On the contrary, when using an Ecole environment such as :py:class:`~ecole.environment.Branching`, the environment +pauses on every branch-and-bound node (*i.e.* every branchrule callback call) to let the user make a decision, +or inspect the :py:class:`~ecole.scip.Model`. +We say that the *user (or the agent) has the control*. +To do so, we did not reconstruct the solving algorithm ``SCIPsolve`` to fit our needs. +Rather, we have implemented a general *inversion of control* mechanism to let SCIP pause and be resumed on every +callback call (using a form of *stackful coroutine*). +We call this approach *iterative solving* and it runs exactly the same ``SCIPsolve`` algorithm, without noticable +overhead, while perfectly forwarding all information available in the callback. + +To use this tool, the user start by calling :py:meth:`ecole.scip.Model.solve_iter`, with a set of call callback +constructor arguments. +Iterative solving will then add these callbacks, start solving, and return the first time that one of these callback +is executed. +The return value describes where the solving has stopped, and the parameters of the callback where it has stopped. +This is the time for the user to perform whichever action they would have done in the callback. +Solving can be resumed by calling :py:meth:`ecole.scip.Model.solve_iter_continue` with the +:py:class:`ecole.scip.callback.Result` that would have been set in the callback. +Solving is finished when one of the iterative solving function returns ``None``. +The :py:class:`ecole.scip.Model` can safely be deleted an any time (SCIP termination is handled automatically). + +For instance, iterative solving an environement while pausing on branchrule and heuristic callbacks look like the +following. + +.. testcode:: + + model = ecole.scip.Model.from_file("path/to/file") + + # Start solving until the first pause, if any. + fcall = model.solve_iter( + # Stop on branchrule callback. + ecole.scip.callback.BranchruleConstructor(), + # Stop on heuristic callback after node. + ecole.scip.callback.HeuristicConstructor(timing_mask=ecole.scip.HeurTiming.AfterNode), + ) + # While solving is not finished, `fcall` contains information about the current stop. + while fcall is not None: + # Solving stopped on a branchrule callback. + if isinstance(fcall, ecole.scip.callback.BranchruleCall): + # Perform some branching (through PyScipOpt). + ... + # Resume solving until next pause. + fcall = model.solve_iter_continue(ecole.scip.callback.Result.Branched) + # Solving stopped on a heurisitc callback. + elif isinstance(fcall, ecole.scip.callback.HeuristicCall): + # Return as no heuristic was performed (only data collection) + fcall = model.solve_iter_continue(ecole.scip.callback.Result.DidNotRun) + +See :py:class:`~ecole.scip.callback.BranchruleConstructor`, :py:class:`~ecole.scip.callback.HeuristicConstructor` for +callback constructor parameters, as well as :py:class:`~ecole.scip.callback.BranchruleCall` and +:py:class:`~ecole.scip.callback.BranchruleCall` for callbacks functions parameters passed by SCIP to the callback +methods. + +.. note:: + + By default callback parameters such as ``priority``, ``frequency``, and ``max_depth`` taht control how when + the callback are evaluated by SCIP are set to run as often as possible. + However, it is entirely possible to run it with lower priority or frequency for create specific environments or + whatever other purpose. + +To create dynamics using iterative solving, one should call :py:meth:`ecole.scip.Model.solve_iter` in +:py:meth:`~ecole.typing.Dynamics.reset_dynamics` and :py:meth:`ecole.scip.Model.solve_iter_continue` in +:py:meth:`~ecole.typing.Dynamics.step_dynamics`. +For instance, a branching environment could be created with the following dynamics. + +.. testcode:: + :skipif: pyscipopt is None + + class MyBranchingDynamics: + def __init__(self, pseudo_candidates=False, max_depth=ecole.scip.callback.max_depth_none): + self.pseudo_candidates = pseudo_candidates + self.max_depth = max_depth + + def action_set(self, model): + if self.pseudo_candidates: + return model.as_pyscipopt().getPseudoBranchCands() + else: + return model.as_pyscipopt().getLPBranchCands() + return ... + + def reset_dynamics(self, model): + fcall = model.solve_iter( + ecole.scip.callback.BranchruleConstructor(max_depth=self.max_depth) + ) + return (fcall is None), self.action_set(model) + + def step_dynamics(self, model, action): + model.as_pyscipopt().branchVar(action) + fcall = model.solve_iter_continue(ecole.scip.callback.Result.Branched) + return (fcall is None), self.action_set(model) diff --git a/ecole/docs/howto/create-functions.rst b/ecole/docs/howto/create-functions.rst new file mode 100644 index 0000000..f161ef0 --- /dev/null +++ b/ecole/docs/howto/create-functions.rst @@ -0,0 +1,160 @@ +.. _create-new-functions: + +Create New Functions +==================== + +:py:class:`~ecole.typing.ObservationFunction` and :py:class:`~ecole.typing.RewardFunction` functions +can be adapted and created from Python. + +At the core of the environment, a SCIP :py:class:`~ecole.scip.Model` (equivalent abstraction to a +``pyscipopt.Model`` or a ``SCIP*`` in ``C``), describes the state of the environment. +The idea of observation and reward functions is to have a function that takes as input a +:py:class:`~ecole.scip.Model`, and returns the desired value (an observation, or a reward). +The environment itself does nothing more than calling the functions and forward their output to the +user. + +Pratically speaking, it is more convenient to implement such functions as a class than a function, +as it makes it easier to keep information between states. + +Extending a Function +-------------------- +To reuse a function, Python inheritance can be used. For example, the method in an observation function called +to extract the features from the model is called :py:meth:`~ecole.typing.ObservationFunction.extract`. +In the following example, we will extend the :py:class:`~ecole.observation.NodeBipartite` observation function by +overloading its :py:meth:`~ecole.typing.ObservationFunction.extract` function to scale the features by their +maximum absolute value. + +.. testcode:: + + import numpy as np + from ecole.observation import NodeBipartite + + + class ScaledNodeBipartite(NodeBipartite): + def extract(self, model, done): + # Call parent method to get the original observation + obs = super().extract(model, done) + # Apply scaling + column_max_abs = np.abs(obs.column_features).max(0) + obs.column_features[:] /= column_max_abs + row_max_abs = np.abs(obs.row_features).max(0) + obs.row_features[:] /= row_max_abs + # Return the updated observation + return obs + +By using inheritance, we used :py:class:`~ecole.observation.NodeBipartite`'s own :py:meth:`~ecole.typing.ObservationFunction.extract` +to do the heavy lifting, only appending the additional scaling code. +The resulting ``ScaledNodeBipartite`` class is a perfectly valid observation function that can be given to an +environment. + +As an additional example, instead of scaling by the maximum absolute value one might want to use a scaling factor smoothed by +`exponential moving averaging `_, with some coefficient α. +This will illustrate how the class paradigm is useful to saving information between states. + +.. testcode:: + + class MovingScaledNodeBipartite(NodeBipartite): + def __init__(self, alpha, *args, **kwargs): + # Construct parent class with other parameters + super().__init__(*args, **kwargs) + self.alpha = alpha + + def before_reset(self, model): + super().before_reset(model) + # Reset the exponential moving average (ema) on new episodes + self.column_ema = None + self.row_ema = None + + def extract(self, model, done): + obs = super().extract(model, done) + + # Compute the max absolute vector for the current observation + column_max_abs = np.abs(obs.column_features).max(0) + row_max_abs = np.abs(obs.row_features).max(0) + + if self.column_ema is None: + # New exponential moving average on a new episode + self.column_ema = column_max_abs + self.row_ema = row_max_abs + else: + # Update the exponential moving average + self.column_ema = self.alpha * column_max_abs + (1 - alpha) * self.column_ema + self.row_ema = self.alpha * row_max_abs + (1 - alpha) * self.row_ema + + # Scale features and return the new observation + obs.column_features[:] /= self.column_ema + obs.row_features[:] /= self.row_ema + return obs + +Here, you can notice how we used the constructor to customize the coefficient of the +exponential moving average. +Note also that we inherited the :py:meth:`~ecole.typing.ObservationFunction.before_reset` method which does not +return anything: this method is called at the begining of the episode by +:py:meth:`~ecole.environment.Environment.reset` and is used to reintialize the class +internal attribute on new episodes. +Finally, the :py:meth:`~ecole.typing.ObservationFunction.extract` is also called during during +:py:meth:`~ecole.environment.Environment.reset`, hence the ``if`` else ``else`` condition. +Both these methods call the parent method to let it do its own initialization/resetting. + +.. warning:: + + The scaling shown in this example is naive implementation meant to showcase the use of + observation function. + For proper scaling functions consider `Scikit-Learn Scalers + `_ + + +Writing a Function from Scratch +------------------------------- +The :py:class:`~ecole.typing.ObservationFunction` and :py:class:`~ecole.typing.RewardFunction` classes don't do +anything more than what is explained in the previous section. +This means that to create new function in Python, one can simply create a class with the previous +methods. + +For instance, we can create a ``StochasticReward`` function that will wrap any given +:py:class:`~ecole.typing.RewardFunction`, and with some probability return either the given reward or +0. + +.. testcode:: + + import random + + + class StochasticReward: + def __init__(self, reward_function, probability=0.05): + self.reward_function = reward_function + self.probability = probability + + def before_reset(self, model): + self.reward_function.before_reset(model) + + def extract(self, model, done): + # Unconditionally getting reward as reward_funcition.extract may have side effects + reward = self.reward_function.extract(model, done) + if random.random() < probability: + return 0.0 + else: + return reward + +The resulting class is a perfectly valid reward function which can be used in any environment, for example as follows. + +.. doctest:: + + >> stochastic_lpiterations = StochaticReward(-ecole.reward.LpIteration, probability=0.1) + >> env = ecole.environment.Branching(reward_function=stochastic_lpiterations) + + +Using PySCIPOpt +--------------- +The extraction functions described on this page, by definition, aim to extract information from the solver about the state +of the process. An excellent reason to create or extend a reward function is to access information not provided by the +default functions in Ecole. To do so in Python, one might want to use `PyScipOpt `_, +the official Python interface to SCIP. + +In ``PySCIPOpt`, the state of the SCIP solver is stored in an ``pyscipopt.Model`` object. This is closely related to, +but not quite the same, as Ecole's :py:class:`~ecole.scip.Model` class. For a number of reasons (such as C++ compatibility), +the two classes don't coincide. However, for ease of use, it is possible to convert back and forth without any copy. + +Using :py:meth:`ecole.scip.Model.as_pyscipopt`, one can get a ``pyscipopt.Model`` that shares its +internal data with :py:class:`ecole.scip.Model`. Conversely, given a ``pyscipopt.Model``, it is possible to to create a :py:class:`ecole.scip.Model` +using the static method :py:meth:`ecole.scip.Model.from_pyscipopt`. diff --git a/ecole/docs/howto/instances.rst b/ecole/docs/howto/instances.rst new file mode 100644 index 0000000..75f3d33 --- /dev/null +++ b/ecole/docs/howto/instances.rst @@ -0,0 +1,171 @@ +.. _generate-instances: + +Generate Problem Instances +========================== + +Ecole contains a number of combinatorial optimization instance generators in the``ecole.instance`` module. The various +:py:class:`~ecole.typing.InstanceGenerator` classes generate instances as :py:class:`ecole.scip.Model` objects. + +To use those classes to generate instances, you first instantiate a generator object from the desired class. The various +generator classes take problem-specific hyperparameters as constructor arguments, which can be used to control the type +of instances being generated. The resulting :py:class:`~ecole.typing.InstanceGenerator` objects are infinite `Python +iterators `_, which can then be iterated over to yield as many instances as desired. + +For instance, to generate `set covering problems `_, one would use +:py:class:`~ecole.instance.SetCoverGenerator` in the following fashion. + +.. testcode:: + + from ecole.instance import SetCoverGenerator + + + generator = SetCoverGenerator(n_rows=100, n_cols=200, density=0.1) + + for i in range(50): + instance = next(generator) + + # Do anything with the ecole.scip.Model + instance.write_problem("some-folder/set-cover-{i:04}.lp") + + +Note how we are iterating over a ``range(50)`` and calling ``next`` on the generator, as iterating directly over +the iterator would produce an infinite loop. Another simple syntax would be to use `islice `_ +from the standard Python library. + + +Generator Random States +----------------------- +Internally, an :py:class:`~ecole.typing.InstanceGenerator` holds a random state , which gets updated after generating an instance. +This state can be reset using the :py:meth:`~ecole.typing.InstanceGenerator.seed` method of the generator. + +.. testcode:: + + generator_a = SetCoverGenerator(n_rows=100, n_cols=200, density=0.1) + generator_b = SetCoverGenerator(n_rows=100, n_cols=200, density=0.1) + + # These are not the same instance + instance_a = next(generator_a) + instance_b = next(generator_b) + + generator_a.seed(809) + generator_b.seed(809) + + # These are exactly the same instances + instance_a = next(generator_a) + instance_b = next(generator_b) + + +With an Environment +------------------- +The instance objects generated by :py:class:`~ecole.typing.InstanceGenerator`s, +of type :py:class:`ecole.scip.Model`, can be passed directly to an environment's +:py:meth:`~ecole.environment.Environment.reset` method. + +A typical example training over 1000 instances/episodes would look like: + +.. testcode:: + + import ecole + + + env = ecole.environment.Branching() + gen = ecole.instance.SetCoverGenerator(n_rows=100, n_cols=200) + + for _ in range(1000): + observation, action_set, reward_offset, done, info = env.reset(next(gen)) + while not done: + observation, action_set, reward, done, info = env.step(action_set[0]) + +.. note:: + The generated instance objects can be, in principle, modified between their generation and their usage in an environment + :py:meth:`~ecole.environment.Environment.reset` method. To keep code clean, however, we recommend that such modifications + be wrapped in a custom environment class. Details about custom environments :ref:`can be found here`. + + +Extending Instance Generators +----------------------------- +In various use cases, the provided :py:class:`~ecole.typing.InstanceGenerator` are too limited. Thankfully, it is easy to extend +the provided generators in various ways. This section presents a few common patterns. + +Combining Multiple Generators +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +To learn over multiple problem types, one can build a generator that, for every instance to generate, chooses a +a problem type at random, and returns it. + +.. testcode:: + + import random + + + def CombineGenerators(*generators): + # A random state for choice + rng = random.Random() + while True: + # Randomly pick a generator + gen = rng.choice(generators) + # And yield the instance it generates + yield next(gen) + + +Note that this is not quite a fully-fledged instance generator, as it is missing a way to set the seed. A more complete instance generator +could be written as follows. + +.. testcode:: + + class CombinedGenerator: + def __init__(self, *generators): + self.generators = generators + self.rng = random.Random() + + def __next__(self): + return next(self.rng.choice(self.generators)) + + def __iter__(self): + return self + + def seed(self, val): + self.rng.seed(val) + for gen in self.generators: + gen.seed(val) + +Generator with Random Parameters +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The provided instance generators have fixed hyperparameters, but to increase variability it might be desirable to randomly vary them as well. + +This can be without creating various :py:class:`~ecole.typing.InstanceGenerator` objects by using a generator's +:py:meth:`~ecole.typing.InstanceGenerator.generate_instance` static method, and manually pass a :py:class:`~ecole.RandomGenerator`. +For instance, to randomly choose the ``n_cols`` and ``n_rows`` parameters from +:py:class:`~ecole.instance.SetCoverGenerator`, one could use + +.. testcode:: + + import random + import ecole + + + class VariableSizeSetCoverGenerator: + def __init__(self, n_cols_range, n_rows_range): + self.n_cols_range = n_cols_range + self.n_rows_range = n_rows_range + # A Python random state for randint + self.py_rng = random.Random() + # An Ecole random state to pass to generating functions + # This function returns a random state whose seed depends on Ecole global random state + self.ecole_rng = ecole.spawn_random_generator() + + def __next__(self): + return ecole.instance.SetCoverGenerator( + n_cols=self.py_rng.randint(*self.n_cols_range), + n_rows=self.py_rng.randint(*self.n_rows_range), + rng=self.ecole_rng, + ) + + def __iter__(self): + return self + + def seed(self, val): + self.py_rng.seed(val) + self.ecole_rng.seed(val) + + +See :ref:`the discussion on seeding` for an explanation of :py:func:`ecole.spawn_random_generator`. diff --git a/ecole/docs/howto/observation-functions.rst b/ecole/docs/howto/observation-functions.rst new file mode 100644 index 0000000..c9c6fa1 --- /dev/null +++ b/ecole/docs/howto/observation-functions.rst @@ -0,0 +1,92 @@ +.. _use-observation-functions: + +Use Observation Functions +========================= + +Using any environment, the observation [#observation]_ received by the user to take the +next action can be customized changing the :py:class:`~ecole.typing.ObservationFunction` used by the solver. +The environment is not extracting data directly but delegates that responsibility to an +:py:class:`~ecole.typing.ObservationFunction` object. +The object has complete access to the solver and extract the data it needs. + +Specifying an observation function is as easy as specifying a parameter when +creating an environment. +For instance with the :py:class:`~ecole.environment.Branching` environment: + +.. doctest:: + + >>> env = ecole.environment.Branching(observation_function=ecole.observation.Nothing()) + >>> env.observation_function # doctest: +SKIP + ecole.observation.Nothing() + >>> obs, _, _, _, _ = env.reset("path/to/problem") + >>> obs is None + True + +Environments have an observation function set as default parameter for convenience. + +.. doctest:: + + >>> env = ecole.environment.Branching() + >>> env.observation_function # doctest: +SKIP + ecole.observation.NodeBipartite() + >>> obs, _, _, _, _ = env.reset("path/to/problem") + >>> obs # doctest: +SKIP + ecole.observation.NodeBipartiteObs(...) + +.. TODO Use an observation function that is more intutive than Nothing +.. TODO Adapt the output to the actual __repr__ and remove #doctest: +SKIP + + +See :ref:`the reference` for the list of available observation functions, +as well as :ref:`the documention` for explanation on how to create one. + + +No Observation Function +----------------------- +To not use any observation function, for instance for learning with a bandit algorithm, +you can explicitly pass ``None`` to the environment constructor. + +.. doctest:: + + >>> env = ecole.environment.Branching(observation_function=None) + >>> env.observation_function # doctest: +SKIP + ecole.observation.nothing() + >>> obs, _, _, _, _ = env.reset("path/to/problem") + >>> obs is None + True + +.. TODO Adapt the output to the actual __repr__ and remove #doctest: +SKIP + +Multiple Observation Functions +------------------------------ +To use multiple observation functions, wrap them in a ``list`` or ``dict``. + +.. doctest:: + + >>> obs_func = { + ... "some_name": ecole.observation.NodeBipartite(), + ... "other_name": ecole.observation.Nothing(), + ... } + >>> env = ecole.environment.Branching(observation_function=obs_func) + >>> obs, _, _, _, _ = env.reset("path/to/problem") + >>> obs # doctest: +SKIP + {'some_name': ecole.observation.NodeBipartiteObs(), 'other_name': None} + +.. TODO Adapt the output to the actual __repr__ and remove #doctest: +SKIP + +Similarily with a tuple + +.. doctest:: + + >>> obs_func = (ecole.observation.NodeBipartite(), ecole.observation.Nothing()) + >>> env = ecole.environment.Branching(observation_function=obs_func) + >>> obs, _, _, _, _ = env.reset("path/to/problem") + >>> obs # doctest: +SKIP + [ecole.observation.NodeBipartiteObs(), None] + +.. TODO Use an observation function that is more intutive than Nothing +.. TODO Adapt the output to the actual __repr__ and remove #doctest: +SKIP + +.. [#observation] We use the term *observation* rather than state since the state + is really the whole state of the solver, which is unaccessible. Thus, mathematically, + we really have a Partially Observable Markov Decision Process. diff --git a/ecole/docs/howto/reward-functions.rst b/ecole/docs/howto/reward-functions.rst new file mode 100644 index 0000000..a6feb41 --- /dev/null +++ b/ecole/docs/howto/reward-functions.rst @@ -0,0 +1,120 @@ +.. _use-reward-functions: + +Use Reward Functions +==================== + +Similarily to :ref:`observation functions ` the reward received by +the user for learning can be customized by changing the :py:class:`~ecole.typing.RewardFunction` used by the +solver. +In fact, the mechanism of reward functions is very similar to that of observation +functions: environments do not compute the reward directly but delegate that +responsibility to a :py:class:`~ecole.typing.RewardFunction` object. +The object has complete access to the solver and extracts the data it needs. + +Specifying a reward function is performed by passing the :py:class:`~ecole.typing.RewardFunction` object to +the ``reward_function`` environment parameter. +For instance, specifying a reward function with the :py:class:`~ecole.environment.Configuring` environment +looks as follows: + +.. doctest:: + + >>> env = ecole.environment.Configuring(reward_function=ecole.reward.LpIterations()) + >>> env.reward_function # doctest: +SKIP + ecole.reward.LpIterations() + >>> env.reset("path/to/problem") # doctest: +ELLIPSIS + (..., ..., 0.0, ..., ...) + >>> env.step({}) # doctest: +SKIP + (..., ..., 45.0, ..., ...) + +Environments also have a default reward function, which will be used if the user does not specify any. + +.. doctest:: + + >>> env = ecole.environment.Configuring() + >>> env.reward_function # doctest: +SKIP + ecole.reward.IsDone() + +.. TODO Adapt the output to the actual __repr__ and remove #doctest: +SKIP + +See :ref:`the reference` for the list of available reward functions, +as well as :ref:`the documention` for explanations on how to create one. + + +Arithmetic on Reward Functions +------------------------------ +Reinforcement learning in combinatorial optimization solving is an active area of research, and +there is at this point little consensus on reward functions to use. In recognition of that fact, +reward functions have been explicitely designed in Ecole to be easily combined with Python arithmetic. + +For instance, one might want to minimize the number of LP iterations used throughout the solving process. +To achieve this using a standard reinforcement learning algorithm, one would might use the negative +number of LP iterations between two steps as a reward: this can be achieved by negating the +:py:class:`~ecole.reward.LpIterations` function. + +.. doctest:: + + >>> env = ecole.environment.Configuring(reward_function=-ecole.reward.LpIterations()) + >>> env.reset("path/to/problem") # doctest: +ELLIPSIS + (..., ..., -0.0, ..., ...) + >>> env.step({}) # doctest: +SKIP + (..., ..., -45.0, ..., ...) + +More generally, any operation, such as + +.. testcode:: + + from ecole.reward import LpIterations + + -3.5 * LpIterations() ** 2.1 + 4.4 + +is valid. + +Note that this is a full reward *function* object that can be given to an environment: +it is equivalent to doing the following. + +.. doctest:: + + >>> env = ecole.environment.Configuring(reward_function=ecole.reward.LpIterations()) + >>> env.reset("path/to/problem") # doctest: +ELLIPSIS + (..., ..., ..., ..., ...) + >>> _, _, lp_iter_reward, _, _ = env.step({}) + >>> reward = -3.5 * lp_iter_reward ** 2.1 + 4.4 + +Arithmetic operations are even allowed between different reward functions, + +.. testcode:: + + from ecole.reward import LpIterations, IsDone + + 4.0 * LpIterations() ** 2 - 3 * IsDone() + +which is especially powerful because in this normally it would *not* be possible to pass both +:py:class:`~ecole.reward.LpIterations` and :py:class:`~ecole.reward.IsDone` to the +environment. + +All operations that are valid between scalars are valid between reward functions. + +.. testcode:: + + -IsDone() ** abs(LpIterations() // 4) + +In addition, not all commonly used mathematical operations have a dedicated Python operator: to +accomodate this, Ecole implements a number of other operations as methods of reward functions. +For instance, to get the exponential of :py:class:`~ecole.reward.LpIterations`, one can use + +.. testcode:: + + LpIterations().exp() + +This also works with rewards functions created from arithmetic expressions. + +.. testcode:: + + (3 - 2 * LpIterations()).exp() + +Finally, reward functions have an ``apply`` method to compose rewards with any +function. + +.. testcode:: + + LpIterations().apply(lambda reward: math.factorial(round(reward))) diff --git a/ecole/docs/images/mdp.png b/ecole/docs/images/mdp.png new file mode 100644 index 0000000000000000000000000000000000000000..770f409b34e8f30eca743157bb8115ee47de2c67 GIT binary patch literal 61218 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 literal 0 HcmV?d00001 diff --git a/ecole/docs/index.rst b/ecole/docs/index.rst new file mode 100644 index 0000000..563fe35 --- /dev/null +++ b/ecole/docs/index.rst @@ -0,0 +1,106 @@ +Introduction +============ + +Ecole is a library of *Extensible Combinatorial Optimization Learning Environments* +designed to ease the development of machine learning approaches for +combinatorial optimization. More precisely, the goal of Ecole is to allow for a fast +and safe prototyping of any ML for CO approach that can be formulated as a control +problem (*i.e.*, a Markov Decision Process), as well as providing reproducible benchmarking protocols +for comparison to existing approaches. + +.. testcode:: + + import ecole + + env = ecole.environment.Branching( + reward_function=-1.5 * ecole.reward.LpIterations() ** 2, + observation_function=ecole.observation.NodeBipartite(), + ) + instances = ecole.instance.SetCoverGenerator(n_rows=100, n_cols=200) + + for _ in range(10): + observation, action_set, reward_offset, done, info = env.reset(next(instances)) + while not done: + observation, action_set, reward, done, info = env.step(action_set[0]) + + +Combinatorial optimization solvers typically rely on a plethora of handcrafted expert heuristics, +which can fail to exploit subtle statistical similarities between problem intances. +`Machine Learning `_ algorithms offer +a promising approach for replacing those heuristics, by learning data-driven policies that automatically +account for such statistical relationships, and thereby creating a new kind of highly adaptive solvers. + +For instance, many combinatorial optimization problems can be modeled using `Mixed Integer +Linear Programming `_ and solved using +the `branch-and-bound `_ algorithm. +Despite its simplicity, the algorithm requires many non-trivial decisions, such as iteratively +picking the next variable to branch on. Ecole aims at exposing these algorithmic control problems with a +standard reinforcement learning API (agent / environment loop), in order to ease the exploration +of new machine learning models and algorithms for learning data-driven policies. + +Ecole's interface is inspired from `OpenAI Gym `_ and will look +familiar to reinforcement learning praticionners. +The state-of-the-art Mixed Integer Linear Programming solver that acts as a controllable +algorithm inside Ecole is `SCIP `_. + +The reader is referred to [Bengio2020]_ for motivation on why machine learning is a promising +candidate to use for combinatorial optimization, as well as the methodology to do so. + +.. [Bengio2020] + Bengio, Yoshua, Andrea Lodi, and Antoine Prouvost. + "`Machine learning for combinatorial optimization: a methodological tour d'horizon. + `_" + *European Journal of Operational Research*. 2020. + + +.. toctree:: + :caption: Getting started + :hidden: + + self + installation + using-environments + +.. toctree:: + :caption: How to + :hidden: + + howto/observation-functions.rst + howto/reward-functions.rst + howto/create-functions.rst + howto/create-environments.rst + howto/instances.rst + +.. toctree:: + :caption: Practical Tutorials + :hidden: + + Configuring the Solver with Bandits + Branching with Imitation Learning + +.. toctree:: + :caption: Reference + :hidden: + + reference/environments.rst + reference/observations.rst + reference/rewards.rst + reference/information.rst + reference/scip-interface.rst + reference/instances.rst + reference/utilities.rst + +.. toctree:: + :caption: Discussion + :hidden: + + discussion/gym-differences.rst + discussion/seeding.rst + discussion/theory.rst + +.. toctree:: + :caption: Developer Zone + :hidden: + + contributing.rst + developers/example-observation.rst diff --git a/ecole/docs/installation.rst b/ecole/docs/installation.rst new file mode 100644 index 0000000..4bdbcc5 --- /dev/null +++ b/ecole/docs/installation.rst @@ -0,0 +1,71 @@ +.. _installation: + +Installation +============ + +Conda +----- +.. image:: https://img.shields.io/conda/vn/conda-forge/ecole?label=version&logo=conda-forge + :alt: Conda-Forge version +.. image:: https://img.shields.io/conda/pn/conda-forge/ecole?logo=conda-forge + :alt: Conda-Forge platforms + +.. code-block:: bash + + conda install -c conda-forge ecole + +All dependencies are resolved by conda, no compiler is required. + +`PyScipOpt `_ is not required but is the main SCIP +interface to develop new Ecole components from Python + +.. code-block:: bash + + conda install -c conda-forge ecole pyscipopt + +Currenlty, conda packages are only available for Linux and MacOS. + +Pip wheel (binary) +------------------ +Currently unavailable. + +Pip source +----------- +.. image:: https://img.shields.io/pypi/v/ecole?logo=python + :target: https://pypi.org/project/ecole/ + :alt: PyPI version + +Building from source requires: + - A `C++17 compiler `_, + - A `SCIP `_ installation. + +For the stable `PyPI version `_: + +.. code-block:: bash + + python -m pip install ecole + +To specify the where to find SCIP (or any CMake parameters): + +.. code-block:: bash + + CMAKE_ARGS="-DSCIP_DIR=path/to/lib/cmake/scip -DCMAKE_INSTALL_RPATH_USE_LINK_PATH=ON" python -m pip install ecole + +For the latest Github version: + +.. code-block:: bash + + python -m pip install git+https://github.com/ds4dm/ecole + +Or if the latest version is already available locally: + +.. code-block:: bash + + python -m pip install . + +If all dependencies (build time such as CMake and compiler, and run time such as NumPy) are already installed, +as is the case when developping Ecole, one can install Ecole with: + +.. code-block:: bash + + python -m pip install --no-deps --no-build-isolation [ecole | git+https://github.com/ds4dm/ecole | .] diff --git a/ecole/docs/reference/environments.rst b/ecole/docs/reference/environments.rst new file mode 100644 index 0000000..3fa6416 --- /dev/null +++ b/ecole/docs/reference/environments.rst @@ -0,0 +1,27 @@ +Environments +============ + +Interface +--------- +.. autoclass:: ecole.environment.Environment + +Protocol +-------- +.. autoclass:: ecole.typing.Dynamics + +Listing +------- +Branching +^^^^^^^^^ +.. autoclass:: ecole.environment.Branching +.. autoclass:: ecole.dynamics.BranchingDynamics + +Configuring +^^^^^^^^^^^ +.. autoclass:: ecole.environment.Configuring +.. autoclass:: ecole.dynamics.ConfiguringDynamics + +PrimalSearch +^^^^^^^^^^^^ +.. autoclass:: ecole.environment.PrimalSearch +.. autoclass:: ecole.dynamics.PrimalSearchDynamics diff --git a/ecole/docs/reference/information.rst b/ecole/docs/reference/information.rst new file mode 100644 index 0000000..936f6de --- /dev/null +++ b/ecole/docs/reference/information.rst @@ -0,0 +1,17 @@ +.. _information-reference: + +Informations +============ + +Interface +--------- +.. autoclass:: ecole.typing.InformationFunction + + +Listing +------- +The list of information functions relevant to users is given below. + +Nothing +^^^^^^^ +.. autoclass:: ecole.information.Nothing diff --git a/ecole/docs/reference/instances.rst b/ecole/docs/reference/instances.rst new file mode 100644 index 0000000..ab7f3f3 --- /dev/null +++ b/ecole/docs/reference/instances.rst @@ -0,0 +1,34 @@ +Instance Generators +=================== + +Protocol +-------- +The protocol of instance generators in Ecole. +There are no constraints for user defined gnerators. +The protocol is given for users to know what they can expect from Ecole generators. + +.. autoclass:: ecole.typing.InstanceGenerator + +Listing +------- +The list of instance generators is given below. + +Local Files +^^^^^^^^^^^ +.. autoclass:: ecole.instance.FileGenerator + +Set Cover +^^^^^^^^^ +.. autoclass:: ecole.instance.SetCoverGenerator + +Combinatorial Auction +^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: ecole.instance.CombinatorialAuctionGenerator + +Capacitated Facility Location +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: ecole.instance.CapacitatedFacilityLocationGenerator + +Independent Set +^^^^^^^^^^^^^^^ +.. autoclass:: ecole.instance.IndependentSetGenerator diff --git a/ecole/docs/reference/observations.rst b/ecole/docs/reference/observations.rst new file mode 100644 index 0000000..00df8a5 --- /dev/null +++ b/ecole/docs/reference/observations.rst @@ -0,0 +1,45 @@ +.. _observation-reference: + +Observations +============ + +Interface +--------- +.. autoclass:: ecole.typing.ObservationFunction + + +Listing +------- +The list of observation functions relevant to users is given below. + +Nothing +^^^^^^^ +.. autoclass:: ecole.observation.Nothing + +Node Bipartite +^^^^^^^^^^^^^^ +.. autoclass:: ecole.observation.NodeBipartite +.. autoclass:: ecole.observation.NodeBipartiteObs + +Milp Bipartite +^^^^^^^^^^^^^^ +.. autoclass:: ecole.observation.MilpBipartite +.. autoclass:: ecole.observation.MilpBipartiteObs + +Strong Branching Scores +^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: ecole.observation.StrongBranchingScores + +Pseudocosts +^^^^^^^^^^^ +.. autoclass:: ecole.observation.Pseudocosts + +Khalil et al. 2016 +^^^^^^^^^^^^^^^^^^ +.. autoclass:: ecole.observation.Khalil2016 +.. autoclass:: ecole.observation.Khalil2016Obs + +Hutter et al. 2011 +^^^^^^^^^^^^^^^^^^ +.. autoclass:: ecole.observation.Hutter2011 +.. autoclass:: ecole.observation.Hutter2011Obs diff --git a/ecole/docs/reference/rewards.rst b/ecole/docs/reference/rewards.rst new file mode 100644 index 0000000..933badf --- /dev/null +++ b/ecole/docs/reference/rewards.rst @@ -0,0 +1,65 @@ +.. _reward-reference: + +Rewards +======= + +Interface +--------- +.. autoclass:: ecole.typing.RewardFunction + +Listing +------- +The list of reward functions relevant to users is given below. + +Is Done +^^^^^^^ +.. autoclass:: ecole.reward.IsDone + :no-members: + :members: before_reset, extract + +LP Iterations +^^^^^^^^^^^^^ +.. autoclass:: ecole.reward.LpIterations + :no-members: + :members: before_reset, extract + +NNodes +^^^^^^ +.. autoclass:: ecole.reward.NNodes + :no-members: + :members: before_reset, extract + +Solving Time +^^^^^^^^^^^^ +.. autoclass:: ecole.reward.SolvingTime + :no-members: + :members: before_reset, extract + +Primal and dual Integrals +^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: ecole.reward.PrimalIntegral + :no-members: + :members: before_reset, extract +.. autoclass:: ecole.reward.DualIntegral + :no-members: + :members: before_reset, extract +.. autoclass:: ecole.reward.PrimalDualIntegral + :no-members: + :members: before_reset, extract + + +Utilities +--------- +The following reward functions are used internally by Ecole. + +Constant +^^^^^^^^ +.. autoclass:: ecole.reward.Constant + :no-members: + :members: before_reset, extract + +Arithmetic +^^^^^^^^^^ +.. autoclass:: ecole.reward.Arithmetic + :no-members: + :members: before_reset, extract diff --git a/ecole/docs/reference/scip-interface.rst b/ecole/docs/reference/scip-interface.rst new file mode 100644 index 0000000..ea924a9 --- /dev/null +++ b/ecole/docs/reference/scip-interface.rst @@ -0,0 +1,34 @@ +SCIP Interface +============== + +Model +----- +.. autoclass:: ecole.scip.Model + +Callbacks +--------- +Branchrule +^^^^^^^^^^ +.. autoclass:: ecole.scip.callback.BranchruleConstructor +.. autoclass:: ecole.scip.callback.BranchruleCall + +Heuristic +^^^^^^^^^ +.. autoclass:: ecole.scip.callback.HeuristicConstructor +.. autoclass:: ecole.scip.callback.HeuristicCall + +Utilities +^^^^^^^^^ +.. autoattribute:: ecole.scip.callback.priority_max +.. autoattribute:: ecole.scip.callback.max_depth_none +.. autoattribute:: ecole.scip.callback.max_bound_distance_none +.. autoattribute:: ecole.scip.callback.frequency_always +.. autoattribute:: ecole.scip.callback.frequency_offset_none + +.. autoclass:: ecole.scip.callback.Result +.. autoclass:: ecole.scip.callback.Type + +SCIP Data Types +--------------- +.. autoclass:: ecole.scip.Stage +.. autoclass:: ecole.scip.HeurTiming diff --git a/ecole/docs/reference/utilities.rst b/ecole/docs/reference/utilities.rst new file mode 100644 index 0000000..39bf3e7 --- /dev/null +++ b/ecole/docs/reference/utilities.rst @@ -0,0 +1,8 @@ +Utilities +========= + +Random +------ +.. autoclass:: ecole.RandomGenerator +.. autofunction:: ecole.seed +.. autofunction:: ecole.spawn_random_generator diff --git a/ecole/docs/using-environments.rst b/ecole/docs/using-environments.rst new file mode 100644 index 0000000..f9928c3 --- /dev/null +++ b/ecole/docs/using-environments.rst @@ -0,0 +1,175 @@ +Using Environments +================== + +The goal of Ecole is to provide Markov decision process abstractions of common sequential decision making tasks that +appear when solving combinatorial optimization problems using a solver. +These control tasks are represented by stateful classes called environments. + +In this formulation, each solving of an instance is an episode. +The environment class must first be instantiated, and then a specific instance must be loaded by a call to +:py:meth:`~ecole.environment.Environment.reset`, which will bring the process to its initial state. +Afterwards, successive calls to :py:meth:`~ecole.environment.Environment.step` will take an action from the +user and transition to the next state. +Finally, when the episode is finished, that is when the instance has been fully solved, a new solving episode can be +started with another call to :py:meth:`~ecole.environment.Environment.reset`. + +For instance, using the :py:class:`~ecole.environment.Branching` environment for branch-and-bound variable selection, +solving a specific instance once by always selecting the first fractional variable would look as follows. + +.. testcode:: + + import ecole + + env = ecole.environment.Branching() + env.seed(42) + + for _ in range(10): + observation, action_set, reward_offset, done, info = env.reset("path/to/instance") + while not done: + observation, action_set, reward, done, info = env.step(action_set[0]) + + +Let us analyze this example in more detail. + + +General structure +----------------- +The example is driven by two loops. +The inner ``while`` loop, the so-called *control loop*, transitions from an initial state until a +terminal state is reached, which is signaled with the boolean flag ``done == True``. +In Ecole, the termination of the environment coincides with the termination of the underlying combinatorial +optimization algorithm. +A full execution of this loop is known as an *episode*. +The control loop matches a Markov decision process formulation, as used in control theory, dynamic programming and +reinforcement learning. + +.. figure:: images/mdp.png + :alt: Markov Decision Process interaction loop. + :align: center + :width: 60% + + The control loop of a Markov decision process. + +.. note:: + + More exactly, the control loop in Ecole is that of a `partially-observable Markov decision process + `_ (PO-MDP), since + only a subset of the MDP state is extracted from the environment in the form of an *observation*. We omit + this detail here for simplicity. + +The outer ``for`` loop in the example simply repeats the control loop several times, and is in +charge of generating the initial state of each episode. +In order to obtain a sufficient statistical signal for learning the control policy, numerous episodes are usually +required for learning. +Also, although not showcased here, there is usually little practical interest in using the same combinatorial problem +instance for generating each episode. +Indeed, it is usually desirable to learn policies that will generalize to new, unseen instances, which is very unlikely +if the learning policy is tailored to solve a single specific instance. +Ideally, one would like to sample training episodes from a family of similar instances, in order to solve new, similar +instances in the future. +For more details, see the :ref:`Ecole theortical model` in the discussion. + + +.. _environment-parameters: + +Environment parameters +---------------------- +Each environment can be given a set of parameters at construction, in order to further customize the task being +solved. +For instance, the :py:class:`~ecole.environment.Branching` environment takes a ``pseudo_candidates`` +boolean parameter, to decide whether branching candidates should include all non fixed integral variables, or only the +fractional ones. +Environments can be instantiated with no constructor arguments, as in the previous example, in which case a set of +default parameters will be used. + +Every environment can optionally take a dictionary of +`SCIP parameters `_ that will be used to +initialize the solver at every episode. +For instance, to customize the clique inequalities generated, one could set: + +.. testcode:: + + env = ecole.environment.Branching( + scip_params={"separating/clique/freq": 0.5, "separating/clique/maxsepacuts": 5} + ) + + +.. warning:: + + Depending on the nature of the environment, some user-given parameters can be overriden + or ignored (*e.g.*, branching parameters in the :py:class:`~ecole.environment.Branching` + environment). + It is the responsibility of the user to understand the environment they are using. + +.. note:: + + For out-out-the-box strategies on presolving, heuristics, and cutting planes, consider + using the dedicated + `SCIP methods `_ + (``SCIPsetHeuristics`` *etc.*). + +:ref:`Observation functions ` and +:ref:`reward functions ` are more advanced environment +parameters, which we will discuss later on. + + +.. _resetting-environments: + +Resetting environments +---------------------- +Each episode in the inner ``while`` starts with a call to +:py:meth:`~ecole.environment.Environment.reset` in order to bring the environment into a new +initial state. +The method is parameterized with a problem instance: the combinatorial optimization problem that will be loaded and +solved by the `SCIP `_ solver during the episode. +In the most simple case this is the path to a problem file. +For problems instances that are generated programatically +(for instance using `PyScipOpt `_ or using +:ref:`instance generators`) a :py:class:`ecole.scip.Model` is also accepted. + +* The ``observation`` consists of information about the state of the solver that should be used to select the next + action to perform (for example, using a machine learning algorithm.) Note that this entry is always ``None`` when + the state is terminal (that is, when the ``done`` flag described below is ``True``.) +* The ``action_set``, when not ``None``, describes the set of candidate actions which are valid for the next transition. + This is necessary for environments where the action set varies from state to state. + For instance, in the :py:class:`~ecole.environment.Branching` environment the set of candidate variables + for branching depends on the value of the current LP solution, which changes at every iteration of the algorithm. +* The ``reward_offset`` is an offset to the reward function that accounts for any computation happening in + :py:meth:`~ecole.environment.Environment.reset` when generating the initial state. + For example, if clock time is selected as a reward function in a :py:class:`~ecole.environment.Branching` environment, + this would account for time spent in the preprocessing phase before any branching is performed. + This offset is thus important for benchmarking, but has no effect + on the control problem, and can be ignored when training a machine learning agent. +* The boolean flag ``done`` indicates whether the initial state is also a terminal state. + This can happen in some environments, such as :py:class:`~ecole.environment.Branching`, where the problem instance + could be solved though presolving only (never actually getting to branching). + +See the reference section for the exact documentation of +:py:meth:`~ecole.environment.Environment.reset`. + + +Transitioning +------------- +The inner ``while`` loop transitions the environment from one state to the next by giving +an action to :py:meth:`~ecole.environment.Environment.step`. +The nature of ``observation``, ``action_set``, and ``done`` is the same as in the previous +section :ref:`resetting-environments`. +The ``reward`` and ``info`` variables provide additional information about +the current transition. + +See the reference section for the exact documentation of +:py:meth:`~ecole.environment.Environment.step`. + + +Seeding environments +-------------------- +Environments can be seeded by using the +:py:meth:`~ecole.environment.Environment.seed` method. +The seed is used by the environment (and in particular the solver) for all the +subsequent episode trajectories. +The solver is given a new seed at the beginning of every new trajectory (call to +:py:meth:`~ecole.environment.Environment.reset`), in a way that preserves +determinism, without re-using the same seed repeatedly. + +See the reference section for the exact documentation of +:py:meth:`~ecole.environment.Environment.seed`. diff --git a/ecole/examples/branching-imitation/conda-requirements.yaml b/ecole/examples/branching-imitation/conda-requirements.yaml new file mode 100644 index 0000000..e5cad7d --- /dev/null +++ b/ecole/examples/branching-imitation/conda-requirements.yaml @@ -0,0 +1,13 @@ +channels: + - pyg + - pytorch + - conda-forge + +dependencies: + - python + - jupyter + - ipykernel + - ecole + - numpy + - pytorch + - pyg diff --git a/ecole/examples/branching-imitation/example.ipynb b/ecole/examples/branching-imitation/example.ipynb new file mode 100644 index 0000000..26b970c --- /dev/null +++ b/ecole/examples/branching-imitation/example.ipynb @@ -0,0 +1,1498 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Branching with Imitation Learning and a GNN\n", + "\n", + "In this tutorial we will reproduce a simplified version of the paper of Gasse et al. (2019) on learning to branch with Ecole with `pytorch` and `pytorch geometric`. We collect strong branching examples on randomly generated maximum set covering instances, then train a graph neural network with bipartite state encodings to imitate the expert by classification. Finally, we will evaluate the quality of the policy.\n", + "\n", + "The biggest difference with Gasse et al. (2019) is that only n=1,000 training examples of expert decisions are collected for training, to keep the time needed to run the tutorial reasonable. As a consequence, the resulting policy is undertrained and is not competitive with SCIP's default branching rule.\n", + "\n", + "Users that are interested in reproducing competitive performance should use a larger sample size, such as the n=100,000 samples used for training in the paper. In this case, we strongly recommend to parallelize data collection, as in the original Gasse et al. (2019) code.\n", + "\n", + "### Requirements\n", + "The requirements can be found in `conda-requirements.yaml`, lock files with pinned versions are also available\n", + "for various configurations." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import gzip\n", + "import pickle\n", + "from pathlib import Path\n", + "\n", + "import ecole\n", + "import numpy as np\n", + "import torch\n", + "import torch.nn.functional as F\n", + "import torch_geometric" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "tags": [ + "parameters" + ] + }, + "outputs": [], + "source": [ + "DATA_MAX_SAMPLES = 1000\n", + "LEARNING_RATE = 0.001\n", + "NB_EPOCHS = 50\n", + "NB_EVAL_INSTANCES = 20" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Data collection\n", + "\n", + "Our first step will be to run explore-then-strong-branch on randomly generated maximum set covering instances, and save the branching decisions to build a dataset. We will also record the state of the branch-and-bound process as a bipartite graph, which is already implemented in Ecole with the same features as Gasse et al. (2019)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will use the Ecole-provided set cover instance generator." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "instances = ecole.instance.SetCoverGenerator(n_rows=500, n_cols=1000, density=0.05)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The explore-then-strong-branch scheme described in the paper is not implemented by default in Ecole. In this scheme, to diversify the states in which we collect examples of strong branching behavior, we mostly follow a weak but cheap expert (pseudocost branching) and only occasionally call the strong expert (strong branching). This also ensures that samples are closer to being independent and identically distributed.\n", + "\n", + "This can be realized in Ecole by creating a custom observation function, which will randomly compute and return the pseudocost scores (cheap) or the strong branching scores (expensive). It also showcases extensibility in Ecole by showing how easily a custom observation function can be created and used, directly in Python." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "class ExploreThenStrongBranch:\n", + " \"\"\"\n", + " This custom observation function class will randomly return either strong branching scores (expensive expert)\n", + " or pseudocost scores (weak expert for exploration) when called at every node.\n", + " \"\"\"\n", + "\n", + " def __init__(self, expert_probability):\n", + " self.expert_probability = expert_probability\n", + " self.pseudocosts_function = ecole.observation.Pseudocosts()\n", + " self.strong_branching_function = ecole.observation.StrongBranchingScores()\n", + "\n", + " def before_reset(self, model):\n", + " \"\"\"\n", + " This function will be called at initialization of the environment (before dynamics are reset).\n", + " \"\"\"\n", + " self.pseudocosts_function.before_reset(model)\n", + " self.strong_branching_function.before_reset(model)\n", + "\n", + " def extract(self, model, done):\n", + " \"\"\"\n", + " Should we return strong branching or pseudocost scores at time node?\n", + " \"\"\"\n", + " probabilities = [1 - self.expert_probability, self.expert_probability]\n", + " expert_chosen = bool(np.random.choice(np.arange(2), p=probabilities))\n", + " if expert_chosen:\n", + " return (self.strong_branching_function.extract(model, done), True)\n", + " else:\n", + " return (self.pseudocosts_function.extract(model, done), False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now create the environment with the correct parameters (no restarts, 1h time limit, 5% expert sampling probability).\n", + "\n", + "Besides the (pseudocost or strong branching) scores, our environment will return the node bipartite graph representation of \n", + "branch-and-bound states used in Gasse et al. (2019), using the `ecole.observation.NodeBipartite` observation function.\n", + "On one side of that bipartite graph, nodes represent the variables of the problem, with a vector encoding features of \n", + "that variable. On the other side of the bipartite graph, nodes represent the constraints of the problem, similarly with \n", + "a vector encoding features of that constraint. An edge links a variable and a constraint node if the variable participates \n", + "in that constraint, that is, its coefficient is nonzero in that constraint. The constraint coefficient is attached as an\n", + "attribute of the edge." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "# We can pass custom SCIP parameters easily\n", + "scip_parameters = {\n", + " \"separating/maxrounds\": 0,\n", + " \"presolving/maxrestarts\": 0,\n", + " \"limits/time\": 3600,\n", + "}\n", + "\n", + "# Note how we can tuple observation functions to return complex state information\n", + "env = ecole.environment.Branching(\n", + " observation_function=(\n", + " ExploreThenStrongBranch(expert_probability=0.05),\n", + " ecole.observation.NodeBipartite(),\n", + " ),\n", + " scip_params=scip_parameters,\n", + ")\n", + "\n", + "# This will seed the environment for reproducibility\n", + "env.seed(0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we loop over the instances, following the strong branching expert 5% of the time and saving its decision, until enough samples are collected." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Episode 1, 2 samples collected so far\n", + "Episode 2, 3 samples collected so far\n", + "Episode 3, 4 samples collected so far\n", + "Episode 4, 4 samples collected so far\n", + "Episode 5, 8 samples collected so far\n", + "Episode 6, 8 samples collected so far\n", + "Episode 7, 10 samples collected so far\n", + "Episode 8, 10 samples collected so far\n", + "Episode 9, 10 samples collected so far\n", + "Episode 10, 11 samples collected so far\n", + "Episode 11, 11 samples collected so far\n", + "Episode 12, 88 samples collected so far\n", + "Episode 13, 88 samples collected so far\n", + "Episode 14, 91 samples collected so far\n", + "Episode 15, 91 samples collected so far\n", + "Episode 16, 91 samples collected so far\n", + "Episode 17, 91 samples collected so far\n", + "Episode 18, 92 samples collected so far\n", + "Episode 19, 93 samples collected so far\n", + "Episode 20, 99 samples collected so far\n", + "Episode 21, 103 samples collected so far\n", + "Episode 22, 106 samples collected so far\n", + "Episode 23, 106 samples collected so far\n", + "Episode 24, 107 samples collected so far\n", + "Episode 25, 117 samples collected so far\n", + "Episode 26, 171 samples collected so far\n", + "Episode 27, 171 samples collected so far\n", + "Episode 28, 172 samples collected so far\n", + "Episode 29, 174 samples collected so far\n", + "Episode 30, 175 samples collected so far\n", + "Episode 31, 175 samples collected so far\n", + "Episode 32, 175 samples collected so far\n", + "Episode 33, 175 samples collected so far\n", + "Episode 34, 175 samples collected so far\n", + "Episode 35, 182 samples collected so far\n", + "Episode 36, 183 samples collected so far\n", + "Episode 37, 194 samples collected so far\n", + "Episode 38, 194 samples collected so far\n", + "Episode 39, 195 samples collected so far\n", + "Episode 40, 196 samples collected so far\n", + "Episode 41, 196 samples collected so far\n", + "Episode 42, 198 samples collected so far\n", + "Episode 43, 203 samples collected so far\n", + "Episode 44, 205 samples collected so far\n", + "Episode 45, 205 samples collected so far\n", + "Episode 46, 205 samples collected so far\n", + "Episode 47, 205 samples collected so far\n", + "Episode 48, 207 samples collected so far\n", + "Episode 49, 208 samples collected so far\n", + "Episode 50, 208 samples collected so far\n", + "Episode 51, 208 samples collected so far\n", + "Episode 52, 208 samples collected so far\n", + "Episode 53, 208 samples collected so far\n", + "Episode 54, 208 samples collected so far\n", + "Episode 55, 208 samples collected so far\n", + "Episode 56, 222 samples collected so far\n", + "Episode 57, 228 samples collected so far\n", + "Episode 58, 239 samples collected so far\n", + "Episode 59, 239 samples collected so far\n", + "Episode 60, 240 samples collected so far\n", + "Episode 61, 240 samples collected so far\n", + "Episode 62, 242 samples collected so far\n", + "Episode 63, 242 samples collected so far\n", + "Episode 64, 242 samples collected so far\n", + "Episode 65, 248 samples collected so far\n", + "Episode 66, 248 samples collected so far\n", + "Episode 67, 249 samples collected so far\n", + "Episode 68, 249 samples collected so far\n", + "Episode 69, 249 samples collected so far\n", + "Episode 70, 250 samples collected so far\n", + "Episode 71, 251 samples collected so far\n", + "Episode 72, 252 samples collected so far\n", + "Episode 73, 254 samples collected so far\n", + "Episode 74, 255 samples collected so far\n", + "Episode 75, 256 samples collected so far\n", + "Episode 76, 256 samples collected so far\n", + "Episode 77, 283 samples collected so far\n", + "Episode 78, 287 samples collected so far\n", + "Episode 79, 288 samples collected so far\n", + "Episode 80, 288 samples collected so far\n", + "Episode 81, 348 samples collected so far\n", + "Episode 82, 354 samples collected so far\n", + "Episode 83, 354 samples collected so far\n", + "Episode 84, 354 samples collected so far\n", + "Episode 85, 355 samples collected so far\n", + "Episode 86, 363 samples collected so far\n", + "Episode 87, 363 samples collected so far\n", + "Episode 88, 363 samples collected so far\n", + "Episode 89, 363 samples collected so far\n", + "Episode 90, 363 samples collected so far\n", + "Episode 91, 365 samples collected so far\n", + "Episode 92, 366 samples collected so far\n", + "Episode 93, 367 samples collected so far\n", + "Episode 94, 367 samples collected so far\n", + "Episode 95, 368 samples collected so far\n", + "Episode 96, 374 samples collected so far\n", + "Episode 97, 374 samples collected so far\n", + "Episode 98, 374 samples collected so far\n", + "Episode 99, 374 samples collected so far\n", + "Episode 100, 375 samples collected so far\n", + "Episode 101, 375 samples collected so far\n", + "Episode 102, 378 samples collected so far\n", + "Episode 103, 380 samples collected so far\n", + "Episode 104, 383 samples collected so far\n", + "Episode 105, 384 samples collected so far\n", + "Episode 106, 393 samples collected so far\n", + "Episode 107, 394 samples collected so far\n", + "Episode 108, 395 samples collected so far\n", + "Episode 109, 396 samples collected so far\n", + "Episode 110, 406 samples collected so far\n", + "Episode 111, 415 samples collected so far\n", + "Episode 112, 419 samples collected so far\n", + "Episode 113, 419 samples collected so far\n", + "Episode 114, 420 samples collected so far\n", + "Episode 115, 421 samples collected so far\n", + "Episode 116, 421 samples collected so far\n", + "Episode 117, 421 samples collected so far\n", + "Episode 118, 423 samples collected so far\n", + "Episode 119, 423 samples collected so far\n", + "Episode 120, 425 samples collected so far\n", + "Episode 121, 426 samples collected so far\n", + "Episode 122, 426 samples collected so far\n", + "Episode 123, 426 samples collected so far\n", + "Episode 124, 429 samples collected so far\n", + "Episode 125, 436 samples collected so far\n", + "Episode 126, 444 samples collected so far\n", + "Episode 127, 445 samples collected so far\n", + "Episode 128, 448 samples collected so far\n", + "Episode 129, 450 samples collected so far\n", + "Episode 130, 451 samples collected so far\n", + "Episode 131, 469 samples collected so far\n", + "Episode 132, 469 samples collected so far\n", + "Episode 133, 476 samples collected so far\n", + "Episode 134, 481 samples collected so far\n", + "Episode 135, 483 samples collected so far\n", + "Episode 136, 486 samples collected so far\n", + "Episode 137, 487 samples collected so far\n", + "Episode 138, 488 samples collected so far\n", + "Episode 139, 489 samples collected so far\n", + "Episode 140, 489 samples collected so far\n", + "Episode 141, 502 samples collected so far\n", + "Episode 142, 518 samples collected so far\n", + "Episode 143, 518 samples collected so far\n", + "Episode 144, 520 samples collected so far\n", + "Episode 145, 522 samples collected so far\n", + "Episode 146, 522 samples collected so far\n", + "Episode 147, 522 samples collected so far\n", + "Episode 148, 522 samples collected so far\n", + "Episode 149, 523 samples collected so far\n", + "Episode 150, 523 samples collected so far\n", + "Episode 151, 523 samples collected so far\n", + "Episode 152, 523 samples collected so far\n", + "Episode 153, 524 samples collected so far\n", + "Episode 154, 527 samples collected so far\n", + "Episode 155, 530 samples collected so far\n", + "Episode 156, 530 samples collected so far\n", + "Episode 157, 530 samples collected so far\n", + "Episode 158, 531 samples collected so far\n", + "Episode 159, 539 samples collected so far\n", + "Episode 160, 539 samples collected so far\n", + "Episode 161, 542 samples collected so far\n", + "Episode 162, 543 samples collected so far\n", + "Episode 163, 543 samples collected so far\n", + "Episode 164, 554 samples collected so far\n", + "Episode 165, 556 samples collected so far\n", + "Episode 166, 558 samples collected so far\n", + "Episode 167, 564 samples collected so far\n", + "Episode 168, 565 samples collected so far\n", + "Episode 169, 565 samples collected so far\n", + "Episode 170, 565 samples collected so far\n", + "Episode 171, 577 samples collected so far\n", + "Episode 172, 577 samples collected so far\n", + "Episode 173, 578 samples collected so far\n", + "Episode 174, 578 samples collected so far\n", + "Episode 175, 581 samples collected so far\n", + "Episode 176, 583 samples collected so far\n", + "Episode 177, 583 samples collected so far\n", + "Episode 178, 584 samples collected so far\n", + "Episode 179, 584 samples collected so far\n", + "Episode 180, 585 samples collected so far\n", + "Episode 181, 585 samples collected so far\n", + "Episode 182, 585 samples collected so far\n", + "Episode 183, 586 samples collected so far\n", + "Episode 184, 606 samples collected so far\n", + "Episode 185, 608 samples collected so far\n", + "Episode 186, 609 samples collected so far\n", + "Episode 187, 610 samples collected so far\n", + "Episode 188, 610 samples collected so far\n", + "Episode 189, 610 samples collected so far\n", + "Episode 190, 611 samples collected so far\n", + "Episode 191, 611 samples collected so far\n", + "Episode 192, 613 samples collected so far\n", + "Episode 193, 614 samples collected so far\n", + "Episode 194, 616 samples collected so far\n", + "Episode 195, 617 samples collected so far\n", + "Episode 196, 621 samples collected so far\n", + "Episode 197, 621 samples collected so far\n", + "Episode 198, 624 samples collected so far\n", + "Episode 199, 629 samples collected so far\n", + "Episode 200, 629 samples collected so far\n", + "Episode 201, 629 samples collected so far\n", + "Episode 202, 629 samples collected so far\n", + "Episode 203, 631 samples collected so far\n", + "Episode 204, 631 samples collected so far\n", + "Episode 205, 632 samples collected so far\n", + "Episode 206, 639 samples collected so far\n", + "Episode 207, 640 samples collected so far\n", + "Episode 208, 642 samples collected so far\n", + "Episode 209, 642 samples collected so far\n", + "Episode 210, 650 samples collected so far\n", + "Episode 211, 650 samples collected so far\n", + "Episode 212, 653 samples collected so far\n", + "Episode 213, 655 samples collected so far\n", + "Episode 214, 655 samples collected so far\n", + "Episode 215, 655 samples collected so far\n", + "Episode 216, 655 samples collected so far\n", + "Episode 217, 656 samples collected so far\n", + "Episode 218, 657 samples collected so far\n", + "Episode 219, 657 samples collected so far\n", + "Episode 220, 660 samples collected so far\n", + "Episode 221, 662 samples collected so far\n", + "Episode 222, 663 samples collected so far\n", + "Episode 223, 663 samples collected so far\n", + "Episode 224, 667 samples collected so far\n", + "Episode 225, 667 samples collected so far\n", + "Episode 226, 668 samples collected so far\n", + "Episode 227, 670 samples collected so far\n", + "Episode 228, 670 samples collected so far\n", + "Episode 229, 671 samples collected so far\n", + "Episode 230, 679 samples collected so far\n", + "Episode 231, 679 samples collected so far\n", + "Episode 232, 679 samples collected so far\n", + "Episode 233, 680 samples collected so far\n", + "Episode 234, 687 samples collected so far\n", + "Episode 235, 689 samples collected so far\n", + "Episode 236, 691 samples collected so far\n", + "Episode 237, 699 samples collected so far\n", + "Episode 238, 700 samples collected so far\n", + "Episode 239, 700 samples collected so far\n", + "Episode 240, 700 samples collected so far\n", + "Episode 241, 700 samples collected so far\n", + "Episode 242, 707 samples collected so far\n", + "Episode 243, 708 samples collected so far\n", + "Episode 244, 711 samples collected so far\n", + "Episode 245, 712 samples collected so far\n", + "Episode 246, 712 samples collected so far\n", + "Episode 247, 712 samples collected so far\n", + "Episode 248, 722 samples collected so far\n", + "Episode 249, 722 samples collected so far\n", + "Episode 250, 729 samples collected so far\n", + "Episode 251, 729 samples collected so far\n", + "Episode 252, 729 samples collected so far\n", + "Episode 253, 734 samples collected so far\n", + "Episode 254, 738 samples collected so far\n", + "Episode 255, 739 samples collected so far\n", + "Episode 256, 741 samples collected so far\n", + "Episode 257, 741 samples collected so far\n", + "Episode 258, 741 samples collected so far\n", + "Episode 259, 741 samples collected so far\n", + "Episode 260, 741 samples collected so far\n", + "Episode 261, 741 samples collected so far\n", + "Episode 262, 741 samples collected so far\n", + "Episode 263, 743 samples collected so far\n", + "Episode 264, 743 samples collected so far\n", + "Episode 265, 744 samples collected so far\n", + "Episode 266, 749 samples collected so far\n", + "Episode 267, 751 samples collected so far\n", + "Episode 268, 753 samples collected so far\n", + "Episode 269, 753 samples collected so far\n", + "Episode 270, 753 samples collected so far\n", + "Episode 271, 754 samples collected so far\n", + "Episode 272, 754 samples collected so far\n", + "Episode 273, 756 samples collected so far\n", + "Episode 274, 756 samples collected so far\n", + "Episode 275, 756 samples collected so far\n", + "Episode 276, 756 samples collected so far\n", + "Episode 277, 757 samples collected so far\n", + "Episode 278, 757 samples collected so far\n", + "Episode 279, 759 samples collected so far\n", + "Episode 280, 759 samples collected so far\n", + "Episode 281, 760 samples collected so far\n", + "Episode 282, 763 samples collected so far\n", + "Episode 283, 771 samples collected so far\n", + "Episode 284, 772 samples collected so far\n", + "Episode 285, 772 samples collected so far\n", + "Episode 286, 772 samples collected so far\n", + "Episode 287, 773 samples collected so far\n", + "Episode 288, 774 samples collected so far\n", + "Episode 289, 781 samples collected so far\n", + "Episode 290, 784 samples collected so far\n", + "Episode 291, 805 samples collected so far\n", + "Episode 292, 805 samples collected so far\n", + "Episode 293, 806 samples collected so far\n", + "Episode 294, 813 samples collected so far\n", + "Episode 295, 815 samples collected so far\n", + "Episode 296, 816 samples collected so far\n", + "Episode 297, 818 samples collected so far\n", + "Episode 298, 818 samples collected so far\n", + "Episode 299, 818 samples collected so far\n", + "Episode 300, 818 samples collected so far\n", + "Episode 301, 818 samples collected so far\n", + "Episode 302, 819 samples collected so far\n", + "Episode 303, 821 samples collected so far\n", + "Episode 304, 822 samples collected so far\n", + "Episode 305, 822 samples collected so far\n", + "Episode 306, 822 samples collected so far\n", + "Episode 307, 825 samples collected so far\n", + "Episode 308, 825 samples collected so far\n", + "Episode 309, 825 samples collected so far\n", + "Episode 310, 826 samples collected so far\n", + "Episode 311, 829 samples collected so far\n", + "Episode 312, 831 samples collected so far\n", + "Episode 313, 831 samples collected so far\n", + "Episode 314, 833 samples collected so far\n", + "Episode 315, 836 samples collected so far\n", + "Episode 316, 836 samples collected so far\n", + "Episode 317, 837 samples collected so far\n", + "Episode 318, 837 samples collected so far\n", + "Episode 319, 837 samples collected so far\n", + "Episode 320, 837 samples collected so far\n", + "Episode 321, 837 samples collected so far\n", + "Episode 322, 841 samples collected so far\n", + "Episode 323, 865 samples collected so far\n", + "Episode 324, 877 samples collected so far\n", + "Episode 325, 881 samples collected so far\n", + "Episode 326, 881 samples collected so far\n", + "Episode 327, 881 samples collected so far\n", + "Episode 328, 882 samples collected so far\n", + "Episode 329, 882 samples collected so far\n", + "Episode 330, 882 samples collected so far\n", + "Episode 331, 886 samples collected so far\n", + "Episode 332, 893 samples collected so far\n", + "Episode 333, 897 samples collected so far\n", + "Episode 334, 897 samples collected so far\n", + "Episode 335, 897 samples collected so far\n", + "Episode 336, 901 samples collected so far\n", + "Episode 337, 906 samples collected so far\n", + "Episode 338, 907 samples collected so far\n", + "Episode 339, 907 samples collected so far\n", + "Episode 340, 907 samples collected so far\n", + "Episode 341, 907 samples collected so far\n", + "Episode 342, 910 samples collected so far\n", + "Episode 343, 910 samples collected so far\n", + "Episode 344, 910 samples collected so far\n", + "Episode 345, 911 samples collected so far\n", + "Episode 346, 911 samples collected so far\n", + "Episode 347, 913 samples collected so far\n", + "Episode 348, 917 samples collected so far\n", + "Episode 349, 917 samples collected so far\n", + "Episode 350, 917 samples collected so far\n", + "Episode 351, 919 samples collected so far\n", + "Episode 352, 919 samples collected so far\n", + "Episode 353, 923 samples collected so far\n", + "Episode 354, 927 samples collected so far\n", + "Episode 355, 928 samples collected so far\n", + "Episode 356, 928 samples collected so far\n", + "Episode 357, 933 samples collected so far\n", + "Episode 358, 938 samples collected so far\n", + "Episode 359, 944 samples collected so far\n", + "Episode 360, 946 samples collected so far\n", + "Episode 361, 949 samples collected so far\n", + "Episode 362, 953 samples collected so far\n", + "Episode 363, 954 samples collected so far\n", + "Episode 364, 957 samples collected so far\n", + "Episode 365, 960 samples collected so far\n", + "Episode 366, 962 samples collected so far\n", + "Episode 367, 968 samples collected so far\n", + "Episode 368, 968 samples collected so far\n", + "Episode 369, 968 samples collected so far\n", + "Episode 370, 975 samples collected so far\n", + "Episode 371, 975 samples collected so far\n", + "Episode 372, 975 samples collected so far\n", + "Episode 373, 976 samples collected so far\n", + "Episode 374, 977 samples collected so far\n", + "Episode 375, 979 samples collected so far\n", + "Episode 376, 979 samples collected so far\n", + "Episode 377, 979 samples collected so far\n", + "Episode 378, 980 samples collected so far\n", + "Episode 379, 980 samples collected so far\n", + "Episode 380, 980 samples collected so far\n", + "Episode 381, 980 samples collected so far\n", + "Episode 382, 980 samples collected so far\n", + "Episode 383, 981 samples collected so far\n", + "Episode 384, 983 samples collected so far\n", + "Episode 385, 991 samples collected so far\n", + "Episode 386, 998 samples collected so far\n", + "Episode 387, 998 samples collected so far\n", + "Episode 388, 1000 samples collected so far\n" + ] + } + ], + "source": [ + "episode_counter, sample_counter = 0, 0\n", + "Path(\"samples/\").mkdir(exist_ok=True)\n", + "\n", + "# We will solve problems (run episodes) until we have saved enough samples\n", + "while sample_counter < DATA_MAX_SAMPLES:\n", + " episode_counter += 1\n", + "\n", + " observation, action_set, _, done, _ = env.reset(next(instances))\n", + " while not done:\n", + " (scores, scores_are_expert), node_observation = observation\n", + " action = action_set[scores[action_set].argmax()]\n", + "\n", + " # Only save samples if they are coming from the expert (strong branching)\n", + " if scores_are_expert and (sample_counter < DATA_MAX_SAMPLES):\n", + " sample_counter += 1\n", + " data = [node_observation, action, action_set, scores]\n", + " filename = f\"samples/sample_{sample_counter}.pkl\"\n", + "\n", + " with gzip.open(filename, \"wb\") as f:\n", + " pickle.dump(data, f)\n", + "\n", + " observation, action_set, _, done, _ = env.step(action)\n", + "\n", + " print(f\"Episode {episode_counter}, {sample_counter} samples collected so far\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 2. Train a GNN\n", + "\n", + "Our next step is to train a GNN classifier on these collected samples to predict similar choices to strong branching." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "DEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will first define pytorch geometric data classes to handle the bipartite graph data." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "class BipartiteNodeData(torch_geometric.data.Data):\n", + " \"\"\"\n", + " This class encode a node bipartite graph observation as returned by the `ecole.observation.NodeBipartite`\n", + " observation function in a format understood by the pytorch geometric data handlers.\n", + " \"\"\"\n", + "\n", + " def __init__(\n", + " self,\n", + " constraint_features,\n", + " edge_indices,\n", + " edge_features,\n", + " variable_features,\n", + " candidates,\n", + " nb_candidates,\n", + " candidate_choice,\n", + " candidate_scores,\n", + " ):\n", + " super().__init__()\n", + " self.constraint_features = constraint_features\n", + " self.edge_index = edge_indices\n", + " self.edge_attr = edge_features\n", + " self.variable_features = variable_features\n", + " self.candidates = candidates\n", + " self.nb_candidates = nb_candidates\n", + " self.candidate_choices = candidate_choice\n", + " self.candidate_scores = candidate_scores\n", + "\n", + " def __inc__(self, key, value, store, *args, **kwargs):\n", + " \"\"\"\n", + " We overload the pytorch geometric method that tells how to increment indices when concatenating graphs\n", + " for those entries (edge index, candidates) for which this is not obvious.\n", + " \"\"\"\n", + " if key == \"edge_index\":\n", + " return torch.tensor(\n", + " [[self.constraint_features.size(0)], [self.variable_features.size(0)]]\n", + " )\n", + " elif key == \"candidates\":\n", + " return self.variable_features.size(0)\n", + " else:\n", + " return super().__inc__(key, value, *args, **kwargs)\n", + "\n", + "\n", + "class GraphDataset(torch_geometric.data.Dataset):\n", + " \"\"\"\n", + " This class encodes a collection of graphs, as well as a method to load such graphs from the disk.\n", + " It can be used in turn by the data loaders provided by pytorch geometric.\n", + " \"\"\"\n", + "\n", + " def __init__(self, sample_files):\n", + " super().__init__(root=None, transform=None, pre_transform=None)\n", + " self.sample_files = sample_files\n", + "\n", + " def len(self):\n", + " return len(self.sample_files)\n", + "\n", + " def get(self, index):\n", + " \"\"\"\n", + " This method loads a node bipartite graph observation as saved on the disk during data collection.\n", + " \"\"\"\n", + " with gzip.open(self.sample_files[index], \"rb\") as f:\n", + " sample = pickle.load(f)\n", + "\n", + " sample_observation, sample_action, sample_action_set, sample_scores = sample\n", + " \n", + " constraint_features = sample_observation.row_features\n", + " edge_indices = sample_observation.edge_features.indices.astype(np.int32)\n", + " edge_features = np.expand_dims(sample_observation.edge_features.values, axis=-1)\n", + " variable_features = sample_observation.variable_features\n", + "\n", + " # We note on which variables we were allowed to branch, the scores as well as the choice\n", + " # taken by strong branching (relative to the candidates)\n", + " candidates = np.array(sample_action_set, dtype=np.int32)\n", + " candidate_scores = np.array([sample_scores[j] for j in candidates])\n", + " candidate_choice = np.where(candidates == sample_action)[0][0]\n", + "\n", + " graph = BipartiteNodeData(\n", + " torch.FloatTensor(constraint_features),\n", + " torch.LongTensor(edge_indices),\n", + " torch.FloatTensor(edge_features),\n", + " torch.FloatTensor(variable_features),\n", + " torch.LongTensor(candidates),\n", + " len(candidates),\n", + " torch.LongTensor([candidate_choice]),\n", + " torch.FloatTensor(candidate_scores)\n", + " )\n", + "\n", + " # We must tell pytorch geometric how many nodes there are, for indexing purposes\n", + " graph.num_nodes = constraint_features.shape[0] + variable_features.shape[0]\n", + "\n", + " return graph" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then prepare the data loaders." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "sample_files = [str(path) for path in Path(\"samples/\").glob(\"sample_*.pkl\")]\n", + "train_files = sample_files[: int(0.8 * len(sample_files))]\n", + "valid_files = sample_files[int(0.8 * len(sample_files)) :]\n", + "\n", + "train_data = GraphDataset(train_files)\n", + "train_loader = torch_geometric.loader.DataLoader(train_data, batch_size=32, shuffle=True)\n", + "valid_data = GraphDataset(valid_files)\n", + "valid_loader = torch_geometric.loader.DataLoader(valid_data, batch_size=128, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we will define our graph neural network architecture." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "class GNNPolicy(torch.nn.Module):\n", + " def __init__(self):\n", + " super().__init__()\n", + " emb_size = 64\n", + " cons_nfeats = 5\n", + " edge_nfeats = 1\n", + " var_nfeats = 19\n", + "\n", + " # CONSTRAINT EMBEDDING\n", + " self.cons_embedding = torch.nn.Sequential(\n", + " torch.nn.LayerNorm(cons_nfeats),\n", + " torch.nn.Linear(cons_nfeats, emb_size),\n", + " torch.nn.ReLU(),\n", + " torch.nn.Linear(emb_size, emb_size),\n", + " torch.nn.ReLU(),\n", + " )\n", + "\n", + " # EDGE EMBEDDING\n", + " self.edge_embedding = torch.nn.Sequential(\n", + " torch.nn.LayerNorm(edge_nfeats),\n", + " )\n", + "\n", + " # VARIABLE EMBEDDING\n", + " self.var_embedding = torch.nn.Sequential(\n", + " torch.nn.LayerNorm(var_nfeats),\n", + " torch.nn.Linear(var_nfeats, emb_size),\n", + " torch.nn.ReLU(),\n", + " torch.nn.Linear(emb_size, emb_size),\n", + " torch.nn.ReLU(),\n", + " )\n", + "\n", + " self.conv_v_to_c = BipartiteGraphConvolution()\n", + " self.conv_c_to_v = BipartiteGraphConvolution()\n", + "\n", + " self.output_module = torch.nn.Sequential(\n", + " torch.nn.Linear(emb_size, emb_size),\n", + " torch.nn.ReLU(),\n", + " torch.nn.Linear(emb_size, 1, bias=False),\n", + " )\n", + "\n", + " def forward(\n", + " self, constraint_features, edge_indices, edge_features, variable_features\n", + " ):\n", + " reversed_edge_indices = torch.stack([edge_indices[1], edge_indices[0]], dim=0)\n", + "\n", + " # First step: linear embedding layers to a common dimension (64)\n", + " constraint_features = self.cons_embedding(constraint_features)\n", + " edge_features = self.edge_embedding(edge_features)\n", + " variable_features = self.var_embedding(variable_features)\n", + "\n", + " # Two half convolutions\n", + " constraint_features = self.conv_v_to_c(\n", + " variable_features, reversed_edge_indices, edge_features, constraint_features\n", + " )\n", + " variable_features = self.conv_c_to_v(\n", + " constraint_features, edge_indices, edge_features, variable_features\n", + " )\n", + "\n", + " # A final MLP on the variable features\n", + " output = self.output_module(variable_features).squeeze(-1)\n", + " return output\n", + "\n", + "\n", + "class BipartiteGraphConvolution(torch_geometric.nn.MessagePassing):\n", + " \"\"\"\n", + " The bipartite graph convolution is already provided by pytorch geometric and we merely need\n", + " to provide the exact form of the messages being passed.\n", + " \"\"\"\n", + "\n", + " def __init__(self):\n", + " super().__init__(\"add\")\n", + " emb_size = 64\n", + "\n", + " self.feature_module_left = torch.nn.Sequential(\n", + " torch.nn.Linear(emb_size, emb_size)\n", + " )\n", + " self.feature_module_edge = torch.nn.Sequential(\n", + " torch.nn.Linear(1, emb_size, bias=False)\n", + " )\n", + " self.feature_module_right = torch.nn.Sequential(\n", + " torch.nn.Linear(emb_size, emb_size, bias=False)\n", + " )\n", + " self.feature_module_final = torch.nn.Sequential(\n", + " torch.nn.LayerNorm(emb_size),\n", + " torch.nn.ReLU(),\n", + " torch.nn.Linear(emb_size, emb_size),\n", + " )\n", + "\n", + " self.post_conv_module = torch.nn.Sequential(torch.nn.LayerNorm(emb_size))\n", + "\n", + " # output_layers\n", + " self.output_module = torch.nn.Sequential(\n", + " torch.nn.Linear(2 * emb_size, emb_size),\n", + " torch.nn.ReLU(),\n", + " torch.nn.Linear(emb_size, emb_size),\n", + " )\n", + "\n", + " def forward(self, left_features, edge_indices, edge_features, right_features):\n", + " \"\"\"\n", + " This method sends the messages, computed in the message method.\n", + " \"\"\"\n", + " output = self.propagate(\n", + " edge_indices,\n", + " size=(left_features.shape[0], right_features.shape[0]),\n", + " node_features=(left_features, right_features),\n", + " edge_features=edge_features,\n", + " )\n", + " return self.output_module(\n", + " torch.cat([self.post_conv_module(output), right_features], dim=-1)\n", + " )\n", + "\n", + " def message(self, node_features_i, node_features_j, edge_features):\n", + " output = self.feature_module_final(\n", + " self.feature_module_left(node_features_i)\n", + " + self.feature_module_edge(edge_features)\n", + " + self.feature_module_right(node_features_j)\n", + " )\n", + " return output\n", + "\n", + "\n", + "policy = GNNPolicy().to(DEVICE)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With this model we can predict a probability distribution over actions as follows." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "tensor([0.0155, 0.0153, 0.0153, 0.0154, 0.0153, 0.0153, 0.0154, 0.0154, 0.0153,\n", + " 0.0154, 0.0155, 0.0153, 0.0154, 0.0153, 0.0154, 0.0154, 0.0153, 0.0153,\n", + " 0.0154, 0.0154, 0.0154, 0.0153, 0.0154, 0.0154, 0.0154, 0.0153, 0.0153,\n", + " 0.0154, 0.0154, 0.0153, 0.0153, 0.0154, 0.0154, 0.0155, 0.0153, 0.0154,\n", + " 0.0153, 0.0153, 0.0154, 0.0154, 0.0153, 0.0154, 0.0154, 0.0154, 0.0154,\n", + " 0.0154, 0.0153, 0.0153, 0.0154, 0.0154, 0.0154, 0.0155, 0.0154, 0.0154,\n", + " 0.0154, 0.0154, 0.0154, 0.0154, 0.0154, 0.0154, 0.0154, 0.0154, 0.0154,\n", + " 0.0154, 0.0154], device='cuda:0', grad_fn=)\n" + ] + } + ], + "source": [ + "observation = train_data[0].to(DEVICE)\n", + "\n", + "logits = policy(\n", + " observation.constraint_features,\n", + " observation.edge_index,\n", + " observation.edge_attr,\n", + " observation.variable_features,\n", + ")\n", + "action_distribution = F.softmax(logits[observation.candidates], dim=-1)\n", + "\n", + "print(action_distribution)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As can be seen, with randomly initialized weights, the initial distributions tend to be close to uniform.\n", + "Next, we will define two helper functions: one to train or evaluate the model on a whole epoch and compute metrics for monitoring, and one for padding tensors when doing predictions on a batch of graphs of potentially different number of variables." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "def process(policy, data_loader, optimizer=None):\n", + " \"\"\"\n", + " This function will process a whole epoch of training or validation, depending on whether an optimizer is provided.\n", + " \"\"\"\n", + " mean_loss = 0\n", + " mean_acc = 0\n", + "\n", + " n_samples_processed = 0\n", + " with torch.set_grad_enabled(optimizer is not None):\n", + " for batch in data_loader:\n", + " batch = batch.to(DEVICE)\n", + " # Compute the logits (i.e. pre-softmax activations) according to the policy on the concatenated graphs\n", + " logits = policy(\n", + " batch.constraint_features,\n", + " batch.edge_index,\n", + " batch.edge_attr,\n", + " batch.variable_features,\n", + " )\n", + " # Index the results by the candidates, and split and pad them\n", + " logits = pad_tensor(logits[batch.candidates], batch.nb_candidates)\n", + " # Compute the usual cross-entropy classification loss\n", + " loss = F.cross_entropy(logits, batch.candidate_choices)\n", + "\n", + " if optimizer is not None:\n", + " optimizer.zero_grad()\n", + " loss.backward()\n", + " optimizer.step()\n", + "\n", + " true_scores = pad_tensor(batch.candidate_scores, batch.nb_candidates)\n", + " true_bestscore = true_scores.max(dim=-1, keepdims=True).values\n", + "\n", + " predicted_bestindex = logits.max(dim=-1, keepdims=True).indices\n", + " accuracy = (\n", + " (true_scores.gather(-1, predicted_bestindex) == true_bestscore)\n", + " .float()\n", + " .mean()\n", + " .item()\n", + " )\n", + "\n", + " mean_loss += loss.item() * batch.num_graphs\n", + " mean_acc += accuracy * batch.num_graphs\n", + " n_samples_processed += batch.num_graphs\n", + "\n", + " mean_loss /= n_samples_processed\n", + " mean_acc /= n_samples_processed\n", + " return mean_loss, mean_acc\n", + "\n", + "\n", + "def pad_tensor(input_, pad_sizes, pad_value=-1e8):\n", + " \"\"\"\n", + " This utility function splits a tensor and pads each split to make them all the same size, then stacks them.\n", + " \"\"\"\n", + " max_pad_size = pad_sizes.max()\n", + " output = input_.split(pad_sizes.cpu().numpy().tolist())\n", + " output = torch.stack(\n", + " [\n", + " F.pad(slice_, (0, max_pad_size - slice_.size(0)), \"constant\", pad_value)\n", + " for slice_ in output\n", + " ],\n", + " dim=0,\n", + " )\n", + " return output" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "After this, we can actually create the model and train it." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch 1\n", + "Train loss: 4.116, accuracy 0.294\n", + "Valid loss: 3.767, accuracy 0.305\n", + "Epoch 2\n", + "Train loss: 3.720, accuracy 0.399\n", + "Valid loss: 3.390, accuracy 0.440\n", + "Epoch 3\n", + "Train loss: 3.485, accuracy 0.465\n", + "Valid loss: 3.275, accuracy 0.430\n", + "Epoch 4\n", + "Train loss: 3.490, accuracy 0.469\n", + "Valid loss: 3.289, accuracy 0.385\n", + "Epoch 5\n", + "Train loss: 3.489, accuracy 0.484\n", + "Valid loss: 3.275, accuracy 0.435\n", + "Epoch 6\n", + "Train loss: 3.447, accuracy 0.505\n", + "Valid loss: 3.241, accuracy 0.445\n", + "Epoch 7\n", + "Train loss: 3.427, accuracy 0.501\n", + "Valid loss: 3.239, accuracy 0.430\n", + "Epoch 8\n", + "Train loss: 3.441, accuracy 0.500\n", + "Valid loss: 3.222, accuracy 0.405\n", + "Epoch 9\n", + "Train loss: 3.424, accuracy 0.502\n", + "Valid loss: 3.227, accuracy 0.465\n", + "Epoch 10\n", + "Train loss: 3.420, accuracy 0.491\n", + "Valid loss: 3.283, accuracy 0.410\n", + "Epoch 11\n", + "Train loss: 3.418, accuracy 0.486\n", + "Valid loss: 3.206, accuracy 0.440\n", + "Epoch 12\n", + "Train loss: 3.399, accuracy 0.497\n", + "Valid loss: 3.174, accuracy 0.455\n", + "Epoch 13\n", + "Train loss: 3.383, accuracy 0.506\n", + "Valid loss: 3.245, accuracy 0.440\n", + "Epoch 14\n", + "Train loss: 3.391, accuracy 0.512\n", + "Valid loss: 3.182, accuracy 0.435\n", + "Epoch 15\n", + "Train loss: 3.370, accuracy 0.517\n", + "Valid loss: 3.183, accuracy 0.460\n", + "Epoch 16\n", + "Train loss: 3.381, accuracy 0.507\n", + "Valid loss: 3.206, accuracy 0.425\n", + "Epoch 17\n", + "Train loss: 3.336, accuracy 0.520\n", + "Valid loss: 3.149, accuracy 0.455\n", + "Epoch 18\n", + "Train loss: 3.342, accuracy 0.517\n", + "Valid loss: 3.183, accuracy 0.450\n", + "Epoch 19\n", + "Train loss: 3.363, accuracy 0.525\n", + "Valid loss: 3.144, accuracy 0.450\n", + "Epoch 20\n", + "Train loss: 3.322, accuracy 0.514\n", + "Valid loss: 3.182, accuracy 0.455\n", + "Epoch 21\n", + "Train loss: 3.316, accuracy 0.526\n", + "Valid loss: 3.131, accuracy 0.475\n", + "Epoch 22\n", + "Train loss: 3.283, accuracy 0.522\n", + "Valid loss: 3.130, accuracy 0.465\n", + "Epoch 23\n", + "Train loss: 3.232, accuracy 0.552\n", + "Valid loss: 3.119, accuracy 0.430\n", + "Epoch 24\n", + "Train loss: 3.221, accuracy 0.529\n", + "Valid loss: 3.147, accuracy 0.465\n", + "Epoch 25\n", + "Train loss: 3.302, accuracy 0.520\n", + "Valid loss: 3.244, accuracy 0.435\n", + "Epoch 26\n", + "Train loss: 3.248, accuracy 0.511\n", + "Valid loss: 3.058, accuracy 0.475\n", + "Epoch 27\n", + "Train loss: 3.145, accuracy 0.530\n", + "Valid loss: 3.075, accuracy 0.455\n", + "Epoch 28\n", + "Train loss: 3.136, accuracy 0.524\n", + "Valid loss: 2.994, accuracy 0.450\n", + "Epoch 29\n", + "Train loss: 3.123, accuracy 0.530\n", + "Valid loss: 2.982, accuracy 0.495\n", + "Epoch 30\n", + "Train loss: 3.098, accuracy 0.550\n", + "Valid loss: 3.010, accuracy 0.470\n", + "Epoch 31\n", + "Train loss: 3.066, accuracy 0.546\n", + "Valid loss: 3.024, accuracy 0.485\n", + "Epoch 32\n", + "Train loss: 3.088, accuracy 0.527\n", + "Valid loss: 3.022, accuracy 0.460\n", + "Epoch 33\n", + "Train loss: 3.119, accuracy 0.540\n", + "Valid loss: 3.110, accuracy 0.425\n", + "Epoch 34\n", + "Train loss: 3.070, accuracy 0.527\n", + "Valid loss: 3.070, accuracy 0.440\n", + "Epoch 35\n", + "Train loss: 3.054, accuracy 0.531\n", + "Valid loss: 3.034, accuracy 0.445\n", + "Epoch 36\n", + "Train loss: 3.045, accuracy 0.544\n", + "Valid loss: 3.073, accuracy 0.435\n", + "Epoch 37\n", + "Train loss: 3.036, accuracy 0.535\n", + "Valid loss: 3.001, accuracy 0.470\n", + "Epoch 38\n", + "Train loss: 3.052, accuracy 0.539\n", + "Valid loss: 3.097, accuracy 0.435\n", + "Epoch 39\n", + "Train loss: 3.110, accuracy 0.529\n", + "Valid loss: 2.977, accuracy 0.435\n", + "Epoch 40\n", + "Train loss: 3.071, accuracy 0.537\n", + "Valid loss: 3.034, accuracy 0.450\n", + "Epoch 41\n", + "Train loss: 3.064, accuracy 0.545\n", + "Valid loss: 3.006, accuracy 0.485\n", + "Epoch 42\n", + "Train loss: 3.002, accuracy 0.557\n", + "Valid loss: 3.027, accuracy 0.410\n", + "Epoch 43\n", + "Train loss: 3.000, accuracy 0.540\n", + "Valid loss: 3.044, accuracy 0.430\n", + "Epoch 44\n", + "Train loss: 3.005, accuracy 0.531\n", + "Valid loss: 3.032, accuracy 0.465\n", + "Epoch 45\n", + "Train loss: 3.019, accuracy 0.552\n", + "Valid loss: 3.055, accuracy 0.415\n", + "Epoch 46\n", + "Train loss: 2.972, accuracy 0.531\n", + "Valid loss: 3.039, accuracy 0.440\n", + "Epoch 47\n", + "Train loss: 3.006, accuracy 0.536\n", + "Valid loss: 3.016, accuracy 0.445\n", + "Epoch 48\n", + "Train loss: 3.008, accuracy 0.531\n", + "Valid loss: 3.041, accuracy 0.440\n", + "Epoch 49\n", + "Train loss: 3.006, accuracy 0.545\n", + "Valid loss: 3.002, accuracy 0.450\n", + "Epoch 50\n", + "Train loss: 2.946, accuracy 0.556\n", + "Valid loss: 3.030, accuracy 0.450\n" + ] + } + ], + "source": [ + "optimizer = torch.optim.Adam(policy.parameters(), lr=LEARNING_RATE)\n", + "for epoch in range(NB_EPOCHS):\n", + " print(f\"Epoch {epoch+1}\")\n", + "\n", + " train_loss, train_acc = process(policy, train_loader, optimizer)\n", + " print(f\"Train loss: {train_loss:0.3f}, accuracy {train_acc:0.3f}\")\n", + "\n", + " valid_loss, valid_acc = process(policy, valid_loader, None)\n", + " print(f\"Valid loss: {valid_loss:0.3f}, accuracy {valid_acc:0.3f}\")\n", + "\n", + "torch.save(policy.state_dict(), \"trained_params.pkl\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 3 Evaluation\n", + "\n", + "Finally, we can evaluate the performance of the model. We first define appropriate environments. For benchmarking purposes, we include a trivial environment that merely runs SCIP." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "scip_parameters = {\n", + " \"separating/maxrounds\": 0,\n", + " \"presolving/maxrestarts\": 0,\n", + " \"limits/time\": 3600,\n", + "}\n", + "env = ecole.environment.Branching(\n", + " observation_function=ecole.observation.NodeBipartite(),\n", + " information_function={\n", + " \"nb_nodes\": ecole.reward.NNodes(),\n", + " \"time\": ecole.reward.SolvingTime(),\n", + " },\n", + " scip_params=scip_parameters,\n", + ")\n", + "default_env = ecole.environment.Configuring(\n", + " observation_function=None,\n", + " information_function={\n", + " \"nb_nodes\": ecole.reward.NNodes(),\n", + " \"time\": ecole.reward.SolvingTime(),\n", + " },\n", + " scip_params=scip_parameters,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then we can simply follow the environments, taking steps appropriately according to the GNN policy." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Instance 0 | SCIP nb nodes 97 | SCIP time 4.98 \n", + " | GNN nb nodes 466 | GNN time 4.19 \n", + " | Gain -380.41% | Gain 15.80%\n", + "Instance 1 | SCIP nb nodes 13 | SCIP time 2.54 \n", + " | GNN nb nodes 85 | GNN time 1.76 \n", + " | Gain -553.85% | Gain 30.84%\n", + "Instance 2 | SCIP nb nodes 11 | SCIP time 2.79 \n", + " | GNN nb nodes 154 | GNN time 2.41 \n", + " | Gain -1300.00% | Gain 13.60%\n", + "Instance 3 | SCIP nb nodes 1 | SCIP time 1.67 \n", + " | GNN nb nodes 12 | GNN time 1.19 \n", + " | Gain -1100.00% | Gain 28.74%\n", + "Instance 4 | SCIP nb nodes 23 | SCIP time 3.73 \n", + " | GNN nb nodes 195 | GNN time 2.46 \n", + " | Gain -747.83% | Gain 34.12%\n", + "Instance 5 | SCIP nb nodes 3 | SCIP time 1.92 \n", + " | GNN nb nodes 31 | GNN time 1.39 \n", + " | Gain -933.33% | Gain 27.76%\n", + "Instance 6 | SCIP nb nodes 1 | SCIP time 1.30 \n", + " | GNN nb nodes 1 | GNN time 1.47 \n", + " | Gain 0.00% | Gain -13.20%\n", + "Instance 7 | SCIP nb nodes 1 | SCIP time 1.32 \n", + " | GNN nb nodes 1 | GNN time 1.38 \n", + " | Gain 0.00% | Gain -3.89%\n", + "Instance 8 | SCIP nb nodes 3 | SCIP time 2.31 \n", + " | GNN nb nodes 29 | GNN time 1.56 \n", + " | Gain -866.67% | Gain 32.55%\n", + "Instance 9 | SCIP nb nodes 3 | SCIP time 2.04 \n", + " | GNN nb nodes 25 | GNN time 1.50 \n", + " | Gain -733.33% | Gain 26.25%\n", + "Instance 10 | SCIP nb nodes 7 | SCIP time 1.59 \n", + " | GNN nb nodes 75 | GNN time 1.52 \n", + " | Gain -971.43% | Gain 4.56%\n", + "Instance 11 | SCIP nb nodes 11 | SCIP time 2.76 \n", + " | GNN nb nodes 93 | GNN time 2.37 \n", + " | Gain -745.45% | Gain 14.02%\n", + "Instance 12 | SCIP nb nodes 73 | SCIP time 3.58 \n", + " | GNN nb nodes 185 | GNN time 2.59 \n", + " | Gain -153.42% | Gain 27.68%\n", + "Instance 13 | SCIP nb nodes 1 | SCIP time 0.03 \n", + " | GNN nb nodes 1 | GNN time 0.03 \n", + " | Gain 0.00% | Gain 1.69%\n", + "Instance 14 | SCIP nb nodes 3 | SCIP time 1.55 \n", + " | GNN nb nodes 21 | GNN time 1.38 \n", + " | Gain -600.00% | Gain 11.01%\n", + "Instance 15 | SCIP nb nodes 7 | SCIP time 2.31 \n", + " | GNN nb nodes 51 | GNN time 1.65 \n", + " | Gain -628.57% | Gain 28.30%\n", + "Instance 16 | SCIP nb nodes 3 | SCIP time 1.59 \n", + " | GNN nb nodes 13 | GNN time 1.47 \n", + " | Gain -333.33% | Gain 7.35%\n", + "Instance 17 | SCIP nb nodes 1 | SCIP time 1.10 \n", + " | GNN nb nodes 5 | GNN time 1.16 \n", + " | Gain -400.00% | Gain -5.15%\n", + "Instance 18 | SCIP nb nodes 15 | SCIP time 2.18 \n", + " | GNN nb nodes 153 | GNN time 1.74 \n", + " | Gain -920.00% | Gain 20.00%\n", + "Instance 19 | SCIP nb nodes 13 | SCIP time 3.26 \n", + " | GNN nb nodes 61 | GNN time 2.54 \n", + " | Gain -369.23% | Gain 22.27%\n" + ] + } + ], + "source": [ + "instances = ecole.instance.SetCoverGenerator(n_rows=500, n_cols=1000, density=0.05)\n", + "for instance_count, instance in zip(range(NB_EVAL_INSTANCES), instances):\n", + " # Run the GNN brancher\n", + " nb_nodes, time = 0, 0\n", + " observation, action_set, _, done, info = env.reset(instance)\n", + " nb_nodes += info[\"nb_nodes\"]\n", + " time += info[\"time\"]\n", + " while not done:\n", + " with torch.no_grad():\n", + " observation = (\n", + " torch.from_numpy(observation.row_features.astype(np.float32)).to(DEVICE),\n", + " torch.from_numpy(observation.edge_features.indices.astype(np.int64)).to(DEVICE),\n", + " torch.from_numpy(observation.edge_features.values.astype(np.float32)).view(-1, 1).to(DEVICE),\n", + " torch.from_numpy(observation.variable_features.astype(np.float32)).to(DEVICE),\n", + " )\n", + " logits = policy(*observation)\n", + " action = action_set[logits[action_set.astype(np.int64)].argmax()]\n", + " observation, action_set, _, done, info = env.step(action)\n", + " nb_nodes += info[\"nb_nodes\"]\n", + " time += info[\"time\"]\n", + "\n", + " # Run SCIP's default brancher\n", + " default_env.reset(instance)\n", + " _, _, _, _, default_info = default_env.step({})\n", + "\n", + " print(f\"Instance {instance_count: >3} | SCIP nb nodes {int(default_info['nb_nodes']): >4d} | SCIP time {default_info['time']: >6.2f} \")\n", + " print(f\" | GNN nb nodes {int(nb_nodes): >4d} | GNN time {time: >6.2f} \")\n", + " print(f\" | Gain {100*(1-nb_nodes/default_info['nb_nodes']): >8.2f}% | Gain {100*(1-time/default_info['time']): >8.2f}%\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also evaluate on instances larger and harder than those trained on, say with 600 rather than 500 constraints.\n", + "In addition, we showcase that the cumulative number of nodes and time required to solve an instance can also be computed directly using the `.cumsum()` method." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Instance 0 | SCIP nb nodes 29 | SCIP time 3.21 \n", + " | GNN nb nodes 113 | GNN time 2.12 \n", + " | Gain -289.66% | Gain 34.19%\n", + "Instance 1 | SCIP nb nodes 9 | SCIP time 3.32 \n", + " | GNN nb nodes 93 | GNN time 2.57 \n", + " | Gain -933.33% | Gain 22.41%\n", + "Instance 2 | SCIP nb nodes 17 | SCIP time 4.60 \n", + " | GNN nb nodes 103 | GNN time 3.13 \n", + " | Gain -505.88% | Gain 31.89%\n", + "Instance 3 | SCIP nb nodes 3 | SCIP time 2.63 \n", + " | GNN nb nodes 19 | GNN time 1.89 \n", + " | Gain -533.33% | Gain 28.39%\n", + "Instance 4 | SCIP nb nodes 7 | SCIP time 2.88 \n", + " | GNN nb nodes 52 | GNN time 2.33 \n", + " | Gain -642.86% | Gain 19.36%\n", + "Instance 5 | SCIP nb nodes 201 | SCIP time 5.39 \n", + " | GNN nb nodes 569 | GNN time 4.66 \n", + " | Gain -183.08% | Gain 13.69%\n", + "Instance 6 | SCIP nb nodes 912 | SCIP time 9.93 \n", + " | GNN nb nodes 1548 | GNN time 10.13 \n", + " | Gain -69.74% | Gain -2.02%\n", + "Instance 7 | SCIP nb nodes 7 | SCIP time 3.50 \n", + " | GNN nb nodes 75 | GNN time 2.54 \n", + " | Gain -971.43% | Gain 27.37%\n", + "Instance 8 | SCIP nb nodes 37 | SCIP time 3.78 \n", + " | GNN nb nodes 391 | GNN time 3.51 \n", + " | Gain -956.76% | Gain 6.94%\n", + "Instance 9 | SCIP nb nodes 1 | SCIP time 1.29 \n", + " | GNN nb nodes 1 | GNN time 1.13 \n", + " | Gain 0.00% | Gain 12.59%\n", + "Instance 10 | SCIP nb nodes 59 | SCIP time 3.98 \n", + " | GNN nb nodes 177 | GNN time 2.75 \n", + " | Gain -200.00% | Gain 30.96%\n", + "Instance 11 | SCIP nb nodes 1 | SCIP time 1.50 \n", + " | GNN nb nodes 15 | GNN time 1.49 \n", + " | Gain -1400.00% | Gain 0.51%\n", + "Instance 12 | SCIP nb nodes 3 | SCIP time 2.17 \n", + " | GNN nb nodes 25 | GNN time 1.72 \n", + " | Gain -733.33% | Gain 20.78%\n", + "Instance 13 | SCIP nb nodes 3 | SCIP time 2.50 \n", + " | GNN nb nodes 23 | GNN time 1.81 \n", + " | Gain -666.67% | Gain 27.66%\n", + "Instance 14 | SCIP nb nodes 147 | SCIP time 5.41 \n", + " | GNN nb nodes 349 | GNN time 4.39 \n", + " | Gain -137.41% | Gain 18.86%\n", + "Instance 15 | SCIP nb nodes 1 | SCIP time 1.33 \n", + " | GNN nb nodes 5 | GNN time 1.28 \n", + " | Gain -400.00% | Gain 3.52%\n", + "Instance 16 | SCIP nb nodes 122 | SCIP time 4.74 \n", + " | GNN nb nodes 223 | GNN time 3.16 \n", + " | Gain -82.79% | Gain 33.40%\n", + "Instance 17 | SCIP nb nodes 1 | SCIP time 2.05 \n", + " | GNN nb nodes 53 | GNN time 1.96 \n", + " | Gain -5200.00% | Gain 4.36%\n", + "Instance 18 | SCIP nb nodes 1 | SCIP time 1.41 \n", + " | GNN nb nodes 5 | GNN time 1.24 \n", + " | Gain -400.00% | Gain 12.35%\n", + "Instance 19 | SCIP nb nodes 14 | SCIP time 2.88 \n", + " | GNN nb nodes 93 | GNN time 1.98 \n", + " | Gain -564.29% | Gain 31.17%\n" + ] + } + ], + "source": [ + "instances = ecole.instance.SetCoverGenerator(n_rows=600, n_cols=1000, density=0.05)\n", + "scip_parameters = {\n", + " \"separating/maxrounds\": 0,\n", + " \"presolving/maxrestarts\": 0,\n", + " \"limits/time\": 3600,\n", + "}\n", + "env = ecole.environment.Branching(\n", + " observation_function=ecole.observation.NodeBipartite(),\n", + " information_function={\n", + " \"nb_nodes\": ecole.reward.NNodes().cumsum(),\n", + " \"time\": ecole.reward.SolvingTime().cumsum(),\n", + " },\n", + " scip_params=scip_parameters,\n", + ")\n", + "default_env = ecole.environment.Configuring(\n", + " observation_function=None,\n", + " information_function={\n", + " \"nb_nodes\": ecole.reward.NNodes().cumsum(),\n", + " \"time\": ecole.reward.SolvingTime().cumsum(),\n", + " },\n", + " scip_params=scip_parameters,\n", + ")\n", + "\n", + "for instance_count, instance in zip(range(NB_EVAL_INSTANCES), instances):\n", + " # Run the GNN brancher\n", + " observation, action_set, _, done, info = env.reset(instance)\n", + " while not done:\n", + " with torch.no_grad():\n", + " observation = (\n", + " torch.from_numpy(observation.row_features.astype(np.float32)).to(DEVICE),\n", + " torch.from_numpy(observation.edge_features.indices.astype(np.int64)).to(DEVICE),\n", + " torch.from_numpy(observation.edge_features.values.astype(np.float32)).view(-1, 1).to(DEVICE),\n", + " torch.from_numpy(observation.variable_features.astype(np.float32)).to(DEVICE),\n", + " )\n", + " logits = policy(*observation)\n", + " action = action_set[logits[action_set.astype(np.int64)].argmax()]\n", + " observation, action_set, _, done, info = env.step(action)\n", + " nb_nodes = info[\"nb_nodes\"]\n", + " time = info[\"time\"]\n", + "\n", + " # Run SCIP's default brancher\n", + " default_env.reset(instance)\n", + " _, _, _, _, default_info = default_env.step({})\n", + "\n", + " print(\n", + " f\"Instance {instance_count: >3} | SCIP nb nodes {int(default_info['nb_nodes']): >4d} | SCIP time {default_info['time']: >6.2f} \"\n", + " )\n", + " print(\n", + " f\" | GNN nb nodes {int(nb_nodes): >4d} | GNN time {time: >6.2f} \"\n", + " )\n", + " print(\n", + " f\" | Gain {100*(1-nb_nodes/default_info['nb_nodes']): >8.2f}% | Gain {100*(1-time/default_info['time']): >8.2f}%\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### References\n", + "\n", + "Gasse, M., Chételat, D., Ferroni, N., Charlin, L. and Lodi, A. (2019). Exact combinatorial optimization with graph convolutional neural networks. In Advances in Neural Information Processing Systems (pp. 15580-15592)." + ] + } + ], + "metadata": { + "celltoolbar": "Tags", + "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.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ecole/examples/configuring-bandits/conda-requirements.yaml b/ecole/examples/configuring-bandits/conda-requirements.yaml new file mode 100644 index 0000000..3c13c14 --- /dev/null +++ b/ecole/examples/configuring-bandits/conda-requirements.yaml @@ -0,0 +1,13 @@ +channels: + - conda-forge + +dependencies: + - python + - ipykernel + - jupyter + - ecole + - numpy + - scikit-optimize=0.8.1 + # Pin scikit-learn for scikit-optimize https://github.com/scikit-optimize/scikit-optimize/issues/569 + - scikit-learn<1.0 + - matplotlib diff --git a/ecole/examples/configuring-bandits/example.ipynb b/ecole/examples/configuring-bandits/example.ipynb new file mode 100644 index 0000000..144dc01 --- /dev/null +++ b/ecole/examples/configuring-bandits/example.ipynb @@ -0,0 +1,370 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Solver configuration as a bandit problem\n", + "\n", + "In this tutorial we are interested in minimizing the expected branch-and-bound tree size of the SCIP solver by tuning the parameter [`branching/scorefac`](https://www.scipopt.org/doc/html/PARAMETERS.php), which takes values in the range $[0,1]$. This parameter, used in combination with the sum score function (`branching/scorefunc=s`), controls the weighting of downward and upward gain predictions in the computation of branching scores. It has a default value of 0.167.\n", + "\n", + "Dependencies are given for conda in the file `conda-requirements.yaml`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Train, optimization, and test parameters of the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [ + "parameters" + ] + }, + "outputs": [], + "source": [ + "# Parameters set Papermill during testing, do not rename.\n", + "train_n_items = 100\n", + "train_n_bids = 100\n", + "train_add_item_prob = 0.7\n", + "optim_n_iters = 100\n", + "optim_n_burnins = 10\n", + "optim_seed = 42\n", + "test_n_items = 150\n", + "test_n_bids = 750\n", + "test_add_item_prob = 0.7\n", + "test_seed = 1337\n", + "test_n_evals = 5" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import ecole as ec\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import skopt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "## 1. Setting up the Ecole environment\n", + "\n", + "We formulate this parameter tuning task as a continuum-armed bandit problem, which we instantiate using a [`Configuring`](https://doc.ecole.ai/py/en/stable/reference/environments.html#configuring) environment. We request no observation (non-contextual bandit), and use the negative number of nodes as a reward (tree size minimization)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "env = ec.environment.Configuring(\n", + " # set up a few SCIP parameters\n", + " scip_params={\n", + " \"branching/scorefunc\": \"s\", # sum score function\n", + " \"branching/vanillafullstrong/priority\": 666666, # use vanillafullstrong (highest priority)\n", + " \"presolving/maxrounds\": 0, # deactivate presolving\n", + " },\n", + " # pure bandit, no observation\n", + " observation_function=None,\n", + " # minimize the total number of nodes\n", + " reward_function=-ec.reward.NNodes(),\n", + " # collect additional metrics for information purposes\n", + " information_function={\n", + " \"nnodes\": ec.reward.NNodes().cumsum(),\n", + " \"lpiters\": ec.reward.LpIterations().cumsum(),\n", + " \"time\": ec.reward.SolvingTime().cumsum(),\n", + " },\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We set up SCIP to use the sum score function for branching (`branching/scorefunc=s`), and the *vanillafullstrong* branching rule to mitigate the impact of branching heuristics (`branching/vanillafullstrong/priority=666666`). For the purpose of the tutorial we also deactivate presolving (`presolving/maxrounds=0`) in order to reduce computational time." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Setting up the training distribution\n", + "\n", + "For the purpose of this tutorial we will consider randomly generated Combinatorial Auction problems, as the problem distribution for which we want to configure the solver. We hence set up a `CombinatorialAuctionGenerator` that will generate such instances on the fly." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# infinite instance generator, new instances will be generated on-the-fly\n", + "instances = ec.instance.CombinatorialAuctionGenerator(\n", + " n_items=train_n_items, n_bids=train_n_bids, add_item_prob=train_add_item_prob\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For training we consider small-sized instances ($100\\times 100$), which are solved within seconds by SCIP but are difficult enough to produce tens of branch-and-bound nodes." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Solving the control problem\n", + "\n", + "We can now readily solve the optimization problem using an off-the-shelf optimization library, such as `scikit-optimize`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "iteration 10 / 100\n", + "iteration 20 / 100\n", + "iteration 30 / 100\n", + "iteration 40 / 100\n", + "iteration 50 / 100\n", + "iteration 60 / 100\n", + "iteration 70 / 100\n", + "iteration 80 / 100\n", + "iteration 90 / 100\n", + "iteration 100 / 100\n" + ] + } + ], + "source": [ + "env.seed(optim_seed) # environment (SCIP)\n", + "instances.seed(optim_seed) # instance generator\n", + "rng = np.random.RandomState(optim_seed) # optimizer\n", + "\n", + "# set up the optimizer\n", + "opt = skopt.Optimizer(\n", + " dimensions=[(0.0, 1.0)],\n", + " base_estimator=\"GP\",\n", + " n_initial_points=optim_n_burnins,\n", + " random_state=rng,\n", + " acq_func=\"PI\",\n", + " acq_optimizer=\"sampling\",\n", + " acq_optimizer_kwargs={\"n_points\": 10},\n", + ")\n", + "\n", + "assert optim_n_iters > optim_n_burnins\n", + "\n", + "# run the optimization\n", + "for i in range(optim_n_iters):\n", + "\n", + " if (i + 1) % 10 == 0:\n", + " print(f\"iteration {i+1} / {optim_n_iters}\")\n", + "\n", + " # pick up a new random instance\n", + " instance = next(instances)\n", + "\n", + " # start a new episode\n", + " env.reset(instance)\n", + "\n", + " # get the next action from the optimizer\n", + " x = opt.ask()\n", + " action = {\"branching/scorefac\": x[0]}\n", + "\n", + " # apply the action and collect the reward\n", + " _, _, reward, _, _ = env.step(action)\n", + "\n", + " # update the optimizer\n", + " opt.tell(x, -reward) # minimize the negated reward (eq. maximize the reward)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now visualize the result of the optimization process." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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vV6v7t69Wzt37qw7d+7UzZs3a//+/VVVdcGCBXruueeqquqvfvUrffbZZ1VV9dChQ9qzZ0/Nz8/Xv//973rRRRdpWVmZqqoeOHBAVVW7du2q+/btU1XVffv26amnnqr5+fmqqjpz5ky95557tKioSDt16qTr169Xn8+n06ZNqzhfIA888IBec801qqr6zTffqMfj0UWLFlU6n9fr1dNPP12/+eab485fU7lYptbXjyWsAAUaA/dwN69GnULCWkZRYN26Y1+6z2e2I8Rnn33GZZddhsfjoW3btpx++uksWrSo2vLvvfceM2fOZPDgwYwdO5bi4mK2bdvGBx98wI033ojfUaZly5bH1V24cCGrV69m9OjRDB48mKeffpqtW7eydu1acnJy6NmzJyLClVcG99H55JNPKo7l5uaSm5tbcezFF19k6NChDBkyhFWrVrF69eqgbbgtZ7HEI406HJBVRlGgd29Yu9Z88QkJZrse9O/fn5dfDp7LS2u5YFtVeeWVV+hdRSZVDemGrKqceeaZ/Oc/lbM6L1u2zLULc7Bymzdv5v7772fRokW0aNGCGTNmBF2b47acxRKvWMvIEl7mzYM+fcDjMe/z5tWrufHjx1NSUsK//vWvin2LFi3i448/5rTTTuOFF16gvLycffv28cknnzBy5Mhq25o4cSKPPPJIhRJbunQpAGeddRaPPfYYXq8XgIMHDwKQlZXF0aNHARg1ahSff/45GzZsAKCwsJD169fTp08fNm/ezMaNGwGOU1Z+TjvtNJ577jkAVq5cyfLlywE4cuQIGRkZNG/enD179vD2229X1Ak8f03lLHFEqDnVMM+5xhNWGVnCS/fusGoVeL3mvXv3ejUnIrz22mu8//779OjRg/79+3P33XfToUMHLrzwQnJzcxk0aBDjx4/nT3/6E+3atau2rTvuuIOysjJyc3MZMGAAd9xxBwDXX389Xbp0qWjr+eefB+CGG27gnHPOYdy4cbRp04bZs2dz2WWXkZuby6hRo1i7di2pqanMmjWLc889lzFjxtC1a9eg5/7hD39Ifn4+ubm5/OlPf6pQmoMGDWLIkCH079+fa6+9ltGjR1fUCTx/TeUsccTkyWbkoLzcvE+eXLvjjZiIxaYLJ3WNTfd//wf33QdlZREQqomwZs0a+vbtG20xLHGKvX6qkJhoFI0fj8c8uLk9XktsbLoYwVpGFoslpujd+9gauGBzqqGON2KsMrJYLJaGItScapjnXOOJRu9NB8ccuywWiyWq+OdU63q8EdOob9GByshisVgssYtVRhaLxWKJOlYZWSwWiyXqNGpl5PGYd6uM4pexY8fy7rvvVtr30EMPcdNNN9VYJ1R6Brf41xX95S9/CUt7YNJMfPHFFxXbjz32GM8880zY2rdY4pEm48BgiU8uu+wy5syZw8SJEyv2zZkzhz//+c8RP/fu3bv54osv2Lp1a1jb/eijj8jMzOSUU04BqBSR3GJpqjRqy8gqo/hn6tSpzJ8/n5KSEgC2bNnCzp07GTNmjKuUCpmZmRWfX375ZWbMmAHAvn37uPjiixkxYgQjRozg888/P67uWWedxd69exk8eDCffvppJYtr//79dOvWDYDZs2dz0UUXcfbZZ9OzZ09uvfXWijbeeecdhg4dyqBBg5gwYQJbtmzhscce4y9/+UtFu3fffTf3338/YGLdjRo1itzcXC688EIOHToEGGvvtttuY+TIkfTq1YtPP/20fl+sxRJjWMvIUivGzh573L5L+l/CTSNuorCskEnPTTru+IzBM5gxeAb7C/cz9cWplY59NOOjGs/XqlUrRo4cyTvvvMOUKVOYM2cOl156KSLC73//e1q2bEl5eTkTJkxg+fLllaJh18TPfvYzfv7znzNmzBi2bdvGxIkTWbNmTaUyb7zxBueddx7Lli0L2d6yZctYunQpKSkp9O7dm5/85Cekpqby/e9/n08++YScnBwOHjxIy5YtufHGG8nMzOQXv/gFAB9++GFFO1dffTWPPPIIp59+OnfeeSf33HMPDz30EABer5evvvqKt956i3vuuYcPPvjAVV8tluoQkSeB84C9qjogyPErgNuczXzgh6r6TSRkscrIEvP4h+r8yujJJ58ETEqFWbNm4fV62bVrF6tXr3atjD744INKKRiOHDnC0aNHycrKqpOMEyZMoHnz5gD069ePrVu3cujQIU477TRycnKA4KkpAjl8+DB5eXmcfvrpAHzve99j2rRpFccvuugiAIYNG8aWLVvqJKfFUoXZwKNAdZOWm4HTVfWQiJyDSZB6UiQEiZgyEpHewAsBu7oDd2I6/QLQDdgCXKKqhyIhg1VG4acmSyY9Kb3G463TW4e0hIJxwQUXcMstt/D1119TVFTE0KFDXadUCEzbEHjc5/Pxv//9j7S0NNdyJCYm4nMupqrnSklJqfjs8Xjwer2uUlPUBv85/O1bLPVFVT8RkW41HP8iYHMh0ClSskQy7fg6VR2sqoOBYUAh8BpwO/ChqvYEPnS2I4JVRo2DzMxMxo4dy7XXXstll10GuE+p0LZtW9asWYPP5+O1116r2H/WWWfx6KOPVmy7GYrr1q0bS5YsAag2x1IgJ598Mh9//DGbN28GgqemCKR58+a0aNGiYj7o2WefrbCSLJY6kigiiwNeN9SjreuAiOUuaahhugnARlXdKiJTgLHO/qeBjzg2JhlWrDJqPFx22WVcdNFFzJkzB6iceqF79+7VplSYOXMm5513Hp07d2bAgAHk5+cD8PDDD/OjH/2I3NxcvF4vp512Go899liNMvziF7/gkksu4dlnn2X8+PEhZW7Tpg2zZs3ioosuwufzccIJJ/D+++8zefJkpk6dyty5c3nkkUcq1Xn66ae58cYbKSwspHv37jz11FNuvh6LpTq8qjq8vo2IyDiMMhpTf5GqOUdDpJBwJsm+VtVHRSRPVbMDjh1S1RZB6twA3ACQnJw8zO9NVRv+9S+44QbYsQM6dqy7/E0ZmwLAUh/s9RNd3KSQcIbp5gdzYHCO52JGtc5R1fXhl9JQo2UkIp2A6cCpQAegCFgJvAm8raohbQ4RSQbOB35VG8FUdRZmsoyMjIw6aUxrGVksFkvdEZEuwKvAVZFURFCDMhKRp4COwHzgPmAvkAr0As4GfiMit6vqJyHOcQ7GKtrjbO8RkfaquktE2jvtRgSrjCwWi6V6ROQ/mGmT1iKyA7gLSAJQ1ccwTmetgL87zjhhGfYLRk2W0QOqujLI/pXAq47F08XFOS4D/hOw/QbwPWCm8z7Xpay1xioji8ViqR5VvSzE8euB6xtClmq96apRRIHHS1V1Q01lRCQdOBNj5vmZCZwpIt86x2a6F7d2WGVksVgs8UFNw3QrgGrnalQ15OpCVS3EmHiB+w5gvOsijlVGFovFEh/UNEx3nvP+I+f9Wef9CsyaoZjHRu22WCyW+KCmYbqtqroVGK2qt6rqCud1OzCxunqxhLWMGgcej4fBgwczYMAApk2bRmGh+2eh2bNn8+Mf/zgscqxdu5bBgwczZMgQNm7cWOnYpEmTyMvLC8t54oktW7bw/PPPR1sMSyPATQSGDBGpWOgkIqcANfqtxwpWGTUO0tLSWLZsGStXriQ5Ofm4xanl5eUNIsfrr7/OlClTWLp0KT169Kh07K233iI7Ozus52uoftUHq4ws4cKNMroW+JuIbBGRzcDfnX0xj1VGjY9TTz2VDRs28NFHHzFu3Dguv/xyBg4cSHFxMddccw0DBw5kyJAhLFiwoKLO9u3bOfvss+nduzf33HNPyHMES+Pw1ltv8dBDD/H4448zbty44+p069aN/fv3s2XLFvr06cP111/PgAEDuOKKK/jggw8YPXo0PXv25KuvvgLg7rvv5qqrrmL8+PH07NmTf/3rXwCu+3XSSSexatWqivOPHTuWJUuWUFBQwLXXXsuIESMYMmQIc+caZ9XZs2dzwQUXMHnyZHJycnj00Ud58MEHGTJkCKNGjaoIVbRx40bOPvtshg0bxqmnnsratWsBmDFjBj/96U855ZRT6N69e0U4pNtvv51PP/2UwYMHhzUBoaXpEWrRqwcTsXWQiDTDRGw43DCi1R+rjMLLzTeDixButWLwYHAyJITE6/Xy9ttvc/bZZwPw1VdfsXLlSnJycnjggQcAWLFiBWvXruWss85i/fr1lcqlp6czYsQIzj33XIYPr36pRHVpHKqmfqiODRs28NJLLzFr1ixGjBjB888/z2effcYbb7zBH/7wB15//XUAli9fzsKFCykoKGDIkCGce+65rvs1ffp0XnzxRe655x527drFzp07GTZsGL/+9a8ZP348Tz75JHl5eYwcOZIzzjgDgJUrV7J06VKKi4s58cQTue+++1i6dCk///nPeeaZZ7j55pu54YYbeOyxx+jZsydffvklN910E//9738B2LVrF5999hlr167l/PPPZ+rUqcycOZP777+f+fPnu/sRLZZqqNEyUtVyYIrz+Ug8KSKwyqixUFRUxODBgxk+fDhdunThuuuuA2DkyJEV6Rk+++wzrrrqKgD69OlD165dK5TRmWeeSatWrUhLS+Oiiy7is88+q/ZcwdI4fPJJqHXdlcnJyWHgwIEkJCTQv39/JkyYgIgwcODASqkfpkyZQlpaGq1bt2bcuHEVVpObfl1yySW89NJLgEml4U818d577zFz5kwGDx7M2LFjKS4uZtu2bQCMGzeOrKws2rRpQ/PmzZk8eTJAhVz5+fl88cUXTJs2jcGDB/ODH/yAXbt2Vch7wQUXkJCQQL9+/diz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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Best parameter value: branching/scorefac = 0.0\n" + ] + } + ], + "source": [ + "model = opt.models[-1]\n", + "\n", + "x = np.linspace(0, 1, 500)\n", + "x_model = opt.space.transform(x.reshape(-1, 1).tolist())\n", + "\n", + "fig, ax1 = plt.subplots()\n", + "\n", + "# points sampled during optimization\n", + "lns1 = ax1.plot(opt.Xi, opt.yi, \"r.\", markersize=8, label=\"Collected data\")\n", + "\n", + "# value function estimation\n", + "y_mean, y_std = model.predict(x_model, return_std=True)\n", + "lns2 = ax1.plot(x, y_mean, \"g--\", label=r\"Value function\")\n", + "ax1.fill_between(\n", + " x, y_mean - 1.6 * y_std, y_mean + 1.6 * y_std, alpha=0.2, fc=\"g\", ec=\"None\"\n", + ")\n", + "\n", + "# probability of improvement estimation\n", + "x_pi = skopt.acquisition.gaussian_pi(x_model, model, y_opt=np.min(opt.yi))\n", + "ax2 = ax1.twinx()\n", + "lns3 = ax2.plot(x, x_pi, \"b\", label=\"Prob. of improvement\")\n", + "\n", + "ax1.set_title(f\"Model obtained after {optim_n_iters} iterations\")\n", + "ax1.set_ylabel(f\"number of nodes (neg. reward)\")\n", + "ax1.set_xlabel(f\"$branching/scorefac$ parameter value (action)\")\n", + "\n", + "ax2.set_ylabel(f\"Probability value\")\n", + "\n", + "# Legend\n", + "lns = lns1 + lns2 + lns3\n", + "labs = [l.get_label() for l in lns]\n", + "ax1.legend(lns, labs, loc=\"upper center\")\n", + "\n", + "plt.show()\n", + "\n", + "# get best value based on a grid search on the value function estimator\n", + "best_value = x[np.argmin(y_mean)]\n", + "print(f\"Best parameter value: branching/scorefac = {best_value}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Evaluation on harder instances" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "evaluating policy 'default'\n", + " instance 1: 222.0 nodes, 39899.0 lpiters, 144.09331 secs\n", + " instance 2: 911.0 nodes, 130092.0 lpiters, 542.881938 secs\n", + " instance 3: 246.0 nodes, 39946.0 lpiters, 116.528529 secs\n", + " instance 4: 936.0 nodes, 115326.0 lpiters, 460.294398 secs\n", + " instance 5: 296.0 nodes, 46306.0 lpiters, 146.594021 secs\n", + " average performance: 522.2 nodes\n", + "evaluating policy 'learned'\n", + " instance 1: 78.0 nodes, 21443.0 lpiters, 50.283315 secs\n", + " instance 2: 547.0 nodes, 71001.0 lpiters, 292.93503899999996 secs\n", + " instance 3: 136.0 nodes, 28901.0 lpiters, 62.354701 secs\n", + " instance 4: 704.0 nodes, 93288.0 lpiters, 369.48535599999997 secs\n", + " instance 5: 120.0 nodes, 24476.0 lpiters, 51.498628 secs\n", + " average performance: 317.0 nodes\n" + ] + } + ], + "source": [ + "# we set up more challenging instances\n", + "test_instances = ec.instance.CombinatorialAuctionGenerator(\n", + " n_items=test_n_items, n_bids=test_n_bids, add_item_prob=test_add_item_prob\n", + ")\n", + "\n", + "for policy in (\"default\", \"learned\"):\n", + "\n", + " print(f\"evaluating policy '{policy}'\")\n", + " results = []\n", + "\n", + " for i in range(test_n_evals):\n", + "\n", + " # evaluate each policy in the exact same settings\n", + " env.seed(test_seed + i) # environment (SCIP)\n", + " test_instances.seed(test_seed + i) # instance generator\n", + "\n", + " # pick up the next instance\n", + " instance = next(test_instances)\n", + "\n", + " # set up the episode initial state\n", + " env.reset(instance)\n", + "\n", + " # get the action from the policy\n", + " if policy == \"default\":\n", + " action = {} # will use the default value from SCIP\n", + " else:\n", + " action = {\"branching/scorefac\": best_value}\n", + "\n", + " # apply the action and collect the reward\n", + " _, _, _, _, info = env.step(action)\n", + "\n", + " print(\n", + " f\" instance {i+1}: {info['nnodes']} nodes, {info['lpiters']} lpiters, {info['time']} secs\"\n", + " )\n", + "\n", + " results.append(info[\"nnodes\"])\n", + "\n", + " print(f\" average performance: {np.mean(results)} nodes\")" + ] + } + ], + "metadata": { + "celltoolbar": "Tags", + "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.7" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ecole/examples/libecole/src/branching.cpp b/ecole/examples/libecole/src/branching.cpp new file mode 100644 index 0000000..b476d7e --- /dev/null +++ b/ecole/examples/libecole/src/branching.cpp @@ -0,0 +1,30 @@ +#include +#include +#include +#include + +#include "ecole/environment/branching.hpp" +#include "ecole/information/nothing.hpp" +#include "ecole/instance/set-cover.hpp" +#include "ecole/observation/node-bipartite.hpp" +#include "ecole/reward/n-nodes.hpp" + +int main() { + try { + auto env = ecole::environment:: + Branching{}; + std::size_t constexpr n_rows = 100; + std::size_t constexpr n_cols = 200; + auto gen = ecole::instance::SetCoverGenerator{{n_rows, n_cols}}; + + static constexpr auto n_episodes = 2; + for (std::size_t i = 0; i < n_episodes; ++i) { + auto [obs, action_set, reward, done, info] = env.reset(gen.next()); + while (!done && action_set.has_value()) { + std::tie(obs, action_set, reward, done, info) = env.step(action_set.value()[0]); + } + } + } catch (std::exception const& e) { + std::cerr << "Error: " << e.what() << '\n'; + } +} diff --git a/ecole/libecole/CMakeLists.txt b/ecole/libecole/CMakeLists.txt new file mode 100644 index 0000000..41e1683 --- /dev/null +++ b/ecole/libecole/CMakeLists.txt @@ -0,0 +1,205 @@ +add_library( + ecole-lib + + src/version.cpp + src/random.cpp + src/exception.cpp + + src/utility/chrono.cpp + src/utility/graph.cpp + + src/scip/scimpl.cpp + src/scip/model.cpp + src/scip/cons.cpp + src/scip/var.cpp + src/scip/row.cpp + src/scip/col.cpp + src/scip/exception.cpp + + src/instance/files.cpp + src/instance/set-cover.cpp + src/instance/independent-set.cpp + src/instance/combinatorial-auction.cpp + src/instance/capacitated-facility-location.cpp + + src/reward/is-done.cpp + src/reward/lp-iterations.cpp + src/reward/solving-time.cpp + src/reward/n-nodes.cpp + src/reward/bound-integral.cpp + + src/observation/node-bipartite.cpp + src/observation/node-bipartite-candidate.cpp + src/observation/milp-bipartite.cpp + src/observation/khalil-2016.cpp + src/observation/hutter-2011.cpp + src/observation/strong-branching-scores.cpp + src/observation/pseudocosts.cpp + + src/dynamics/parts.cpp + src/dynamics/branching.cpp + src/dynamics/configuring.cpp + src/dynamics/primal-search.cpp +) + +add_library(Ecole::ecole-lib ALIAS ecole-lib) + +# Unconditionally generate version file at build time +string(TIMESTAMP Ecole_BUILD_TIME) +add_custom_target( + ecole-lib-version + COMMAND ${CMAKE_COMMAND} + -D SOURCE_FILE="${CMAKE_CURRENT_SOURCE_DIR}/include/ecole/version.hpp.in" + -D TARGET_FILE="${CMAKE_CURRENT_BINARY_DIR}/include/ecole/version.hpp" + -D Ecole_VERSION_MAJOR="${Ecole_VERSION_MAJOR}" + -D Ecole_VERSION_MINOR="${Ecole_VERSION_MINOR}" + -D Ecole_VERSION_PATCH="${Ecole_VERSION_PATCH}" + -D Ecole_VERSION_REVISION="${Ecole_VERSION_REVISION}" # Not defined by default, but let if override for conda + -D Ecole_BUILD_TYPE="${CMAKE_BUILD_TYPE}" + -D Ecole_BUILD_OS="${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_VERSION}" + -D Ecole_BUILD_TIME="${Ecole_BUILD_TIME}" + -D Ecole_BUILD_COMPILER="${CMAKE_CXX_COMPILER_ID}-${CMAKE_CXX_COMPILER_VERSION}" + -P "${Ecole_SOURCE_DIR}/cmake/CreateVersionFile.cmake" > /dev/null +) +add_dependencies(ecole-lib ecole-lib-version) + +# Control symbol visibility +include(GenerateExportHeader) +generate_export_header( + ecole-lib + BASE_NAME Ecole + EXPORT_FILE_NAME "${CMAKE_CURRENT_BINARY_DIR}/include/ecole/export.hpp" +) + +# Set library file name and soname. +# ABI compatibility (SOVERSION) is kept only at the minor level. +set_target_properties( + ecole-lib + PROPERTIES + OUTPUT_NAME ecole + VERSION "${Ecole_VERSION_MAJOR}.${${PROJECT_NAME}_VERSION_MINOR}.${${PROJECT_NAME}_VERSION_PATCH}" + SOVERSION "${Ecole_VERSION_MAJOR}.${${PROJECT_NAME}_VERSION_MINOR}" +) + +target_include_directories( + ecole-lib + PUBLIC + $ + $ + $ + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +# Files that download the dependencies of libecole +include(dependencies/public.cmake) +include(dependencies/private.cmake) + +find_package(SCIP 8 REQUIRED) +find_package(Threads REQUIRED) +find_package(xtl REQUIRED) +find_package(xsimd REQUIRED) +find_package(xtensor REQUIRED) +find_package(span-lite REQUIRED) +find_package(range-v3 REQUIRED) +find_package(fmt REQUIRED) +find_package(robin_hood REQUIRED) + + +target_link_libraries( + ecole-lib + PUBLIC + libscip + xtensor + xtensor::use_xsimd + nonstd::span-lite + Threads::Threads + PRIVATE + fmt::fmt + range-v3::range-v3 + robin_hood::robin_hood +) + +ecole_target_add_compile_warnings(ecole-lib) +ecole_target_add_sanitizers(ecole-lib) +ecole_target_add_coverage(ecole-lib) + +# System CPU time, silently ignored if LibRT is not present +find_library(LIBRT rt) +if(LIBRT) + target_link_libraries(ecole-lib PRIVATE "${LIBRT}") +endif() + +target_compile_features(ecole-lib PUBLIC cxx_std_17) + +# Installation library and symlink +include(GNUInstallDirs) +install( + TARGETS ecole-lib + EXPORT "EcoleTargets" + RUNTIME + DESTINATION "${CMAKE_INSTALL_BINDIR}" + COMPONENT Ecole_Runtime + LIBRARY + DESTINATION "${CMAKE_INSTALL_LIBDIR}" + COMPONENT Ecole_Runtime + NAMELINK_COMPONENT Ecole_Development + ARCHIVE + DESTINATION "${CMAKE_INSTALL_LIBDIR}" + COMPONENT Ecole_Development +) + +# Install CMake targets definition +install( + EXPORT "EcoleTargets" + FILE "EcoleTargets.cmake" + NAMESPACE Ecole:: + COMPONENT Ecole_Development + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/Ecole" +) + +# Install headers and generated headers +install( + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/include/ecole" "${CMAKE_CURRENT_BINARY_DIR}/include/ecole" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" + COMPONENT Ecole_Development + FILES_MATCHING PATTERN "*.hpp" +) + +# Generate and install config and version files +include(CMakePackageConfigHelpers) +configure_package_config_file( + "EcoleConfig.cmake.in" + "EcoleConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/Ecole" +) +write_basic_package_version_file( + "EcoleConfigVersion.cmake" + VERSION "${Ecole_VERSION}" + COMPATIBILITY SameMinorVersion +) +install( + FILES + "${CMAKE_CURRENT_BINARY_DIR}/EcoleConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/EcoleConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/Ecole" + COMPONENT Ecole_Development +) + +# Install the files to download dependencies (not mandatory but useful for users) +install( + FILES + "${Ecole_SOURCE_DIR}/cmake/DependenciesResolver.cmake" + "${CMAKE_CURRENT_SOURCE_DIR}/dependencies/public.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/Ecole" + COMPONENT Ecole_Development +) + +# Add test if this is the main project and testing is enabled +if(ECOLE_BUILD_TESTS) + add_subdirectory(tests) +endif() + +if(ECOLE_BUILD_BENCHMARKS) + add_subdirectory(benchmarks) +endif() diff --git a/ecole/libecole/EcoleConfig.cmake.in b/ecole/libecole/EcoleConfig.cmake.in new file mode 100644 index 0000000..3a7d0e5 --- /dev/null +++ b/ecole/libecole/EcoleConfig.cmake.in @@ -0,0 +1,17 @@ +@PACKAGE_INIT@ + +option(ECOLE_DOWNLOAD_DEPENDENCIES "Download the static and header libraries used in Ecole public interface" ON) +if(ECOLE_DOWNLOAD_DEPENDENCIES) + include("${CMAKE_CURRENT_LIST_DIR}/DependenciesResolver.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/public.cmake") +endif() + +include(CMakeFindDependencyMacro) +find_dependency(xtensor @xtensor_VERSION@ REQUIRED) +find_dependency(SCIP @SCIP_VERSION@ REQUIRED) +find_dependency(span-lite @span-lite_VERSION@ REQUIRED) +find_package(Threads REQUIRED) + +if(NOT TARGET Ecole::ecole-lib) + include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake") +endif() diff --git a/ecole/libecole/README.md b/ecole/libecole/README.md new file mode 100644 index 0000000..1b69064 --- /dev/null +++ b/ecole/libecole/README.md @@ -0,0 +1,3 @@ +# The C++ Ecole library + +The C++ library contains the complete set of Ecole features. diff --git a/ecole/libecole/benchmarks/CMakeLists.txt b/ecole/libecole/benchmarks/CMakeLists.txt new file mode 100644 index 0000000..6b4454c --- /dev/null +++ b/ecole/libecole/benchmarks/CMakeLists.txt @@ -0,0 +1,31 @@ +add_executable( + ecole-lib-benchmark + src/main.cpp + src/benchmark.cpp + src/bench-branching.cpp +) + +target_include_directories(ecole-lib-benchmark PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + +# File that download the dependencies of libecole +include(dependencies/private.cmake) + +find_package(CLI11 REQUIRED) + +target_link_libraries( + ecole-lib-benchmark + PRIVATE + Ecole::ecole-lib + CLI11::CLI11 + fmt::fmt +) + +ecole_target_add_compile_warnings(ecole-lib) +ecole_target_add_sanitizers(ecole-lib) + +set_target_properties( + ecole-lib-benchmark + PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON +) diff --git a/ecole/libecole/benchmarks/dependencies/private.cmake b/ecole/libecole/benchmarks/dependencies/private.cmake new file mode 100644 index 0000000..b1e6688 --- /dev/null +++ b/ecole/libecole/benchmarks/dependencies/private.cmake @@ -0,0 +1,8 @@ +find_or_download_package( + NAME CLI11 + URL https://github.com/CLIUtils/CLI11/archive/v1.9.1.tar.gz + URL_HASH SHA256=c780cf8cf3ba5ec2648a7eeb20a47e274493258f38a9b417628e0576f473a50b + CONFIGURE_ARGS + -D CLI11_BUILD_TESTS=OFF + -D CLI11_BUILD_EXAMPLES=OFF +) diff --git a/ecole/libecole/benchmarks/src/bench-branching.cpp b/ecole/libecole/benchmarks/src/bench-branching.cpp new file mode 100644 index 0000000..8e73004 --- /dev/null +++ b/ecole/libecole/benchmarks/src/bench-branching.cpp @@ -0,0 +1,77 @@ +#include +#include +#include + +#include +#include + +#include "ecole/dynamics/branching.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/utility/chrono.hpp" + +#include "bench-branching.hpp" +#include "branching/index-branchrule.hpp" +#include "csv.hpp" + +namespace ecole::benchmark { + +namespace { + +template auto measure_on_model(Func&& func_to_bench, scip::Model model) -> Metrics { + auto const cpu_time_before = utility::cpu_clock::now(); + auto const wall_time_before = std::chrono::steady_clock::now(); + func_to_bench(model); + auto const wall_time_after = std::chrono::steady_clock::now(); + auto const cpu_time_after = utility::cpu_clock::now(); + + return { + std::chrono::duration(wall_time_after - wall_time_before).count(), + std::chrono::duration(cpu_time_after - cpu_time_before).count(), + static_cast(SCIPgetNTotalNodes(model.get_scip_ptr())), + static_cast(SCIPgetNLPIterations(model.get_scip_ptr())), + }; +} + +auto measure_branching_dynamics(scip::Model model) -> Metrics { + return measure_on_model( + [](scip::Model& m) { + auto dyn = dynamics::BranchingDynamics{}; + auto [done, action_set] = dyn.reset_dynamics(m); + while (!done) { + std::tie(done, action_set) = dyn.step_dynamics(m, action_set.value()[0]); + } + }, + std::move(model)); +} + +auto measure_branching_rule(scip::Model model) -> Metrics { + return measure_on_model( + [](scip::Model& m) { + auto* branch_rule = new ecole::scip::IndexBranchrule{m.get_scip_ptr(), "FirstVarBranching", 0UL}; + SCIPincludeObjBranchrule(m.get_scip_ptr(), branch_rule, true); + // NOLINTNEXTLINE dynamically allocated object ownership is given to SCIP + m.solve(); + }, + std::move(model)); +} + +} // namespace + +auto BranchingResult::csv_title() -> std::string { + return merge_csv( + InstanceFeatures::csv_title(), Metrics::csv_title("branching_dynamics:"), Metrics::csv_title("branching_rule:")); +} + +auto BranchingResult::csv() -> std::string { + return merge_csv(instance.csv(), branching_dynamics_metrics.csv(), branching_rule_metrics.csv()); +} + +auto benchmark_branching(scip::Model const& model) -> BranchingResult { + return { + InstanceFeatures::from_model(model.copy_orig()), + measure_branching_dynamics(model.copy_orig()), + measure_branching_rule(model.copy_orig()), + }; +} + +} // namespace ecole::benchmark diff --git a/ecole/libecole/benchmarks/src/bench-branching.hpp b/ecole/libecole/benchmarks/src/bench-branching.hpp new file mode 100644 index 0000000..be43969 --- /dev/null +++ b/ecole/libecole/benchmarks/src/bench-branching.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "ecole/scip/model.hpp" + +#include "benchmark.hpp" + +namespace ecole::benchmark { + +struct BranchingResult { + InstanceFeatures instance; + Metrics branching_dynamics_metrics; + Metrics branching_rule_metrics; + + static auto csv_title() -> std::string; + auto csv() -> std::string; +}; + +/** Benchmark the branching dynamics against a branch rule on a given model. */ +auto benchmark_branching(scip::Model const& model) -> BranchingResult; + +} // namespace ecole::benchmark diff --git a/ecole/libecole/benchmarks/src/benchmark.cpp b/ecole/libecole/benchmarks/src/benchmark.cpp new file mode 100644 index 0000000..4afd749 --- /dev/null +++ b/ecole/libecole/benchmarks/src/benchmark.cpp @@ -0,0 +1,56 @@ +#include +#include +#include +#include + +#include + +#include "ecole/dynamics/branching.hpp" +#include "ecole/scip/model.hpp" + +#include "benchmark.hpp" +#include "csv.hpp" + +namespace ecole::benchmark { + +auto InstanceFeatures::from_model(scip::Model model) -> InstanceFeatures { + // Get model to the root note to extract root node info + auto dyn = dynamics::BranchingDynamics{}; + dyn.reset_dynamics(model); + // FIXME in practice there is might be LP even if we never branch. Should use SCIP_EVENTTYPE_FIRSTLPSOLVED + if (model.stage() != SCIP_STAGE_SOLVING) { + return { + model.variables().size(), + model.constraints().size(), + }; + } + return { + model.variables().size(), + model.constraints().size(), + model.nnz(), + model.lp_columns().size(), + model.lp_rows().size(), + model.name()}; +} + +auto InstanceFeatures::csv_title() -> std::string { + return make_csv("n_vars", "n_cons", "root_nnz", "root_n_cols", "root_n_rows", "name"); +} + +auto InstanceFeatures::csv() -> std::string { + return make_csv(n_vars, n_cons, root_nnz, root_n_cols, root_n_rows, name); +} + +auto Metrics::csv_title(std::string_view prefix) -> std::string { + return make_csv( + fmt::format("{}{}", prefix, "wall_time_s"), + fmt::format("{}{}", prefix, "cpu_time_s"), + fmt::format("{}{}", prefix, "n_nodes"), + fmt::format("{}{}", prefix, "n_lp_iterations")); +} + +auto Metrics::csv() -> std::string { + return make_csv(wall_time_s, cpu_time_s, n_nodes, n_lp_iterations); +} + +} // namespace ecole::benchmark diff --git a/ecole/libecole/benchmarks/src/benchmark.hpp b/ecole/libecole/benchmarks/src/benchmark.hpp new file mode 100644 index 0000000..d78a29e --- /dev/null +++ b/ecole/libecole/benchmarks/src/benchmark.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include "ecole/scip/model.hpp" + +namespace ecole::benchmark { + +struct InstanceFeatures { + std::size_t n_vars = 0; + std::size_t n_cons = 0; + std::size_t root_nnz = 0; + std::size_t root_n_cols = 0; + std::size_t root_n_rows = 0; + std::string name = {}; + + static auto from_model(scip::Model model) -> InstanceFeatures; + + static auto csv_title() -> std::string; + auto csv() -> std::string; +}; + +struct Metrics { + double wall_time_s = 0.; + double cpu_time_s = 0.; + std::size_t n_nodes = 0; + std::size_t n_lp_iterations = 0; + + static auto csv_title(std::string_view prefix = "") -> std::string; + auto csv() -> std::string; +}; + +} // namespace ecole::benchmark diff --git a/ecole/libecole/benchmarks/src/branching/index-branchrule.hpp b/ecole/libecole/benchmarks/src/branching/index-branchrule.hpp new file mode 100644 index 0000000..4e233f9 --- /dev/null +++ b/ecole/libecole/benchmarks/src/branching/index-branchrule.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include + +#include "branching/lambda-branchrule.hpp" + +namespace ecole::scip { + +namespace internal { + +class IndexBranchruleFunc { +public: + IndexBranchruleFunc(std::size_t branching_index) noexcept : m_branching_index(branching_index) {} + + auto operator()(SCIP* scip) const noexcept { + SCIP_VAR** branch_cands = nullptr; + int n_cands [[maybe_unused]] = 0; + auto retcode + [[maybe_unused]] = SCIPgetLPBranchCands(scip, &branch_cands, nullptr, nullptr, &n_cands, nullptr, nullptr); + assert(n_cands > 0); + assert(retcode == SCIP_OKAY); + retcode = SCIPbranchVar(scip, branch_cands[m_branching_index], nullptr, nullptr, nullptr); + assert(retcode == SCIP_OKAY); + return SCIP_BRANCHED; + } + +private: + std::size_t m_branching_index; +}; + +} // namespace internal + +class IndexBranchrule : public LambdaBranchrule { +public: + IndexBranchrule(SCIP* scip, const char* name, std::size_t branching_index) noexcept : + LambdaBranchrule(scip, name, {branching_index}) {} +}; + +} // namespace ecole::scip diff --git a/ecole/libecole/benchmarks/src/branching/lambda-branchrule.hpp b/ecole/libecole/benchmarks/src/branching/lambda-branchrule.hpp new file mode 100644 index 0000000..bfb38bc --- /dev/null +++ b/ecole/libecole/benchmarks/src/branching/lambda-branchrule.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace ecole::scip { + +template class LambdaBranchrule : public ::scip::ObjBranchrule { +public: + static constexpr int max_priority = 536870911; + static constexpr int no_maxdepth = -1; + static constexpr double no_maxbounddist = 1.0; + + LambdaBranchrule(SCIP* scip, const char* name, Func branching_rule); + + auto scip_execlp(SCIP* scip, SCIP_BRANCHRULE* branchrule, SCIP_Bool allowaddcons, SCIP_RESULT* result) + -> SCIP_RETCODE override; + +private: + Func branching_rule; +}; + +template +scip::LambdaBranchrule::LambdaBranchrule(SCIP* scip, const char* name, Func branching_rule_) : + ::scip::ObjBranchrule( + scip, + "Branchrule that wait for another thread to make the branching.", + name, + max_priority, + no_maxdepth, + no_maxbounddist), + branching_rule(std::move(branching_rule_)) {} + +template +auto LambdaBranchrule::scip_execlp(SCIP* scip, SCIP_BRANCHRULE* /*branchrule*/, SCIP_Bool, SCIP_RESULT* result) + -> SCIP_RETCODE { + try { + *result = branching_rule(scip); + return SCIP_OKAY; + } catch (...) { + *result = SCIP_DIDNOTRUN; + return SCIP_BRANCHERROR; + } +} + +} // namespace ecole::scip diff --git a/ecole/libecole/benchmarks/src/csv.hpp b/ecole/libecole/benchmarks/src/csv.hpp new file mode 100644 index 0000000..ba1789b --- /dev/null +++ b/ecole/libecole/benchmarks/src/csv.hpp @@ -0,0 +1,18 @@ +#include +#include +#include + +#include +#include + +namespace ecole::benchmark { + +template auto make_csv(Args&&... args) -> std::string { + return fmt::format(R"("{}")", fmt::join(std::tuple{std::forward(args)...}, R"(",")")); +} + +template auto merge_csv(Args&&... args) -> std::string { + return fmt::format("{}", fmt::join(std::tuple{std::forward(args)...}, ",")); +} + +} // namespace ecole::benchmark diff --git a/ecole/libecole/benchmarks/src/main.cpp b/ecole/libecole/benchmarks/src/main.cpp new file mode 100644 index 0000000..b779575 --- /dev/null +++ b/ecole/libecole/benchmarks/src/main.cpp @@ -0,0 +1,94 @@ +#include +#include +#include +#include + +#include + +#include "ecole/instance/capacitated-facility-location.hpp" +#include "ecole/instance/combinatorial-auction.hpp" +#include "ecole/instance/independent-set.hpp" +#include "ecole/instance/set-cover.hpp" +#include "ecole/random.hpp" +#include "ecole/scip/seed.hpp" + +#include "bench-branching.hpp" +#include "benchmark.hpp" + +using namespace ecole::benchmark; +using namespace ecole::instance; + +/** Apply func on each element of the tuple. */ +template void for_each(std::tuple& t, Func&& func) { + std::apply([&func](auto&&... t_elem) { ((func(std::forward(t_elem))), ...); }, t); +} + +void seed_model(ecole::scip::Model& model, ecole::RandomGenerator& rng) { + std::uniform_int_distribution seed_distrib{ecole::scip::min_seed, ecole::scip::max_seed}; + model.set_param("randomization/permuteconss", true); + model.set_param("randomization/permutevars", true); + model.set_param("randomization/permutationseed", seed_distrib(rng)); + model.set_param("randomization/randomseedshift", seed_distrib(rng)); + model.set_param("randomization/lpseed", seed_distrib(rng)); +} + +/** The generators used to benchmark branching dynamics. */ +auto benchmark_branching(std::size_t n_instances, std::size_t n_nodes) { + using GraphType = typename ecole::instance::IndependentSetGenerator::Parameters::GraphType; + auto generators = std::tuple{ + SetCoverGenerator{{500, 1000}}, // NOLINT(readability-magic-numbers) + SetCoverGenerator{{1000, 1000}}, // NOLINT(readability-magic-numbers) + SetCoverGenerator{{2000, 1000}}, // NOLINT(readability-magic-numbers) + CombinatorialAuctionGenerator{{100, 500}}, // NOLINT(readability-magic-numbers) + CombinatorialAuctionGenerator{{200, 1000}}, // NOLINT(readability-magic-numbers) + CombinatorialAuctionGenerator{{300, 1500}}, // NOLINT(readability-magic-numbers) + CapacitatedFacilityLocationGenerator{{100, 100}}, // NOLINT(readability-magic-numbers) + CapacitatedFacilityLocationGenerator{{200, 100}}, // NOLINT(readability-magic-numbers) + CapacitatedFacilityLocationGenerator{{400, 100}}, // NOLINT(readability-magic-numbers) + IndependentSetGenerator{{500, GraphType::erdos_renyi}}, // NOLINT(readability-magic-numbers) + IndependentSetGenerator{{1000, GraphType::erdos_renyi}}, // NOLINT(readability-magic-numbers) + IndependentSetGenerator{{1500, GraphType::erdos_renyi}}, // NOLINT(readability-magic-numbers) + }; + auto rng = ecole::spawn_random_generator(); + + std::cout << BranchingResult::csv_title() << '\n'; + for (std::size_t i = 0; i < n_instances; ++i) { + auto benchmark_and_print = [&](auto& gen) noexcept { + try { + auto model = gen.next(); + model.disable_presolve(); + model.disable_cuts(); + model.set_param("limits/totalnodes", n_nodes); + seed_model(model, rng); + std::cout << benchmark_branching(model).csv() << '\n'; + } catch (std::exception const& e) { + std::cerr << "Error when benchmarking an instance: " << e.what() << '\n'; + } + }; + for_each(generators, benchmark_and_print); + } +} + +int main(int argc, char** argv) { + try { + + auto app = CLI::App{}; + app.failure_message(CLI::FailureMessage::help); + auto n_instances = std::size_t{10}; // NOLINT(readability-magic-numbers) + app.add_option( + "--intances-per-generator,--ipg", n_instances, "Number of instances generated by each instance generator"); + auto n_nodes = std::size_t{100}; // NOLINT(readability-magic-numbers) + app.add_option("--node-limit,--nl", n_nodes, "Limit the number of nodes in each run"); + auto seed = std::optional{}; + app.add_option("--seed,-s", seed, "Global Ecole random seed"); + CLI11_PARSE(app, argc, argv); + + if (seed.has_value()) { + ecole::seed(seed.value()); + } + benchmark_branching(n_instances, n_nodes); + + } catch (std::exception const& e) { + std::cerr << "An error occured: " << e.what() << '\n'; + } +} diff --git a/ecole/libecole/dependencies/private.cmake b/ecole/libecole/dependencies/private.cmake new file mode 100644 index 0000000..2e3e1f0 --- /dev/null +++ b/ecole/libecole/dependencies/private.cmake @@ -0,0 +1,30 @@ +find_or_download_package( + NAME range-v3 + URL https://github.com/ericniebler/range-v3/archive/0.11.0.tar.gz + URL_HASH SHA256=376376615dbba43d3bef75aa590931431ecb49eb36d07bb726a19f680c75e20c + CONFIGURE_ARGS + -D RANGE_V3_TESTS=OFF + -D RANGE_V3_EXAMPLES=OFF + -D RANGE_V3_PERF=OFF + -D RANGE_V3_DOCS=OFF +) + +find_or_download_package( + NAME fmt + URL https://github.com/fmtlib/fmt/archive/8.0.1.tar.gz + URL_HASH SHA256=b06ca3130158c625848f3fb7418f235155a4d389b2abc3a6245fb01cb0eb1e01 + CONFIGURE_ARGS + -D FMT_TEST=OFF + -D FMT_DOC=OFF + -D FMT_INSTALL=ON + -D CMAKE_BUILD_TYPE=Release + -D BUILD_SHARED_LIBS=OFF + -D CMAKE_POSITION_INDEPENDENT_CODE=${CMAKE_POSITION_INDEPENDENT_CODE} +) + +find_or_download_package( + NAME robin_hood + URL https://github.com/martinus/robin-hood-hashing/archive/refs/tags/3.11.2.tar.gz + URL_HASH SHA256=148b4fbd4fbb30ba10cc97143dcbe385078801b9c9e329cd477c1ea27477cb73 + CONFIGURE_ARGS -D RH_STANDALONE_PROJECT=OFF +) diff --git a/ecole/libecole/dependencies/public.cmake b/ecole/libecole/dependencies/public.cmake new file mode 100644 index 0000000..5a42b82 --- /dev/null +++ b/ecole/libecole/dependencies/public.cmake @@ -0,0 +1,29 @@ +find_or_download_package( + NAME xtl + URL https://github.com/xtensor-stack/xtl/archive/0.7.2.tar.gz + URL_HASH SHA256=95c221bdc6eaba592878090916383e5b9390a076828552256693d5d97f78357c + CONFIGURE_ARGS -D BUILD_TESTS=OFF +) + +find_or_download_package( + NAME xsimd + URL https://github.com/xtensor-stack/xsimd/archive/7.4.9.tar.gz + URL_HASH SHA256=f6601ffb002864ec0dc6013efd9f7a72d756418857c2d893be0644a2f041874e + CONFIGURE_ARGS -D BUILD_TESTS=OFF +) + +find_or_download_package( + NAME xtensor + URL https://github.com/xtensor-stack/xtensor/archive/0.23.1.tar.gz + URL_HASH SHA256=b9bceea49db240ab64eede3776d0103bb0503d9d1f3ce5b90b0f06a0d8ac5f08 + CONFIGURE_ARGS -D BUILD_TESTS=OFF +) + +find_or_download_package( + NAME span-lite + URL https://github.com/martinmoene/span-lite/archive/v0.9.0.tar.gz + URL_HASH SHA256=cdb5f86e5f5e679d63700a56de734c44fe22a574a17347d09dbaaef80619af91 + CONFIGURE_ARGS + -D SPAN_LITE_OPT_BUILD_TESTS=OFF + -D SPAN_LITE_OPT_BUILD_EXAMPLES=OFF +) diff --git a/ecole/libecole/include/ecole/data/abstract.hpp b/ecole/libecole/include/ecole/data/abstract.hpp new file mode 100644 index 0000000..42dfb9f --- /dev/null +++ b/ecole/libecole/include/ecole/data/abstract.hpp @@ -0,0 +1,5 @@ +#pragma once + +namespace ecole::scip { +class Model; +} diff --git a/ecole/libecole/include/ecole/data/constant.hpp b/ecole/libecole/include/ecole/data/constant.hpp new file mode 100644 index 0000000..5fc82a7 --- /dev/null +++ b/ecole/libecole/include/ecole/data/constant.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "ecole/reward/abstract.hpp" + +namespace ecole::data { + +template class ConstantFunction { +public: + ConstantFunction() = default; + ConstantFunction(Data data_) : data{std::move(data_)} {} + + auto before_reset(scip::Model const& /*model*/) -> void {} + + [[nodiscard]] auto extract(scip::Model const& /* model */, bool /* done */) const -> Data { return data; }; + +private: + Data data; +}; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/dynamic.hpp b/ecole/libecole/include/ecole/data/dynamic.hpp new file mode 100644 index 0000000..1d69c4e --- /dev/null +++ b/ecole/libecole/include/ecole/data/dynamic.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include +#include + +#include "ecole/data/abstract.hpp" + +namespace ecole::data { + +/** + * Type erased wrapper for data function with similar data. + * + * This class allow to wrap any type of data function with similar data inside a wrapper of the same type. + * This enables dynamic polymorphism for data functions. + * For instance, using ``DynamicFunction``, one can store any other reward function inside a + * container (``std::vector``, ``std::map``...). + * + * @tparam Data Type of data returned by this function. All wrapped functions must be able to extracc + * data convertible to this type. + * This can be achieved for instance by choosing ``Data`` to be ``std::variant``. + */ +template class DynamicFunction { +public: + /** Create a ``DynamicFunction`` from any compatible data function. */ + template + explicit DynamicFunction(DataFunction data_function) : + m_pimpl{std::make_unique>(std::move(data_function))} {} + + /** Default move semantics. */ + DynamicFunction(DynamicFunction&&) noexcept = default; + /** Copy by copying the wrapped data function. */ + DynamicFunction(DynamicFunction const& other) : DynamicFunction{other.m_pimpl->clone()} {} + + /** Default move assign semantics. */ + DynamicFunction& operator=(DynamicFunction&&) noexcept = default; + /** Copy assign by copying the wrapped data function. */ + DynamicFunction& operator=(DynamicFunction const& other) { + if (this != &other) { + m_pimpl = other.m_pimpl->clone(); + } + return *this; + } + + /** Call ``before_reset`` onto the wrapped item. */ + auto before_reset(scip::Model& model) -> void { return m_pimpl->before_reset(model); } + + /** Call ``extract`` onto the wrapped item. */ + auto extract(scip::Model& model, bool done) -> Data { return m_pimpl->extract(model, done); } + +private: + /** + * Interface expected of a data function. + * + * This is used for dynamic polymorphism based on virtual inheritance. + */ + struct DataFunctionAbstract { + virtual ~DataFunctionAbstract() = default; + virtual auto clone() -> std::unique_ptr = 0; + virtual auto before_reset(scip::Model& model) -> void = 0; + virtual auto extract(scip::Model& model, bool done) -> Data = 0; + }; + + /** + * Wrapper for any compatible data function. + * + * The wrapper implements the ``DataFunctionAbstract`` interface. + */ + template struct DataFunctionWrapper final : DataFunctionAbstract { + explicit DataFunctionWrapper(DataFunction data_function) : m_data_function{std::move(data_function)} {} + auto clone() -> std::unique_ptr override { + return std::make_unique(*this); + } + auto before_reset(scip::Model& model) -> void override { return m_data_function.before_reset(model); } + auto extract(scip::Model& model, bool done) -> Data override { return m_data_function.extract(model, done); } + + DataFunction m_data_function; + }; + + explicit DynamicFunction(std::unique_ptr ptr) : m_pimpl{std::move(ptr)} {}; + + std::unique_ptr m_pimpl; +}; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/map.hpp b/ecole/libecole/include/ecole/data/map.hpp new file mode 100644 index 0000000..a487c6a --- /dev/null +++ b/ecole/libecole/include/ecole/data/map.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include "ecole/data/abstract.hpp" +#include "ecole/traits.hpp" + +namespace ecole::data { + +/** Combine multiple data into a map of data. */ +template class MapFunction { +public: + using DataMap = std::map>; + + /** Default construct all functions. */ + MapFunction() = default; + + /** Store a copy of the functions. */ + MapFunction(std::map functions) : data_functions{std::move(functions)} {} + + /** Call before_reset on all functions. */ + void before_reset(scip::Model& model) { + for (auto& [_, func] : data_functions) { + func.before_reset(model); + } + } + + /** Return data extracted from all functions as a map. */ + DataMap extract(scip::Model& model, bool done) { + auto data = DataMap{}; + for (auto& [key, func] : data_functions) { + data.emplace_hint(data.end(), key, func.extract(model, done)); + } + return data; + } + +private: + std::map data_functions; +}; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/multiary.hpp b/ecole/libecole/include/ecole/data/multiary.hpp new file mode 100644 index 0000000..878221a --- /dev/null +++ b/ecole/libecole/include/ecole/data/multiary.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "ecole/data/abstract.hpp" +#include "ecole/traits.hpp" + +namespace ecole::data { + +/** + * A class to map multiary operation onto data extraction functions. + * + * If the arity is one, then the operations are unary, such as exp(), sqrt(), and apply(...). + * If the arity is two, then the operations are binary, such as + and *. + */ +template class MultiaryFunction { +public: + using CombinedData = std::invoke_result_t...>; + + /** Default construct all functions. */ + MultiaryFunction() = default; + + /** Store a copy of all functions. */ + MultiaryFunction(DataCombiner combiner, Functions... functions) : + data_functions{std::move(functions)...}, data_combiner{std::move(combiner)} {} + + /** Call before_reset on all functions. */ + auto before_reset(scip::Model& model) -> void { + std::apply([&model](auto&... functions) { ((functions.before_reset(model)), ...); }, data_functions); + } + + /** Extract data from all functions and call the multiart operation on it. */ + auto extract(scip::Model& model, bool done = false) -> CombinedData { + return std::apply( + [&](auto&... functions) { return data_combiner(functions.extract(model, done)...); }, data_functions); + } + +private: + std::tuple data_functions; + DataCombiner data_combiner; +}; + +/** + * Alias for a single function. + * + * No type deduction before C++20. + */ +template using UnaryFunction = MultiaryFunction; + +/** + * Alias for two functions. + * + * No type deduction before C++20. + */ +template +using BinaryFunction = MultiaryFunction; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/none.hpp b/ecole/libecole/include/ecole/data/none.hpp new file mode 100644 index 0000000..7138745 --- /dev/null +++ b/ecole/libecole/include/ecole/data/none.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "ecole/data/abstract.hpp" +#include "ecole/none.hpp" + +namespace ecole::data { + +class NoneFunction { +public: + auto before_reset(scip::Model const& /*model*/) -> void {} + + auto extract(scip::Model const& /*model*/, bool /*done*/) -> NoneType { return ecole::None; } +}; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/parser.hpp b/ecole/libecole/include/ecole/data/parser.hpp new file mode 100644 index 0000000..2e3d5f7 --- /dev/null +++ b/ecole/libecole/include/ecole/data/parser.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ecole/data/constant.hpp" +#include "ecole/data/map.hpp" +#include "ecole/data/none.hpp" +#include "ecole/data/tuple.hpp" +#include "ecole/data/vector.hpp" +#include "ecole/traits.hpp" + +namespace ecole::data { + +inline auto parse(NoneType /* None */) noexcept { + return NoneFunction{}; +} + +template constexpr auto parse(Function func) noexcept { + if constexpr (trait::is_data_function_v) { + return func; + } else { + return ConstantFunction{std::move(func)}; + } +} + +template constexpr auto parse(std::tuple func_tuple) { + return std::apply([](auto&&... funcs) { return TupleFunction{parse(funcs)...}; }, std::move(func_tuple)); +} + +template auto parse(std::vector funcs) { + using ParsedFunction = decltype(parse(std::declval())); + if constexpr (std::is_same_v) { + return VectorFunction{std::move(funcs)}; + } else { + auto parsed_funcs = std::vector{}; + parsed_funcs.reserve(funcs.size()); + std::transform( + std::move_iterator{funcs.begin()}, + std::move_iterator{funcs.end()}, + std::back_inserter(parsed_funcs), + [](Function&& func) { return parse(std::move(func)); }); + return parsed_funcs; + } +} + +template +auto parse(std::map funcs) { + using ParsedFunction = decltype(parse(std::declval())); + if constexpr (std::is_same_v) { + return MapFunction{std::move(funcs)}; + } else { + auto parsed_funcs = std::map{}; + std::for_each(std::move_iterator{funcs.begin()}, std::move_iterator{funcs.end()}, [&parsed_funcs](auto&& key_func) { + parsed_funcs.emplace_hint(parsed_funcs.end(), std::move(key_func.first), parse(std::move(key_func.second))); + }); + return parsed_funcs; + } +} + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/timed.hpp b/ecole/libecole/include/ecole/data/timed.hpp new file mode 100644 index 0000000..7ae1e00 --- /dev/null +++ b/ecole/libecole/include/ecole/data/timed.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +#include "ecole/data/abstract.hpp" +#include "ecole/utility/chrono.hpp" + +namespace ecole::data { + +namespace internal { + +/** Time in seconds to execute the given function. + * + * FIXME Should it prevent compiler optimizations? + * See example in https://github.com/facebook/folly/blob/master/folly/Benchmark.h + */ +template auto time(Func&& func) -> double { + auto const start = Clock::now(); + func(); + auto const end = Clock::now(); + return std::chrono::duration{end - start}.count(); +} + +} // namespace internal + +template class TimedFunction { +public: + TimedFunction(Function func_, bool wall_ = false) : func{std::move(func_)}, wall{wall_} {} + TimedFunction(bool wall_ = false) : wall{wall_} {} + + /** Reset the function being timed. **/ + auto before_reset(scip::Model& model) -> void { func.before_reset(model); } + + /** Time the extract method of the function. **/ + auto extract(scip::Model& model, bool done) -> double { + if (wall) { + return internal::time([&]() { return func.extract(model, done); }); + } + return internal::time([&]() { return func.extract(model, done); }); + } + +private: + Function func{}; + bool wall = false; +}; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/tuple.hpp b/ecole/libecole/include/ecole/data/tuple.hpp new file mode 100644 index 0000000..d949025 --- /dev/null +++ b/ecole/libecole/include/ecole/data/tuple.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "ecole/data/abstract.hpp" +#include "ecole/traits.hpp" + +namespace ecole::data { + +template class TupleFunction { +public: + using DataTuple = std::tuple...>; + + /** Default construct all functions. */ + TupleFunction() = default; + + /** Store a copy of the functions. */ + TupleFunction(Functions... functions) : data_functions{std::move(functions)...} {} + TupleFunction(std::tuple functions) : data_functions{std::move(functions)} {} + + /** Call before_reset on all functions. */ + auto before_reset(scip::Model& model) -> void { + std::apply([&model](auto&... functions) { ((functions.before_reset(model)), ...); }, data_functions); + } + + /** Return data from all functions as a tuple. */ + auto extract(scip::Model& model, bool done) -> DataTuple { + return std::apply( + [&model, done](auto&... functions) { return std::tuple{functions.extract(model, done)...}; }, data_functions); + } + +private: + std::tuple data_functions; +}; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/data/vector.hpp b/ecole/libecole/include/ecole/data/vector.hpp new file mode 100644 index 0000000..7e068c6 --- /dev/null +++ b/ecole/libecole/include/ecole/data/vector.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include "ecole/data/abstract.hpp" +#include "ecole/traits.hpp" + +namespace ecole::data { + +/* Combine multiple data into a vector of data. */ +template class VectorFunction { +public: + using DataVector = std::vector>; + + /** Default construct all functions. */ + VectorFunction() = default; + + /** Store a copy of the functions. */ + VectorFunction(std::vector functions) : data_functions{std::move(functions)} {} + + /** Call before_reset on all functions. */ + auto before_reset(scip::Model& model) -> void { + for (auto& func : data_functions) { + func.before_reset(model); + } + } + + /** Return data extracted from all functions as a vector. */ + auto extract(scip::Model& model, bool done) -> DataVector { + auto data = DataVector{}; + data.reserve(data_functions.size()); + std::transform(data_functions.begin(), data_functions.end(), std::back_inserter(data), [&model, done](auto& func) { + return func.extract(model, done); + }); + return data; + } + +private: + std::vector data_functions; +}; + +} // namespace ecole::data diff --git a/ecole/libecole/include/ecole/default.hpp b/ecole/libecole/include/ecole/default.hpp new file mode 100644 index 0000000..ef96643 --- /dev/null +++ b/ecole/libecole/include/ecole/default.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include "ecole/export.hpp" + +namespace ecole { + +/** + * Type of Default, an empty type. + * + * This is used to tell various Ecole functions to explicitly use the default behaviour. + * This is more explicity and less error prone than using None or optional. + */ +struct ECOLE_EXPORT DefaultType { + constexpr bool operator==(DefaultType /*unused*/) const { return true; } + constexpr bool operator!=(DefaultType /*unused*/) const { return false; } +}; + +/** + * A constant expression representing a default behaviour. + */ +constexpr inline DefaultType Default; + +/** + * Represent a type that is either a value or a DefaultType. + */ +template using Defaultable = std::variant; + +} // namespace ecole diff --git a/ecole/libecole/include/ecole/dynamics/branching.hpp b/ecole/libecole/include/ecole/dynamics/branching.hpp new file mode 100644 index 0000000..6838ff8 --- /dev/null +++ b/ecole/libecole/include/ecole/dynamics/branching.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include + +#include "ecole/default.hpp" +#include "ecole/dynamics/parts.hpp" +#include "ecole/export.hpp" + +namespace ecole::dynamics { + +class ECOLE_EXPORT BranchingDynamics : public DefaultSetDynamicsRandomState { +public: + using Action = Defaultable; + using ActionSet = std::optional>; + + using DefaultSetDynamicsRandomState::set_dynamics_random_state; + + ECOLE_EXPORT BranchingDynamics(bool pseudo_candidates = false) noexcept; + + ECOLE_EXPORT auto reset_dynamics(scip::Model& model) const -> std::tuple; + + ECOLE_EXPORT auto step_dynamics(scip::Model& model, Action maybe_var_idx) const -> std::tuple; + +private: + bool pseudo_candidates; +}; + +} // namespace ecole::dynamics diff --git a/ecole/libecole/include/ecole/dynamics/configuring.hpp b/ecole/libecole/include/ecole/dynamics/configuring.hpp new file mode 100644 index 0000000..8e91880 --- /dev/null +++ b/ecole/libecole/include/ecole/dynamics/configuring.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "ecole/dynamics/parts.hpp" +#include "ecole/export.hpp" +#include "ecole/none.hpp" +#include "ecole/scip/type.hpp" + +namespace ecole::dynamics { + +/** + * A Dictionnary of parameter names to parameter values. + */ +using ParamDict = std::map; + +class ECOLE_EXPORT ConfiguringDynamics : public DefaultSetDynamicsRandomState { +public: + using Action = ParamDict; + using ActionSet = NoneType; + + using DefaultSetDynamicsRandomState::set_dynamics_random_state; + + ECOLE_EXPORT auto reset_dynamics(scip::Model& model) const -> std::tuple; + + ECOLE_EXPORT auto step_dynamics(scip::Model& model, Action const& param_dict) const -> std::tuple; +}; + +} // namespace ecole::dynamics diff --git a/ecole/libecole/include/ecole/dynamics/parts.hpp b/ecole/libecole/include/ecole/dynamics/parts.hpp new file mode 100644 index 0000000..202b584 --- /dev/null +++ b/ecole/libecole/include/ecole/dynamics/parts.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "ecole/export.hpp" +#include "ecole/random.hpp" +#include "ecole/scip/seed.hpp" + +namespace ecole::scip { +class Model; +} + +namespace ecole::dynamics { + +/** Implementation of a default set_dynamics_random_state for dynamics classes. */ +struct ECOLE_EXPORT DefaultSetDynamicsRandomState { + + /** Set random elements of the Model for the current episode. */ + ECOLE_EXPORT auto set_dynamics_random_state(scip::Model& model, RandomGenerator& rng) const -> void; +}; + +} // namespace ecole::dynamics diff --git a/ecole/libecole/include/ecole/dynamics/primal-search.hpp b/ecole/libecole/include/ecole/dynamics/primal-search.hpp new file mode 100644 index 0000000..a32fd67 --- /dev/null +++ b/ecole/libecole/include/ecole/dynamics/primal-search.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +#include "ecole/dynamics/parts.hpp" +#include "ecole/export.hpp" + +namespace ecole::dynamics { + +class ECOLE_EXPORT PrimalSearchDynamics : public DefaultSetDynamicsRandomState { +public: + /** An array of variable identifiers in the transformed problem. */ + using ActionSet = std::optional>; + /** A tuple of variable identifiers and variable values. */ + using Action = std::pair, nonstd::span>; + + ECOLE_EXPORT + PrimalSearchDynamics(int trials_per_node = 1, int depth_freq = 1, int depth_start = 0, int depth_stop = -1); + + using DefaultSetDynamicsRandomState::set_dynamics_random_state; + + ECOLE_EXPORT auto reset_dynamics(scip::Model& model) const -> std::tuple; + + ECOLE_EXPORT auto step_dynamics(scip::Model& model, Action action) -> std::tuple; + +private: + int trials_per_node; + int depth_freq; + int depth_start; + int depth_stop; + + unsigned int trials_spent = 0; // to keep track of the number of trials during each search + SCIP_RESULT result = SCIP_DIDNOTRUN; // the final result of each search (several trials) +}; + +} // namespace ecole::dynamics diff --git a/ecole/libecole/include/ecole/environment/branching.hpp b/ecole/libecole/include/ecole/environment/branching.hpp new file mode 100644 index 0000000..da567b2 --- /dev/null +++ b/ecole/libecole/include/ecole/environment/branching.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "ecole/dynamics/branching.hpp" +#include "ecole/environment/environment.hpp" +#include "ecole/information/nothing.hpp" +#include "ecole/observation/node-bipartite.hpp" +#include "ecole/reward/is-done.hpp" + +namespace ecole::environment { + +template < + typename ObservationFunction = observation::NodeBipartite, + typename RewardFunction = reward::IsDone, + typename InformationFunction = information::Nothing> +using Branching = Environment; + +} // namespace ecole::environment diff --git a/ecole/libecole/include/ecole/environment/configuring.hpp b/ecole/libecole/include/ecole/environment/configuring.hpp new file mode 100644 index 0000000..104bc2b --- /dev/null +++ b/ecole/libecole/include/ecole/environment/configuring.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "ecole/dynamics/configuring.hpp" +#include "ecole/environment/environment.hpp" +#include "ecole/information/nothing.hpp" +#include "ecole/observation/nothing.hpp" +#include "ecole/reward/is-done.hpp" + +namespace ecole::environment { + +template < + typename ObservationFunction = observation::Nothing, + typename RewardFunction = reward::IsDone, + typename InformationFunction = information::Nothing> +using Configuring = + Environment; + +} // namespace ecole::environment diff --git a/ecole/libecole/include/ecole/environment/environment.hpp b/ecole/libecole/include/ecole/environment/environment.hpp new file mode 100644 index 0000000..e46148c --- /dev/null +++ b/ecole/libecole/include/ecole/environment/environment.hpp @@ -0,0 +1,222 @@ +#pragma once + +#include +#include +#include +#include + +#include "ecole/data/parser.hpp" +#include "ecole/exception.hpp" +#include "ecole/information/abstract.hpp" +#include "ecole/random.hpp" +#include "ecole/reward/abstract.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/seed.hpp" +#include "ecole/traits.hpp" + +#include + +template struct is_optional : std::false_type {}; +template struct is_optional> : std::true_type {}; +template inline constexpr bool is_optional_v = is_optional::value; + +namespace ecole::environment { + +/** + * Environment class orchestrating environment dynamics and state functions. + * + * Environments are the main abstraction exposed by Ecole. + * They characterise the Markov Decision Process task to solve. + * The interface to environments is meant to be close to that of + * [OpenAi Gym](https://www.gymlibrary.dev/), with some differences nontheless due to the + * requirements of Ecole. + * + * @tparam Dynamics The ecole::environment::EnvironmentDynamics driving the initial state and transition of the + * environment + * @tparam ObservationFunction The ecole::observation::ObservationFunction to extract an observation out of the + * current state. + * @tparam RewardFunction The ecole::reward::RewardFunction to extract the reward of the last transition. + * @tparam InformationFunction The ecole::information::InformationFunction to extract additional informations. + */ +template +class Environment { +public: + using Seed = ecole::Seed; + using Observation = trait::observation_of_t; + using OptionalObservation = std::conditional_t, Observation, std::optional>; + using Action = trait::action_of_t; + using ActionSet = trait::action_set_of_t; + using Reward = reward::Reward; + using Information = trait::information_of_t; + using InformationMap = information::InformationMap; + + /** + * Default construct everything and seed environment with random value. + */ + Environment() : the_rng(spawn_random_generator()) {} + + /** + * Fully customize environment and seed environment with random value. + */ + template + Environment( + ObservationFunction observation_function = {}, + RewardFunction reward_function = {}, + InformationFunction information_function = {}, + std::map scip_params = {}, + Args&&... args) : + the_dynamics(std::forward(args)...), + the_reward_function(data::parse(std::move(reward_function))), + the_observation_function(data::parse(std::move(observation_function))), + the_information_function(data::parse(std::move(information_function))), + the_scip_params(std::move(scip_params)), + the_rng(spawn_random_generator()) {} + + /** + * Set the random seed for the environment, hence making its internals deterministic. + * + * Internally, the behavior of the environment uses a random number generator to + * change its behavior on every trajectroy (every call to reset. + * Hence it is only required to seed the environment once. + * + * To get the same trajectory at every episode (provided the problem instance and + * sequence of action taken are also unchanged), one has to seed the environment before + * every call to reset. + */ + void seed(Seed new_seed) { rng().seed(new_seed); } + + /** + * Reset the environment to the initial state on the given problem instance. + * + * Takes as input a filename or loaded model. + * + * @param new_model Passed to the EnvironmentDynamics to start a new trajectory. + * @param args Passed to the EnvironmentDynamics. + * @return An observation of the new state, or nothing on terminal states. + * @return An subset of actions accepted on the next transition (call to step). + * @return A scalar reward from the signal to maximize. + * @return A boolean flag indicating whether the state is terminal. + * @return Any additional information about the transition. + * @post Unless the (initial) state is also terminal, transitioning (using step) is + * possible. + */ + template + auto reset(scip::Model&& new_model, Args&&... args) + -> std::tuple { + can_transition = true; + try { + // Create clean new Model + model() = std::move(new_model); + model().set_params(scip_params()); + dynamics().set_dynamics_random_state(model(), rng()); + + // Reset data extraction function and bring model to initial state. + reward_function().before_reset(model()); + observation_function().before_reset(model()); + information_function().before_reset(model()); + + // Place the environment in its initial state + auto [done, action_set] = dynamics().reset_dynamics(model(), std::forward(args)...); + can_transition = !done; + + // Extract additional information to be returned by reset + auto [reward, observation, information] = extract_reward_observation_information(done); + + return { + std::move(observation), + std::move(action_set), + std::move(reward), + done, + std::move(information), + }; + } catch (std::exception const&) { + can_transition = false; + throw; + } + } + + template + auto reset(scip::Model const& model, Args&&... args) + -> std::tuple { + return reset(model.copy_orig(), std::forward(args)...); + } + + template + auto reset(std::string const& filename, Args&&... args) + -> std::tuple { + return reset(scip::Model::from_file(filename), std::forward(args)...); + } + + /** + * Transition from one state to another. + * + * Take an action on the previously observed state and transition to a new state. + * + * @param action Passed to the EnvironmentDynamics. + * @param args Passed to the EnvironmentDynamics. + * @return An observation of the new state, or nothing on terminal states. + * @return An subset of actions accepted on the next transition (call to step). + * @return A scalar reward from the signal to maximize. + * @return A boolean flag indicating whether the state is terminal. + * @return Any additional information about the transition. + * @pre A call to reset must have been done prior to transitioning. + * @pre The envrionment must not be on a terminal state, or have thrown an exception. + * In such cases, a call to reset must be perform before continuing. + */ + template + auto step(Action const& action, Args&&... args) + -> std::tuple { + if (!can_transition) { + throw MarkovError{"Environment need to be reset."}; + } + try { + // Transition the environment to the next state + auto [done, action_set] = dynamics().step_dynamics(model(), action, std::forward(args)...); + can_transition = !done; + + // Extract additional information to be returned by step + auto [reward, observation, information] = extract_reward_observation_information(done); + + return { + std::move(observation), + std::move(action_set), + std::move(reward), + done, + std::move(information), + }; + } catch (std::exception const&) { + can_transition = false; + throw; + } + } + + auto& dynamics() { return the_dynamics; } + auto& model() { return the_model; } + auto& observation_function() { return the_observation_function; } + auto& reward_function() { return the_reward_function; } + auto& information_function() { return the_information_function; } + auto& scip_params() { return the_scip_params; } + auto& rng() { return the_rng; } + +private: + Dynamics the_dynamics; + scip::Model the_model; + RewardFunction the_reward_function; + ObservationFunction the_observation_function; + InformationFunction the_information_function; + std::map the_scip_params; + RandomGenerator the_rng; + bool can_transition = false; + + // extract reward, observation and information (in that order) + auto extract_reward_observation_information(bool done) -> std::tuple { + auto reward = reward_function().extract(model(), done); + // Don't extract observations in final states + auto observation = done ? OptionalObservation{} : observation_function().extract(model(), done); + auto information = information_function().extract(model(), done); + + return {std::move(reward), std::move(observation), std::move(information)}; + } +}; + +} // namespace ecole::environment diff --git a/ecole/libecole/include/ecole/environment/primal-search.hpp b/ecole/libecole/include/ecole/environment/primal-search.hpp new file mode 100644 index 0000000..3c632c7 --- /dev/null +++ b/ecole/libecole/include/ecole/environment/primal-search.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "ecole/dynamics/primal-search.hpp" +#include "ecole/environment/environment.hpp" +#include "ecole/information/nothing.hpp" +#include "ecole/observation/node-bipartite.hpp" +#include "ecole/reward/is-done.hpp" + +namespace ecole::environment { + +template < + typename ObservationFunction = observation::NodeBipartite, + typename RewardFunction = reward::IsDone, + typename InformationFunction = information::Nothing> +using PrimalSearch = + Environment; + +} // namespace ecole::environment diff --git a/ecole/libecole/include/ecole/exception.hpp b/ecole/libecole/include/ecole/exception.hpp new file mode 100644 index 0000000..a0229a3 --- /dev/null +++ b/ecole/libecole/include/ecole/exception.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include "ecole/export.hpp" + +namespace ecole { + +/* + * Exception class indicating that the environment interface is not use in the intended way. + */ +class ECOLE_EXPORT MarkovError : public std::logic_error { +public: + using std::logic_error::logic_error; +}; + +/* + * Exception class indicating that an generator cannot generate any new items. + */ +class ECOLE_EXPORT IteratorExhausted : public std::logic_error { +public: + using std::logic_error::logic_error; + + ECOLE_EXPORT IteratorExhausted(); +}; + +} // namespace ecole diff --git a/ecole/libecole/include/ecole/information/abstract.hpp b/ecole/libecole/include/ecole/information/abstract.hpp new file mode 100644 index 0000000..1989275 --- /dev/null +++ b/ecole/libecole/include/ecole/information/abstract.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +#include "ecole/data/abstract.hpp" + +namespace ecole::information { + +/** The type of information dictionnaries. */ +template using InformationMap = std::map; +} // namespace ecole::information diff --git a/ecole/libecole/include/ecole/information/nothing.hpp b/ecole/libecole/include/ecole/information/nothing.hpp new file mode 100644 index 0000000..56b8c1d --- /dev/null +++ b/ecole/libecole/include/ecole/information/nothing.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "ecole/information/abstract.hpp" +#include "ecole/none.hpp" + +namespace ecole::information { + +/** + * Empty information function. + */ +class Nothing { +public: + auto before_reset(scip::Model& /*model*/) -> void {} + + auto extract(scip::Model& /* model */, bool /* done */) -> InformationMap { return {}; } +}; + +} // namespace ecole::information diff --git a/ecole/libecole/include/ecole/none.hpp b/ecole/libecole/include/ecole/none.hpp new file mode 100644 index 0000000..3f6bddc --- /dev/null +++ b/ecole/libecole/include/ecole/none.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "ecole/export.hpp" + +namespace ecole { + +/** + * Type of None, an empty type. + * + * This is used to represent a consistent non-use of a type. + * For instance, when not needing an observation function, this is used as the type + * of the observation. + */ +struct ECOLE_EXPORT NoneType { + constexpr bool operator==(NoneType /*unused*/) const { return true; } + constexpr bool operator!=(NoneType /*unused*/) const { return false; } +}; + +/** + * A constant expression representing no value. + */ +constexpr inline NoneType None; + +} // namespace ecole diff --git a/ecole/libecole/include/ecole/observation/abstract.hpp b/ecole/libecole/include/ecole/observation/abstract.hpp new file mode 100644 index 0000000..2df06ea --- /dev/null +++ b/ecole/libecole/include/ecole/observation/abstract.hpp @@ -0,0 +1,3 @@ +#pragma once + +#include "ecole/data/abstract.hpp" diff --git a/ecole/libecole/include/ecole/observation/hutter-2011.hpp b/ecole/libecole/include/ecole/observation/hutter-2011.hpp new file mode 100644 index 0000000..abf7d31 --- /dev/null +++ b/ecole/libecole/include/ecole/observation/hutter-2011.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include + +#include + +#include "ecole/export.hpp" +#include "ecole/observation/abstract.hpp" + +namespace ecole::observation { + +struct ECOLE_EXPORT Hutter2011Obs { + static inline std::size_t constexpr n_features = 33; + + enum struct ECOLE_EXPORT Features : std::size_t { + /* Problem size features */ + nb_variables = 0, + nb_constraints, + nb_nonzero_coefs, + /* Variable-constraint graph features */ + variable_node_degree_mean, + variable_node_degree_max, + variable_node_degree_min, + variable_node_degree_std, + constraint_node_degree_mean, + constraint_node_degree_max, + constraint_node_degree_min, + constraint_node_degree_std, + /* Variable graph (VG) features */ + node_degree_mean, + node_degree_max, + node_degree_min, + node_degree_std, + node_degree_25q, + node_degree_75q, + // Not computed because too expensive + // clustering_coef_mean, + // clustering_coef_std, + edge_density, + /* LP features */ + lp_slack_mean, + lp_slack_max, + lp_slack_l2, + lp_objective_value, + /* Objective function features */ + objective_coef_m_std, + objective_coef_n_std, + objective_coef_sqrtn_std, + /* Linear constraint matrix features */ + constraint_coef_mean, + constraint_coef_std, + constraint_var_coef_mean, + constraint_var_coef_std, + /* Variable type features */ + discrete_vars_support_size_mean, + discrete_vars_support_size_std, + ratio_unbounded_discrete_vars, + ratio_continuous_vars, + /* General Problem type features */ + // Not computed due to SCIP not supporting MIQP + // problem_type, + // nb_quadratic_constraints, + // nb_quadratic_nonzero_coefs, + // nb_quadratic_variables, + }; + + xt::xtensor features; +}; + +class ECOLE_EXPORT Hutter2011 { +public: + auto before_reset(scip::Model& /*model*/) -> void {} + ECOLE_EXPORT auto extract(scip::Model& model, bool done) -> std::optional; +}; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/observation/khalil-2016.hpp b/ecole/libecole/include/ecole/observation/khalil-2016.hpp new file mode 100644 index 0000000..7cbeb96 --- /dev/null +++ b/ecole/libecole/include/ecole/observation/khalil-2016.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include +#include + +#include + +#include "ecole/export.hpp" +#include "ecole/observation/abstract.hpp" + +namespace ecole::observation { + +struct ECOLE_EXPORT Khalil2016Obs { + static inline std::size_t constexpr n_static_features = 18; + static inline std::size_t constexpr n_dynamic_features = 54; + static inline std::size_t constexpr n_features = n_static_features + n_dynamic_features; + + enum struct ECOLE_EXPORT Features : std::size_t { + /** Static features */ + /** Objective function coeffs. (3) */ + obj_coef = 0, + obj_coef_pos_part, + obj_coef_neg_part, + /** Num. constraints (1) */ + n_rows, + /** Stats. for constraint degrees (4) */ + rows_deg_mean, + rows_deg_stddev, + rows_deg_min, + rows_deg_max, + /** Stats. for constraint coeffs. (10) */ + rows_pos_coefs_count, + rows_pos_coefs_mean, + rows_pos_coefs_stddev, + rows_pos_coefs_min, + rows_pos_coefs_max, + rows_neg_coefs_count, + rows_neg_coefs_mean, + rows_neg_coefs_stddev, + rows_neg_coefs_min, + rows_neg_coefs_max, + + /** Dynamic features */ + /** Slack and ceil distances (2) */ + slack, + ceil_dist, + /** Pseudocosts (5) */ + pseudocost_up, + pseudocost_down, + pseudocost_ratio, + pseudocost_sum, + pseudocost_product, + /** Infeasibility statistics (4) */ + n_cutoff_up, + n_cutoff_down, + n_cutoff_up_ratio, + n_cutoff_down_ratio, + /** Stats. for constraint degrees (7) */ + rows_dynamic_deg_mean, + rows_dynamic_deg_stddev, + rows_dynamic_deg_min, + rows_dynamic_deg_max, + rows_dynamic_deg_mean_ratio, + rows_dynamic_deg_min_ratio, + rows_dynamic_deg_max_ratio, + /** Min/max for ratios of constraint coeffs. to RHS (4) */ + coef_pos_rhs_ratio_min, + coef_pos_rhs_ratio_max, + coef_neg_rhs_ratio_min, + coef_neg_rhs_ratio_max, + /** Min/max for one-to-all coefficient ratios (8) */ + pos_coef_pos_coef_ratio_min, + pos_coef_pos_coef_ratio_max, + pos_coef_neg_coef_ratio_min, + pos_coef_neg_coef_ratio_max, + neg_coef_pos_coef_ratio_min, + neg_coef_pos_coef_ratio_max, + neg_coef_neg_coef_ratio_min, + neg_coef_neg_coef_ratio_max, + /** Stats. for active constraint coefficients (24) */ + active_coef_weight1_count, + active_coef_weight1_sum, + active_coef_weight1_mean, + active_coef_weight1_stddev, + active_coef_weight1_min, + active_coef_weight1_max, + active_coef_weight2_count, + active_coef_weight2_sum, + active_coef_weight2_mean, + active_coef_weight2_stddev, + active_coef_weight2_min, + active_coef_weight2_max, + active_coef_weight3_count, + active_coef_weight3_sum, + active_coef_weight3_mean, + active_coef_weight3_stddev, + active_coef_weight3_min, + active_coef_weight3_max, + active_coef_weight4_count, + active_coef_weight4_sum, + active_coef_weight4_mean, + active_coef_weight4_stddev, + active_coef_weight4_min, + active_coef_weight4_max, + }; + + xt::xtensor features; +}; + +class ECOLE_EXPORT Khalil2016 { +public: + ECOLE_EXPORT Khalil2016(bool pseudo_candidates = false) noexcept; + + ECOLE_EXPORT auto before_reset(scip::Model& model) -> void; + + ECOLE_EXPORT auto extract(scip::Model& model, bool done) -> std::optional; + +private: + bool pseudo_candidates; + xt::xtensor static_features; +}; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/observation/milp-bipartite.hpp b/ecole/libecole/include/ecole/observation/milp-bipartite.hpp new file mode 100644 index 0000000..7a24cd5 --- /dev/null +++ b/ecole/libecole/include/ecole/observation/milp-bipartite.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include + +#include "ecole/export.hpp" +#include "ecole/observation/abstract.hpp" +#include "ecole/utility/sparse-matrix.hpp" + +namespace ecole::observation { + +class ECOLE_EXPORT MilpBipartiteObs { +public: + using value_type = double; + + static inline std::size_t constexpr n_variable_features = 9; + enum struct ECOLE_EXPORT VariableFeatures : std::size_t { + objective = 0, + is_type_binary, // One hot encoded + is_type_integer, // One hot encoded + is_type_implicit_integer, // One hot encoded + is_type_continuous, // One hot encoded + has_lower_bound, + has_upper_bound, + lower_bound, + upper_bound, + }; + static inline std::size_t constexpr n_constraint_features = 1; + enum struct ECOLE_EXPORT ConstraintFeatures : std::size_t { + bias = 0, + }; + + xt::xtensor variable_features; + xt::xtensor constraint_features; + utility::coo_matrix edge_features; +}; + +class ECOLE_EXPORT MilpBipartite { +public: + MilpBipartite(bool normalize_ = false) : normalize{normalize_} {} + + auto before_reset(scip::Model& /*model*/) -> void {} + + ECOLE_EXPORT auto extract(scip::Model& model, bool done) const -> std::optional; + +private: + bool normalize = false; +}; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/observation/node-bipartite-candidate.hpp b/ecole/libecole/include/ecole/observation/node-bipartite-candidate.hpp new file mode 100644 index 0000000..7f5e860 --- /dev/null +++ b/ecole/libecole/include/ecole/observation/node-bipartite-candidate.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include + +#include + +#include "ecole/export.hpp" +#include "ecole/observation/abstract.hpp" +#include "ecole/utility/sparse-matrix.hpp" + +namespace ecole::observation { + +struct ECOLE_EXPORT NodeBipartiteCandObs { + using value_type = double; + + static inline std::size_t constexpr n_static_column_features = 5; // 5 + static inline std::size_t constexpr n_dynamic_column_features = 33; //14 + static inline std::size_t constexpr n_column_features = n_static_column_features + n_dynamic_column_features; + enum struct ECOLE_EXPORT ColumnFeatures : std::size_t { + /** Static features */ + objective = 0, + is_type_binary, // One hot encoded + is_type_integer, // One hot encoded + is_type_implicit_integer, // One hot encoded + is_type_continuous, // One hot encoded + + + /** Dynamic features */ + has_lower_bound, + has_upper_bound, + normed_reduced_cost, + solution_value, + solution_frac, + is_solution_at_lower_bound, + is_solution_at_upper_bound, + scaled_age, + incumbent_value, + average_incumbent_value, + is_basis_lower, + is_basis_basic, + is_basis_upper, + is_basis_zero, + solution_infeasibility, // new feature + edge_mean, + edge_min, + edge_max, + bias_mean, + bias_min, + bias_max, + obj_cos_sim_mean, + obj_cos_sim_min, + obj_cos_sim_max, + is_tight_mean, + is_tight_min, + is_tight_max, + dual_solution_mean, + dual_solution_min, + dual_solution_max, + scaled_age_mean, + scaled_age_min, + scaled_age_max, + + }; + + + xt::xtensor column_features; + +}; + +class ECOLE_EXPORT NodeBipartiteCand { +public: + NodeBipartiteCand(bool cache = false) : use_cache{cache} {} + + ECOLE_EXPORT auto before_reset(scip::Model& model) -> void; + + ECOLE_EXPORT auto extract(scip::Model& model, bool done) -> std::optional; + +private: + NodeBipartiteCandObs the_cache; + bool use_cache = false; + bool cache_computed = false; +}; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/observation/node-bipartite.hpp b/ecole/libecole/include/ecole/observation/node-bipartite.hpp new file mode 100644 index 0000000..77bcfaa --- /dev/null +++ b/ecole/libecole/include/ecole/observation/node-bipartite.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include + +#include + +#include "ecole/export.hpp" +#include "ecole/observation/abstract.hpp" +#include "ecole/utility/sparse-matrix.hpp" + +namespace ecole::observation { + +struct ECOLE_EXPORT NodeBipartiteObs { + using value_type = double; + + static inline std::size_t constexpr n_static_variable_features = 5; + static inline std::size_t constexpr n_dynamic_variable_features = 14; + static inline std::size_t constexpr n_variable_features = n_static_variable_features + n_dynamic_variable_features; + enum struct ECOLE_EXPORT VariableFeatures : std::size_t { + /** Static features */ + objective = 0, + is_type_binary, // One hot encoded + is_type_integer, // One hot encoded + is_type_implicit_integer, // One hot encoded + is_type_continuous, // One hot encoded + + /** Dynamic features */ + has_lower_bound, + has_upper_bound, + normed_reduced_cost, + solution_value, + solution_frac, + is_solution_at_lower_bound, + is_solution_at_upper_bound, + scaled_age, + incumbent_value, + average_incumbent_value, + is_basis_lower, // One hot encoded + is_basis_basic, // One hot encoded + is_basis_upper, // One hot encoded + is_basis_zero, // One hot encoded + }; + + static inline std::size_t constexpr n_static_row_features = 2; + static inline std::size_t constexpr n_dynamic_row_features = 3; + static inline std::size_t constexpr n_row_features = n_static_row_features + n_dynamic_row_features; + enum struct ECOLE_EXPORT RowFeatures : std::size_t { + /** Static features */ + bias = 0, + objective_cosine_similarity, + + /** Dynamic features */ + is_tight, + dual_solution_value, + scaled_age, + }; + + xt::xtensor variable_features; + xt::xtensor row_features; + utility::coo_matrix edge_features; +}; + +class ECOLE_EXPORT NodeBipartite { +public: + NodeBipartite(bool cache = false) : use_cache{cache} {} + + ECOLE_EXPORT auto before_reset(scip::Model& model) -> void; + + ECOLE_EXPORT auto extract(scip::Model& model, bool done) -> std::optional; + +private: + NodeBipartiteObs the_cache; + bool use_cache = false; + bool cache_computed = false; +}; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/observation/nothing.hpp b/ecole/libecole/include/ecole/observation/nothing.hpp new file mode 100644 index 0000000..ac2598c --- /dev/null +++ b/ecole/libecole/include/ecole/observation/nothing.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include "ecole/data/none.hpp" + +namespace ecole::observation { + +using Nothing = data::NoneFunction; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/observation/pseudocosts.hpp b/ecole/libecole/include/ecole/observation/pseudocosts.hpp new file mode 100644 index 0000000..22793e8 --- /dev/null +++ b/ecole/libecole/include/ecole/observation/pseudocosts.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +#include "ecole/export.hpp" +#include "ecole/observation/abstract.hpp" + +namespace ecole::observation { + +class ECOLE_EXPORT Pseudocosts { +public: + auto before_reset(scip::Model& /*model*/) -> void {} + + ECOLE_EXPORT auto extract(scip::Model& model, bool done) -> std::optional>; +}; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/observation/strong-branching-scores.hpp b/ecole/libecole/include/ecole/observation/strong-branching-scores.hpp new file mode 100644 index 0000000..9a6aeb2 --- /dev/null +++ b/ecole/libecole/include/ecole/observation/strong-branching-scores.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include +#include + +#include "ecole/export.hpp" +#include "ecole/observation/abstract.hpp" + +namespace ecole::observation { + +class ECOLE_EXPORT StrongBranchingScores { +public: + ECOLE_EXPORT StrongBranchingScores(bool pseudo_candidates = false); + + auto before_reset(scip::Model& /*model*/) -> void {} + + ECOLE_EXPORT auto extract(scip::Model& model, bool done) const -> std::optional>; + +private: + bool pseudo_candidates; +}; + +} // namespace ecole::observation diff --git a/ecole/libecole/include/ecole/random.hpp b/ecole/libecole/include/ecole/random.hpp new file mode 100644 index 0000000..39a9482 --- /dev/null +++ b/ecole/libecole/include/ecole/random.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +#include "ecole/export.hpp" + +namespace ecole { + +using RandomGenerator = std::mt19937; +using Seed = RandomGenerator::result_type; + +/** + * Seed the main random generator of Ecole. + * + * All random generatorderive from this seeding. + * When no seeding is performed Ecole uses true randomness. + * Seeding does not affect random generators already created. + */ +ECOLE_EXPORT auto seed(Seed val) -> void; + +/** + * Get a new random generator that derive from Ecole's main source of randomness. + * + * This is the function used by all Ecole components that need a random generator. + * While the function is thread safe, undeterministic behaviour can happen if this function is call in different threads + * in a non deterministic order. + */ +ECOLE_EXPORT auto spawn_random_generator() -> RandomGenerator; + +/** + * Convert the state of the random generator to a string. + */ +ECOLE_EXPORT auto serialize(RandomGenerator const& rng) -> std::string; + +/** + * Convert a string representing the state of a random generator to a random generator. + */ +ECOLE_EXPORT auto deserialize(std::string const& data) -> RandomGenerator; + +} // namespace ecole diff --git a/ecole/libecole/include/ecole/reward/abstract.hpp b/ecole/libecole/include/ecole/reward/abstract.hpp new file mode 100644 index 0000000..079a248 --- /dev/null +++ b/ecole/libecole/include/ecole/reward/abstract.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include "ecole/data/abstract.hpp" + +namespace ecole::reward { + +using Reward = double; + +} // namespace ecole::reward diff --git a/ecole/libecole/include/ecole/reward/bound-integral.hpp b/ecole/libecole/include/ecole/reward/bound-integral.hpp new file mode 100644 index 0000000..687976d --- /dev/null +++ b/ecole/libecole/include/ecole/reward/bound-integral.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include "ecole/export.hpp" +#include "ecole/reward/abstract.hpp" + +namespace ecole::reward { + +enum struct ECOLE_EXPORT Bound { primal, dual, primal_dual }; + +template class ECOLE_EXPORT BoundIntegral { +public: + using BoundFunction = std::function(scip::Model& model)>; + + ECOLE_EXPORT BoundIntegral(bool wall_ = false, const BoundFunction& bound_function_ = {}); + + ECOLE_EXPORT auto before_reset(scip::Model& model) -> void; + ECOLE_EXPORT auto extract(scip::Model& model, bool done = false) -> Reward; + +private: + BoundFunction bound_function; + std::string name; + Reward initial_primal_bound = 0.0; + Reward initial_dual_bound = 0.0; + Reward offset = 0.0; + bool wall = false; +}; + +using PrimalIntegral = BoundIntegral; +using DualIntegral = BoundIntegral; +using PrimalDualIntegral = BoundIntegral; + +} // namespace ecole::reward diff --git a/ecole/libecole/include/ecole/reward/constant.hpp b/ecole/libecole/include/ecole/reward/constant.hpp new file mode 100644 index 0000000..ee1788a --- /dev/null +++ b/ecole/libecole/include/ecole/reward/constant.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include "ecole/data/constant.hpp" +#include "ecole/reward/abstract.hpp" + +namespace ecole::reward { + +using Constant = data::ConstantFunction; + +} // namespace ecole::reward diff --git a/ecole/libecole/include/ecole/reward/is-done.hpp b/ecole/libecole/include/ecole/reward/is-done.hpp new file mode 100644 index 0000000..2e92ddd --- /dev/null +++ b/ecole/libecole/include/ecole/reward/is-done.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "ecole/export.hpp" +#include "ecole/reward/abstract.hpp" + +namespace ecole::reward { + +class ECOLE_EXPORT IsDone { +public: + auto before_reset(scip::Model& /*model*/) -> void {} + ECOLE_EXPORT auto extract(scip::Model& model, bool done = false) -> Reward; +}; + +} // namespace ecole::reward diff --git a/ecole/libecole/include/ecole/reward/lp-iterations.hpp b/ecole/libecole/include/ecole/reward/lp-iterations.hpp new file mode 100644 index 0000000..0acd204 --- /dev/null +++ b/ecole/libecole/include/ecole/reward/lp-iterations.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include "ecole/export.hpp" +#include "ecole/reward/abstract.hpp" + +namespace ecole::reward { + +class ECOLE_EXPORT LpIterations { +public: + ECOLE_EXPORT auto before_reset(scip::Model& model) -> void; + ECOLE_EXPORT auto extract(scip::Model& model, bool done = false) -> Reward; + +private: + std::uint64_t last_lp_iter = 0; +}; + +} // namespace ecole::reward diff --git a/ecole/libecole/include/ecole/reward/n-nodes.hpp b/ecole/libecole/include/ecole/reward/n-nodes.hpp new file mode 100644 index 0000000..1d8915a --- /dev/null +++ b/ecole/libecole/include/ecole/reward/n-nodes.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include "ecole/export.hpp" +#include "ecole/reward/abstract.hpp" + +namespace ecole::reward { + +class ECOLE_EXPORT NNodes { +public: + ECOLE_EXPORT auto before_reset(scip::Model& model) -> void; + ECOLE_EXPORT auto extract(scip::Model& model, bool done = false) -> Reward; + +private: + std::uint64_t last_n_nodes = 0; +}; + +} // namespace ecole::reward diff --git a/ecole/libecole/include/ecole/reward/solving-time.hpp b/ecole/libecole/include/ecole/reward/solving-time.hpp new file mode 100644 index 0000000..1945cbb --- /dev/null +++ b/ecole/libecole/include/ecole/reward/solving-time.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "ecole/export.hpp" +#include "ecole/reward/abstract.hpp" + +namespace ecole::reward { + +class ECOLE_EXPORT SolvingTime { +public: + SolvingTime(bool wall_ = false) noexcept : wall{wall_} {} + + ECOLE_EXPORT auto before_reset(scip::Model& model) -> void; + ECOLE_EXPORT auto extract(scip::Model& model, bool done = false) -> Reward; + +private: + bool wall = false; + std::chrono::nanoseconds solving_time_offset; +}; + +} // namespace ecole::reward diff --git a/ecole/libecole/include/ecole/scip/callback.hpp b/ecole/libecole/include/ecole/scip/callback.hpp new file mode 100644 index 0000000..f546962 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/callback.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +#include + +#include "ecole/utility/unreachable.hpp" + +/** + * Reverse callback tools. + * + * Helper tools for using reverse callback for iterative solving. + */ +namespace ecole::scip::callback { + +/** Type of rverse callback available. */ +enum struct Type { Branchrule, Heuristic }; + +/** Return the name used for the reverse callback. */ +constexpr auto name(Type type) { + switch (type) { + case Type::Branchrule: + return "ecole::scip::StopLocation::Branchrule"; + case Type::Heuristic: + return "ecole::scip::StopLocation::Heuristic"; + default: + utility::unreachable(); + } +} + +constexpr inline int priority_max = 536870911; +constexpr inline int max_depth_none = -1; +constexpr inline double max_bound_distance_none = 1.0; +constexpr inline int frequency_always = 1; +constexpr inline int frequency_offset_none = 0; + +/** Parameter passed to create a reverse callback. */ +template struct Constructor; + +/** Parameter passed to a reverse branchrule. */ +template <> struct Constructor { + int priority = priority_max; + int max_depth = max_depth_none; + double max_bound_distance = max_bound_distance_none; +}; +using BranchruleConstructor = Constructor; + +/** Parameter passed to create a reverse heurisitc. */ +template <> struct Constructor { + int priority = priority_max; + int frequency = frequency_always; + int frequency_offset = frequency_offset_none; + int max_depth = max_depth_none; + SCIP_HEURTIMING timing_mask = SCIP_HEURTIMING_AFTERNODE; +}; +using HeuristicConstructor = Constructor; + +using DynamicConstructor = std::variant, Constructor>; + +/** Parameter given by SCIP to the callback function. */ +template struct Call; + +/** Parameter given by SCIP to the branchrule function. */ +template <> struct Call { + /** The method of the Branchrule callback being called. */ + enum struct Where { LP, External, Pseudo }; + + bool allow_add_constraints; + Where where; +}; +using BranchruleCall = Call; + +/** Parameter given by SCIP to the heuristic functions. */ +template <> struct Call { + SCIP_HEURTIMING heuristic_timing; + bool node_infeasible; +}; +using HeuristicCall = Call; + +using DynamicCall = std::variant, Call>; + +} // namespace ecole::scip::callback diff --git a/ecole/libecole/include/ecole/scip/col.hpp b/ecole/libecole/include/ecole/scip/col.hpp new file mode 100644 index 0000000..ed058c8 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/col.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +#include "ecole/export.hpp" + +namespace ecole::scip { + +ECOLE_EXPORT auto get_rows(SCIP_COL const* col) noexcept -> nonstd::span; +ECOLE_EXPORT auto get_vals(SCIP_COL const* col) noexcept -> nonstd::span; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/cons.hpp b/ecole/libecole/include/ecole/scip/cons.hpp new file mode 100644 index 0000000..10cc579 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/cons.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "ecole/export.hpp" +#include "ecole/scip/utils.hpp" +#include "ecole/utility/sparse-matrix.hpp" + +namespace ecole::scip { + +/** Scip deleter for Cons pointers. */ +class ECOLE_EXPORT ConsReleaser { +public: + /** Capture the SCIP pointer but does not extend its lifetime. */ + ECOLE_EXPORT ConsReleaser(SCIP* scip_) noexcept : scip(scip_){}; + + /** Call SCIPconsRelease */ + ECOLE_EXPORT void operator()(SCIP_CONS* ptr); + +private: + SCIP* scip = nullptr; +}; + +/** + * Create a linear constraint with automatic management (RAII). + * + * The constraint is returned in a unique_ptr that will automatically call SCIPreleaseCons on deletion. + * Even if the release is done automatically the SCIP semantics are not changed and the constraint must not outlive the + * SCIP pointer (it is needed to release the constraint). + * + * The arguments are forwarded to SCIPcreateConsBasicLinear. + */ +ECOLE_EXPORT auto create_cons_basic_linear( + SCIP* scip, + char const* name, + std::size_t n_vars, + SCIP_VAR const* const* vars, + SCIP_Real const* vals, + SCIP_Real lhs, + SCIP_Real rhs) -> std::unique_ptr; + +ECOLE_EXPORT auto cons_get_rhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional; +ECOLE_EXPORT auto cons_get_finite_rhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional; +ECOLE_EXPORT auto cons_get_lhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional; +ECOLE_EXPORT auto cons_get_finite_lhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional; + +ECOLE_EXPORT auto get_cons_n_vars(SCIP const* scip, SCIP_CONS const* cons) -> std::optional; +ECOLE_EXPORT auto get_cons_vars(SCIP* scip, SCIP_CONS* cons, nonstd::span out) -> bool; +ECOLE_EXPORT auto get_cons_vars(SCIP const* scip, SCIP_CONS const* cons, nonstd::span out) -> bool; +ECOLE_EXPORT auto get_cons_vars(SCIP* scip, SCIP_CONS* cons) -> std::optional>; +ECOLE_EXPORT auto get_cons_vars(SCIP const* scip, SCIP_CONS const* cons) -> std::optional>; +ECOLE_EXPORT auto get_cons_vals(SCIP const* scip, SCIP_CONS const* cons, nonstd::span out) -> bool; +ECOLE_EXPORT auto get_cons_vals(SCIP const* scip, SCIP_CONS const* cons) -> std::optional>; + +ECOLE_EXPORT auto get_vals_linear(SCIP const* scip, SCIP_CONS const* cons) noexcept -> nonstd::span; +ECOLE_EXPORT auto get_vars_linear(SCIP const* scip, SCIP_CONS const* cons) noexcept + -> nonstd::span; + +ECOLE_EXPORT auto get_constraint_linear_coefs(SCIP* scip, SCIP_CONS* constraint) -> std::optional< + std::tuple, std::vector, std::optional, std::optional>>; +ECOLE_EXPORT auto get_constraint_coefs(SCIP* scip, SCIP_CONS* constraint) + -> std::tuple, std::vector, std::optional, std::optional>; +ECOLE_EXPORT auto get_all_constraints(SCIP* scip, bool normalize = false, bool include_variable_bounds = false) + -> std::tuple, xt::xtensor>; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/exception.hpp b/ecole/libecole/include/ecole/scip/exception.hpp new file mode 100644 index 0000000..a9ad133 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/exception.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include + +#include "ecole/export.hpp" + +namespace ecole::scip { + +class ECOLE_EXPORT ScipError : public std::exception { +public: + ECOLE_EXPORT static ScipError from_retcode(SCIP_RETCODE retcode); + ECOLE_EXPORT static void reset_message_capture(); + + ECOLE_EXPORT ScipError(std::string message); + + [[nodiscard]] ECOLE_EXPORT char const* what() const noexcept override; + +private: + std::string message; +}; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/model.hpp b/ecole/libecole/include/ecole/scip/model.hpp new file mode 100644 index 0000000..034c2ed --- /dev/null +++ b/ecole/libecole/include/ecole/scip/model.hpp @@ -0,0 +1,305 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ecole/export.hpp" +#include "ecole/scip/callback.hpp" +#include "ecole/scip/exception.hpp" +#include "ecole/scip/type.hpp" +#include "ecole/utility/numeric.hpp" +#include "ecole/utility/type-traits.hpp" +#include "ecole/utility/unreachable.hpp" + +namespace ecole::scip { + +/* Forward declare scip holder type */ +class Scimpl; + +/** + * A stateful SCIP solver object. + * + * A RAII class to manage an underlying `SCIP*`. + * This is somehow similar to a `pyscipopt.Model`, but with higher level methods + * tailored for the needs in Ecole. + * This is the only interface to SCIP in the library. + */ +class ECOLE_EXPORT Model { +public: + /** + * Construct an *initialized* model with default SCIP plugins. + */ + ECOLE_EXPORT Model(); + ECOLE_EXPORT Model(Model&& /*other*/) noexcept; + Model(Model const& model) = delete; + ECOLE_EXPORT Model(std::unique_ptr&& /*other_scimpl*/); + + ECOLE_EXPORT ~Model(); + + ECOLE_EXPORT Model& operator=(Model&& /*other*/) noexcept; + ECOLE_EXPORT Model& operator=(Model const&) = delete; + + /** + * Access the underlying SCIP pointer. + * + * Ownership of the pointer is however not released by the Model. + * This function is meant to use the original C API of SCIP. + */ + [[nodiscard]] ECOLE_EXPORT SCIP* get_scip_ptr() noexcept; + [[nodiscard]] ECOLE_EXPORT SCIP const* get_scip_ptr() const noexcept; + + [[nodiscard]] ECOLE_EXPORT Model copy() const; + [[nodiscard]] ECOLE_EXPORT Model copy_orig() const; + + /** + * Compare if two model share the same SCIP pointer, _i.e._ the same memory. + */ + ECOLE_EXPORT bool operator==(Model const& other) const noexcept; + ECOLE_EXPORT bool operator!=(Model const& other) const noexcept; + + /** + * Construct a model by reading a problem file supported by SCIP (LP, MPS,...). + */ + ECOLE_EXPORT static Model from_file(std::filesystem::path const& filename); + + /** + * Constuct an empty problem with empty data structures. + */ + ECOLE_EXPORT static Model prob_basic(std::string const& name = "Model"); + + /** + * Writes the Model into a file. + */ + ECOLE_EXPORT void write_problem(std::filesystem::path const& filename) const; + + /** + * Read a problem file into the Model. + */ + ECOLE_EXPORT void read_problem(std::string const& filename); + + /** + * Change whether or not to write logging messages in the logger. + */ + ECOLE_EXPORT void set_messagehdlr_quiet(bool quiet) noexcept; + + [[nodiscard]] ECOLE_EXPORT std::string name() const noexcept; + ECOLE_EXPORT void set_name(std::string const& name); + + [[nodiscard]] ECOLE_EXPORT SCIP_STAGE stage() const noexcept; + + [[nodiscard]] ECOLE_EXPORT ParamType get_param_type(std::string const& name) const; + + /** + * Get and set parameters by their exact SCIP type. + * + * The method will throw an exception if the type is not *exactly* the one used + * by SCIP. + */ + template + ECOLE_EXPORT void set_param(std::string const& name, utility::value_or_const_ref_t> value); + template [[nodiscard]] ECOLE_EXPORT param_t get_param(std::string const& name) const; + + /** + * Get and set parameters with automatic casting. + * + * Often, it is not required to know the exact type of a parameters to set its value + * (for instance when setting to zero). + * These methods do their best to convert to and from the required type. + */ + template void set_param(std::string const& name, T value); + template [[nodiscard]] T get_param(std::string const& name) const; + + ECOLE_EXPORT void set_params(std::map name_values); + [[nodiscard]] ECOLE_EXPORT std::map get_params() const; + + ECOLE_EXPORT void disable_presolve(); + ECOLE_EXPORT void disable_cuts(); + + [[nodiscard]] ECOLE_EXPORT nonstd::span variables() const noexcept; + [[nodiscard]] ECOLE_EXPORT nonstd::span lp_branch_cands() const; + [[nodiscard]] ECOLE_EXPORT nonstd::span pseudo_branch_cands() const; + [[nodiscard]] ECOLE_EXPORT nonstd::span lp_columns() const; + [[nodiscard]] ECOLE_EXPORT nonstd::span constraints() const noexcept; + [[nodiscard]] ECOLE_EXPORT nonstd::span lp_rows() const; + [[nodiscard]] ECOLE_EXPORT std::size_t nnz() const noexcept; + + ECOLE_EXPORT void transform_prob(); + ECOLE_EXPORT void presolve(); + ECOLE_EXPORT void solve(); + + [[nodiscard]] ECOLE_EXPORT bool is_solved() const noexcept; + [[nodiscard]] ECOLE_EXPORT SCIP_Real primal_bound() const noexcept; + [[nodiscard]] ECOLE_EXPORT SCIP_Real dual_bound() const noexcept; + + /** + * Start iterative solving. + * + * Iterative solving pauses when it encounters a callback and give control back to the user. + * Solving must be explicitly resumed by calling ``solve_iter_continue`` repatedly. + * Iterative solving will only pause on the callbacks that are explicitly passed as paramerters. + * + * Stoping on multiple callbacks can be achieved with: + * ``` + * // Callback on which we want to pause.. + * auto const constructors = std::array{ + * callback::BranchruleConstructor{}, + * callback::HeuristicConstructor{}, + * }; + * auto maybe_fcall = model.solve_iter(constructors); + * + * // While solving has not terminated. + * while (maybe_fcall.has_value()) { + * std::visit([&](auto fcall) { + * // If solving has paused on a Branchrule. + * if constexpr (std::is_same_v) { + * // `fcall` holds a `BranchruleCall`. + * // Perform branching. + * maybe_fcall = model.solve_iter_continue(SCIP_BRANCHED); + * // If solving has paused on a Heuristic. + * } else if constexpr (std::is_same_v) { + * // `fcall` holds a `HeuristicCall`. + * // Add solution. + * maybe_fcall = model.solve_iter_continue(SCIP_FOUNDSOL); + * } + * }, maybe_fcall.value()); + * } + * ``` + * + * @param arg_packs A sequence of construtors parameters defining the reverse callback to pause on. + * @return The callback arguments where iterative solving has stopped, or nothing if solving has terminated. + * @see solve_iter_continue + */ + ECOLE_EXPORT auto solve_iter(nonstd::span arg_packs) + -> std::optional; + + /** + * Start iterative solving with a single callback. + * + * For example branching iteratively could be achieved with: + * ``` + * auto fcall = model.solve_iter(scip::callback::BranchingConstructor{}); + * while (fcall.has_value()) { + * auto const cands = model.lp_branch_cands(); + * scip::call(SCIPbranchVar, model.get_scip_ptr(), cands[0], nullptr, nullptr, nullptr); + * fcall = model.solve_iter_continue(SCIP_BRANCHED); + * } + * ``` + */ + ECOLE_EXPORT auto solve_iter(callback::DynamicConstructor arg_pack) -> std::optional; + + /** + * Continue iterative solving. + * + * Continue until the next reverse callback is encountered. + * + * @param result The result given to the SCIP callback for the action taken on the current pause. + * @return The callback arguments where iterative solving has stopped, or nothing if solving has terminated. + * @see solve_iter_continue + */ + ECOLE_EXPORT auto solve_iter_continue(SCIP_RESULT result) -> std::optional; + +private: + std::unique_ptr scimpl; +}; + +/***************************** + * Implementation of Model * + *****************************/ + +template <> ECOLE_EXPORT void Model::set_param(std::string const& name, bool value); +template <> ECOLE_EXPORT void Model::set_param(std::string const& name, int value); +template <> ECOLE_EXPORT void Model::set_param(std::string const& name, SCIP_Longint value); +template <> ECOLE_EXPORT void Model::set_param(std::string const& name, SCIP_Real value); +template <> ECOLE_EXPORT void Model::set_param(std::string const& name, char value); +template <> ECOLE_EXPORT void Model::set_param(std::string const& name, std::string const& value); + +template <> ECOLE_EXPORT auto Model::get_param(std::string const& name) const -> bool; +template <> ECOLE_EXPORT auto Model::get_param(std::string const& name) const -> int; +template <> ECOLE_EXPORT auto Model::get_param(std::string const& name) const -> SCIP_Longint; +template <> ECOLE_EXPORT auto Model::get_param(std::string const& name) const -> SCIP_Real; +template <> ECOLE_EXPORT auto Model::get_param(std::string const& name) const -> char; +template <> ECOLE_EXPORT auto Model::get_param(std::string const& name) const -> std::string; + +namespace internal { + +/** + * Safely cast between various type, throwing an exception when impossible. + */ +template auto cast([[maybe_unused]] From val) -> To { + if constexpr (std::is_pointer_v && std::is_same_v) { + // Fallthrough to error. Don't convert pointers to bool. + } else if constexpr (utility::is_narrow_castable_v) { + return utility::narrow_cast(std::move(val)); + } else if constexpr (std::is_convertible_v) { + return static_cast(val); + } else if constexpr (utility::is_variant_v) { + return std::visit([](auto v) { return cast(v); }, val); + } else if constexpr (std::is_same_v && std::is_same_v) { + return std::string{val}; + } else if constexpr (std::is_same_v) { + if constexpr (std::is_convertible_v) { + if (auto const str = std::string_view{val}; str.length() == 1) { + return str[0]; + } + // Fallthrough to error. Don't convert long string to char. + } + } + throw ScipError::from_retcode(SCIP_PARAMETERWRONGTYPE); +} + +} // namespace internal + +template void Model::set_param(std::string const& name, T value) { + using internal::cast; + switch (get_param_type(name)) { + case ParamType::Bool: + return set_param(name, cast(value)); + case ParamType::Int: + return set_param(name, cast(value)); + case ParamType::LongInt: + return set_param(name, cast(value)); + case ParamType::Real: + return set_param(name, cast(value)); + case ParamType::Char: + return set_param(name, cast(value)); + case ParamType::String: + return set_param(name, cast(value)); + default: + utility::unreachable(); + } +} + +template T Model::get_param(std::string const& name) const { + using namespace internal; + switch (get_param_type(name)) { + case ParamType::Bool: + return cast(get_param(name)); + case ParamType::Int: + return cast(get_param(name)); + case ParamType::LongInt: + return cast(get_param(name)); + case ParamType::Real: + return cast(get_param(name)); + case ParamType::Char: + return cast(get_param(name)); + case ParamType::String: + return cast(get_param(name)); + default: + utility::unreachable(); + } +} + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/row.hpp b/ecole/libecole/include/ecole/scip/row.hpp new file mode 100644 index 0000000..b5ad690 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/row.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include +#include + +#include "ecole/export.hpp" + +namespace ecole::scip { + +ECOLE_EXPORT auto get_unshifted_rhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> std::optional; +ECOLE_EXPORT auto get_unshifted_lhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> std::optional; + +ECOLE_EXPORT auto is_at_rhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> bool; +ECOLE_EXPORT auto is_at_lhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> bool; + +ECOLE_EXPORT auto get_cols(SCIP_ROW const* row) noexcept -> nonstd::span; +ECOLE_EXPORT auto get_vals(SCIP_ROW const* row) noexcept -> nonstd::span; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/scimpl.hpp b/ecole/libecole/include/ecole/scip/scimpl.hpp new file mode 100644 index 0000000..15b35b4 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/scimpl.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "ecole/export.hpp" +#include "ecole/scip/callback.hpp" + +namespace ecole::utility { +template class Coroutine; +} + +namespace ecole::scip { + +struct ECOLE_EXPORT ScipDeleter { + ECOLE_EXPORT void operator()(SCIP* ptr); +}; + +class ECOLE_EXPORT Scimpl { +public: + ECOLE_EXPORT Scimpl(); + ECOLE_EXPORT Scimpl(Scimpl&& /*other*/) noexcept; + ECOLE_EXPORT Scimpl(std::unique_ptr&& /*scip_ptr*/) noexcept; + ECOLE_EXPORT ~Scimpl(); + + ECOLE_EXPORT auto get_scip_ptr() noexcept -> SCIP*; + + [[nodiscard]] ECOLE_EXPORT auto copy() const -> Scimpl; + [[nodiscard]] ECOLE_EXPORT auto copy_orig() const -> Scimpl; + + ECOLE_EXPORT auto solve_iter(nonstd::span arg_packs) + -> std::optional; + ECOLE_EXPORT auto solve_iter_continue(SCIP_RESULT result) -> std::optional; + +private: + using Controller = utility::Coroutine; + + std::unique_ptr m_scip; + std::unique_ptr m_controller; +}; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/seed.hpp b/ecole/libecole/include/ecole/scip/seed.hpp new file mode 100644 index 0000000..b444c9f --- /dev/null +++ b/ecole/libecole/include/ecole/scip/seed.hpp @@ -0,0 +1,9 @@ +#pragma once + +namespace ecole::scip { + +using Seed = int; +constexpr Seed min_seed = 1; // 0 might be used for default. +constexpr Seed max_seed = 2147483647; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/type.hpp b/ecole/libecole/include/ecole/scip/type.hpp new file mode 100644 index 0000000..024a929 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/type.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +#include + +namespace ecole::scip { + +/** + * Types of parameters supported by SCIP. + * + * @see param_t to get the associated type. + */ +enum class ParamType { Bool, Int, LongInt, Real, Char, String }; + +namespace internal { +// Use with `param_t`. +template struct ParamType_get; +template <> struct ParamType_get { using type = bool; }; +template <> struct ParamType_get { using type = int; }; +template <> struct ParamType_get { using type = SCIP_Longint; }; +template <> struct ParamType_get { using type = SCIP_Real; }; +template <> struct ParamType_get { using type = char; }; +template <> struct ParamType_get { using type = std::string; }; +} // namespace internal + +/** + * Type associated with a ParamType. + */ +template using param_t = typename internal::ParamType_get::type; + +using Param = std::variant< + param_t, + param_t, + param_t, + param_t, + param_t, + param_t>; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/utils.hpp b/ecole/libecole/include/ecole/scip/utils.hpp new file mode 100644 index 0000000..e9247b4 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/utils.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "ecole/scip/exception.hpp" + +namespace ecole::scip { + +template inline void call(Func func, Arguments&&... args) { + scip::ScipError::reset_message_capture(); + auto retcode = func(std::forward(args)...); + if (retcode != SCIP_OKAY) { + throw scip::ScipError::from_retcode(retcode); + } +} + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/scip/var.hpp b/ecole/libecole/include/ecole/scip/var.hpp new file mode 100644 index 0000000..7582355 --- /dev/null +++ b/ecole/libecole/include/ecole/scip/var.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include + +#include "ecole/export.hpp" +#include "ecole/scip/utils.hpp" + +namespace ecole::scip { + +/** Scip deleter for Var pointers. */ +class ECOLE_EXPORT VarReleaser { +public: + /** Capture the SCIP pointer but does not extend its lifetime. */ + VarReleaser(SCIP* scip_) noexcept : scip(scip_){}; + + /** Call SCIPvarRelease */ + ECOLE_EXPORT void operator()(SCIP_VAR* ptr); + +private: + SCIP* scip = nullptr; +}; + +/** + * Create a variable with automatic management (RAII). + * + * The variable is returned in a unique_ptr that will automatically call SCIPreleaseVar on deletion. + * Even if the release is done automatically the SCIP semantics are not changed and the variable must not outlive the + * SCIP pointer (it is needed to release the variable). + * + * The arguments are forwarded to SCIPcreateVarBasic. + */ +ECOLE_EXPORT auto +create_var_basic(SCIP* scip, char const* name, SCIP_Real lb, SCIP_Real ub, SCIP_Real obj, SCIP_VARTYPE vartype) + -> std::unique_ptr; + +} // namespace ecole::scip diff --git a/ecole/libecole/include/ecole/traits.hpp b/ecole/libecole/include/ecole/traits.hpp new file mode 100644 index 0000000..9d99ccd --- /dev/null +++ b/ecole/libecole/include/ecole/traits.hpp @@ -0,0 +1,180 @@ +#pragma once + +#include +#include + +#include "ecole/information/abstract.hpp" +#include "ecole/reward/abstract.hpp" +#include "ecole/utility/function-traits.hpp" + +namespace ecole::trait { + +/********************************* + * Detection of data functions * + *********************************/ + +/** + * Check that a type is a reward. + */ +template using is_reward = std::is_same; +template inline constexpr bool is_reward_v = is_reward::value; + +/** + * Check that a type is an information map. + */ +template struct is_information_map : std::false_type {}; +template struct is_information_map> : std::true_type {}; +template inline constexpr bool is_information_map_v = is_information_map::value; + +namespace internal { + +/** + * Check that a type has a `before_reset` member function. + * + * The type must have member function with the signature compatible with + * `auto before_reset(scip::Model&) -> void;`. + */ +template struct has_before_reset : std::false_type {}; +template +struct has_before_reset< + T, + std::enable_if_t().before_reset(std::declval()))>>> : + std::true_type {}; +template inline constexpr bool has_before_reset_v = has_before_reset::value; + +/** + * Check that a type has an `extract` member function. + * + * The type must have member function with the signature compatible with + * `auto extract(scip::Model&, bool) -> Data;`. + * where `Data` is not `void`. + */ +template struct has_extract : std::false_type {}; +template +struct has_extract< + T, + std::enable_if_t().extract(std::declval(), true))>>> : + std::true_type {}; + +template typename, typename = void> struct extract_return_is : std::false_type {}; +template typename Pred> +struct extract_return_is> : + Pred> {}; + +} // namespace internal + +template +using is_data_function = std::conjunction, internal::has_extract>; +template inline constexpr bool is_data_function_v = is_data_function::value; + +template using is_observation_function = is_data_function; +template inline constexpr bool is_observation_function_v = is_observation_function::value; + +template +using is_reward_function = std::conjunction, internal::extract_return_is>; +template inline constexpr bool is_reward_function_v = is_reward_function::value; + +template +using is_information_function = + std::conjunction, internal::extract_return_is>; +template inline constexpr bool is_information_function_v = is_information_function::value; + +/****************************** + * Detection of environment * + ******************************/ + +namespace internal { + +template struct has_template_step : std::false_type {}; +template struct has_template_step)>> : std::true_type {}; +template inline constexpr bool has_template_step_v = has_template_step::value; + +} // namespace internal + +template inline constexpr bool is_environment_v = internal::has_template_step_v; + +/*************************** + * Detection of dynamics * + ***************************/ + +namespace internal { + +template struct has_step_dynamics : std::false_type {}; +template struct has_step_dynamics> : std::true_type {}; +template inline constexpr bool has_step_dynamics_v = has_step_dynamics::value; + +} // namespace internal + +template inline constexpr bool is_dynamics_v = internal::has_step_dynamics_v; + +/********************************* + * Detection of extracted data * + *********************************/ + +template using data_of_t = utility::return_t; + +/*********************************** + * Detection of observation type * + ***********************************/ + +template struct observation_of; + +template struct observation_of>> { + using type = data_of_t; +}; + +template struct observation_of>> { + using type = std::tuple_element_t<0, utility::return_t)>>; +}; + +template using observation_of_t = typename observation_of::type; + +/*********************************** + * Detection of information type * + ***********************************/ + +template struct information_of; + +template struct information_of>> { + using type = typename data_of_t::mapped_type; +}; + +template struct information_of>> { + using type = typename std::tuple_element_t<4, utility::return_t)>>::mapped_type; +}; + +template using information_of_t = typename information_of::type; + +/****************************** + * Detection of action type * + ******************************/ + +template struct action_of; + +template struct action_of>> { + using type = std::decay_t)>>; +}; + +template struct action_of>> { + using type = std::decay_t>; +}; + +template using action_of_t = typename action_of::type; + +/***************************** + * Detection of action set * + *****************************/ + +template struct action_set_of; + +template struct action_set_of>> { + using type = std::tuple_element_t<1, utility::return_t)>>; +}; + +template struct action_set_of>> { + using type = std::tuple_element_t<1, utility::return_t>; +}; + +template using action_set_of_t = typename action_set_of::type; + +} // namespace ecole::trait diff --git a/ecole/libecole/include/ecole/tweak/range.hpp b/ecole/libecole/include/ecole/tweak/range.hpp new file mode 100644 index 0000000..8eef017 --- /dev/null +++ b/ecole/libecole/include/ecole/tweak/range.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include +#include + +/** + * Tell the range library that `nonstd::span` is a view type. + * + * See `Rvalue Ranges and Views in C++20 `_ + * FIXME no longer needed when switching to C++20 ``std::span``. + * */ +namespace ranges { +template inline constexpr bool enable_borrowed_range> = true; +} // namespace ranges diff --git a/ecole/libecole/include/ecole/utility/chrono.hpp b/ecole/libecole/include/ecole/utility/chrono.hpp new file mode 100644 index 0000000..8ab951c --- /dev/null +++ b/ecole/libecole/include/ecole/utility/chrono.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include "ecole/export.hpp" + +namespace ecole::utility { + +/** + * A CPU usage clock. + * + * Measure time the CPU spent processing the program’s instructions. + * This count both the System (kernel) and User CPU time. + * The time spent waiting for other things to complete (like I/O operations) is not included in the CPU time. + * + * The implementation uses OS dependent functionality. + */ +class ECOLE_EXPORT cpu_clock { +public: + using duration = std::chrono::nanoseconds; + using rep = duration::rep; + using period = duration::period; + using time_point = std::chrono::time_point; + static bool constexpr is_steady = true; + + ECOLE_EXPORT static auto now() -> time_point; +}; + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/coroutine.hpp b/ecole/libecole/include/ecole/utility/coroutine.hpp new file mode 100644 index 0000000..aa48431 --- /dev/null +++ b/ecole/libecole/include/ecole/utility/coroutine.hpp @@ -0,0 +1,357 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ecole::utility { + +/** + * Asynchronous cooperative interruptable code execution. + * + * Asynchronously execute a piece of code in an interative fashion while producing intermediary results. + * User-defined messages can be send to communicate with the executor. + * The execution flow is as follow: + * 1. Upon creation, the instruction provided in the constructor start being executed by the executor. + * 2. The ``yield`` function is called by the executor with the first return value. + * 3. The coroutine calls ``wait`` to recieve that first return value. + * 4. If no value is returned, then the executor has finished. + * 5. Else, the coroutine calls ``resume`` with a message to pass to the executor. + * 6. The executor recieve the message and continue its execution unitl the next ``yield``, the process repeats from 2. + * + * @tparam Return The type of return values created by the executor. + * @tparam Message The type of the messages that can be sent to the executor. + */ +template class Coroutine { +public: + /** Return or nothing if the corutine has finished. */ + using MaybeReturn = std::optional; + + /** + * Start the execution. + * + * @param func Function used to define the code that needs to be executed by the executor. + * The first parameter to that function is a ``std::weak_ptr`` used to yield values and recieve + * messages. + * If the weak pointer is expired, the executor must terminate. + * @param args Additional parameters to be passed as additinal arguments to ``func``. + */ + template Coroutine(Function&& func, Args&&... args); + + /** + * Terminate the coroutine + * + * The destructor can safely be called at anytime. + * If the executor is still running, it will recieve a ``StopToken`` and must terminate, otherwise the whole program + * will itself terminate. + * Any return value left to be yielded by the executor will be lost. + * + * @see StopToken + */ + ~Coroutine() noexcept; + + /** + * Wait for the executor to yield a value. + * + * If no return value is given, then the coroutine has finished. + * This function should not be called successively without calling ``resume``. + */ + auto wait() -> MaybeReturn; + + /** + * Send a message and resume the executor. + * + * This function should not be called without first calling ``wait``, or if ``wait`` has not returned a value. + */ + auto resume(Message instruction) -> void; + +private: + /** Type indicating that the executor must terminate. */ + struct StopToken {}; + + /** Message recieved by the executor. */ + using MessageOrStop = std::variant; + + /** + * Lock type to synchronise between the coroutine and executor. + * + * The lock is passed back and forth to guarantee that it is help/release when necessary. + */ + using Lock = std::unique_lock; + + /** Class responsible for synchronizing between the coroutine and executor. */ + class Synchronizer { + public: + auto coroutine_wait_executor() -> Lock; + auto coroutine_pop_return() -> Return; + auto coroutine_resume_executor(Lock&& lk, MessageOrStop instruction) -> void; + auto coroutine_stop_executor(Lock&& lk) -> void; + [[nodiscard]] auto coroutine_executor_is_done(Lock const& lk) const noexcept -> bool; + + auto executor_start() -> Lock; + auto executor_yield(Lock&& lk, Return value) -> std::pair; + auto executor_terminate(Lock&& lk) -> void; + auto executor_terminate(Lock&& lk, std::exception_ptr const& e) -> void; + + private: + std::exception_ptr m_executor_exception = nullptr; // NOLINT(bugprone-throw-keyword-missing) + std::mutex m_exclusion_mutex; + std::condition_variable m_resume_signal; + bool m_executor_running = true; + bool m_executor_finished = false; + Return m_value; + MessageOrStop m_instruction; + + [[nodiscard]] auto is_valid_lock(Lock const& lk) const noexcept -> bool; + auto maybe_throw(Lock&& lk) -> Lock; + }; + +public: + /** Class to communicate with the coroutine from the executor. */ + class Executor { + public: + /** Type indicating that the executor must terminate. */ + using StopToken = Coroutine::StopToken; + /** Message recieved by the executor. */ + using MessageOrStop = Coroutine::MessageOrStop; + + /** Return whether the message is a ``StopToken``/ */ + static auto is_stop(MessageOrStop const& message) -> bool; + + Executor() = delete; + Executor(Executor const&) = delete; + Executor(Executor&&) = delete; + Executor(std::shared_ptr synchronizer) noexcept; + + /** + * Yield a value, and wait to be recieve a message from the coroutine. + * + * If instead of a message, the executor recieves a ``StopToken``, then it must terminate. + */ + auto yield(Return value) -> MessageOrStop; + + private: + std::shared_ptr m_synchronizer; + Lock m_exclusion_lock; + + friend class Coroutine; + /** Indicate to the synchronizer that executor is ready to start. */ + auto start() -> void; + /** Indicate to the synchronizer that executor has terminated without error. */ + auto terminate() -> void; + /** Indicate to the synchronizer that executor has terminated with an error. */ + auto terminate(std::exception_ptr&& e) -> void; + }; + +private: + std::shared_ptr m_synchronizer; + std::thread executor_thread; + Lock m_exclusion_lock; + + auto stop_executor() -> void; +}; +} // namespace ecole::utility + +#include +#include +#include + +#include "ecole/utility/function-traits.hpp" + +namespace ecole::utility { + +/*********************************************** + * Implementation of Coroutine::Synchronizer * + ***********************************************/ + +template +auto Coroutine::Synchronizer::coroutine_wait_executor() -> Lock { + Lock lk{m_exclusion_mutex}; + m_resume_signal.wait(lk, [this] { return !m_executor_running; }); + return maybe_throw(std::move(lk)); +} + +template +auto Coroutine::Synchronizer::coroutine_pop_return() -> Return { + return std::move(m_value); +} + +template +auto Coroutine::Synchronizer::coroutine_resume_executor(Lock&& lk, MessageOrStop new_instruction) + -> void { + assert(is_valid_lock(lk)); + m_instruction = std::move(new_instruction); + m_executor_running = true; + lk.unlock(); + m_resume_signal.notify_one(); +} + +template +auto Coroutine::Synchronizer::coroutine_executor_is_done( + [[maybe_unused]] Lock const& lk) const noexcept -> bool { + assert(is_valid_lock(lk)); + return m_executor_finished; +} + +template auto Coroutine::Synchronizer::executor_start() -> Lock { + return Lock{m_exclusion_mutex}; +} + +template +auto Coroutine::Synchronizer::executor_yield(Lock&& lk, Return value) + -> std::pair { + assert(is_valid_lock(lk)); + m_executor_running = false; + m_value = value; + lk.unlock(); + m_resume_signal.notify_one(); + lk.lock(); + m_resume_signal.wait(lk, [this] { return m_executor_running; }); + return {std::move(lk), std::move(m_instruction)}; +} + +template +auto Coroutine::Synchronizer::executor_terminate(Lock&& lk) -> void { + assert(is_valid_lock(lk)); + m_executor_running = false; + m_executor_finished = true; + lk.unlock(); + m_resume_signal.notify_one(); +} + +template +auto Coroutine::Synchronizer::executor_terminate(Lock&& lk, std::exception_ptr const& e) -> void { + assert(is_valid_lock(lk)); + m_executor_exception = e; + executor_terminate(std::move(lk)); +} + +template +auto Coroutine::Synchronizer::is_valid_lock(Lock const& lk) const noexcept -> bool { + return lk && (lk.mutex() == &m_exclusion_mutex); +} + +template +auto Coroutine::Synchronizer::maybe_throw(Lock&& lk) -> Lock { + assert(is_valid_lock(lk)); + auto e_ptr = m_executor_exception; + m_executor_exception = nullptr; + if (e_ptr) { + assert(m_executor_finished); + std::rethrow_exception(e_ptr); + } + return std::move(lk); +} + +/******************************************* + * Implementation of Coroutine::Executor * + *******************************************/ + +template +auto Coroutine::Executor::is_stop(MessageOrStop const& message) -> bool { + return std::holds_alternative(message); +} + +template +Coroutine::Executor::Executor(std::shared_ptr synchronizer) noexcept : + m_synchronizer(std::move(synchronizer)) {} + +template auto Coroutine::Executor::start() -> void { + m_exclusion_lock = m_synchronizer->executor_start(); +} + +template +auto Coroutine::Executor::yield(Return value) -> MessageOrStop { + auto [lock, instruction] = m_synchronizer->executor_yield(std::move(m_exclusion_lock), std::move(value)); + m_exclusion_lock = std::move(lock); + return instruction; +} + +template auto Coroutine::Executor::terminate() -> void { + m_synchronizer->executor_terminate(std::move(m_exclusion_lock)); +} + +template +auto Coroutine::Executor::terminate(std::exception_ptr&& except) -> void { + m_synchronizer->executor_terminate(std::move(m_exclusion_lock), except); +} + +/********************************* + * Implementation of Coroutine * + *********************************/ + +template +template +Coroutine::Coroutine(Function&& func_, Args&&... args_) : + m_synchronizer(std::make_shared()) { + auto executor = std::make_shared(m_synchronizer); + + auto executor_func = [executor](Function&& func, Args&&... args) { + executor->start(); + try { + using ExecutorArg = std::remove_const_t>>; + if constexpr (std::is_same_v>) { + func(executor, std::forward(args)...); + } else if constexpr (std::is_same_v>) { + func(std::weak_ptr(executor), std::forward(args)...); + } else { + func(*executor, std::forward(args)...); + } + executor->terminate(); + } catch (...) { + executor->terminate(std::current_exception()); + } + }; + + executor_thread = std::thread(executor_func, std::forward(func_), std::forward(args_)...); +} + +template Coroutine::~Coroutine() noexcept { + assert(std::this_thread::get_id() != executor_thread.get_id()); + if (executor_thread.joinable()) { + try { + stop_executor(); + } catch (...) { + // if the Coroutine is deleted but not waited on, then we ignore potential + // exceptions + } + executor_thread.join(); + } +} + +template auto Coroutine::wait() -> MaybeReturn { + m_exclusion_lock = m_synchronizer->coroutine_wait_executor(); + if (m_synchronizer->coroutine_executor_is_done(m_exclusion_lock)) { + return std::nullopt; + } + return m_synchronizer->coroutine_pop_return(); +} + +template auto Coroutine::resume(Message instruction) -> void { + m_synchronizer->coroutine_resume_executor(std::move(m_exclusion_lock), std::move(instruction)); +} + +template +auto Coroutine::Synchronizer::coroutine_stop_executor(Lock&& lk) -> void { + coroutine_resume_executor(std::move(lk), Coroutine::StopToken{}); +} + +template auto Coroutine::stop_executor() -> void { + if (!m_exclusion_lock.owns_lock()) { + m_exclusion_lock = m_synchronizer->coroutine_wait_executor(); + } + // Could be an `if` statement because executors are supposed to terminate directly when being sent a StopToken. + // However, using coroutine with multiple SCIP callbacks, some callbacks might still be called even after + // `SCIPinterrupt` is called on `StopToken`. + while (!m_synchronizer->coroutine_executor_is_done(m_exclusion_lock)) { + m_synchronizer->coroutine_stop_executor(std::move(m_exclusion_lock)); + m_exclusion_lock = m_synchronizer->coroutine_wait_executor(); + } +} + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/function-traits.hpp b/ecole/libecole/include/ecole/utility/function-traits.hpp new file mode 100644 index 0000000..e7c7079 --- /dev/null +++ b/ecole/libecole/include/ecole/utility/function-traits.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include +#include + +namespace ecole::utility { + +/** + * Traits of functions, functions pointer, and function objects. + * + * Provide static access to: + * - `n_args` number of argument of the function. + * - `args` arguements types. + * - `return_type` the type of the function return. + * + * Implementation from https://functionalcpp.wordpress.com/2013/08/05/function-traits/ + */ +template struct function_traits; + +template struct function_traits { + using return_type = Return; + + static constexpr std::size_t n_args = sizeof...(Args); + + template struct args {}; + template struct args> { + using type = std::tuple_element_t>; + }; +}; + +/** + * Specialization for function pointers. + */ +template +struct function_traits : function_traits {}; + +/** + * Specialization for member function pointers. + */ +template +struct function_traits : public function_traits {}; + +/** + * Specialization for const member function pointer. + */ +template +struct function_traits : function_traits {}; + +/** + * Specialization for member object pointers. + */ +template +struct function_traits : function_traits {}; + +/** + * Specialization for functors. + */ +template struct function_traits { +private: + using call_type = function_traits; + +public: + using return_type = typename call_type::return_type; + + static constexpr std::size_t n_args = call_type::n_args - 1; + + template struct args {}; + template struct args> { + using type = typename call_type::template args::type; + }; +}; + +/** + * Specialization for ref functors. + */ +template struct function_traits : public function_traits {}; + +/** + * Specialization for rvalue ref functors. + */ +template struct function_traits : public function_traits {}; + +/** + * Helper alias for return type. + */ +template using return_t = typename function_traits::return_type; + +/** + * Helper type for argument type. + */ +template using arg_t = typename function_traits::template args::type; + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/numeric.hpp b/ecole/libecole/include/ecole/utility/numeric.hpp new file mode 100644 index 0000000..2fb5725 --- /dev/null +++ b/ecole/libecole/include/ecole/utility/numeric.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +namespace ecole::utility { + +template > struct is_narrow_castable : std::false_type {}; + +/** + * Do not narrow cast char to anything else. + */ +template struct is_narrow_castable : std::false_type {}; +template struct is_narrow_castable : std::false_type {}; +template <> struct is_narrow_castable : std::true_type {}; + +template +struct is_narrow_castable< + To, + From, + std::enable_if_t< + std::is_convertible_v && std::is_convertible_v && !std::is_same_v && + !std::is_same_v>> : std::true_type {}; + +template inline constexpr bool is_narrow_castable_v = is_narrow_castable::value; + +/** + * A narrow cast raises if any numerical loss is detected. + */ +template To narrow_cast(From val) { + auto val_to = static_cast(val); + if (static_cast(val_to) != val) { + throw std::runtime_error("Numerical loss converting."); + } + return val_to; +} + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/random.hpp b/ecole/libecole/include/ecole/utility/random.hpp new file mode 100644 index 0000000..e686796 --- /dev/null +++ b/ecole/libecole/include/ecole/utility/random.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "ecole/utility/vector.hpp" + +namespace ecole::utility { + +/** + * Sample without replacement according to the given probabilities. + * + * FIXME Merged into Xtensor 0.22.0 xt::choice + * + * Sample items according to the probability distribution given by normalizing the weights of the items left. + * Items are not replaced when sampled. + * + * Algorithm from + * Efraimidis PS, Spirakis PG (2006). "Weighted random sampling with a reservoir." + * Information Processing Letters, 97 (5), 181-185. ISSN 0020-0190. + * doi:10.1016/j.ipl.2005.11.003. + * http://www.sciencedirect.com/science/article/pii/S002001900500298X + * + * The keys computed are replaced with weight/randexp(1) instead rand()^(1/weight) as done in wrlmlR: + * https://web.archive.org/web/20201021162211/https://krlmlr.github.io/wrswoR/ + * https://web.archive.org/web/20201021162520/https://github.com/krlmlr/wrswoR/blob/master/src/sample_int_crank.cpp + * As well as in JuliaStats: + * https://web.archive.org/web/20201021162949/https://github.com/JuliaStats/StatsBase.jl/blob/master/src/sampling.jl + * + * @tparam T Type of the weights is used to make computation. + * @param n_samples Number of items to sample without replacement. + * @param weights The weights of each items (implicty their index). + * @param rng The source of randomness used to sample. + * @return A vector of the n_samples items selected as their index in the weights vector. + */ +template +auto arg_choice(std::size_t n_samples, std::vector weights, RandomGenerator& rng) { + static_assert(std::is_floating_point_v, "Weights must be real numbers."); + + auto const n_items = weights.size(); + if (n_samples > n_items) { + throw std::invalid_argument{"Cannot sample more than there are items."}; + } + + // Compute (modified) keys as weight/randexp(1) reusing weights vector. + auto randexp = std::exponential_distribution{1.}; + for (auto& w : weights) { + w /= randexp(rng); + } + + // Sort an array of indices using -keys[i] as comparing value. + // We only the top n_sample largest keys. + auto indices = arange(n_items); + auto compare = [&weights](auto i, auto j) { return weights[i] > weights[j]; }; + std::partial_sort(indices.begin(), indices.begin() + static_cast(n_items), indices.end(), compare); + indices.resize(n_samples); + + return indices; +} + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/sparse-matrix.hpp b/ecole/libecole/include/ecole/utility/sparse-matrix.hpp new file mode 100644 index 0000000..1bb7eca --- /dev/null +++ b/ecole/libecole/include/ecole/utility/sparse-matrix.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#include + +namespace ecole::utility { + +/** + * Simple coordinate sparse matrix. + * + * Indices are given with shape (2, nnz, that is indices[0] are row indices and indices[1] are columns indicies. + * + * FIXME there is early development of a sparse xtensor to replace this class, but it still a bit early. + * https://github.com/xtensor-stack/xtensor-sparse + */ +template struct coo_matrix { + using value_type = T; + + xt::xtensor values; + xt::xtensor indices; + std::array shape = {0, 0}; + + using Tuple = std::tuple; + + [[nodiscard]] static auto from_tuple(Tuple t) -> coo_matrix; + + [[nodiscard]] auto to_tuple() const& -> Tuple; + [[nodiscard]] auto to_tuple() && -> Tuple; + + [[nodiscard]] auto nnz() const noexcept -> std::size_t { return values.size(); } + + auto operator==(coo_matrix const& other) const -> bool; +}; + +/********************************** + * Implementation of coo_matrix * + **********************************/ + +template auto coo_matrix::from_tuple(Tuple t) -> coo_matrix { + return std::apply([](auto&&... vals) { return coo_matrix{std::forward(vals)...}; }, std::move(t)); +} + +template auto coo_matrix::to_tuple() const& -> Tuple { + return {values, indices, shape}; +} +template auto coo_matrix::to_tuple() && -> Tuple { + return {std::move(values), std::move(indices), shape}; +} + +template auto coo_matrix::operator==(coo_matrix const& other) const -> bool { + return std::tie(values, indices, shape) == std::tie(other.values, other.indices, other.shape); +} + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/type-traits.hpp b/ecole/libecole/include/ecole/utility/type-traits.hpp new file mode 100644 index 0000000..340b1d1 --- /dev/null +++ b/ecole/libecole/include/ecole/utility/type-traits.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +namespace ecole::utility { + +/** + * Check if a type is an std::variant. + */ +template struct is_variant : std::false_type {}; +template struct is_variant> : std::true_type {}; +template inline constexpr bool is_variant_v = is_variant::value; + +/** + * Dispatch between the type and a lvalue reference to a constant. + * + * Used for template fuctions where the input argument could be better taken by value (e.g. an int + * or other arithmetic type), or const reference (e.g. a std::string). + */ +template using value_or_const_ref_t = std::conditional_t, T, T const&>; + +/** + * A trait to detect if a type has a dereference operator, that is behaves like a pointer. + * + * This is useful to write generic code where a function should have the same behaviour on a type or a pointer to that + * type. + */ +template > struct has_dereference : std::false_type {}; +template struct has_dereference())>> : std::true_type {}; +template inline constexpr bool has_dereference_v = has_dereference::value; + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/unreachable.hpp b/ecole/libecole/include/ecole/utility/unreachable.hpp new file mode 100644 index 0000000..a1194c1 --- /dev/null +++ b/ecole/libecole/include/ecole/utility/unreachable.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace ecole::utility { + +/** To be used in part of the code that are known to be unreachable (e.g. the default case in an enum `switch`). */ +[[noreturn]] inline void unreachable() { + std::terminate(); +} + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/utility/vector.hpp b/ecole/libecole/include/ecole/utility/vector.hpp new file mode 100644 index 0000000..33fc2d5 --- /dev/null +++ b/ecole/libecole/include/ecole/utility/vector.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace ecole::utility { + +/** Return vector with values 0 to n (excluded). */ +template auto arange(std::size_t n) -> std::vector { + auto indices = std::vector(n); + std::generate(indices.begin(), indices.end(), [i = T{0}]() mutable { return i++; }); + return indices; +} + +} // namespace ecole::utility diff --git a/ecole/libecole/include/ecole/version.hpp.in b/ecole/libecole/include/ecole/version.hpp.in new file mode 100644 index 0000000..8fe6b80 --- /dev/null +++ b/ecole/libecole/include/ecole/version.hpp.in @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#include + +#include "ecole/export.hpp" + +namespace ecole::version { + +struct ECOLE_EXPORT VersionInfo { + unsigned int major; + unsigned int minor; + unsigned int patch; + std::string_view revision = "unknown"; + std::string_view build_type = "unknown"; + std::string_view build_os = "unknown"; + std::string_view build_time = "unknown"; + std::string_view build_compiler = "unknown"; +}; + +/** + * Ecole version, as per header files. + */ +inline constexpr auto get_ecole_header_version() noexcept -> VersionInfo { + return { + @Ecole_VERSION_MAJOR@, // NOLINT(readability-magic-numbers) + @Ecole_VERSION_MINOR@, // NOLINT(readability-magic-numbers) + @Ecole_VERSION_PATCH@, // NOLINT(readability-magic-numbers) + "@Ecole_VERSION_REVISION@", + "@Ecole_BUILD_TYPE@", + "@Ecole_BUILD_OS@", + "@Ecole_BUILD_TIME@", + "@Ecole_BUILD_COMPILER@", + }; +} + +/** + * Ecole version of the library. + * + * This is the version of Ecole when compiling it as a library. + * This is useful for detecting incompatibilities when loading as a dynamic library. + */ +ECOLE_EXPORT auto get_ecole_lib_version() noexcept -> VersionInfo; + +/** + * Path of the libecole shared library when it exists. + * + * This is used for Ecole extensions to locate the library. + */ +ECOLE_EXPORT auto get_ecole_lib_path() -> std::filesystem::path; + +/** + * SCIP version, as per current header files. + */ +inline constexpr auto get_scip_header_version() noexcept -> VersionInfo { + return {SCIP_VERSION_MAJOR, SCIP_VERSION_MINOR, SCIP_VERSION_PATCH}; +} + +/** + * SCIP version, as per the (dynamically) loaded library. + */ +ECOLE_EXPORT auto get_scip_lib_version() noexcept -> VersionInfo; + +/** + * Path of the libscip shared library when it exists. + * + * This is used for Ecole extensions to locate the library. + */ +ECOLE_EXPORT auto get_scip_lib_path() -> std::filesystem::path; + +/** + * SCIP version used to compile Ecole library. + */ +ECOLE_EXPORT auto get_scip_buildtime_version() noexcept -> VersionInfo; + +} // namespace ecole diff --git a/ecole/libecole/src/dynamics/branching.cpp b/ecole/libecole/src/dynamics/branching.cpp new file mode 100644 index 0000000..ecc649e --- /dev/null +++ b/ecole/libecole/src/dynamics/branching.cpp @@ -0,0 +1,79 @@ +#include +#include + +#include +#include + +#include "ecole/dynamics/branching.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/utils.hpp" + +namespace ecole::dynamics { + +BranchingDynamics::BranchingDynamics(bool pseudo_candidates_) noexcept : pseudo_candidates(pseudo_candidates_) {} + +namespace { + +auto action_set(scip::Model const& model, bool pseudo) -> std::optional> { + if (model.stage() != SCIP_STAGE_SOLVING) { + return {}; + } + auto const branch_cands = pseudo ? model.pseudo_branch_cands() : model.lp_branch_cands(); + auto branch_cols = xt::xtensor::from_shape({branch_cands.size()}); + auto const var_to_idx = [](auto const var) { return SCIPvarGetProbindex(var); }; + std::transform(branch_cands.begin(), branch_cands.end(), branch_cols.begin(), var_to_idx); + + assert(branch_cols.size() > 0); + return branch_cols; +} + +/** Iterative solving until next LP branchrule call and return the action_set. */ +template +auto keep_solving_until_next_LP_callback(scip::Model& model, FCall& fcall, bool pseudo_candidates) + -> std::tuple { + using Call = scip::callback::BranchruleCall; + // While solving is not finished. + while (fcall.has_value()) { + // LP branchrule found, we give control back to the agent. + // Assuming Branchrules are the only reverse callbacks. + if (std::get(fcall.value()).where == Call::Where::LP) { + return {false, action_set(model, pseudo_candidates)}; + } + // Otherwise keep looping, ignoring the callback. + fcall = model.solve_iter_continue(SCIP_DIDNOTRUN); + } + // Solving is finished. + return {true, {}}; +} + +} // namespace + +auto BranchingDynamics::reset_dynamics(scip::Model& model) const -> std::tuple { + auto fcall = model.solve_iter(scip::callback::BranchruleConstructor{}); + return keep_solving_until_next_LP_callback(model, fcall, pseudo_candidates); +} + +auto BranchingDynamics::step_dynamics(scip::Model& model, Defaultable maybe_var_idx) const + -> std::tuple { + // Default fallback to SCIP default branching + auto scip_result = SCIP_DIDNOTRUN; + + if (std::holds_alternative(maybe_var_idx)) { + auto const var_idx = std::get(maybe_var_idx); + auto const vars = model.variables(); + // Error handling + if (var_idx >= vars.size()) { + throw std::invalid_argument{ + fmt::format("Branching candidate index {} larger than the number of variables ({}).", var_idx, vars.size())}; + } + // Branching + scip::call(SCIPbranchVar, model.get_scip_ptr(), vars[var_idx], nullptr, nullptr, nullptr); + scip_result = SCIP_BRANCHED; + } + + // Looping until the next LP branchrule rule callback, if it exists. + auto fcall = model.solve_iter_continue(scip_result); + return keep_solving_until_next_LP_callback(model, fcall, pseudo_candidates); +} + +} // namespace ecole::dynamics diff --git a/ecole/libecole/src/dynamics/configuring.cpp b/ecole/libecole/src/dynamics/configuring.cpp new file mode 100644 index 0000000..a599fa3 --- /dev/null +++ b/ecole/libecole/src/dynamics/configuring.cpp @@ -0,0 +1,19 @@ +#include "ecole/dynamics/configuring.hpp" +#include "ecole/scip/model.hpp" + +namespace ecole::dynamics { + +auto ConfiguringDynamics::reset_dynamics(scip::Model& /* model */) const -> std::tuple { + return {false, None}; +} + +auto ConfiguringDynamics::step_dynamics(scip::Model& model, ParamDict const& param_dict) const + -> std::tuple { + for (auto const& [name, value] : param_dict) { + model.set_param(name, value); + } + model.solve(); + return {true, None}; +} + +} // namespace ecole::dynamics diff --git a/ecole/libecole/src/dynamics/parts.cpp b/ecole/libecole/src/dynamics/parts.cpp new file mode 100644 index 0000000..680acf8 --- /dev/null +++ b/ecole/libecole/src/dynamics/parts.cpp @@ -0,0 +1,17 @@ +#include + +#include "ecole/dynamics/parts.hpp" +#include "ecole/scip/model.hpp" + +namespace ecole::dynamics { + +auto DefaultSetDynamicsRandomState::set_dynamics_random_state(scip::Model& model, RandomGenerator& rng) const -> void { + std::uniform_int_distribution seed_distrib{scip::min_seed, scip::max_seed}; + model.set_param("randomization/permuteconss", true); + model.set_param("randomization/permutevars", true); + model.set_param("randomization/permutationseed", seed_distrib(rng)); + model.set_param("randomization/randomseedshift", seed_distrib(rng)); + model.set_param("randomization/lpseed", seed_distrib(rng)); +} + +} // namespace ecole::dynamics diff --git a/ecole/libecole/src/dynamics/primal-search.cpp b/ecole/libecole/src/dynamics/primal-search.cpp new file mode 100644 index 0000000..e1f77f0 --- /dev/null +++ b/ecole/libecole/src/dynamics/primal-search.cpp @@ -0,0 +1,153 @@ +#include +#include +#include + +#include +#include + +#include "ecole/dynamics/primal-search.hpp" +#include "ecole/exception.hpp" +#include "ecole/scip/callback.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/utils.hpp" + +namespace ecole::dynamics { + +PrimalSearchDynamics::PrimalSearchDynamics(int trials_per_node_, int depth_freq_, int depth_start_, int depth_stop_) : + trials_per_node(trials_per_node_), depth_freq(depth_freq_), depth_start(depth_start_), depth_stop(depth_stop_) { + if (trials_per_node < -1) { + throw std::invalid_argument{fmt::format("Illegal value for number of trials per node: {}.", trials_per_node)}; + } +} + +namespace { + +auto action_set(scip::Model const& model) -> PrimalSearchDynamics::ActionSet { + if (model.stage() != SCIP_STAGE_SOLVING) { + return {}; + } + auto vars = model.pseudo_branch_cands(); // non-fixed discrete variables + auto var_ids = xt::xtensor::from_shape({vars.size()}); + std::transform(vars.begin(), vars.end(), var_ids.begin(), SCIPvarGetProbindex); + + return var_ids; +} + +auto add_solution_from_lp(SCIP* scip) -> bool { + SCIP_Bool solution_kept = false; + SCIP_SOL* sol = nullptr; + auto* const heur = SCIPfindHeur(scip, scip::callback::name(scip::callback::Type::Heuristic)); + scip::call(SCIPcreateSol, scip, &sol, heur); + try { + scip::call(SCIPlinkLPSol, scip, sol); + } catch (std::exception const& e) { + // In case of failure, the solution must be free anyway. + scip::call(SCIPtrySolFree, scip, &sol, false, true, true, true, true, &solution_kept); + throw; + } + scip::call(SCIPtrySolFree, scip, &sol, false, true, true, true, true, &solution_kept); + return solution_kept; +} + +} // namespace + +auto PrimalSearchDynamics::reset_dynamics(scip::Model& model) const -> std::tuple { + if (trials_per_node == 0) { + model.solve(); + return {true, {}}; + } + auto const args = scip::callback::HeuristicConstructor{ + scip::callback::priority_max, + depth_freq, + depth_start, + depth_stop, + }; + if (model.solve_iter(args).has_value()) { + return {false, action_set(model)}; + } + return {true, {}}; +} + +auto PrimalSearchDynamics::step_dynamics(scip::Model& model, Action action) -> std::tuple { + auto const [var_indices, vals] = action; + auto problem_vars = model.variables(); + + // check that both spans have same size + if (var_indices.size() != vals.size()) { + throw std::invalid_argument{ + fmt::format("Invalid action: {} variable indices for {} values.", var_indices.size(), vals.size())}; + } + + // check that variable indices are within range + for (auto const var_id : var_indices) { + if (var_id >= problem_vars.size()) { + throw std::invalid_argument{fmt::format("Invalid action: variable index {} is out of range.", var_id)}; + } + } + + auto* scip_ptr = model.get_scip_ptr(); + auto solution_kept = false; // result of the current action (solution found or not) + + // if the action is not empty, run a search iteration + // try to improve the (partial) solution by fixing variables and then re-solving the LP + if (not var_indices.empty()) { + SCIP_Bool lperror = false; + SCIP_Bool cutoff = false; + + // enter probing mode + scip::call(SCIPstartProbing, scip_ptr); + + // fix variables in the (partial) solution to their given values + for (std::size_t i = 0; i < var_indices.size(); i++) { + auto id = var_indices[i]; + auto* var = problem_vars[id]; + auto val = vals[i]; + scip::call(SCIPfixVarProbing, scip_ptr, var, val); + } + + // propagate + scip::call(SCIPpropagateProbing, scip_ptr, 0, &cutoff, nullptr); + if (!cutoff) { + // build the LP if needed + if (!SCIPisLPConstructed(scip_ptr)) { + scip::call(SCIPconstructLP, scip_ptr, &cutoff); + } + if (!cutoff) { + // solve the LP + scip::call(SCIPsolveProbingLP, scip_ptr, -1, &lperror, &cutoff); + if (!lperror && !cutoff) { + // try the LP solution in the original problem + solution_kept = add_solution_from_lp(scip_ptr); + } + } + } + + // exit probing mode + scip::call(SCIPendProbing, scip_ptr); + } + + // update the final search result depending on the action result + if (solution_kept) { + result = SCIP_FOUNDSOL; + } else if (result == SCIP_DIDNOTRUN) { + result = SCIP_DIDNOTFIND; + } + + // increment the number of search trials spent + trials_spent++; + + // if all trials are exhausted, or if SCIP should be stopped, stop the search and let SCIP proceed + if ((trials_spent == static_cast(trials_per_node)) || SCIPisStopped(scip_ptr)) { + // reset data for the next time the search is triggered + trials_spent = 0; + result = SCIP_DIDNOTRUN; + // Continue SCIP + if (!model.solve_iter_continue(result).has_value()) { + return {true, {}}; + } + } + + return {false, action_set(model)}; +} + +} // namespace ecole::dynamics diff --git a/ecole/libecole/src/exception.cpp b/ecole/libecole/src/exception.cpp new file mode 100644 index 0000000..ddaba94 --- /dev/null +++ b/ecole/libecole/src/exception.cpp @@ -0,0 +1,9 @@ +#include + +#include "ecole/exception.hpp" + +namespace ecole { + +IteratorExhausted::IteratorExhausted() : std::logic_error{"No item to iterate over."} {} + +} // namespace ecole diff --git a/ecole/libecole/src/observation/hutter-2011.cpp b/ecole/libecole/src/observation/hutter-2011.cpp new file mode 100644 index 0000000..d5c9122 --- /dev/null +++ b/ecole/libecole/src/observation/hutter-2011.cpp @@ -0,0 +1,393 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ecole/observation/hutter-2011.hpp" +#include "ecole/scip/cons.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/utility/sparse-matrix.hpp" + +#include "utility/graph.hpp" +#include "utility/math.hpp" + +namespace ecole::observation { + +namespace { + +namespace views = ranges::views; + +using Features = Hutter2011Obs::Features; +using value_type = decltype(Hutter2011Obs::features)::value_type; +using ConstraintMatrix = ecole::utility::coo_matrix; +std::size_t constexpr cons_axis = 0; +std::size_t constexpr var_axis = 1; + +/** Convert an enum to its underlying index. */ +template constexpr auto idx(E e) { + return static_cast>(e); +} + +/** [1-3] Problem size features. */ +template void set_problem_size(Tensor&& out, ConstraintMatrix const& cons_matrix) { + out[idx(Features::nb_variables)] = static_cast(cons_matrix.shape[var_axis]); + out[idx(Features::nb_constraints)] = static_cast(cons_matrix.shape[cons_axis]); + out[idx(Features::nb_nonzero_coefs)] = static_cast(cons_matrix.nnz()); +} + +/** [4-11] Variable-constraint graph features. */ +template void set_var_cons_degrees(Tensor&& out, ConstraintMatrix const& cons_matrix) { + // A degree counter to be reused. + auto degrees = std::vector(std::max(cons_matrix.shape[var_axis], cons_matrix.shape[cons_axis])); + + { // Compute variables degrees + degrees.resize(cons_matrix.shape[var_axis]); + for (auto var_idx : xt::row(cons_matrix.indices, var_axis)) { + assert(var_idx < degrees.size()); + degrees[var_idx]++; + } + auto const var_stats = utility::compute_stats(degrees); + out[idx(Features::variable_node_degree_mean)] = var_stats.mean; + out[idx(Features::variable_node_degree_max)] = var_stats.max; + out[idx(Features::variable_node_degree_min)] = var_stats.min; + out[idx(Features::variable_node_degree_std)] = var_stats.stddev; + } + { // Reset degree vector and compute constraint degrees + degrees.resize(cons_matrix.shape[cons_axis]); + std::fill(degrees.begin(), degrees.end(), 0); + for (auto cons_idx : xt::row(cons_matrix.indices, cons_axis)) { + assert(cons_idx < degrees.size()); + degrees[cons_idx]++; + } + auto const cons_stats = utility::compute_stats(degrees); + out[idx(Features::constraint_node_degree_mean)] = cons_stats.mean; + out[idx(Features::constraint_node_degree_max)] = cons_stats.max; + out[idx(Features::constraint_node_degree_min)] = cons_stats.min; + out[idx(Features::constraint_node_degree_std)] = cons_stats.stddev; + } +} + +/** + * Compute the quantile of an xtensor expression. + * + * The quantiles are computed by linear interpolation of the two data points in which it falls. + * + * @param data The (unsorted) data from which to extract the quantiles. + * @param percentages Quantiles to compute, between 0 and 1. + */ +template auto quantiles(E&& data, std::array const& percentages) { + static_assert(std::is_floating_point_v); + assert(std::all_of(percentages.begin(), percentages.end(), [](auto p) { return (0 <= p) && (p <= 1); })); + + auto const data_size = static_cast(data.size()); + + // IILF Fill an array with upper and lower index of each quantile + auto const quantile_idx = [&]() { + auto pos = std::array{}; + auto pos_iter = pos.begin(); + for (auto p : percentages) { + auto const continuous_idx = p * data_size; + *(pos_iter++) = static_cast(std::floor(continuous_idx)); + *(pos_iter++) = static_cast(std::ceil(continuous_idx)); + } + return pos; + }(); + + // Partially sort data so that the element whose index are given by quantile_idx are in the correct postiion. + auto const partially_sorted_data = xt::partition(std::forward(data), quantile_idx); + + // + auto quants = std::array{}; + auto* quants_iter = quants.begin(); + for (auto p : percentages) { + auto const continuous_idx = p * data_size; + auto const down_idx = static_cast(std::floor(continuous_idx)); + auto const down_val = static_cast(partially_sorted_data[down_idx]); + auto const up_idx = static_cast(std::floor(continuous_idx)); + auto const up_val = static_cast(partially_sorted_data[up_idx]); + auto const frac = continuous_idx - std::floor(continuous_idx); + *(quants_iter++) = (1 - frac) * down_val + frac * up_val; + } + return quants; +} + +/** [12-17,20] Variable graph features. */ +template void set_var_degrees(Tensor&& out, ConstraintMatrix const& matrix) { + auto const n_var = matrix.shape[var_axis]; + auto const n_cons = matrix.shape[cons_axis]; + // Build variable graph. + // TODO could be optimized if we know matrix.indices[cons_axis] is sorted (or sort it). + auto graph = utility::Graph{n_var}; + for (std::size_t cons = 0; cons < n_cons; ++cons) { + auto const vars = + xt::eval(xt::filter(xt::row(matrix.indices, var_axis), xt::equal(xt::row(matrix.indices, cons_axis), cons))); + auto const* const var_end = vars.end(); + for (auto const* var1_iter = vars.begin(); var1_iter < var_end; ++var1_iter) { + for (auto const* var2_iter = var1_iter + 1; var2_iter < var_end; ++var2_iter) { + if (!graph.are_connected(*var1_iter, *var2_iter)) { + graph.add_edge({*var1_iter, *var2_iter}); + } + } + } + } + + // Compute stats + auto get_var_degree = [&graph](auto var) { return graph.neighbors(var).size(); }; + auto var_degrees = views::ints(0UL, n_var) | views::transform(get_var_degree) | ranges::to(); + + auto const stats = utility::compute_stats(var_degrees); + out[idx(Features::node_degree_mean)] = stats.mean; + out[idx(Features::node_degree_max)] = stats.max; + out[idx(Features::node_degree_min)] = stats.min; + out[idx(Features::node_degree_std)] = stats.stddev; + auto const quants = quantiles(xt::adapt(var_degrees), std::array{0.25, 0.75}); + out[idx(Features::node_degree_25q)] = quants[0]; + out[idx(Features::node_degree_75q)] = quants[1]; + auto const n_edges_complete_graph = static_cast(n_var * (n_var - 1)) / 2.; + out[idx(Features::edge_density)] = static_cast(graph.n_edges()) / n_edges_complete_graph; +} + +/** Solves the LP relaxation of a model by making a copy, and setting all its variables continuous. */ +auto solve_lp_relaxation(scip::Model const& model) { + auto relax_model = model.copy(); + auto* const relax_scip = relax_model.get_scip_ptr(); + auto const variables = relax_model.variables(); + + // Change active variables to continuous + for (auto* const var : variables) { + SCIP_Bool infeasible = FALSE; + scip::call(SCIPchgVarType, relax_scip, var, SCIP_VARTYPE_CONTINUOUS, &infeasible); + assert(!infeasible); + } + + // Change constraint variables to continuous + for (auto* const constraint : relax_model.constraints()) { + auto const cons_vars = scip::get_cons_vars(relax_scip, constraint); + assert(cons_vars.has_value()); + for (auto* const var : cons_vars.value()) { + SCIP_Bool infeasible = FALSE; + scip::call(SCIPchgVarType, relax_scip, var, SCIP_VARTYPE_CONTINUOUS, &infeasible); + assert(!infeasible); + } + } + + // Solve the LP + scip::call(SCIPsolve, relax_scip); + + // Collect the solution + // Note: technically this is the solution with respect to the copy model's active variables + // Hopefully this should match 1-1 the original model's active variables? + SCIP_SOL* optimal_sol = SCIPgetBestSol(relax_scip); + SCIP_Real optimal_value = SCIPgetSolOrigObj(relax_scip, optimal_sol); + auto optimal_sol_coefs = std::vector(variables.size()); + scip::call( + SCIPgetSolVals, + relax_scip, + optimal_sol, + static_cast(variables.size()), + variables.data(), + optimal_sol_coefs.data()); + + return std::tuple{std::move(optimal_sol_coefs), optimal_value}; +} + +/** [21-24] LP based features. */ +template void set_lp_based_features(Tensor&& out, scip::Model const& model) { + auto const [lp_solution, lp_objective] = solve_lp_relaxation(model); + + // Compute the integer slack vector + auto* const scip = const_cast(model.get_scip_ptr()); + int const nb_integer_variables = SCIPgetNBinVars(scip) + SCIPgetNIntVars(scip); + + if (nb_integer_variables > 0) { + // Compute the integer slack vector + auto integer_slack = std::vector(static_cast(nb_integer_variables)); + std::size_t int_var_idx = 0; + for (auto& variable : model.variables()) { + if (SCIPvarIsIntegral(variable)) { + auto lp_solution_coef = lp_solution[int_var_idx]; + integer_slack[int_var_idx] = std::abs(lp_solution_coef - std::round(lp_solution_coef)); + int_var_idx++; + } + } + + // Compute statistics of the integer slack vector + auto const slack_stats = utility::compute_stats(integer_slack); + auto const square = [](auto val) { return val * val; }; + auto const slack_l2_norm = ranges::accumulate(integer_slack | views::transform(square), 0.); + + out[idx(Features::lp_slack_mean)] = slack_stats.mean; + out[idx(Features::lp_slack_max)] = slack_stats.max; + out[idx(Features::lp_slack_l2)] = slack_l2_norm; + } else { + out[idx(Features::lp_slack_mean)] = 0; + out[idx(Features::lp_slack_max)] = 0; + out[idx(Features::lp_slack_l2)] = 0; + } + out[idx(Features::lp_objective_value)] = lp_objective; +} + +/** [25-27] Objective function features. */ +template +void set_obj_features(Tensor&& out, scip::Model const& model, ConstraintMatrix const& cons_matrix) { + auto const variables = model.variables(); + auto coefficients_m = std::vector{}; + auto coefficients_n = std::vector{}; + auto coefficients_sqrtn = std::vector{}; + + auto nb_cons_of_vars = std::vector(variables.size(), 0); + for (std::size_t coef_idx = 0; coef_idx < cons_matrix.nnz(); ++coef_idx) { + nb_cons_of_vars[cons_matrix.indices(var_axis, coef_idx)]++; + } + + coefficients_m.resize(variables.size()); + coefficients_n.resize(variables.size()); + coefficients_sqrtn.resize(variables.size()); + auto const nb_constraints = static_cast(cons_matrix.shape[cons_axis]); + for (std::size_t var_idx = 0; var_idx < variables.size(); ++var_idx) { + auto c = SCIPvarGetObj(variables[var_idx]); + coefficients_m.push_back(c / nb_constraints); + if (nb_cons_of_vars[var_idx] != 0) { + coefficients_n.push_back(c / nb_cons_of_vars[var_idx]); + coefficients_sqrtn.push_back(c / std::sqrt(nb_cons_of_vars[var_idx])); + } + } + + auto const coefficients_m_stats = utility::compute_stats(coefficients_m); + auto const coefficients_n_stats = utility::compute_stats(coefficients_n); + auto const coefficients_sqrtn_stats = utility::compute_stats(coefficients_sqrtn); + + out[idx(Features::objective_coef_m_std)] = coefficients_m_stats.stddev; + out[idx(Features::objective_coef_n_std)] = coefficients_n_stats.stddev; + out[idx(Features::objective_coef_sqrtn_std)] = coefficients_sqrtn_stats.stddev; +} + +/** [28-31] Linear constraint matrix features. */ +template +void set_cons_matrix_features( + Tensor&& out, + ConstraintMatrix const& cons_matrix, + xt::xtensor const& cons_biases) { + auto nb_constraints = cons_matrix.shape[cons_axis]; + auto normalized_coefs = std::vector{}; + auto norm_abs_coefs_counts = std::vector(nb_constraints, 0); + auto norm_abs_coefs_means = std::vector(nb_constraints, 0); + auto norm_abs_coefs_ssms = std::vector(nb_constraints, 0); + + for (std::size_t coef_idx = 0; coef_idx < cons_matrix.nnz(); ++coef_idx) { + auto cons_idx = cons_matrix.indices(cons_axis, coef_idx); + auto bias = cons_biases(cons_idx); + + if (bias != 0) { + auto normalized_coef = cons_matrix.values(coef_idx) / bias; + normalized_coefs.push_back(normalized_coef); + + normalized_coef = std::abs(normalized_coef); + + // Online update formula + norm_abs_coefs_counts[cons_idx]++; + auto count = norm_abs_coefs_counts[cons_idx]; + if (count == 1) { + norm_abs_coefs_means[cons_idx] = normalized_coef; + } else { + // At least two elements + auto const delta = normalized_coef - norm_abs_coefs_means[cons_idx]; + norm_abs_coefs_means[cons_idx] += delta / count; + auto const delta2 = normalized_coef - norm_abs_coefs_means[cons_idx]; + norm_abs_coefs_ssms[cons_idx] += delta * delta2; + } + } + } + + auto norm_abs_var_coefs = std::vector(nb_constraints, 0); + for (std::size_t cons_idx = 0; cons_idx < nb_constraints; ++cons_idx) { + if (auto const ssm = norm_abs_coefs_ssms[cons_idx]; ssm != 0) { + norm_abs_var_coefs[cons_idx] = norm_abs_coefs_means[cons_idx] * norm_abs_coefs_counts[cons_idx] / ssm; + } + } + + auto const normalized_coefs_stats = utility::compute_stats(normalized_coefs); + auto const norm_abs_var_coefs_stats = utility::compute_stats(norm_abs_var_coefs); + + out[idx(Features::constraint_coef_mean)] = normalized_coefs_stats.mean; + out[idx(Features::constraint_coef_std)] = normalized_coefs_stats.stddev; + out[idx(Features::constraint_var_coef_mean)] = norm_abs_var_coefs_stats.mean; + out[idx(Features::constraint_var_coef_std)] = norm_abs_var_coefs_stats.stddev; +} + +/** [32-35] Variable type features. */ +template void set_variable_type_features(Tensor&& out, scip::Model const& model) { + auto* const scip = const_cast(model.get_scip_ptr()); + double nb_unbounded_int_vars = 0.; + + auto support_sizes = std::vector{}; + for (auto* const variable : model.variables()) { + if (SCIPvarGetType(variable) == SCIP_VARTYPE_BINARY) { + support_sizes.push_back(2); + } else if (SCIPvarGetType(variable) == SCIP_VARTYPE_INTEGER) { + auto ub = SCIPvarGetUbGlobal(variable); + auto lb = SCIPvarGetLbGlobal(variable); + if (SCIPisInfinity(scip, std::abs(ub)) || SCIPisInfinity(scip, std::abs(lb))) { + nb_unbounded_int_vars++; + } else { + support_sizes.push_back(static_cast(ub - lb)); + } + } + } + + auto const support_sizes_stats = utility::compute_stats(support_sizes); + auto const nb_int_vars = static_cast(SCIPgetNBinVars(scip) + SCIPgetNIntVars(scip)); + auto const nb_cont_vars = static_cast(SCIPgetNContVars(scip)); + + out[idx(Features::discrete_vars_support_size_mean)] = support_sizes_stats.mean; + out[idx(Features::discrete_vars_support_size_std)] = support_sizes_stats.stddev; + if (nb_int_vars > 0) { + out[idx(Features::ratio_unbounded_discrete_vars)] = nb_unbounded_int_vars / nb_int_vars; + } else { + out[idx(Features::ratio_unbounded_discrete_vars)] = 1.0; + } + out[idx(Features::ratio_continuous_vars)] = nb_cont_vars / (nb_int_vars + nb_cont_vars); +} + +auto extract_features(scip::Model& model) { + auto observation = xt::xtensor::from_shape({Hutter2011Obs::n_features}); + auto const [cons_matrix, cons_biases] = scip::get_all_constraints(model.get_scip_ptr()); + + set_problem_size(observation, cons_matrix); + set_var_cons_degrees(observation, cons_matrix); + set_var_degrees(observation, cons_matrix); + set_lp_based_features(observation, model); + set_obj_features(observation, model, cons_matrix); + set_cons_matrix_features(observation, cons_matrix, cons_biases); + set_variable_type_features(observation, model); + + return observation; +} + +} // namespace + +/************************************* + * Observation extracting function * + *************************************/ + +auto Hutter2011::extract(scip::Model& model, bool /* done */) -> std::optional { + if (model.stage() >= SCIP_STAGE_SOLVING) { + return {}; + } + return {{extract_features(model)}}; +} + +} // namespace ecole::observation diff --git a/ecole/libecole/src/observation/khalil-2016.cpp b/ecole/libecole/src/observation/khalil-2016.cpp new file mode 100644 index 0000000..cb9a58a --- /dev/null +++ b/ecole/libecole/src/observation/khalil-2016.cpp @@ -0,0 +1,730 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ecole/observation/khalil-2016.hpp" +#include "ecole/scip/col.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/row.hpp" + +#include "utility/math.hpp" + +namespace ecole::observation { + +namespace { + +namespace views = ranges::views; + +using Features = Khalil2016Obs::Features; +using value_type = decltype(Khalil2016Obs::features)::value_type; + +using ecole::utility::safe_div; +using ecole::utility::square; + +/************************* + * Algorithm functions * + *************************/ + +/** + * Return the sum of positive numbers and the sum of negative numbers in a range. + */ + +auto sum_positive_negative = [](auto const& row_cols, auto const& row_cols_vals) { + auto positive_sum = value_type{0.}; + auto negative_sum = value_type{0.}; + + + for (auto const [col, val] : views::zip(row_cols, row_cols_vals)) { + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 > SCIPcolGetUb(col)) + continue; + if (val > 0) + positive_sum += val; + else + negative_sum += val; + } + + } + return std::pair{positive_sum, negative_sum}; +}; + + +/** Convert an enum to its underlying index. */ +template constexpr auto idx(E e) { + return static_cast>(e); +} + +/****************************************** + * Static features extraction functions * + ******************************************/ + +/* Feature as defined and split in table 1 of the paper Khalil et al. "Learning to Branch in Mixed + * Integer Programming" Thirtieth AAAI Conference on Artificial Intelligence. 2016. + * + * https://dl.acm.org/doi/10.5555/3015812.3015920 + */ + +/** + * Objective function coeffs. + * + * Value of the coefficient (raw, positive only, negative only). + */ +template void set_objective_function_coefficient(Tensor&& out, SCIP_COL* const col) noexcept { + auto const obj = SCIPcolGetObj(col); + out[idx(Features::obj_coef)] = obj; + out[idx(Features::obj_coef_pos_part)] = std::max(obj, 0.); + out[idx(Features::obj_coef_neg_part)] = std::min(obj, 0.); +} + +/** + * Num. constraints. + * + * Number of constraints that the variable participates in (with a non-zero coefficient). + */ +template void set_number_constraints(Tensor&& out, SCIP_COL* const col) noexcept { + out[idx(Features::n_rows)] = static_cast(SCIPcolGetNLPNonz(col)); +} + +/** + * Stats. for constraint degrees. + * + * The degree of a constraint is the number of variables that participate in it. + * A variable may participate in multiple constraints, and statistics over those constraints' + * degrees are used. + * The constraint degree is computed on the root LP (mean, stdev., min, max) + */ +template +void set_static_stats_for_constraint_degree(Tensor&& out, nonstd::span const rows) noexcept { + + auto row_get_nnonfixed_cols = [](auto const row) { + int ctr = 0; + auto const row_cols = scip::get_cols(row); + for (auto* const col: row_cols) { + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 > SCIPcolGetUb(col)) + continue; + ctr ++; + } + } + return ctr; + }; + + auto const stats = utility::compute_stats(rows | ranges::views::transform(row_get_nnonfixed_cols)); + out[idx(Features::rows_deg_mean)] = stats.mean; + out[idx(Features::rows_deg_stddev)] = stats.stddev; + out[idx(Features::rows_deg_min)] = stats.min; + out[idx(Features::rows_deg_max)] = stats.max; +} + +/** + * Stats. for constraint coeffs. + * + * A variable's positive coefficients in the constraints it participates in + * (count, mean, stdev., min, max). + */ +template +void set_stats_for_constraint_positive_coefficients(Tensor&& out, nonstd::span const coefficients) noexcept { + auto const stats = utility::compute_stats(coefficients | views::filter([](auto x) { return x > 0.; })); + out[idx(Features::rows_pos_coefs_count)] = stats.count; + out[idx(Features::rows_pos_coefs_mean)] = stats.mean; + out[idx(Features::rows_pos_coefs_stddev)] = stats.stddev; + out[idx(Features::rows_pos_coefs_min)] = stats.min; + out[idx(Features::rows_pos_coefs_max)] = stats.max; +} + +/** + * Stats. for constraint coeffs. + * + * A variable's negative coefficients in the constraints it participates in + * (count, mean, stdev., min, max). + */ +template +void set_stats_for_constraint_negative_coefficients(Tensor&& out, nonstd::span const coefficients) noexcept { + auto const stats = utility::compute_stats(coefficients | views::filter([](auto x) { return x < 0.; })); + out[idx(Features::rows_neg_coefs_count)] = stats.count; + out[idx(Features::rows_neg_coefs_mean)] = stats.mean; + out[idx(Features::rows_neg_coefs_stddev)] = stats.stddev; + out[idx(Features::rows_neg_coefs_min)] = stats.min; + out[idx(Features::rows_neg_coefs_max)] = stats.max; +} + +/** + * Extract the static features for a single LP columns. + */ +template void set_static_features(Tensor&& out, SCIP_COL* const col) { + + auto const rows = scip::get_rows(col); + auto const coefficients = scip::get_vals(col); + + auto const n_col_lp_rows = SCIPcolGetNLPNonz(col); + + auto const filtered_rows = views::take(rows, n_col_lp_rows); + auto const filtered_coefficients = views::take(coefficients, n_col_lp_rows); + + set_objective_function_coefficient(out, col); + set_number_constraints(out, col); + set_static_stats_for_constraint_degree(out, filtered_rows); + set_stats_for_constraint_positive_coefficients(out, filtered_coefficients); + set_stats_for_constraint_negative_coefficients(out, filtered_coefficients); +} + +/** + * Extract the static features for all LP columns in a Model. + */ +auto extract_static_features(scip::Model& model) { + auto const columns = model.lp_columns(); + xt::xtensor static_features{{columns.size(), Khalil2016Obs::n_static_features}, 0.}; + + auto const n_columns = columns.size(); + for (std::size_t i = 0; i < n_columns; ++i) { + set_static_features(xt::row(static_features, static_cast(i)), columns[i]); + } + + return static_features; +} + +/******************************************* + * Dynamic features extraction functions * + *******************************************/ + +/* Feature as defined and split in table 1 of the paper Khalil et al. "Learning to Branch in Mixed + * Integer Programming" Thirtieth AAAI Conference on Artificial Intelligence. 2016. + * + * https://dl.acm.org/doi/10.5555/3015812.3015920 + */ + +/** + * Slack, ceil distances, and Pseudocosts. + * + * This function combines two feature sets from Khalil et al. + * + * Slack and ceil distance: + * min{xij−floor(xij),ceil(xij) −xij} and ceil(xij) −xij + * + * Pseudocosts: + * Upwards and downwards values, and their corresponding ratio, sum and product, weighted by the + * fractionality of xj. + */ +template +void set_slack_ceil_and_pseudocosts(Tensor&& out, SCIP* const scip, SCIP_VAR* const var, SCIP_COL* const col) noexcept { + auto const solval = SCIPcolGetPrimsol(col); + auto const floor_distance = SCIPfeasFrac(scip, solval); + auto const ceil_distance = 1. - floor_distance; + auto const weighted_pseudocost_up = ceil_distance * SCIPgetVarPseudocost(scip, var, SCIP_BRANCHDIR_UPWARDS); + auto const weighted_pseudocost_down = floor_distance * SCIPgetVarPseudocost(scip, var, SCIP_BRANCHDIR_DOWNWARDS); + auto constexpr epsilon = 1e-5; + auto const wpu_approx = std::max(weighted_pseudocost_up, epsilon); + auto const wpd_approx = std::max(weighted_pseudocost_down, epsilon); + auto const weighted_pseudocost_ratio = safe_div(std::min(wpu_approx, wpd_approx), std::max(wpu_approx, wpd_approx)); + out[idx(Features::slack)] = std::min(floor_distance, ceil_distance); + out[idx(Features::ceil_dist)] = ceil_distance; + out[idx(Features::pseudocost_up)] = weighted_pseudocost_up; + out[idx(Features::pseudocost_down)] = weighted_pseudocost_down; + out[idx(Features::pseudocost_ratio)] = weighted_pseudocost_ratio; + out[idx(Features::pseudocost_sum)] = weighted_pseudocost_up + weighted_pseudocost_down; + out[idx(Features::pseudocost_product)] = weighted_pseudocost_up * weighted_pseudocost_down; +} + +/** + * Infeasibility statistics. + * + * Number and fraction of nodes for which applying SB to variable xj led to one (two) infeasible + * children (during data collection). + * + * N.B. replaced by left, right infeasibility. + */ +template void set_infeasibility_statistics(Tensor&& out, SCIP_VAR* const var) noexcept { + auto const n_infeasibles_up = SCIPvarGetCutoffSum(var, SCIP_BRANCHDIR_UPWARDS); + auto const n_infeasibles_down = SCIPvarGetCutoffSum(var, SCIP_BRANCHDIR_DOWNWARDS); + auto const n_branchings_up = static_cast(SCIPvarGetNBranchings(var, SCIP_BRANCHDIR_UPWARDS)); + auto const n_branchings_down = static_cast(SCIPvarGetNBranchings(var, SCIP_BRANCHDIR_DOWNWARDS)); + out[idx(Features::n_cutoff_up)] = n_infeasibles_up; + out[idx(Features::n_cutoff_down)] = n_infeasibles_down; + out[idx(Features::n_cutoff_up_ratio)] = safe_div(n_infeasibles_up, n_branchings_up); + out[idx(Features::n_cutoff_down_ratio)] = safe_div(n_infeasibles_down, n_branchings_down); +} + +/** + * Stats. for constraint degrees. + * + * A dynamic variant of the static version above. Here, the constraint degrees are on the current + * node's LP. + * The ratios of the static mean, maximum and minimum to their dynamic counterparts are also + * features. + * + * The precomputed static features given as input parameters are wrapped in their strong type to + * avoid passing the wrong ones. + */ +template +void set_dynamic_stats_for_constraint_degree(Tensor&& out, nonstd::span const rows) noexcept { + + + auto row_get_nnonfixed_cols = [](auto const row) { + int ctr = 0; + auto const row_cols = scip::get_cols(row); + for (auto* const col: row_cols) { + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 > SCIPcolGetUb(col)) + continue; + ctr ++; + } + } + return ctr; + }; + + auto const stats = utility::compute_stats(rows | views::transform(row_get_nnonfixed_cols)); + auto const root_deg_mean = out[idx(Features::rows_deg_mean)]; + auto const root_deg_min = out[idx(Features::rows_deg_min)]; + auto const root_deg_max = out[idx(Features::rows_deg_max)]; + out[idx(Features::rows_dynamic_deg_mean)] = stats.mean; + out[idx(Features::rows_dynamic_deg_stddev)] = stats.stddev; + out[idx(Features::rows_dynamic_deg_min)] = stats.min; + out[idx(Features::rows_dynamic_deg_max)] = stats.max; + out[idx(Features::rows_dynamic_deg_mean_ratio)] = safe_div(stats.mean, root_deg_mean + stats.mean); + out[idx(Features::rows_dynamic_deg_min_ratio)] = safe_div(stats.min, root_deg_min + stats.min); + out[idx(Features::rows_dynamic_deg_max_ratio)] = safe_div(stats.max, root_deg_max + stats.max); +} + +/** + * Min/max for ratios of constraint coeffs. to RHS. + * + * Minimum and maximum ratios across positive and negative right-hand-sides (RHS). + */ +template +void set_min_max_for_ratios_constraint_coeffs_rhs( + Tensor&& out, + SCIP* const scip, + nonstd::span const rows, + nonstd::span const coefficients) noexcept { + + value_type positive_rhs_ratio_max = -1.; + value_type positive_rhs_ratio_min = 1.; + value_type negative_rhs_ratio_max = -1.; + value_type negative_rhs_ratio_min = 1.; + + auto rhs_ratio_updates = [&](auto const coef, auto const rhs) { + auto const ratio_val = safe_div(coef, std::abs(coef) + std::abs(rhs)); + if (rhs >= 0) { + positive_rhs_ratio_max = std::max(positive_rhs_ratio_max, ratio_val); + positive_rhs_ratio_min = std::min(positive_rhs_ratio_min, ratio_val); + } else { + negative_rhs_ratio_max = std::max(negative_rhs_ratio_max, ratio_val); + negative_rhs_ratio_min = std::min(negative_rhs_ratio_min, ratio_val); + } + }; + + for (auto const [row, coef] : views::zip(rows, coefficients)) { + + double temp_sum = 0; + + auto const row_values = scip::get_vals(row); + + auto const row_cols = scip::get_cols(row); + + for (auto const [val, col] : views::zip(row_values, row_cols)) { + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 > SCIPcolGetUb(col)) //fixed column! + temp_sum += val * SCIPcolGetLb(col); //value.. + } + } + + if (auto const rhs = SCIProwGetRhs(row); !SCIPisInfinity(scip, std::abs(rhs))) { + //printf("My Id: %d Rhs: %f Red rhs: %f\n", SCIProwGetLPPos(row), rhs, rhs - temp_sum); + rhs_ratio_updates(coef, rhs - SCIProwGetConstant(row) - temp_sum); + } + if (auto const lhs = SCIProwGetLhs(row); !SCIPisInfinity(scip, std::abs(lhs))) { + //printf("My Id: %d Lhs: %f Red lhs: %f\n", SCIProwGetLPPos(row),lhs, lhs - temp_sum); + // lhs constraints are multiply by -1 to be considered as rhs constraints. + rhs_ratio_updates(-coef, -1 * (lhs - SCIProwGetConstant(row)) + temp_sum); + } + + } + + + out[idx(Features::coef_pos_rhs_ratio_min)] = positive_rhs_ratio_min; + out[idx(Features::coef_pos_rhs_ratio_max)] = positive_rhs_ratio_max; + out[idx(Features::coef_neg_rhs_ratio_min)] = negative_rhs_ratio_min; + out[idx(Features::coef_neg_rhs_ratio_max)] = negative_rhs_ratio_max; +} + +/** + * Min/max for one-to-all coefficient ratios. + * + * The statistics are over the ratios of a variable's coefficient, to the sum over all other + * variables' coefficients, for a given constraint. + * Four versions of these ratios are considered: positive (negative) coefficient to sum of + * positive (negative) coefficients. + */ +template +void set_min_max_for_one_to_all_coefficient_ratios( + Tensor&& out, + nonstd::span const rows, + nonstd::span const coefficients) noexcept { + + value_type positive_positive_ratio_max = 0; + value_type positive_positive_ratio_min = 1; + value_type positive_negative_ratio_max = 0; + value_type positive_negative_ratio_min = 1; + value_type negative_positive_ratio_max = 0; + value_type negative_positive_ratio_min = 1; + value_type negative_negative_ratio_max = 0; + value_type negative_negative_ratio_min = 1; + + for (auto const [row, coef] : views::zip(rows, coefficients)) { + + auto const row_values = scip::get_vals(row); + + auto const row_cols = scip::get_cols(row); + + auto const n_row_lp_cols = static_cast(SCIProwGetNLPNonz(row)); + + auto const filtered_row_values = views::take(row_values, n_row_lp_cols); + auto const filtered_row_cols = views::take(row_cols, n_row_lp_cols); + + + auto const [positive_coeficients_sum, negative_coeficients_sum] = sum_positive_negative(filtered_row_cols, filtered_row_values); + if (coef > 0) { + auto const positive_ratio = coef / positive_coeficients_sum; + auto const negative_ratio = coef / (coef - negative_coeficients_sum); + positive_positive_ratio_max = std::max(positive_positive_ratio_max, positive_ratio); + positive_positive_ratio_min = std::min(positive_positive_ratio_min, positive_ratio); + positive_negative_ratio_max = std::max(positive_negative_ratio_max, negative_ratio); + positive_negative_ratio_min = std::min(positive_negative_ratio_min, negative_ratio); + } else if (coef < 0) { + auto const positive_ratio = coef / (coef - positive_coeficients_sum); + auto const negative_ratio = coef / negative_coeficients_sum; + negative_positive_ratio_max = std::max(negative_positive_ratio_max, positive_ratio); + negative_positive_ratio_min = std::min(negative_positive_ratio_min, positive_ratio); + negative_negative_ratio_max = std::max(negative_negative_ratio_max, negative_ratio); + negative_negative_ratio_min = std::min(negative_negative_ratio_min, negative_ratio); + } + } + + out[idx(Features::pos_coef_pos_coef_ratio_min)] = positive_positive_ratio_min; + out[idx(Features::pos_coef_pos_coef_ratio_max)] = positive_positive_ratio_max; + out[idx(Features::pos_coef_neg_coef_ratio_min)] = positive_negative_ratio_min; + out[idx(Features::pos_coef_neg_coef_ratio_max)] = positive_negative_ratio_max; + out[idx(Features::neg_coef_pos_coef_ratio_min)] = negative_positive_ratio_min; + out[idx(Features::neg_coef_pos_coef_ratio_max)] = negative_positive_ratio_max; + out[idx(Features::neg_coef_neg_coef_ratio_min)] = negative_negative_ratio_min; + out[idx(Features::neg_coef_neg_coef_ratio_max)] = negative_negative_ratio_max; +} + +/** + * Return if a row in the constraints is active in the LP. + */ +auto row_is_active(SCIP* const scip, SCIP_ROW* const row) noexcept -> bool { + auto const activity = SCIPgetRowActivity(scip, row); + auto const lhs = SCIProwGetLhs(row); + auto const rhs = SCIProwGetRhs(row); + return (SCIPisEQ(scip, activity, rhs) || SCIPisEQ(scip, activity, lhs)); +} + +/** + * Compute the weight necessary for the stats for active constraints coefficients. + * + * The four coefficients are + * - unit weight, + * - inverse of the sum of the coefficients of all variables in constraint, + * - inverse of the sum of the coefficients of only candidate variables in constraint + * - dual cost of the constraint. + * They are computed for every row that is active, as defined by @ref row_is_active. + * Weights for non activate rows are left as NaN and ununsed. + * This is equivalent to an unsafe/unchecked masked tensor. + */ +auto stats_for_active_constraint_coefficients_weights(scip::Model& model, bool pseudo) { //Selin added + auto* const scip = model.get_scip_ptr(); + auto const lp_rows = model.lp_rows(); + auto const candidates = pseudo ? model.pseudo_branch_cands() : model.lp_branch_cands(); // SELIN added + auto const branch_candidates = candidates | ranges::to(); //SELIN changed, normally model.pseudo_branch_cands() + + /** Check if a column is a branching candidate. */ + auto is_candidate = [&branch_candidates](auto* col) { return branch_candidates.count(SCIPcolGetVar(col)) > 0; }; + + /** Compute the sum of absolute values in a range. */ + auto sum_abs = [](auto const& row_cols, auto const& row_cols_vals) { + auto sum = value_type{0.}; + for (auto const [col, val] : views::zip(row_cols, row_cols_vals)) { + + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 > SCIPcolGetUb(col)) + continue; + + sum += std::abs(val); + + } + } + return sum; + }; + + /** Compute the sum of absolute value of column coefficient if the column is a branching candidate. */ + auto sum_abs_if_candidate = [&is_candidate](auto const& row_cols, auto const& row_cols_vals) { + auto sum = value_type{0.}; + for (auto const [col, val] : views::zip(row_cols, row_cols_vals)) { + + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 > SCIPcolGetUb(col)) + continue; + if (is_candidate(col)) { + sum += std::abs(val); + } + } + } + return sum; + }; + + /** Compute the inverse of a number or 1 if the number is zero. */ + auto safe_inv = [](auto const x) { return x != 0. ? 1. / x : 1.; }; + + auto row_get_reduced_norm = [](auto const row) { + double row_norm = 0; + auto const row_cols = scip::get_cols(row); + auto const row_values = scip::get_vals(row); + + for (auto const [val, col] : views::zip(row_values, row_cols)) { + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 > SCIPcolGetUb(col)) //fixed column! + continue; + else { + row_norm += val * val; + } + } + } + return sqrt(row_norm); + }; + + xt::xtensor weights{{lp_rows.size(), 4}, std::nan("")}; + auto* weights_iter = weights.begin(); + + for (auto* const row : lp_rows) { //OK, only lp rows + if (row_is_active(scip, row)) { + + auto const red_norm = row_get_reduced_norm(row); + + auto const row_values = scip::get_vals(row); + auto const n_row_lp_cols = static_cast(SCIProwGetNLPNonz(row)); + auto const filtered_row_values = views::take(row_values, n_row_lp_cols); + + *(weights_iter++) = 1.; + *(weights_iter++) = safe_inv(safe_div(sum_abs(scip::get_cols(row), filtered_row_values), red_norm)); + *(weights_iter++) = safe_inv(safe_div(sum_abs_if_candidate(scip::get_cols(row), filtered_row_values), red_norm)); + *(weights_iter++) = std::abs(SCIProwGetDualsol(row)); + } else { + weights_iter += 4; + } + } + + // Make sure we iterated over as many element as there are in the tensor + assert(weights_iter == weights.cend()); + + return weights; +} + +/** + * Stats. for active constraint coefficients. + * + * An active constraint at a node LP is one which is binding with equality at the optimum. + * We consider 4 weighting schemes for an active constraint: unit weight, inverse of the sum of + * the coefficients of all variables in constraint, inverse of the sum of the coefficients of only + * candidate variables in constraint, dual cost of the constraint. + * Given the absolute value of the coefficients of xj in the active constraints, we compute the + * sum, mean, stdev., max. and min. of those values, for each of the weighting schemes. We also + * compute the weighted number of active constraints that xj is in, with the same 4 weightings. + */ +template +void set_stats_for_active_constraint_coefficients( + Tensor&& out, + SCIP* const scip, + nonstd::span const rows, + nonstd::span const coefficients, + xt::xtensor const& lp_rows_weights) noexcept { + + auto weights_stats = std::array, 4>{}; + for (auto& stats : weights_stats) { + stats.min = std::numeric_limits::max(); + stats.max = std::numeric_limits::min(); + } + + std::size_t n_active_rows = 0UL; + for (auto const [row, coef] : views::zip(rows, coefficients)) { + + auto const row_lp_idx = SCIProwGetLPPos(row); + + if (row_is_active(scip, row)) { + n_active_rows++; + + assert(row_lp_idx >= 0); + + for (std::size_t weight_idx = 0; weight_idx < weights_stats.size(); ++weight_idx) { + auto const weight = lp_rows_weights(row_lp_idx, weight_idx); + assert(!std::isnan(weight)); // If NaN likely hit a maked value + auto const weighted_abs_coef = weight * std::abs(coef); + + auto& stats = weights_stats[weight_idx]; + stats.count += weight; + stats.sum += weighted_abs_coef; + stats.min = std::min(stats.min, weighted_abs_coef); + stats.max = std::max(stats.max, weighted_abs_coef); + } + } + } + + if (n_active_rows > 0) { + for (auto& stats : weights_stats) { + stats.mean = stats.sum / static_cast(n_active_rows); + } + + for (auto const [row, coef] : views::zip(rows, coefficients)) { + auto const row_lp_idx = SCIProwGetLPPos(row); + if (row_is_active(scip, row)) { + for (std::size_t weight_idx = 0; weight_idx < weights_stats.size(); ++weight_idx) { + auto const weight = lp_rows_weights(row_lp_idx, weight_idx); + assert(!std::isnan(weight)); // If NaN likely hit a masked value? + auto const weighted_abs_coef = weight * std::abs(coef); + + auto& stats = weights_stats[weight_idx]; + stats.stddev += square(weighted_abs_coef - stats.mean); // SELIN changed + } + } + } + + for (auto& stats : weights_stats) { + stats.stddev = std::sqrt(stats.stddev / static_cast(n_active_rows)); + } + + } else { + for (auto& stats : weights_stats) { + stats = {}; + } + } + + out[idx(Features::active_coef_weight1_count)] = weights_stats[0].count; + out[idx(Features::active_coef_weight1_sum)] = weights_stats[0].sum; + out[idx(Features::active_coef_weight1_mean)] = weights_stats[0].mean; + out[idx(Features::active_coef_weight1_stddev)] = weights_stats[0].stddev; + out[idx(Features::active_coef_weight1_min)] = weights_stats[0].min; + out[idx(Features::active_coef_weight1_max)] = weights_stats[0].max; + out[idx(Features::active_coef_weight2_count)] = weights_stats[1].count; + out[idx(Features::active_coef_weight2_sum)] = weights_stats[1].sum; + out[idx(Features::active_coef_weight2_mean)] = weights_stats[1].mean; + out[idx(Features::active_coef_weight2_stddev)] = weights_stats[1].stddev; + out[idx(Features::active_coef_weight2_min)] = weights_stats[1].min; + out[idx(Features::active_coef_weight2_max)] = weights_stats[1].max; + out[idx(Features::active_coef_weight3_count)] = weights_stats[2].count; + out[idx(Features::active_coef_weight3_sum)] = weights_stats[2].sum; + out[idx(Features::active_coef_weight3_mean)] = weights_stats[2].mean; + out[idx(Features::active_coef_weight3_stddev)] = weights_stats[2].stddev; + out[idx(Features::active_coef_weight3_min)] = weights_stats[2].min; + out[idx(Features::active_coef_weight3_max)] = weights_stats[2].max; + out[idx(Features::active_coef_weight4_count)] = weights_stats[3].count; + out[idx(Features::active_coef_weight4_sum)] = weights_stats[3].sum; + out[idx(Features::active_coef_weight4_mean)] = weights_stats[3].mean; + out[idx(Features::active_coef_weight4_stddev)] = weights_stats[3].stddev; + out[idx(Features::active_coef_weight4_min)] = weights_stats[3].min; + out[idx(Features::active_coef_weight4_max)] = weights_stats[3].max; +} + +/** + * Extract the dynamic features for a single branching candidate variable. + * + * The precomputed static features given as input parameters are wrapped in their strong type to + * avoid passing the wrong ones. + */ +template +void set_dynamic_features( + Tensor&& out, + SCIP* const scip, + SCIP_VAR* const var, + xt::xtensor const& lp_rows_weights) { + auto* const col = SCIPvarGetCol(var); + + auto const rows = scip::get_rows(col); + auto const coefficients = scip::get_vals(col); + + auto const n_col_lp_rows = SCIPcolGetNLPNonz(col); + + auto const filtered_rows = views::take(rows, n_col_lp_rows); + auto const filtered_coefficients = views::take(coefficients, n_col_lp_rows); + + + set_slack_ceil_and_pseudocosts(out, scip, var, col); + set_infeasibility_statistics(out, var); + set_dynamic_stats_for_constraint_degree(out, filtered_rows); + set_min_max_for_ratios_constraint_coeffs_rhs(out, scip, filtered_rows, filtered_coefficients); + set_min_max_for_one_to_all_coefficient_ratios(out, filtered_rows, filtered_coefficients); + set_stats_for_active_constraint_coefficients(out, scip, filtered_rows, filtered_coefficients, lp_rows_weights); +} + +/** + * Extract the static features already computed. + * + * The static features have been computed for all LP columns and stored in the order of `LPcolumns`. + * We need to find the one associated with the given variable. + */ +template +void set_precomputed_static_features(TensorOut&& var_features, TensorIn const& var_static_features) { + using namespace xt::placeholders; + xt::view(var_features, xt::range(_, Khalil2016Obs::n_static_features)) = var_static_features; +} + +/****************************** + * Main extraction function * + ******************************/ + +auto extract_all_features(scip::Model& model, bool pseudo, xt::xtensor const& static_features) { + auto const branch_cands = pseudo ? model.pseudo_branch_cands() : model.lp_branch_cands(); + auto observation = xt::xtensor{{model.variables().size(), Khalil2016Obs::n_features}, std::nan("")}; + + auto* const scip = model.get_scip_ptr(); + auto const lp_rows_weights = stats_for_active_constraint_coefficients_weights(model, pseudo); + + for (auto* var : branch_cands) { + auto const var_idx = SCIPvarGetProbindex(var); + auto var_features = xt::row(observation, var_idx); + auto var_static_features = xt::row(static_features, var_idx); + set_precomputed_static_features(var_features, var_static_features); + set_dynamic_features(var_features, scip, var, lp_rows_weights); + } + + return observation; +} + +auto is_on_root_node(scip::Model& model) -> bool { + auto* const scip = model.get_scip_ptr(); + return SCIPgetCurrentNode(scip) == SCIPgetRootNode(scip); +} + +} // namespace + +/************************************* + * Observation extracting function * + *************************************/ + +Khalil2016::Khalil2016(bool pseudo_candidates_) noexcept : pseudo_candidates(pseudo_candidates_) {} + +void Khalil2016::before_reset(scip::Model& /* model */) { + static_features = decltype(static_features){}; +} + +auto Khalil2016::extract(scip::Model& model, bool /* done */) -> std::optional { + if (model.stage() == SCIP_STAGE_SOLVING) { + if (is_on_root_node(model)) { + static_features = extract_static_features(model); + } + return {{extract_all_features(model, pseudo_candidates, static_features)}}; + } + return {}; +} + +} // namespace ecole::observation diff --git a/ecole/libecole/src/observation/milp-bipartite.cpp b/ecole/libecole/src/observation/milp-bipartite.cpp new file mode 100644 index 0000000..05b37d2 --- /dev/null +++ b/ecole/libecole/src/observation/milp-bipartite.cpp @@ -0,0 +1,153 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "ecole/exception.hpp" +#include "ecole/observation/milp-bipartite.hpp" +#include "ecole/scip/cons.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/utility/unreachable.hpp" + +namespace ecole::observation { + +namespace { + +/********************* + * Common helpers * + *********************/ + +using xmatrix = decltype(MilpBipartiteObs::variable_features); +using value_type = xmatrix::value_type; +using coo_xmatrix = utility::coo_matrix; + +using VariableFeatures = MilpBipartiteObs::VariableFeatures; +using ConstraintFeatures = MilpBipartiteObs::ConstraintFeatures; + +/****************************************** + * Variable extraction functions * + ******************************************/ + +/* Computes the L2 norm of the objective. + This is done by hand because SCIPgetObjNorm is not available for all stages (need >= SCIP_STAGE_TRANSFORMED) */ +SCIP_Real obj_l2_norm(SCIP* const scip, scip::Model& model) noexcept { + SCIP_Real norm = 0.; + + if (SCIPgetStage(scip) >= SCIP_STAGE_TRANSFORMED) { + norm = SCIPgetObjNorm(scip); + } else { + // If too early, this must be done by hand + for (auto* variable : model.variables()) { + norm += std::pow(SCIPvarGetObj(variable), 2); + } + norm = std::sqrt(norm); + } + + return norm > 0 ? norm : 1.; +} + +/** Convert an enum to its underlying index. */ +template constexpr auto idx(E e) { + return static_cast>(e); +} + +template +void set_static_features_for_var( + Features&& out, + SCIP* const scip, + SCIP_VAR* const var, + std::optional obj_norm = {}) { + double const objsense = (SCIPgetObjsense(scip) == SCIP_OBJSENSE_MINIMIZE) ? 1. : -1.; + + out[idx(VariableFeatures::objective)] = objsense * SCIPvarGetObj(var); + if (obj_norm.has_value()) { + out[idx(VariableFeatures::objective)] /= obj_norm.value(); + } + // One-hot enconding of variable type + out[idx(VariableFeatures::is_type_binary)] = 0.; + out[idx(VariableFeatures::is_type_integer)] = 0.; + out[idx(VariableFeatures::is_type_implicit_integer)] = 0.; + out[idx(VariableFeatures::is_type_continuous)] = 0.; + switch (SCIPvarGetType(var)) { + case SCIP_VARTYPE_BINARY: + out[idx(VariableFeatures::is_type_binary)] = 1.; + break; + case SCIP_VARTYPE_INTEGER: + out[idx(VariableFeatures::is_type_integer)] = 1.; + break; + case SCIP_VARTYPE_IMPLINT: + out[idx(VariableFeatures::is_type_implicit_integer)] = 1.; + break; + case SCIP_VARTYPE_CONTINUOUS: + out[idx(VariableFeatures::is_type_continuous)] = 1.; + break; + default: + utility::unreachable(); + } + + auto const lower_bound = SCIPvarGetLbLocal(var); + if (SCIPisInfinity(scip, std::abs(lower_bound))) { + out[idx(VariableFeatures::has_lower_bound)] = 0.; + out[idx(VariableFeatures::lower_bound)] = 0.; + } else { + out[idx(VariableFeatures::has_lower_bound)] = 1.; + out[idx(VariableFeatures::lower_bound)] = lower_bound; + } + + auto const upper_bound = SCIPvarGetUbLocal(var); + if (SCIPisInfinity(scip, std::abs(upper_bound))) { + out[idx(VariableFeatures::has_upper_bound)] = 0.; + out[idx(VariableFeatures::upper_bound)] = 0.; + } else { + out[idx(VariableFeatures::has_upper_bound)] = 1.; + out[idx(VariableFeatures::upper_bound)] = upper_bound; + } +} + +void set_features_for_all_vars(xmatrix& out, scip::Model& model, bool normalize) { + auto* const scip = model.get_scip_ptr(); + + // Contant reused in every iterations + auto const obj_norm = normalize ? std::optional{obj_l2_norm(scip, model)} : std::nullopt; + + auto const variables = model.variables(); + auto const n_vars = variables.size(); + for (std::size_t var_idx = 0; var_idx < n_vars; ++var_idx) { + auto features = xt::row(out, static_cast(var_idx)); + set_static_features_for_var(features, scip, variables[var_idx], obj_norm); + } +} + +/** Convert a xtensor of size (N) into an xtensor of size (N, 1) without copy. */ +template auto vec_to_col(xt::xtensor&& t) -> xt::xtensor { + return xt::xtensor{std::move(t.storage()), {t.size(), 1}, {1, 0}}; +} + +} // namespace + +/************************************* + * Observation extracting function * + *************************************/ + +auto MilpBipartite::extract(scip::Model& model, bool /* done */) const -> std::optional { + if (model.stage() < SCIP_STAGE_SOLVING) { + auto [edge_features, constraint_features] = scip::get_all_constraints(model.get_scip_ptr(), normalize); + + auto variable_features = xmatrix::from_shape({model.variables().size(), MilpBipartiteObs::n_variable_features}); + set_features_for_all_vars(variable_features, model, normalize); + + return MilpBipartiteObs{ + std::move(variable_features), + vec_to_col(std::move(constraint_features)), + std::move(edge_features), + }; + } + return {}; +} + +} // namespace ecole::observation diff --git a/ecole/libecole/src/observation/node-bipartite-candidate.cpp b/ecole/libecole/src/observation/node-bipartite-candidate.cpp new file mode 100644 index 0000000..2a0a755 --- /dev/null +++ b/ecole/libecole/src/observation/node-bipartite-candidate.cpp @@ -0,0 +1,479 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "ecole/observation/node-bipartite-candidate.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/row.hpp" +#include "ecole/utility/unreachable.hpp" + +namespace ecole::observation { + +namespace { + +namespace views = ranges::views; + +/********************* + * Common helpers * + *********************/ + +using xmatrix = decltype(NodeBipartiteCandObs::column_features); +using value_type = xmatrix::value_type; + +using ColumnFeatures = NodeBipartiteCandObs::ColumnFeatures; + +value_type constexpr cste = 5.; +value_type constexpr nan = std::numeric_limits::quiet_NaN(); + +double root_reduced_obj_norm = -1; + +auto row_get_reduced_norm = [](auto const row) { + double row_norm = 0; + auto const row_cols = scip::get_cols(row); + auto const row_values = scip::get_vals(row); + + for (auto const [val, col] : views::zip(row_values, row_cols)) { + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 <= SCIPcolGetUb(col)) //not fixed column! + row_norm += val * val; + } + } + return sqrt(row_norm); +}; + +SCIP_Real obj_l2_norm(SCIP* const scip) noexcept { + auto const norm = SCIPgetObjNorm(scip); + return norm > 0 ? norm : 1.; +} + +SCIP_Real reduced_obj_l2_norm(scip::Model& model) noexcept { + + double reduced_obj_norm = 0.0; + auto const lp_columns = model.lp_columns(); + for (auto* const col : lp_columns) { + + if (SCIPcolGetLb(col) + 0.5 <= SCIPcolGetUb(col)) + reduced_obj_norm += (SCIPcolGetObj(col) * SCIPcolGetObj(col)); + + } + reduced_obj_norm = sqrt(reduced_obj_norm); + return reduced_obj_norm > 0 ? reduced_obj_norm : 1.; +} + + + + +SCIP_Real row_l2_norm(SCIP_ROW* const row) noexcept { + auto const norm = SCIProwGetNorm(row); + return norm > 0 ? norm : 1.; +} + +SCIP_Real reduced_row_l2_norm(SCIP_ROW* const row) noexcept { + auto const norm = row_get_reduced_norm(row); + return norm > 0 ? norm : 1.; +} + +SCIP_Real obj_cos_sim(SCIP* const scip, SCIP_ROW* const row) noexcept { + auto const norm_prod = SCIProwGetNorm(row) * SCIPgetObjNorm(scip); + if (SCIPisPositive(scip, norm_prod)) { + return row->objprod / norm_prod; + } + return 0.; +} + +SCIP_Real reduced_obj_cos_sim(SCIP* const scip, SCIP_ROW* const row, double reduced_obj_norm) noexcept { + + auto const norm_prod = row_get_reduced_norm(row) * reduced_obj_norm; + + double inner_product = 0.0; + + auto const row_values = scip::get_vals(row); + + auto const row_cols = scip::get_cols(row); + + for (auto const [val, col] : views::zip(row_values, row_cols)) { + if(SCIPcolGetLPPos(col) >= 0) { + if (SCIPcolGetLb(col) + 0.5 <= SCIPcolGetUb(col)) //not fixed column! + inner_product += val * SCIPcolGetObj(col); + } + } + + if (SCIPisPositive(scip, norm_prod)) { + return inner_product / norm_prod; + } + return 0.; +} + +/****************************************** + * Column features extraction functions * + ******************************************/ +std::optional upper_bound(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const ub_val = SCIPcolGetUb(col); + if (SCIPisInfinity(scip, std::abs(ub_val))) { + return {}; + } + return ub_val; +} + +std::optional lower_bound(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const lb_val = SCIPcolGetLb(col); + if (SCIPisInfinity(scip, std::abs(lb_val))) { + return {}; + } + return lb_val; +} + +bool is_prim_sol_at_lb(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const lb_val = lower_bound(scip, col); + if (lb_val) { + return SCIPisEQ(scip, SCIPcolGetPrimsol(col), lb_val.value()); + } + return false; +} + +bool is_prim_sol_at_ub(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const ub_val = upper_bound(scip, col); + if (ub_val) { + return SCIPisEQ(scip, SCIPcolGetPrimsol(col), ub_val.value()); + } + return false; +} + +std::optional best_sol_val(SCIP* const scip, SCIP_VAR* const var) noexcept { + auto* const sol = SCIPgetBestSol(scip); + if (sol != nullptr) { + return SCIPgetSolVal(scip, sol, var); + } + return {}; +} + +std::optional avg_sol(SCIP* const scip, SCIP_VAR* const var) noexcept { + if (SCIPgetBestSol(scip) != nullptr) { + return SCIPvarGetAvgSol(var); + } + return {}; +} + +std::optional feas_frac(SCIP* const scip, SCIP_VAR* const var) noexcept { + if (SCIPvarGetType(var) == SCIP_VARTYPE_CONTINUOUS) { + return {}; + } + return SCIPfeasFrac(scip, SCIPvarGetLPSol(var)); +} + +std::optional feas_infeasibility(SCIP* const scip, SCIP_VAR* const var) noexcept { + if (SCIPvarGetType(var) == SCIP_VARTYPE_CONTINUOUS) { + return {}; + } + + return MIN(SCIPvarGetLPSol(var) - SCIPfeasFloor(scip, SCIPvarGetLPSol(var)), SCIPfeasCeil(scip, SCIPvarGetLPSol(var)) - SCIPvarGetLPSol(var)); + +} + +/** Convert an enum to its underlying index. */ +template constexpr auto idx(E e) { + return static_cast>(e); +} + +template +void set_static_features_for_col(Features&& out, SCIP_VAR* const var, SCIP_COL* const col, value_type obj_norm) { + out[idx(ColumnFeatures::objective)] = SCIPcolGetObj(col) / obj_norm; //reduced norm + // On-hot enconding of variable type + out[idx(ColumnFeatures::is_type_binary)] = 0.; + out[idx(ColumnFeatures::is_type_integer)] = 0.; + out[idx(ColumnFeatures::is_type_implicit_integer)] = 0.; + out[idx(ColumnFeatures::is_type_continuous)] = 0.; + switch (SCIPvarGetType(var)) { + case SCIP_VARTYPE_BINARY: + out[idx(ColumnFeatures::is_type_binary)] = 1.; + break; + case SCIP_VARTYPE_INTEGER: + out[idx(ColumnFeatures::is_type_integer)] = 1.; + break; + case SCIP_VARTYPE_IMPLINT: + out[idx(ColumnFeatures::is_type_implicit_integer)] = 1.; + break; + case SCIP_VARTYPE_CONTINUOUS: + out[idx(ColumnFeatures::is_type_continuous)] = 1.; + break; + default: + utility::unreachable(); + } + +} + +template +void set_dynamic_features_for_col( + Features&& out, + SCIP* const scip, + SCIP_VAR* const var, + SCIP_COL* const col, + value_type reduced_obj_norm, + value_type n_lps) { + + out[idx(ColumnFeatures::has_lower_bound)] = static_cast(lower_bound(scip, col).has_value()); + out[idx(ColumnFeatures::has_upper_bound)] = static_cast(upper_bound(scip, col).has_value()); + out[idx(ColumnFeatures::normed_reduced_cost)] = SCIPgetVarRedcost(scip, var) / reduced_obj_norm; + out[idx(ColumnFeatures::solution_value)] = SCIPvarGetLPSol(var); + out[idx(ColumnFeatures::solution_frac)] = feas_frac(scip, var).value_or(0.); + out[idx(ColumnFeatures::is_solution_at_lower_bound)] = static_cast(is_prim_sol_at_lb(scip, col)); + out[idx(ColumnFeatures::is_solution_at_upper_bound)] = static_cast(is_prim_sol_at_ub(scip, col)); + out[idx(ColumnFeatures::scaled_age)] = static_cast(SCIPcolGetAge(col)) / (n_lps + cste); + out[idx(ColumnFeatures::incumbent_value)] = best_sol_val(scip, var).value_or(nan); + out[idx(ColumnFeatures::average_incumbent_value)] = avg_sol(scip, var).value_or(nan); + // On-hot encoding + out[idx(ColumnFeatures::is_basis_lower)] = 0.; + out[idx(ColumnFeatures::is_basis_basic)] = 0.; + out[idx(ColumnFeatures::is_basis_upper)] = 0.; + out[idx(ColumnFeatures::is_basis_zero)] = 0.; + switch (SCIPcolGetBasisStatus(col)) { + case SCIP_BASESTAT_LOWER: + out[idx(ColumnFeatures::is_basis_lower)] = 1.; + break; + case SCIP_BASESTAT_BASIC: + out[idx(ColumnFeatures::is_basis_basic)] = 1.; + break; + case SCIP_BASESTAT_UPPER: + out[idx(ColumnFeatures::is_basis_upper)] = 1.; + break; + case SCIP_BASESTAT_ZERO: + out[idx(ColumnFeatures::is_basis_zero)] = 1.; + break; + default: + utility::unreachable(); + } + + // New features: infeasibility of solution: ex: For 2.8, min(2.8 - 2, 3 - 2.8) = 0.2 + out[idx(ColumnFeatures::solution_infeasibility)] = feas_infeasibility(scip, var).value_or(0.); + + // One-hot encoding + + SCIP_Real* myvals = SCIPcolGetVals(col); + SCIP_ROW** myrows = SCIPcolGetRows(col); + SCIP_ROW* row = nullptr; + int nlprows = SCIPcolGetNLPNonz(col); + + SCIP_Real edge_sum = 0; + SCIP_Real edge_min = SCIPinfinity(scip); + SCIP_Real edge_max = -SCIPinfinity(scip); + SCIP_Real edge_val; + + SCIP_Real bias_sum = 0; + SCIP_Real bias_min = SCIPinfinity(scip); + SCIP_Real bias_max = -SCIPinfinity(scip); + SCIP_Real bias_val; + + SCIP_Real obj_cos_sim_sum = 0; + SCIP_Real obj_cos_sim_min = 1.0; + SCIP_Real obj_cos_sim_max = -1.0; + SCIP_Real obj_cos_sim_val; + + SCIP_Real is_tight_sum = 0; + SCIP_Real is_tight_min = 1.0; + SCIP_Real is_tight_max = -1.0; + SCIP_Real is_tight_val; + + SCIP_Real dual_solution_sum = 0; + SCIP_Real dual_solution_min = SCIPinfinity(scip); + SCIP_Real dual_solution_max = -SCIPinfinity(scip); + SCIP_Real dual_solution_val; + + SCIP_Real scaled_age_sum = 0; + SCIP_Real scaled_age_min = SCIPinfinity(scip); + SCIP_Real scaled_age_max = -SCIPinfinity(scip); + SCIP_Real scaled_age_val; + + int row_counter = 0; + SCIP_Real row_norm; + + for (int row_idx = 0; row_idx < nlprows; ++row_idx) + { + row = myrows[row_idx]; + row_norm = reduced_row_l2_norm(row); + + auto const row_cols = scip::get_cols(row); + auto const row_values = scip::get_vals(row); + double temp_sum = 0; + for (auto const [val, row_col] : views::zip(row_values, row_cols)) { + if(SCIPcolGetLPPos(row_col) >= 0) { + if (SCIPcolGetLb(row_col) + 0.5 > SCIPcolGetUb(row_col)) //fixed column! + temp_sum += val * SCIPcolGetLb(row_col); //value.. + } + } + + if ( scip::get_unshifted_lhs(scip, row).has_value() ) { + + edge_val = -myvals[row_idx] / row_norm; // in [-1,1] + edge_sum += edge_val; + edge_min = MIN(edge_min, edge_val); + edge_max = MAX(edge_max, edge_val); + + bias_val = -1. * (scip::get_unshifted_lhs(scip, row).value() - temp_sum) / row_norm; + bias_sum += bias_val; + bias_min = MIN(bias_min, bias_val); + bias_max = MAX(bias_max, bias_val); + + obj_cos_sim_val = -1 * reduced_obj_cos_sim(scip,row, reduced_obj_norm); + obj_cos_sim_sum += obj_cos_sim_val; + obj_cos_sim_min = MIN(obj_cos_sim_min, obj_cos_sim_val); + obj_cos_sim_max = MAX(obj_cos_sim_max, obj_cos_sim_val); + + is_tight_val = static_cast(scip::is_at_lhs(scip, row)); + is_tight_sum += is_tight_val; + is_tight_min = MIN(is_tight_min, is_tight_val); + is_tight_max = MAX(is_tight_max, is_tight_val); + + dual_solution_val = -1. * SCIProwGetDualsol(row) / (row_norm * reduced_obj_norm); + dual_solution_sum += dual_solution_val; + dual_solution_min = MIN(dual_solution_min, dual_solution_val); + dual_solution_max = MAX(dual_solution_max, dual_solution_val); + + scaled_age_val = static_cast(SCIProwGetAge(row)) / (n_lps + cste); + scaled_age_sum += scaled_age_val; + scaled_age_min = MIN(scaled_age_min, scaled_age_val); + scaled_age_max = MAX(scaled_age_max, scaled_age_val); + + row_counter ++; + + } + if ( scip::get_unshifted_rhs(scip, row).has_value() ) { + + edge_val = myvals[row_idx] / row_norm; // in [-1,1] + edge_sum += edge_val; + edge_min = MIN(edge_min, edge_val); + edge_max = MAX(edge_max, edge_val); + + bias_val = (scip::get_unshifted_rhs(scip, row).value() - temp_sum) / row_norm; + bias_sum += bias_val; + bias_min = MIN(bias_min, bias_val); + bias_max = MAX(bias_max, bias_val); + + obj_cos_sim_val = reduced_obj_cos_sim(scip,row, reduced_obj_norm); + obj_cos_sim_sum += obj_cos_sim_val; + obj_cos_sim_min = MIN(obj_cos_sim_min, obj_cos_sim_val); + obj_cos_sim_max = MAX(obj_cos_sim_max, obj_cos_sim_val); + + is_tight_val = static_cast(scip::is_at_rhs(scip, row)); + is_tight_sum += is_tight_val; + is_tight_min = MIN(is_tight_min, is_tight_val); + is_tight_max = MAX(is_tight_max, is_tight_val); + + dual_solution_val = SCIProwGetDualsol(row) / (row_norm * reduced_obj_norm); + dual_solution_sum += dual_solution_val; + dual_solution_min = MIN(dual_solution_min, dual_solution_val); + dual_solution_max = MAX(dual_solution_max, dual_solution_val); + + scaled_age_val = static_cast(SCIProwGetAge(row)) / (n_lps + cste); + scaled_age_sum += scaled_age_val; + scaled_age_min = MIN(scaled_age_min, scaled_age_val); + scaled_age_max = MAX(scaled_age_max, scaled_age_val); + + row_counter ++; + + } + } + /* + if (out[idx(ColumnFeatures::solution_frac)] > 0) + printf("Edge mean %.2f min %.2f max %.2f \n", edge_sum / row_counter, edge_min, edge_max); + */ + out[idx(ColumnFeatures::edge_mean)] = edge_sum / row_counter; + out[idx(ColumnFeatures::edge_min)] = edge_min; + out[idx(ColumnFeatures::edge_max)] = edge_max; + + out[idx(ColumnFeatures::bias_mean)] = bias_sum / row_counter; + out[idx(ColumnFeatures::bias_min)] = bias_min; + out[idx(ColumnFeatures::bias_max)] = bias_max; + + out[idx(ColumnFeatures::obj_cos_sim_mean)] = obj_cos_sim_sum / row_counter; + out[idx(ColumnFeatures::obj_cos_sim_min)] = obj_cos_sim_min; + out[idx(ColumnFeatures::obj_cos_sim_max)] = obj_cos_sim_max; + + out[idx(ColumnFeatures::is_tight_mean)] = is_tight_sum / row_counter; + out[idx(ColumnFeatures::is_tight_min)] = is_tight_min; + out[idx(ColumnFeatures::is_tight_max)] = is_tight_max; + + out[idx(ColumnFeatures::dual_solution_mean)] = dual_solution_sum / row_counter; + out[idx(ColumnFeatures::dual_solution_min)] = dual_solution_min; + out[idx(ColumnFeatures::dual_solution_max)] = dual_solution_max; + + out[idx(ColumnFeatures::scaled_age_mean)] = scaled_age_sum / row_counter; + out[idx(ColumnFeatures::scaled_age_min)] = scaled_age_min; + out[idx(ColumnFeatures::scaled_age_max)] = scaled_age_max; + +} + +void set_features_for_all_cols(xmatrix& out, scip::Model& model, bool const update_static) { + auto* const scip = model.get_scip_ptr(); + if(SCIPgetCurrentNode(scip) == SCIPgetRootNode(scip)) + { + root_reduced_obj_norm = reduced_obj_l2_norm(model); + } + // Contant reused in every iterations + auto const n_lps = static_cast(SCIPgetNLPs(scip)); + auto reduced_obj_norm = reduced_obj_l2_norm(model); + auto const columns = model.lp_columns(); + auto const n_columns = columns.size(); + for (std::size_t col_idx = 0; col_idx < n_columns; ++col_idx) { + auto* const col = columns[col_idx]; + auto* const var = SCIPcolGetVar(col); + auto features = xt::row(out, static_cast(col_idx)); + if (update_static) { + set_static_features_for_col(features, var, col, root_reduced_obj_norm); + } + set_dynamic_features_for_col(features, scip, var, col, reduced_obj_norm, n_lps); + } +} + +auto is_on_root_node(scip::Model& model) -> bool { + auto* const scip = model.get_scip_ptr(); + return SCIPgetCurrentNode(scip) == SCIPgetRootNode(scip); +} + +auto extract_observation_fully(scip::Model& model) -> NodeBipartiteCandObs { + auto obs = NodeBipartiteCandObs{ + xmatrix::from_shape({model.lp_columns().size(), NodeBipartiteCandObs::n_column_features}), + }; + set_features_for_all_cols(obs.column_features, model, true); + return obs; +} + +auto extract_observation_from_cache(scip::Model& model, NodeBipartiteCandObs obs) -> NodeBipartiteCandObs { + set_features_for_all_cols(obs.column_features, model, false); + return obs; +} + +} // namespace + +/************************************* + * Observation extracting function * + *************************************/ + +auto NodeBipartiteCand::before_reset(scip::Model& /* model */) -> void { + cache_computed = false; +} + +auto NodeBipartiteCand::extract(scip::Model& model, bool /* done */) -> std::optional { + if (model.stage() == SCIP_STAGE_SOLVING) { + if (use_cache) { + if (is_on_root_node(model)) { + the_cache = extract_observation_fully(model); + cache_computed = true; + return the_cache; + } + if (cache_computed) { + return extract_observation_from_cache(model, the_cache); + } + } + return extract_observation_fully(model); + } + return {}; +} + +} // namespace ecole::observation diff --git a/ecole/libecole/src/observation/node-bipartite.cpp b/ecole/libecole/src/observation/node-bipartite.cpp new file mode 100644 index 0000000..e51b7ce --- /dev/null +++ b/ecole/libecole/src/observation/node-bipartite.cpp @@ -0,0 +1,404 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "ecole/observation/node-bipartite.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/row.hpp" +#include "ecole/utility/unreachable.hpp" + +namespace ecole::observation { + +namespace { + +/********************* + * Common helpers * + *********************/ + +using xmatrix = decltype(NodeBipartiteObs::variable_features); +using value_type = xmatrix::value_type; + +using VariableFeatures = NodeBipartiteObs::VariableFeatures; +using RowFeatures = NodeBipartiteObs::RowFeatures; + +value_type constexpr cste = 5.; +value_type constexpr nan = std::numeric_limits::quiet_NaN(); + +SCIP_Real obj_l2_norm(SCIP* const scip) noexcept { + auto const norm = SCIPgetObjNorm(scip); + return norm > 0 ? norm : 1.; +} + +/******************************************* + * Variable features extraction functions * + *******************************************/ + +std::optional upper_bound(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const ub_val = SCIPcolGetUb(col); + if (SCIPisInfinity(scip, std::abs(ub_val))) { + return {}; + } + return ub_val; +} + +std::optional lower_bound(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const lb_val = SCIPcolGetLb(col); + if (SCIPisInfinity(scip, std::abs(lb_val))) { + return {}; + } + return lb_val; +} + +bool is_prim_sol_at_lb(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const lb_val = lower_bound(scip, col); + if (lb_val) { + return SCIPisEQ(scip, SCIPcolGetPrimsol(col), lb_val.value()); + } + return false; +} + +bool is_prim_sol_at_ub(SCIP* const scip, SCIP_COL* const col) noexcept { + auto const ub_val = upper_bound(scip, col); + if (ub_val) { + return SCIPisEQ(scip, SCIPcolGetPrimsol(col), ub_val.value()); + } + return false; +} + +std::optional best_sol_val(SCIP* const scip, SCIP_VAR* const var) noexcept { + auto* const sol = SCIPgetBestSol(scip); + if (sol != nullptr) { + return SCIPgetSolVal(scip, sol, var); + } + return {}; +} + +std::optional avg_sol(SCIP* const scip, SCIP_VAR* const var) noexcept { + if (SCIPgetBestSol(scip) != nullptr) { + return SCIPvarGetAvgSol(var); + } + return {}; +} + +std::optional feas_frac(SCIP* const scip, SCIP_VAR* const var) noexcept { + if (SCIPvarGetType(var) == SCIP_VARTYPE_CONTINUOUS) { + return {}; + } + return SCIPfeasFrac(scip, SCIPvarGetLPSol(var)); +} + +/** Convert an enum to its underlying index. */ +template constexpr auto idx(E e) { + return static_cast>(e); +} + +template +void set_static_features_for_var(Features&& out, SCIP_VAR* const var, value_type obj_norm) { + out[idx(VariableFeatures::objective)] = SCIPvarGetObj(var) / obj_norm; + // On-hot enconding of variable type + out[idx(VariableFeatures::is_type_binary)] = 0.; + out[idx(VariableFeatures::is_type_integer)] = 0.; + out[idx(VariableFeatures::is_type_implicit_integer)] = 0.; + out[idx(VariableFeatures::is_type_continuous)] = 0.; + switch (SCIPvarGetType(var)) { + case SCIP_VARTYPE_BINARY: + out[idx(VariableFeatures::is_type_binary)] = 1.; + break; + case SCIP_VARTYPE_INTEGER: + out[idx(VariableFeatures::is_type_integer)] = 1.; + break; + case SCIP_VARTYPE_IMPLINT: + out[idx(VariableFeatures::is_type_implicit_integer)] = 1.; + break; + case SCIP_VARTYPE_CONTINUOUS: + out[idx(VariableFeatures::is_type_continuous)] = 1.; + break; + default: + utility::unreachable(); + } +} + +template +void set_dynamic_features_for_var( + Features&& out, + SCIP* const scip, + SCIP_VAR* const var, + SCIP_COL* const col, + value_type obj_norm, + value_type n_lps) { + out[idx(VariableFeatures::has_lower_bound)] = static_cast(lower_bound(scip, col).has_value()); + out[idx(VariableFeatures::has_upper_bound)] = static_cast(upper_bound(scip, col).has_value()); + out[idx(VariableFeatures::normed_reduced_cost)] = SCIPgetVarRedcost(scip, var) / obj_norm; + out[idx(VariableFeatures::solution_value)] = SCIPvarGetLPSol(var); + out[idx(VariableFeatures::solution_frac)] = feas_frac(scip, var).value_or(0.); + out[idx(VariableFeatures::is_solution_at_lower_bound)] = static_cast(is_prim_sol_at_lb(scip, col)); + out[idx(VariableFeatures::is_solution_at_upper_bound)] = static_cast(is_prim_sol_at_ub(scip, col)); + out[idx(VariableFeatures::scaled_age)] = static_cast(SCIPcolGetAge(col)) / (n_lps + cste); + out[idx(VariableFeatures::incumbent_value)] = best_sol_val(scip, var).value_or(nan); + out[idx(VariableFeatures::average_incumbent_value)] = avg_sol(scip, var).value_or(nan); + // On-hot encoding + out[idx(VariableFeatures::is_basis_lower)] = 0.; + out[idx(VariableFeatures::is_basis_basic)] = 0.; + out[idx(VariableFeatures::is_basis_upper)] = 0.; + out[idx(VariableFeatures::is_basis_zero)] = 0.; + switch (SCIPcolGetBasisStatus(col)) { + case SCIP_BASESTAT_LOWER: + out[idx(VariableFeatures::is_basis_lower)] = 1.; + break; + case SCIP_BASESTAT_BASIC: + out[idx(VariableFeatures::is_basis_basic)] = 1.; + break; + case SCIP_BASESTAT_UPPER: + out[idx(VariableFeatures::is_basis_upper)] = 1.; + break; + case SCIP_BASESTAT_ZERO: + out[idx(VariableFeatures::is_basis_zero)] = 1.; + break; + default: + utility::unreachable(); + } +} + +void set_features_for_all_vars(xmatrix& out, scip::Model& model, bool const update_static) { + auto* const scip = model.get_scip_ptr(); + + // Contant reused in every iterations + auto const n_lps = static_cast(SCIPgetNLPs(scip)); + auto const obj_norm = obj_l2_norm(scip); + + auto const variables = model.variables(); + auto const n_vars = variables.size(); + for (std::size_t var_idx = 0; var_idx < n_vars; ++var_idx) { + auto* const var = variables[var_idx]; + auto* const col = SCIPvarGetCol(var); + auto features = xt::row(out, static_cast(var_idx)); + if (update_static) { + set_static_features_for_var(features, var, obj_norm); + } + set_dynamic_features_for_var(features, scip, var, col, obj_norm, n_lps); + } +} + +/*************************************** + * Row features extraction functions * + ***************************************/ + +SCIP_Real row_l2_norm(SCIP_ROW* const row) noexcept { + auto const norm = SCIProwGetNorm(row); + return norm > 0 ? norm : 1.; +} + +SCIP_Real obj_cos_sim(SCIP* const scip, SCIP_ROW* const row) noexcept { + auto const norm_prod = SCIProwGetNorm(row) * SCIPgetObjNorm(scip); + if (SCIPisPositive(scip, norm_prod)) { + return row->objprod / norm_prod; + } + return 0.; +} + +/** + * Number of inequality rows. + * + * Row are counted once per right hand side and once per left hand side. + */ +std::size_t n_ineq_rows(scip::Model& model) { + auto* const scip = model.get_scip_ptr(); + std::size_t count = 0; + for (auto* row : model.lp_rows()) { + count += static_cast(scip::get_unshifted_lhs(scip, row).has_value()); + count += static_cast(scip::get_unshifted_rhs(scip, row).has_value()); + } + return count; +} + +template +void set_static_features_for_lhs_row(Features&& out, SCIP* const scip, SCIP_ROW* const row, value_type row_norm) { + out[idx(RowFeatures::bias)] = -1. * scip::get_unshifted_lhs(scip, row).value() / row_norm; + out[idx(RowFeatures::objective_cosine_similarity)] = -1 * obj_cos_sim(scip, row); +} + +template +void set_static_features_for_rhs_row(Features&& out, SCIP* const scip, SCIP_ROW* const row, value_type row_norm) { + out[idx(RowFeatures::bias)] = scip::get_unshifted_rhs(scip, row).value() / row_norm; + out[idx(RowFeatures::objective_cosine_similarity)] = obj_cos_sim(scip, row); +} + +template +void set_dynamic_features_for_lhs_row( + Features&& out, + SCIP* const scip, + SCIP_ROW* const row, + value_type row_norm, + value_type obj_norm, + value_type n_lps) { + out[idx(RowFeatures::is_tight)] = static_cast(scip::is_at_lhs(scip, row)); + out[idx(RowFeatures::dual_solution_value)] = -1. * SCIProwGetDualsol(row) / (row_norm * obj_norm); + out[idx(RowFeatures::scaled_age)] = static_cast(SCIProwGetAge(row)) / (n_lps + cste); +} + +template +void set_dynamic_features_for_rhs_row( + Features&& out, + SCIP* const scip, + SCIP_ROW* const row, + value_type row_norm, + value_type obj_norm, + value_type n_lps) { + out[idx(RowFeatures::is_tight)] = static_cast(scip::is_at_rhs(scip, row)); + out[idx(RowFeatures::dual_solution_value)] = SCIProwGetDualsol(row) / (row_norm * obj_norm); + out[idx(RowFeatures::scaled_age)] = static_cast(SCIProwGetAge(row)) / (n_lps + cste); +} + +auto set_features_for_all_rows(xmatrix& out, scip::Model& model, bool const update_static) { + auto* const scip = model.get_scip_ptr(); + + auto const n_lps = static_cast(SCIPgetNLPs(scip)); + value_type const obj_norm = obj_l2_norm(scip); + + auto feat_row_idx = std::size_t{0}; + for (auto* const row : model.lp_rows()) { + auto const row_norm = static_cast(row_l2_norm(row)); + + // Rows are counted once per rhs and once per lhs + if (scip::get_unshifted_lhs(scip, row).has_value()) { + auto features = xt::row(out, static_cast(feat_row_idx)); + if (update_static) { + set_static_features_for_lhs_row(features, scip, row, row_norm); + } + set_dynamic_features_for_lhs_row(features, scip, row, row_norm, obj_norm, n_lps); + feat_row_idx++; + } + if (scip::get_unshifted_rhs(scip, row).has_value()) { + auto features = xt::row(out, static_cast(feat_row_idx)); + if (update_static) { + set_static_features_for_rhs_row(features, scip, row, row_norm); + } + set_dynamic_features_for_rhs_row(features, scip, row, row_norm, obj_norm, n_lps); + feat_row_idx++; + } + } + assert(feat_row_idx == n_ineq_rows(model)); +} + +/**************************************** + * Edge features extraction functions * + ****************************************/ + +/** + * Number of non zero element in the constraint matrix. + * + * Row are counted once per right hand side and once per left hand side. + */ +auto matrix_nnz(scip::Model& model) { + auto* const scip = model.get_scip_ptr(); + std::size_t nnz = 0; + for (auto* row : model.lp_rows()) { + auto const row_size = static_cast(SCIProwGetNLPNonz(row)); + if (scip::get_unshifted_lhs(scip, row).has_value()) { + nnz += row_size; + } + if (scip::get_unshifted_rhs(scip, row).has_value()) { + nnz += row_size; + } + } + return nnz; +} + +utility::coo_matrix extract_edge_features(scip::Model& model) { + auto* const scip = model.get_scip_ptr(); + + using coo_matrix = utility::coo_matrix; + auto const nnz = matrix_nnz(model); + auto values = decltype(coo_matrix::values)::from_shape({nnz}); + auto indices = decltype(coo_matrix::indices)::from_shape({2, nnz}); + + std::size_t i = 0; + std::size_t j = 0; + for (auto* const row : model.lp_rows()) { + auto const row_norm = static_cast(row_l2_norm(row)); + auto* const row_cols = SCIProwGetCols(row); + auto const* const row_vals = SCIProwGetVals(row); + auto const row_nnz = static_cast(SCIProwGetNLPNonz(row)); + if (scip::get_unshifted_lhs(scip, row).has_value()) { + for (std::size_t k = 0; k < row_nnz; ++k) { + indices(0, j + k) = i; + indices(1, j + k) = static_cast(SCIPcolGetVarProbindex(row_cols[k])); + values[j + k] = -row_vals[k] / row_norm; + } + j += row_nnz; + i++; + } + if (scip::get_unshifted_rhs(scip, row).has_value()) { + for (std::size_t k = 0; k < row_nnz; ++k) { + indices(0, j + k) = i; + indices(1, j + k) = static_cast(SCIPcolGetVarProbindex(row_cols[k])); + values[j + k] = row_vals[k] / row_norm; + } + j += row_nnz; + i++; + } + } + + auto const n_rows = n_ineq_rows(model); + // Change this here for variables + auto const n_vars = static_cast(SCIPgetNVars(scip)); + return {values, indices, {n_rows, n_vars}}; +} + +auto is_on_root_node(scip::Model& model) -> bool { + auto* const scip = model.get_scip_ptr(); + return SCIPgetCurrentNode(scip) == SCIPgetRootNode(scip); +} + +auto extract_observation_fully(scip::Model& model) -> NodeBipartiteObs { + auto obs = NodeBipartiteObs{ + // Change this here for variables + xmatrix::from_shape({model.variables().size(), NodeBipartiteObs::n_variable_features}), + xmatrix::from_shape({n_ineq_rows(model), NodeBipartiteObs::n_row_features}), + extract_edge_features(model), + }; + set_features_for_all_vars(obs.variable_features, model, true); + set_features_for_all_rows(obs.row_features, model, true); + return obs; +} + +auto extract_observation_from_cache(scip::Model& model, NodeBipartiteObs obs) -> NodeBipartiteObs { + set_features_for_all_vars(obs.variable_features, model, false); + set_features_for_all_rows(obs.row_features, model, false); + return obs; +} + +} // namespace + +/************************************* + * Observation extracting function * + *************************************/ + +auto NodeBipartite::before_reset(scip::Model& /* model */) -> void { + cache_computed = false; +} + +auto NodeBipartite::extract(scip::Model& model, bool /* done */) -> std::optional { + if (model.stage() == SCIP_STAGE_SOLVING) { + if (use_cache) { + if (is_on_root_node(model)) { + the_cache = extract_observation_fully(model); + cache_computed = true; + return the_cache; + } + if (cache_computed) { + return extract_observation_from_cache(model, the_cache); + } + } + return extract_observation_fully(model); + } + return {}; +} + +} // namespace ecole::observation diff --git a/ecole/libecole/src/observation/pseudocosts.cpp b/ecole/libecole/src/observation/pseudocosts.cpp new file mode 100644 index 0000000..04b3304 --- /dev/null +++ b/ecole/libecole/src/observation/pseudocosts.cpp @@ -0,0 +1,54 @@ +#include +#include +#include + +#include +#include +#include +#include + +#include "ecole/observation/pseudocosts.hpp" +#include "ecole/scip/model.hpp" + +namespace ecole::observation { + +namespace views = ranges::views; + +namespace { + +/** Get LP branching candidates variables and LP solution values. */ +auto scip_get_lp_branch_cands(SCIP* const scip) noexcept { + SCIP_VAR** cands = nullptr; + SCIP_Real* cands_lp_values = nullptr; + int n_cands = 0; + SCIPgetLPBranchCands(scip, &cands, &cands_lp_values, nullptr, nullptr, &n_cands, nullptr); + return std::tuple{ + nonstd::span{cands, static_cast(n_cands)}, + nonstd::span{cands_lp_values, static_cast(n_cands)}, + }; +} + +} // namespace + +std::optional> Pseudocosts::extract(scip::Model& model, bool /* done */) { + if (model.stage() != SCIP_STAGE_SOLVING) { + return {}; + } + + auto* const scip = model.get_scip_ptr(); + auto const [cands, lp_values] = scip_get_lp_branch_cands(scip); + + /* Store pseudocosts in tensor */ + auto const nb_vars = static_cast(SCIPgetNVars(scip)); + xt::xtensor pseudocosts({nb_vars}, std::nan("")); + + for (auto const [var, lp_val] : views::zip(cands, lp_values)) { + auto const var_index = static_cast(SCIPvarGetProbindex(var)); + auto const score = SCIPgetVarPseudocostScore(scip, var, lp_val); + pseudocosts[var_index] = static_cast(score); + } + + return pseudocosts; +} + +} // namespace ecole::observation diff --git a/ecole/libecole/src/observation/strong-branching-scores.cpp b/ecole/libecole/src/observation/strong-branching-scores.cpp new file mode 100644 index 0000000..c5e0c21 --- /dev/null +++ b/ecole/libecole/src/observation/strong-branching-scores.cpp @@ -0,0 +1,82 @@ +#include +#include + +#include +#include +#include +#include + +#include "ecole/observation/strong-branching-scores.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/utils.hpp" + +namespace ecole::observation { + +namespace views = ranges::views; + +namespace { + +/** get vanilla full strong branching scores and variables */ +auto scip_get_vanillafullstrong_data(SCIP* const scip) noexcept { + SCIP_VAR** cands = nullptr; + SCIP_Real* cands_scores = nullptr; + int n_cands = 0; + SCIPgetVanillafullstrongData(scip, &cands, &cands_scores, &n_cands, nullptr, nullptr); + return std::tuple{ + nonstd::span{cands, static_cast(n_cands)}, + nonstd::span{cands_scores, static_cast(n_cands)}, + }; +} + +} // namespace + +StrongBranchingScores::StrongBranchingScores(bool pseudo_candidates_) : pseudo_candidates(pseudo_candidates_) {} + +std::optional> StrongBranchingScores::extract(scip::Model& model, bool /* done */) const { + if (model.stage() != SCIP_STAGE_SOLVING) { + return {}; + } + + auto* const scip = model.get_scip_ptr(); + + /* store original SCIP parameters */ + auto const integralcands = model.get_param("branching/vanillafullstrong/integralcands"); + auto const scoreall = model.get_param("branching/vanillafullstrong/scoreall"); + auto const collectscores = model.get_param("branching/vanillafullstrong/collectscores"); + auto const donotbranch = model.get_param("branching/vanillafullstrong/donotbranch"); + auto const idempotent = model.get_param("branching/vanillafullstrong/idempotent"); + + /* set parameters for vanilla full strong branching */ + model.set_param("branching/vanillafullstrong/integralcands", pseudo_candidates); + model.set_param("branching/vanillafullstrong/scoreall", true); + model.set_param("branching/vanillafullstrong/collectscores", true); + model.set_param("branching/vanillafullstrong/donotbranch", true); + model.set_param("branching/vanillafullstrong/idempotent", true); + + /* execute vanilla full strong branching */ + auto* branchrule = SCIPfindBranchrule(scip, "vanillafullstrong"); + SCIP_RESULT result; + scip::call(branchrule->branchexeclp, scip, branchrule, false, &result); + assert(result == SCIP_DIDNOTRUN); + auto const [cands, cands_scores] = scip_get_vanillafullstrong_data(scip); + + /* restore model parameters */ + model.set_param("branching/vanillafullstrong/integralcands", integralcands); + model.set_param("branching/vanillafullstrong/scoreall", scoreall); + model.set_param("branching/vanillafullstrong/collectscores", collectscores); + model.set_param("branching/vanillafullstrong/donotbranch", donotbranch); + model.set_param("branching/vanillafullstrong/idempotent", idempotent); + + /* Store strong branching scores in tensor */ + auto const nb_vars = static_cast(SCIPgetNVars(scip)); + auto strong_branching_scores = xt::xtensor({nb_vars}, std::nan("")); + + for (auto const [var, score] : views::zip(cands, cands_scores)) { + auto const var_index = static_cast(SCIPvarGetProbindex(var)); + strong_branching_scores[var_index] = static_cast(score); + } + + return strong_branching_scores; +} + +} // namespace ecole::observation diff --git a/ecole/libecole/src/random.cpp b/ecole/libecole/src/random.cpp new file mode 100644 index 0000000..14199da --- /dev/null +++ b/ecole/libecole/src/random.cpp @@ -0,0 +1,84 @@ +#include +#include +#include + +#include "ecole/random.hpp" + +namespace ecole { +namespace { + +class RandomGeneratorManager { +public: + static auto get() -> RandomGeneratorManager&; + + auto seed(Seed val) -> void; + auto spawn() -> RandomGenerator; + +private: + std::mutex m; + Seed user_seed = 0; + Seed spawn_seed = 0; + + RandomGeneratorManager(); + + auto new_seed_seq() -> std::seed_seq; +}; + +} // namespace + +auto seed(Seed val) -> void { + RandomGeneratorManager::get().seed(val); +} + +auto spawn_random_generator() -> RandomGenerator { + return RandomGeneratorManager::get().spawn(); +} + +// Not efficient, but operator<< is the only thing we have +auto serialize(RandomGenerator const& rng) -> std::string { + auto osstream = std::ostringstream{}; + osstream.imbue(std::locale("C")); + osstream << rng; + return std::move(osstream).str(); +} + +// Not efficient, but operator>> is the only thing we have +auto deserialize(std::string const& data) -> RandomGenerator { + auto rng = RandomGenerator{}; // NOLINT need not be seeded since we set its state + auto isstream = std::istringstream{data}; + isstream.imbue(std::locale("C")); + std::move(isstream) >> rng; + return rng; +} + +/******************************************* + * Implementation of RandomGeneratorManager * + *******************************************/ + +namespace { + +auto RandomGeneratorManager::get() -> RandomGeneratorManager& { + static auto rng = RandomGeneratorManager{}; + return rng; +} + +auto RandomGeneratorManager::seed(Seed val) -> void { + auto const lk = std::unique_lock{m}; + user_seed = val; + spawn_seed = 0; +} + +auto RandomGeneratorManager::spawn() -> RandomGenerator { + auto seeds = new_seed_seq(); + return RandomGenerator{seeds}; +} + +RandomGeneratorManager::RandomGeneratorManager() : user_seed{std::random_device{}()} {} + +auto RandomGeneratorManager::new_seed_seq() -> std::seed_seq { + auto const lk = std::unique_lock{m}; + return {user_seed, ++spawn_seed}; +} + +} // namespace +} // namespace ecole diff --git a/ecole/libecole/src/reward/bound-integral.cpp b/ecole/libecole/src/reward/bound-integral.cpp new file mode 100644 index 0000000..b0d8245 --- /dev/null +++ b/ecole/libecole/src/reward/bound-integral.cpp @@ -0,0 +1,352 @@ +#include +#include +#include +#include + +#include "scip/scip.h" +#include "scip/type_event.h" +#include + +#include "ecole/reward/bound-integral.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/utils.hpp" +#include "ecole/utility/chrono.hpp" + +namespace ecole::reward { + +namespace { + +/***************************************** + * Declaration of IntegralEventHanlder * + *****************************************/ + +class IntegralEventHandler : public ::scip::ObjEventhdlr { +public: + inline static auto constexpr base_name = "ecole::reward::IntegralEventHandler"; + inline static auto integral_reward_function_counter = 0; + + IntegralEventHandler(SCIP* scip, bool wall_, bool extract_primal_, bool extract_dual_, const char* name_) : + ObjEventhdlr(scip, name_, "Event handler for primal and dual integrals"), + wall{wall_}, + extract_primal{extract_primal_}, + extract_dual{extract_dual_} {} + + ~IntegralEventHandler() override = default; + + [[nodiscard]] std::vector const& get_times() const noexcept { return times; } + [[nodiscard]] std::vector const& get_primal_bounds() const noexcept { return primal_bounds; } + [[nodiscard]] std::vector const& get_dual_bounds() const noexcept { return dual_bounds; } + + /** Catch primal and dual related events. */ + SCIP_RETCODE scip_init(SCIP* scip, SCIP_EVENTHDLR* eventhdlr) override; + /** Drop primal and dual related events. */ + SCIP_RETCODE scip_exit(SCIP* scip, SCIP_EVENTHDLR* eventhdlr) override; + /* Call extract_metrics() to obtain bounds/times at events. */ + SCIP_RETCODE scip_exec(SCIP* scip, SCIP_EVENTHDLR* eventhdlr, SCIP_EVENT* event, SCIP_EVENTDATA* eventdata) override; + + /** Get and adds primal/dual bounds and times to vectors. */ + void extract_metrics(SCIP* scip, SCIP_EVENTTYPE event_type = 0); + void clear_bounds(); + +private: + bool wall; + bool extract_primal; + bool extract_dual; + std::vector times; + std::vector primal_bounds; + std::vector dual_bounds; +}; + +/******************************************** + * Implementation of IntegralEventHanlder * + ********************************************/ + +auto IntegralEventHandler::scip_init(SCIP* scip, SCIP_EVENTHDLR* eventhdlr) -> SCIP_RETCODE { + if (extract_primal) { + SCIP_CALL(SCIPcatchEvent(scip, SCIP_EVENTTYPE_BESTSOLFOUND, eventhdlr, nullptr, nullptr)); + } + if (extract_dual) { + SCIP_CALL(SCIPcatchEvent(scip, SCIP_EVENTTYPE_LPEVENT, eventhdlr, nullptr, nullptr)); + } + return SCIP_OKAY; +} + +auto IntegralEventHandler::scip_exit(SCIP* scip, SCIP_EVENTHDLR* eventhdlr) -> SCIP_RETCODE { + if (extract_primal) { + SCIP_CALL(SCIPdropEvent(scip, SCIP_EVENTTYPE_BESTSOLFOUND, eventhdlr, nullptr, -1)); + } + if (extract_dual) { + SCIP_CALL(SCIPdropEvent(scip, SCIP_EVENTTYPE_LPEVENT, eventhdlr, nullptr, -1)); + } + return SCIP_OKAY; +} + +auto IntegralEventHandler::scip_exec( + SCIP* scip, + SCIP_EVENTHDLR* /*eventhdlr*/, + SCIP_EVENT* event, + SCIP_EVENTDATA* /*eventdata*/) -> SCIP_RETCODE { + extract_metrics(scip, SCIPeventGetType(event)); + return SCIP_OKAY; +} + +/* Get the primal bound of the scip model */ +auto get_primal_bound(SCIP* scip) { + switch (SCIPgetStage(scip)) { + case SCIP_STAGE_TRANSFORMED: + case SCIP_STAGE_INITPRESOLVE: + case SCIP_STAGE_PRESOLVING: + case SCIP_STAGE_EXITPRESOLVE: + case SCIP_STAGE_PRESOLVED: + case SCIP_STAGE_INITSOLVE: + case SCIP_STAGE_SOLVING: + case SCIP_STAGE_SOLVED: + return SCIPgetPrimalbound(scip); + default: + return SCIPgetObjlimit(scip); + } +} + +/* Get the dual bound of the scip model */ +auto get_dual_bound(SCIP* scip) { + switch (SCIPgetStage(scip)) { + case SCIP_STAGE_TRANSFORMED: + case SCIP_STAGE_INITPRESOLVE: + case SCIP_STAGE_PRESOLVING: + case SCIP_STAGE_EXITPRESOLVE: + case SCIP_STAGE_PRESOLVED: + case SCIP_STAGE_INITSOLVE: + case SCIP_STAGE_SOLVING: + case SCIP_STAGE_SOLVED: + return SCIPgetDualbound(scip); + default: + if (SCIPgetObjsense(scip) == SCIP_OBJSENSE_MINIMIZE) { + return -SCIPinfinity(scip); + } + return SCIPinfinity(scip); + } +} + +auto time_now(bool wall) -> std::chrono::nanoseconds { + if (wall) { + return std::chrono::steady_clock::now().time_since_epoch(); + } + return utility::cpu_clock::now().time_since_epoch(); +} + +auto is_lp_event(SCIP_EVENTTYPE event) { + return event & SCIP_EVENTTYPE_LPEVENT; +} + +auto is_bestsol_event(SCIP_EVENTTYPE event) { + return event & SCIP_EVENTTYPE_BESTSOLFOUND; +} + +void IntegralEventHandler::extract_metrics(SCIP* scip, SCIP_EVENTTYPE event_type) { + if (extract_primal) { + if ((is_bestsol_event(event_type)) || (primal_bounds.empty())) { + primal_bounds.push_back(get_primal_bound(scip)); + } else { + primal_bounds.push_back(primal_bounds.back()); + } + } + if (extract_dual) { + if ((is_lp_event(event_type)) || (dual_bounds.empty())) { + dual_bounds.push_back(get_dual_bound(scip)); + } else { + dual_bounds.push_back(dual_bounds.back()); + } + } + times.push_back(time_now(wall)); +} + +void IntegralEventHandler::clear_bounds() { + if (extract_dual) { + auto last_dual = dual_bounds.back(); + dual_bounds.clear(); + dual_bounds.push_back(last_dual); + } + if (extract_primal) { + auto last_primal = primal_bounds.back(); + primal_bounds.clear(); + primal_bounds.push_back(last_primal); + } + auto last_time = times.back(); + times.clear(); + times.push_back(last_time); +} + +/************************************* + * Implementation of BoundIntegral * + *************************************/ + +auto compute_dual_integral( + std::vector const& dual_bounds, + std::vector const& times, + SCIP_Real const offset, + SCIP_Real const initial_dual_bound, + SCIP_Objsense obj_sense) { + SCIP_Real dual_integral = 0.0; + for (std::size_t i = 0; i < dual_bounds.size() - 1; ++i) { + auto const time_diff = std::chrono::duration(times[i + 1] - times[i]).count(); + auto const dual_bound = dual_bounds[i]; + if (obj_sense == SCIP_OBJSENSE_MINIMIZE) { + dual_integral += (offset - std::max(dual_bound, initial_dual_bound)) * time_diff; + } else { + dual_integral += -(offset - std::min(dual_bound, initial_dual_bound)) * time_diff; + } + } + return dual_integral; +} + +auto compute_primal_integral( + std::vector const& primal_bounds, + std::vector const& times, + SCIP_Real const offset, + SCIP_Real const initial_primal_bound, + SCIP_Objsense obj_sense) { + SCIP_Real primal_integral = 0.0; + for (std::size_t i = 0; i < primal_bounds.size() - 1; ++i) { + auto const time_diff = std::chrono::duration(times[i + 1] - times[i]).count(); + auto const primal_bound = primal_bounds[i]; + if (obj_sense == SCIP_OBJSENSE_MINIMIZE) { + primal_integral += -(offset - std::min(primal_bound, initial_primal_bound)) * time_diff; + } else { + primal_integral += (offset - std::max(primal_bound, initial_primal_bound)) * time_diff; + } + } + return primal_integral; +} + +auto compute_primal_dual_integral( + std::vector const& primal_bounds, + std::vector const& dual_bounds, + std::vector const& times, + SCIP_Real const initial_primal_bound, + SCIP_Real const initial_dual_bound, + SCIP_Objsense obj_sense) { + SCIP_Real primal_dual_integral = 0.0; + + for (std::size_t i = 0; i < primal_bounds.size() - 1; ++i) { + auto const time_diff = std::chrono::duration(times[i + 1] - times[i]).count(); + auto const dual_bound = dual_bounds[i]; + auto const primal_bound = primal_bounds[i]; + if (obj_sense == SCIP_OBJSENSE_MINIMIZE) { + primal_dual_integral += + -(std::max(dual_bound, initial_dual_bound) - std::min(primal_bound, initial_primal_bound)) * time_diff; + } else { + primal_dual_integral += + (std::min(dual_bound, initial_dual_bound) - std::max(primal_bound, initial_primal_bound)) * time_diff; + } + } + return primal_dual_integral; +} + +/** Return the integral event handler */ +auto get_eventhdlr(scip::Model& model, const char* name) -> auto& { + auto* const base_handler = SCIPfindObjEventhdlr(model.get_scip_ptr(), name); + assert(base_handler != nullptr); + auto* const handler = dynamic_cast(base_handler); + assert(handler != nullptr); + return *handler; +} + +/** Add the integral event handler to the model. */ +void add_eventhdlr(scip::Model& model, bool wall, bool extract_primal, bool extract_dual, const char* name) { + auto handler = std::make_unique(model.get_scip_ptr(), wall, extract_primal, extract_dual, name); + scip::call(SCIPincludeObjEventhdlr, model.get_scip_ptr(), handler.get(), true); + // NOLINTNEXTLINE memory ownership is passed to SCIP + handler.release(); + // NOLINTNEXTLINE memory ownership is passed to SCIP +} + +/** Default function for returning +/-infinity for the bounds in computing primal-dual integral. */ +auto default_dual_bound_function(scip::Model& model) -> std::tuple { + if (SCIPgetObjsense(model.get_scip_ptr()) == SCIP_OBJSENSE_MINIMIZE) { + return {0.0, -SCIPinfinity(model.get_scip_ptr())}; + } + return {0.0, SCIPinfinity(model.get_scip_ptr())}; +} + +/** Default function for returning +/-infinity for the bounds in computing primal-dual integral. */ +auto default_primal_bound_function(scip::Model& model) -> std::tuple { + if (SCIPgetObjsense(model.get_scip_ptr()) == SCIP_OBJSENSE_MINIMIZE) { + return {0.0, SCIPinfinity(model.get_scip_ptr())}; + } + return {0.0, -SCIPinfinity(model.get_scip_ptr())}; +} + +/** Default function for returning +/-infinity for the bounds in computing primal-dual integral. */ +auto default_primal_dual_bound_function(scip::Model& model) -> std::tuple { + if (SCIPgetObjsense(model.get_scip_ptr()) == SCIP_OBJSENSE_MINIMIZE) { + return {SCIPinfinity(model.get_scip_ptr()), -SCIPinfinity(model.get_scip_ptr())}; + } + return {-SCIPinfinity(model.get_scip_ptr()), SCIPinfinity(model.get_scip_ptr())}; +} + +} // namespace + +template +ecole::reward::BoundIntegral::BoundIntegral(bool wall_, const BoundFunction& bound_function_) : wall{wall_} { + if constexpr (bound == Bound::dual) { + bound_function = bound_function_ ? bound_function_ : default_dual_bound_function; + } else if constexpr (bound == Bound::primal) { + bound_function = bound_function_ ? bound_function_ : default_primal_bound_function; + } else if constexpr (bound == Bound::primal_dual) { + bound_function = bound_function_ ? bound_function_ : default_primal_dual_bound_function; + } + + static auto m = std::mutex{}; + auto g = std::lock_guard{m}; + name = IntegralEventHandler::base_name + std::to_string(IntegralEventHandler::integral_reward_function_counter); + IntegralEventHandler::integral_reward_function_counter++; +} + +template void BoundIntegral::before_reset(scip::Model& model) { + // Initalize bounds and event handler + if constexpr (bound == Bound::dual) { + std::tie(offset, initial_dual_bound) = bound_function(model); + add_eventhdlr(model, wall, false, true, name.c_str()); + } else if constexpr (bound == Bound::primal) { + std::tie(offset, initial_primal_bound) = bound_function(model); + add_eventhdlr(model, wall, true, false, name.c_str()); + } else if constexpr (bound == Bound::primal_dual) { + std::tie(initial_primal_bound, initial_dual_bound) = bound_function(model); + add_eventhdlr(model, wall, true, true, name.c_str()); + } + + // Extract metrics before resetting to get initial reference point + get_eventhdlr(model, name.c_str()).extract_metrics(model.get_scip_ptr()); +} + +template Reward BoundIntegral::extract(scip::Model& model, bool /*done*/) { + // Get info from event handler + auto& handler = get_eventhdlr(model, name.c_str()); + handler.extract_metrics(model.get_scip_ptr()); + + auto const& dual_bounds = handler.get_dual_bounds(); + auto const& primal_bounds = handler.get_primal_bounds(); + auto const& times = handler.get_times(); + auto const obj_sense = SCIPgetObjsense(model.get_scip_ptr()); + + // Compute primal integral and difference + SCIP_Real integral = 0.; + if constexpr (bound == Bound::dual) { + integral = compute_dual_integral(dual_bounds, times, offset, initial_dual_bound, obj_sense); + } else if constexpr (bound == Bound::primal) { + integral = compute_primal_integral(primal_bounds, times, offset, initial_primal_bound, obj_sense); + } else if constexpr (bound == Bound::primal_dual) { + integral = compute_primal_dual_integral( + primal_bounds, dual_bounds, times, initial_primal_bound, initial_dual_bound, obj_sense); + } + + // Reset arrays for storing bounds + handler.clear_bounds(); + return static_cast(integral); +} + +template class BoundIntegral; +template class ecole::reward::BoundIntegral; +template class ecole::reward::BoundIntegral; + +} // namespace ecole::reward diff --git a/ecole/libecole/src/reward/is-done.cpp b/ecole/libecole/src/reward/is-done.cpp new file mode 100644 index 0000000..e27139a --- /dev/null +++ b/ecole/libecole/src/reward/is-done.cpp @@ -0,0 +1,9 @@ +#include "ecole/reward/is-done.hpp" + +namespace ecole::reward { + +Reward IsDone::extract(scip::Model& /*model*/, bool done) { + return done ? 1 : 0; +} + +} // namespace ecole::reward diff --git a/ecole/libecole/src/reward/lp-iterations.cpp b/ecole/libecole/src/reward/lp-iterations.cpp new file mode 100644 index 0000000..85f5501 --- /dev/null +++ b/ecole/libecole/src/reward/lp-iterations.cpp @@ -0,0 +1,33 @@ +#include "ecole/reward/lp-iterations.hpp" +#include "ecole/scip/model.hpp" + +namespace ecole::reward { + +namespace { + +auto n_lp_iterations(scip::Model& model) -> std::uint64_t { + switch (model.stage()) { + // Only stages when the following call is authorized + case SCIP_STAGE_PRESOLVING: + case SCIP_STAGE_PRESOLVED: + case SCIP_STAGE_SOLVING: + case SCIP_STAGE_SOLVED: + return static_cast(SCIPgetNLPIterations(model.get_scip_ptr())); + default: + return 0; + } +} + +} // namespace + +void LpIterations::before_reset(scip::Model& /*unused*/) { + last_lp_iter = 0; +} + +Reward LpIterations::extract(scip::Model& model, bool /* done */) { + auto lp_iter_diff = n_lp_iterations(model) - last_lp_iter; + last_lp_iter += lp_iter_diff; + return static_cast(lp_iter_diff); +} + +} // namespace ecole::reward diff --git a/ecole/libecole/src/reward/n-nodes.cpp b/ecole/libecole/src/reward/n-nodes.cpp new file mode 100644 index 0000000..e6ad650 --- /dev/null +++ b/ecole/libecole/src/reward/n-nodes.cpp @@ -0,0 +1,17 @@ +#include "ecole/reward/n-nodes.hpp" + +#include "ecole/scip/model.hpp" + +namespace ecole::reward { + +void NNodes::before_reset(scip::Model& /* model */) { + last_n_nodes = 0; +} + +Reward NNodes::extract(scip::Model& model, bool /* done */) { + auto n_nodes_diff = static_cast(SCIPgetNTotalNodes(model.get_scip_ptr())) - last_n_nodes; + last_n_nodes += n_nodes_diff; + return static_cast(n_nodes_diff); +} + +} // namespace ecole::reward diff --git a/ecole/libecole/src/reward/solving-time.cpp b/ecole/libecole/src/reward/solving-time.cpp new file mode 100644 index 0000000..2775235 --- /dev/null +++ b/ecole/libecole/src/reward/solving-time.cpp @@ -0,0 +1,31 @@ +#include + +#include "ecole/reward/solving-time.hpp" +#include "ecole/utility/chrono.hpp" + +namespace ecole::reward { + +namespace { + +auto time_now(bool wall) -> std::chrono::nanoseconds { + if (wall) { + return std::chrono::steady_clock::now().time_since_epoch(); + } + return utility::cpu_clock::now().time_since_epoch(); +} + +} // namespace + +void SolvingTime::before_reset(scip::Model& /* model */) { + solving_time_offset = time_now(wall); +} + +Reward SolvingTime::extract(scip::Model& /* model */, bool /* done */) { + auto const now = time_now(wall); + // Casting to seconds represented as a Reward (no ratio). + auto const solving_time_diff = std::chrono::duration{now - solving_time_offset}.count(); + solving_time_offset = now; + return solving_time_diff; +} + +} // namespace ecole::reward diff --git a/ecole/libecole/src/scip/col.cpp b/ecole/libecole/src/scip/col.cpp new file mode 100644 index 0000000..ecbc7e0 --- /dev/null +++ b/ecole/libecole/src/scip/col.cpp @@ -0,0 +1,23 @@ +#include "ecole/scip/col.hpp" + +namespace ecole::scip { + +namespace { + +template auto not_const(T const* ptr) noexcept -> T* { + return const_cast(ptr); +} + +} // namespace + +auto get_rows(SCIP_COL const* col) noexcept -> nonstd::span { + auto const n_rows = SCIPcolGetNNonz(not_const(col)); + return {SCIPcolGetRows(not_const(col)), static_cast(n_rows)}; +} + +auto get_vals(SCIP_COL const* col) noexcept -> nonstd::span { + auto const n_rows = SCIPcolGetNNonz(not_const(col)); + return {SCIPcolGetVals(not_const(col)), static_cast(n_rows)}; +} + +} // namespace ecole::scip diff --git a/ecole/libecole/src/scip/cons.cpp b/ecole/libecole/src/scip/cons.cpp new file mode 100644 index 0000000..9c616f6 --- /dev/null +++ b/ecole/libecole/src/scip/cons.cpp @@ -0,0 +1,349 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "ecole/scip/cons.hpp" +#include "ecole/utility/sparse-matrix.hpp" + +namespace ecole::scip { + +void ConsReleaser::operator()(SCIP_CONS* ptr) { + scip::call(SCIPreleaseCons, scip, &ptr); +} + +auto create_cons_basic_linear( + SCIP* scip, + char const* name, + std::size_t n_vars, + SCIP_VAR const* const* vars, + SCIP_Real const* vals, + SCIP_Real lhs, + SCIP_Real rhs) -> std::unique_ptr { + + SCIP_CONS* cons = nullptr; + scip::call( + SCIPcreateConsBasicLinear, + scip, + &cons, + name, + static_cast(n_vars), + const_cast(vars), + const_cast(vals), + lhs, + rhs); + return {cons, ConsReleaser{scip}}; +} + +auto cons_get_rhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional { + SCIP_Bool success = FALSE; + auto const rhs = SCIPconsGetRhs(const_cast(scip), const_cast(cons), &success); + if (success == FALSE) { + return {}; + } + return {rhs}; +} + +auto cons_get_finite_rhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional { + if (auto rhs = cons_get_rhs(scip, cons); rhs.has_value()) { + if (!SCIPisInfinity(const_cast(scip), std::abs(rhs.value()))) { + return rhs; + } + } + return {}; +} + +auto cons_get_lhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional { + SCIP_Bool success = FALSE; + auto const lhs = SCIPconsGetLhs(const_cast(scip), const_cast(cons), &success); + if (success == FALSE) { + return {}; + } + return {lhs}; +} + +auto cons_get_finite_lhs(SCIP const* scip, SCIP_CONS const* cons) noexcept -> std::optional { + if (auto lhs = cons_get_lhs(scip, cons); lhs.has_value()) { + if (!SCIPisInfinity(const_cast(scip), std::abs(lhs.value()))) { + return lhs; + } + } + return {}; +} + +auto get_cons_n_vars(SCIP const* scip, SCIP_CONS const* cons) -> std::optional { + SCIP_Bool success = false; + int n_vars = 0; + scip::call(SCIPgetConsNVars, const_cast(scip), const_cast(cons), &n_vars, &success); + if (!success) { + return {}; + } + assert(n_vars >= 0); + return {static_cast(n_vars)}; +} + +auto get_cons_vars(SCIP* scip, SCIP_CONS* cons, nonstd::span out) -> bool { + auto const maybe_n_vars = get_cons_n_vars(scip, cons); + if (!maybe_n_vars.has_value()) { + return false; + } + auto const n_vars = maybe_n_vars.value(); + if (out.size() < n_vars) { + throw std::invalid_argument{"Out memory is not large enough to fit variables."}; + } + SCIP_Bool success = FALSE; + scip::call(SCIPgetConsVars, scip, cons, out.data(), static_cast(out.size()), &success); + return success; +} + +auto get_cons_vars(SCIP const* scip, SCIP_CONS const* cons, nonstd::span out) -> bool { + return get_cons_vars( + const_cast(scip), const_cast(cons), {const_cast(out.data()), out.size()}); +} + +auto get_cons_vars(SCIP* scip, SCIP_CONS* cons) -> std::optional> { + if (auto const n_vars = get_cons_n_vars(scip, cons); n_vars.has_value()) { + auto vars = std::vector(n_vars.value()); + if (get_cons_vars(scip, cons, vars)) { + return {std::move(vars)}; + } + } + return {}; +} + +auto get_cons_vars(SCIP const* scip, SCIP_CONS const* cons) -> std::optional> { + if (auto const n_vars = get_cons_n_vars(scip, cons); n_vars.has_value()) { + auto vars = std::vector(n_vars.value()); + if (get_cons_vars(scip, cons, vars)) { + return {std::move(vars)}; + } + } + return {}; +} + +auto get_cons_vals(SCIP const* scip, SCIP_CONS const* cons, nonstd::span out) -> bool { + auto const maybe_n_vars = get_cons_n_vars(scip, cons); + if (!maybe_n_vars.has_value()) { + return false; + } + auto const n_vars = maybe_n_vars.value(); + if (out.size() < n_vars) { + throw std::invalid_argument{"Out memory is not large enough to fit variables."}; + } + SCIP_Bool success = FALSE; + scip::call( + SCIPgetConsVals, + const_cast(scip), + const_cast(cons), + out.data(), + static_cast(out.size()), + &success); + return success; +} + +auto get_cons_vals(SCIP const* scip, SCIP_CONS const* cons) -> std::optional> { + if (auto const n_vars = get_cons_n_vars(scip, cons); n_vars.has_value()) { + auto vals = std::vector(n_vars.value()); + if (get_cons_vals(scip, cons, vals)) { + return {std::move(vals)}; + } + } + return {}; +} + +auto get_vals_linear(SCIP const* scip, SCIP_CONS const* cons) noexcept -> nonstd::span { + return { + SCIPgetValsLinear(const_cast(scip), const_cast(cons)), + static_cast(SCIPgetNVarsLinear(const_cast(scip), const_cast(cons))), + }; +} + +auto get_vars_linear(SCIP const* scip, SCIP_CONS const* cons) noexcept -> nonstd::span { + return { + SCIPgetVarsLinear(const_cast(scip), const_cast(cons)), + static_cast(SCIPgetNVarsLinear(const_cast(scip), const_cast(cons))), + }; +} + +/** + * Obtains the variables involved in a linear constraint and their coefficients in the constraint + */ +auto get_constraint_linear_coefs(SCIP* const scip, SCIP_CONS* const constraint) -> std::optional< + std::tuple, std::vector, std::optional, std::optional>> { + SCIP_Bool success = false; + int n_constraint_variables; + int n_active_variables; + SCIP_Real constant_offset = 0; + int requiredsize = 0; + + // Find how many active variables and constraint variables there are (for allocation) + scip::call(SCIPgetConsNVars, scip, constraint, &n_constraint_variables, &success); + if (!success) { + return std::nullopt; + } + n_active_variables = SCIPgetNVars(scip); + + // Allocate buffers large enough to hold future variables and coefficients + auto const buffer_size = static_cast(std::max(n_constraint_variables, n_active_variables)); + auto variables = std::vector(buffer_size); + auto coefficients = std::vector(buffer_size); + + // Get the variables and their coefficients in the constraint + scip::call(SCIPgetConsVars, scip, constraint, variables.data(), static_cast(buffer_size), &success); + if (!success) { + return std::nullopt; + } + scip::call(SCIPgetConsVals, scip, constraint, coefficients.data(), static_cast(buffer_size), &success); + if (!success) { + return std::nullopt; + } + + // If we are in SCIP_STAGE_TRANSFORMED or later, the variables in the constraint might be inactive + // Re-express the coefficients in terms of active variables + if (SCIPgetStage(scip) >= SCIP_STAGE_TRANSFORMED) { + scip::call( + SCIPgetProbvarLinearSum, + scip, + variables.data(), + coefficients.data(), + &n_constraint_variables, + static_cast(buffer_size), + &constant_offset, + &requiredsize, + true); + } + + variables.resize(static_cast(n_constraint_variables)); + coefficients.resize(static_cast(n_constraint_variables)); + + // Obtain the left and right hand side if their are finite and shift them accordingly. + auto lhs = scip::cons_get_finite_lhs(scip, constraint); + if (lhs.has_value()) { + lhs = lhs.value() - constant_offset; + } + + auto rhs = scip::cons_get_finite_rhs(scip, constraint); + if (rhs.has_value()) { + rhs = rhs.value() - constant_offset; + } + + return {{variables, coefficients, lhs, rhs}}; +} + +auto get_constraint_coefs(SCIP* const scip, SCIP_CONS* const constraint) + -> std::tuple, std::vector, std::optional, std::optional> { + auto constraint_data = get_constraint_linear_coefs(scip, constraint); + if (constraint_data.has_value()) { // Constraint must be linear + return constraint_data.value(); + } + throw ScipError(fmt::format( + "Constraint {} cannot be expressed as a single linear constraint (type \"{}\"), MilpBipartite observation " + "cannot be extracted.", + SCIPconsGetPos(constraint), + SCIPconshdlrGetName(SCIPconsGetHdlr(constraint)))); +} + +SCIP_Real cons_l2_norm(std::vector const& constraint_coefs) { + auto xt_constraint_coefs = xt::adapt(constraint_coefs, {constraint_coefs.size()}); + + auto const norm = xt::norm_l2(xt_constraint_coefs)(); + return norm > 0. ? norm : 1.; +} + +auto get_all_constraints(SCIP* const scip, bool normalize, bool include_variable_bounds) + -> std::tuple, xt::xtensor> { + auto* const variables = SCIPgetVars(scip); + auto* const constraints = SCIPgetConss(scip); + auto nb_variables = static_cast(SCIPgetNVars(scip)); + auto nb_constraints = static_cast(SCIPgetNConss(scip)); + + std::size_t n_rows = 0; + + std::vector values; + std::vector column_indices; + std::vector row_indices; + std::vector biases; + + // For each constraint + for (std::size_t cons_idx = 0; cons_idx < nb_constraints; ++cons_idx) { + auto* const constraint = constraints[cons_idx]; + auto [constraint_vars, constraint_coefs, lhs, rhs] = get_constraint_coefs(scip, constraint); + SCIP_Real const constraint_norm = normalize ? cons_l2_norm(constraint_coefs) : 1.; + + // Inequality has a left hand side? + if (lhs.has_value()) { + for (std::size_t cons_var_idx = 0; cons_var_idx < std::size(constraint_vars); ++cons_var_idx) { + SCIP_Real value = constraint_coefs[cons_var_idx]; + int var_idx = SCIPvarGetProbindex(constraint_vars[cons_var_idx]); + + values.push_back(-value); + row_indices.push_back(n_rows); + column_indices.push_back(static_cast(var_idx)); + } + if (normalize) { + biases.push_back(-lhs.value() / constraint_norm); + } else { + biases.push_back(-lhs.value()); + } + n_rows++; + } + // Inequality has a right hand side? + if (rhs.has_value()) { + for (std::size_t cons_var_idx = 0; cons_var_idx < std::size(constraint_vars); ++cons_var_idx) { + SCIP_Real value = constraint_coefs[cons_var_idx]; + int var_idx = SCIPvarGetProbindex(constraint_vars[cons_var_idx]); + + values.push_back(value); + row_indices.push_back(n_rows); + column_indices.push_back(static_cast(var_idx)); + } + if (normalize) { + biases.push_back(rhs.value() / constraint_norm); + } else { + biases.push_back(rhs.value()); + } + n_rows++; + } + } + + if (include_variable_bounds) { + // Add variable bounds as additional constraints + for (std::size_t var_idx = 0; var_idx < nb_variables; ++var_idx) { + auto lb = SCIPvarGetLbGlobal(variables[var_idx]); + auto ub = SCIPvarGetUbGlobal(variables[var_idx]); + if (!SCIPisInfinity(scip, std::abs(lb))) { + values.push_back(-1.); + row_indices.push_back(n_rows); + column_indices.push_back(static_cast(var_idx)); + biases.push_back(-lb); + n_rows++; + } + if (!SCIPisInfinity(scip, std::abs(ub))) { + values.push_back(1.); + row_indices.push_back(n_rows); + column_indices.push_back(static_cast(var_idx)); + biases.push_back(ub); + n_rows++; + } + } + } + + // Turn values and indices into xt::xarray's + auto const nnz = values.size(); + + utility::coo_matrix constraint_matrix{}; + constraint_matrix.values = xt::adapt(std::move(values), {nnz}); + constraint_matrix.indices = decltype(utility::coo_matrix::indices)::from_shape({2, nnz}); + xt::row(constraint_matrix.indices, 0) = xt::adapt(std::move(row_indices), {nnz}); + xt::row(constraint_matrix.indices, 1) = xt::adapt(std::move(column_indices), {nnz}); + constraint_matrix.shape = {n_rows, nb_variables}; + + xt::xtensor constraint_biases = xt::adapt(std::move(biases), {n_rows}); + + return std::tuple{std::move(constraint_matrix), std::move(constraint_biases)}; +} + +} // namespace ecole::scip diff --git a/ecole/libecole/src/scip/exception.cpp b/ecole/libecole/src/scip/exception.cpp new file mode 100644 index 0000000..c3097d8 --- /dev/null +++ b/ecole/libecole/src/scip/exception.cpp @@ -0,0 +1,194 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ecole/scip/exception.hpp" +#include "ecole/utility/unreachable.hpp" + +namespace ecole::scip { + +/*********************************** + * Declaration of ErrorCollector * + ***********************************/ + +namespace { + +/** + * SCIP message handler to collect error messages. + * + * This message handler stores error message rather than printing them to standard error. + * The messages can then be collected by the exception maker to craft the messsage of the exception. + * + * The class stores the messages in a thread local static variable. + */ +class ErrorCollector : public ::scip::ObjMessagehdlr { +public: + static std::string collect(); + static void clear(); + + ErrorCollector() noexcept; + + void scip_error(SCIP_MESSAGEHDLR* messagehdlr, FILE* file, const char* message) override; + +private: + thread_local static std::string errors; + constexpr static std::size_t buffer_size = 1000; +}; + +/** + * Deleter type for SCIP_MESSAHEHDLR to use with unique_ptr. + */ +struct MessageHandlerDeleter { + void operator()(SCIP_MESSAGEHDLR* ptr); +}; + +/** + * The SCIP_MESSAGE_HANDLER that wraps the Error Collector. + * + * SCIP takes a ObjMessage and allocate a SCIP_MESSAGE_HANDLER that contains it. + * We are responsible for dealocating it, hence the unique_ptr. + */ +extern std::unique_ptr message_handler; + +} // namespace + +/***************************** + * Definition of ScipError * + *****************************/ + +ScipError ScipError::from_retcode(SCIP_RETCODE retcode) { + auto message = ErrorCollector::collect(); + if (!message.empty()) { + return ScipError{std::move(message)}; + } + switch (retcode) { + case SCIP_OKAY: + throw ScipError{"Normal termination must not raise exception"}; + case SCIP_ERROR: + return ScipError{"Unspecified error"}; + case SCIP_NOMEMORY: + return ScipError{"Insufficient memory error"}; + case SCIP_READERROR: + return ScipError{"File read error"}; + case SCIP_WRITEERROR: + return ScipError{"File write error"}; + case SCIP_BRANCHERROR: + return ScipError{"Branch error"}; + case SCIP_NOFILE: + return ScipError{"File not found error"}; + case SCIP_FILECREATEERROR: + return ScipError{"Cannot create file"}; + case SCIP_LPERROR: + return ScipError{"Error in LP solver"}; + case SCIP_NOPROBLEM: + return ScipError{"No problem exists"}; + case SCIP_INVALIDCALL: + return ScipError{"Method cannot be called at tScipError(his time in solution process"}; + case SCIP_INVALIDDATA: + return ScipError{"Method cannot be called with this type of data"}; + case SCIP_INVALIDRESULT: + return ScipError{"Method returned an invalid result code"}; + case SCIP_PLUGINNOTFOUND: + return ScipError{"A required plugin was not found"}; + case SCIP_PARAMETERUNKNOWN: + return ScipError{"The parameter with the given name was not found"}; + case SCIP_PARAMETERWRONGTYPE: + return ScipError{"The parameter is not of the expected type"}; + case SCIP_PARAMETERWRONGVAL: + return ScipError{"The value is invalid for the given parameter"}; + case SCIP_KEYALREADYEXISTING: + return ScipError{"The given key is already existing in table"}; + case SCIP_MAXDEPTHLEVEL: + return ScipError{"Maximal branching depth level exceeded"}; + default: + utility::unreachable(); + } +} + +void scip::ScipError::reset_message_capture() { + ErrorCollector::clear(); +} + +scip::ScipError::ScipError(std::string message_) : message(std::move(message_)) {} + +const char* scip::ScipError::what() const noexcept { + return message.c_str(); +} + +/************************************** + * Implementation of ErrorCollector * + **************************************/ + +namespace { + +thread_local std::string scip::ErrorCollector::errors{}; + +void scip::ErrorCollector::scip_error(SCIP_MESSAGEHDLR* /*messagehdlr*/, FILE* /*file*/, const char* message) { + errors += message; +} + +void scip::ErrorCollector::clear() { + errors.clear(); +} + +std::string scip::ErrorCollector::collect() { + std::string message{}; + message.reserve(buffer_size); + std::swap(message, errors); + return message; +} + +scip::ErrorCollector::ErrorCollector() noexcept : ObjMessagehdlr(false) { + try { + errors.reserve(buffer_size); + } catch (std::exception const&) { + // Cannot reserve space for error string but it can be done (or fail) later + } +} + +void scip::MessageHandlerDeleter::operator()(SCIP_MESSAGEHDLR* ptr) { + // Cannot use scip::call because it accesses the collector which no longer exists. + [[maybe_unused]] auto retcode = SCIPmessagehdlrRelease(&ptr); + assert(retcode == SCIP_OKAY); +} + +auto make_unique_hander() { + SCIP_MESSAGEHDLR* raw_handler = nullptr; + { + auto error_collector = std::make_unique(); + // Cannot use scip::call because it accesses the collector which does not exist yet. + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) Give ownership of raw pointer to SCIP + auto const retcode = SCIPcreateObjMessagehdlr(&raw_handler, error_collector.release(), true); + assert(raw_handler != nullptr); + if (retcode != SCIP_OKAY) { + throw scip::ScipError::from_retcode(retcode); + } + } + + decltype(scip::message_handler) unique_handler; + unique_handler.reset(raw_handler); + return unique_handler; +} + +auto registered_handler() noexcept -> decltype(scip::message_handler) { + try { + auto handler = make_unique_hander(); + SCIPsetStaticErrorPrintingMessagehdlr(handler.get()); + return handler; + } catch (std::exception const& e) { + std::cerr << "Warning: initialization of SCIP error collector failed with exception\n"; + std::cerr << e.what() << '\n'; + return nullptr; + } +} + +decltype(scip::message_handler) message_handler = registered_handler(); + +} // namespace +} // namespace ecole::scip diff --git a/ecole/libecole/src/scip/model.cpp b/ecole/libecole/src/scip/model.cpp new file mode 100644 index 0000000..c12c532 --- /dev/null +++ b/ecole/libecole/src/scip/model.cpp @@ -0,0 +1,310 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "ecole/scip/callback.hpp" +#include "ecole/scip/exception.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/scimpl.hpp" +#include "ecole/scip/utils.hpp" +#include "ecole/utility/unreachable.hpp" + +namespace ecole::scip { + +Model::Model() : Model{std::make_unique()} { + scip::call(SCIPincludeDefaultPlugins, get_scip_ptr()); +} + +Model::Model(Model&&) noexcept = default; + +Model::Model(std::unique_ptr&& other_scimpl) : scimpl(std::move(other_scimpl)) { + set_messagehdlr_quiet(true); +} + +Model::~Model() = default; + +Model& Model::operator=(Model&&) noexcept = default; + +SCIP* Model::get_scip_ptr() noexcept { + return scimpl->get_scip_ptr(); +} +SCIP const* Model::get_scip_ptr() const noexcept { + return scimpl->get_scip_ptr(); +} + +Model Model::copy() const { + return std::make_unique(scimpl->copy()); +} + +Model Model::copy_orig() const { + return std::make_unique(scimpl->copy_orig()); +} + +bool Model::operator==(Model const& other) const noexcept { + return scimpl == other.scimpl; +} + +bool Model::operator!=(Model const& other) const noexcept { + return !(*this == other); +} + +Model Model::from_file(std::filesystem::path const& filename) { + auto model = Model{}; + model.read_problem(filename.c_str()); + return model; +} + +Model Model::prob_basic(std::string const& name) { + auto model = Model{}; + scip::call(SCIPcreateProbBasic, model.get_scip_ptr(), name.c_str()); + return model; +} + +void Model::write_problem(std::filesystem::path const& filename) const { + scip::call(SCIPwriteOrigProblem, const_cast(get_scip_ptr()), filename.c_str(), nullptr, true); +} + +void Model::read_problem(std::string const& filename) { + scip::call(SCIPreadProb, get_scip_ptr(), filename.c_str(), nullptr); +} + +void Model::set_messagehdlr_quiet(bool quiet) noexcept { + SCIPsetMessagehdlrQuiet(get_scip_ptr(), static_cast(quiet)); +} + +std::string Model::name() const noexcept { + return SCIPgetProbName(const_cast(get_scip_ptr())); +} + +void Model::set_name(std::string const& name) { + scip::call(SCIPsetProbName, get_scip_ptr(), name.c_str()); +} + +SCIP_STAGE Model::stage() const noexcept { + return SCIPgetStage(const_cast(get_scip_ptr())); +} + +ParamType Model::get_param_type(std::string const& name) const { + auto* scip_param = SCIPgetParam(const_cast(get_scip_ptr()), name.c_str()); + if (scip_param == nullptr) { + throw scip::ScipError::from_retcode(SCIP_PARAMETERUNKNOWN); + } + switch (SCIPparamGetType(scip_param)) { + case SCIP_PARAMTYPE_BOOL: + return ParamType::Bool; + case SCIP_PARAMTYPE_INT: + return ParamType::Int; + case SCIP_PARAMTYPE_LONGINT: + return ParamType::LongInt; + case SCIP_PARAMTYPE_REAL: + return ParamType::Real; + case SCIP_PARAMTYPE_CHAR: + return ParamType::Char; + case SCIP_PARAMTYPE_STRING: + return ParamType::String; + default: + utility::unreachable(); + } +} + +template <> void Model::set_param(std::string const& name, bool value) { + scip::call(SCIPsetBoolParam, get_scip_ptr(), name.c_str(), value); +} +template <> void Model::set_param(std::string const& name, int value) { + scip::call(SCIPsetIntParam, get_scip_ptr(), name.c_str(), value); +} +template <> void Model::set_param(std::string const& name, SCIP_Longint value) { + scip::call(SCIPsetLongintParam, get_scip_ptr(), name.c_str(), value); +} +template <> void Model::set_param(std::string const& name, SCIP_Real value) { + scip::call(SCIPsetRealParam, get_scip_ptr(), name.c_str(), value); +} +template <> void Model::set_param(std::string const& name, char value) { + scip::call(SCIPsetCharParam, get_scip_ptr(), name.c_str(), value); +} +template <> void Model::set_param(std::string const& name, std::string const& value) { + scip::call(SCIPsetStringParam, get_scip_ptr(), name.c_str(), value.c_str()); +} + +template <> bool Model::get_param(std::string const& name) const { + SCIP_Bool value{}; + scip::call(SCIPgetBoolParam, const_cast(get_scip_ptr()), name.c_str(), &value); + return static_cast(value); +} +template <> int Model::get_param(std::string const& name) const { + int value{}; + scip::call(SCIPgetIntParam, const_cast(get_scip_ptr()), name.c_str(), &value); + return value; +} +template <> SCIP_Longint Model::get_param(std::string const& name) const { + SCIP_Longint value{}; + scip::call(SCIPgetLongintParam, const_cast(get_scip_ptr()), name.c_str(), &value); + return value; +} +template <> SCIP_Real Model::get_param(std::string const& name) const { + SCIP_Real value{}; + scip::call(SCIPgetRealParam, const_cast(get_scip_ptr()), name.c_str(), &value); + return value; +} +template <> char Model::get_param(std::string const& name) const { + char value{}; + scip::call(SCIPgetCharParam, const_cast(get_scip_ptr()), name.c_str(), &value); + return value; +} +template <> std::string Model::get_param(std::string const& name) const { + char* ptr{}; + scip::call(SCIPgetStringParam, const_cast(get_scip_ptr()), name.c_str(), &ptr); + return ptr; +} + +void Model::set_params(std::map name_values) { + for (auto&& [name, value] : ranges::views::move(name_values)) { + set_param(name, std::move(value)); + } +} + +namespace { + +nonstd::span get_params_span(Model const& model) noexcept { + auto* const scip = const_cast(model.get_scip_ptr()); + return {SCIPgetParams(scip), static_cast(SCIPgetNParams(scip))}; +} + +} // namespace + +std::map Model::get_params() const { + std::map name_values{}; + for (auto* const param : get_params_span(*this)) { + auto name = std::string{SCIPparamGetName(param)}; + auto value = get_param(name); + name_values.insert({std::move(name), std::move(value)}); + } + return name_values; +} + +void Model::disable_presolve() { + scip::call(SCIPsetPresolving, get_scip_ptr(), SCIP_PARAMSETTING_OFF, true); +} +void Model::disable_cuts() { + scip::call(SCIPsetSeparating, get_scip_ptr(), SCIP_PARAMSETTING_OFF, true); +} + +nonstd::span Model::variables() const noexcept { + auto* const scip_ptr = const_cast(get_scip_ptr()); + return {SCIPgetVars(scip_ptr), static_cast(SCIPgetNVars(scip_ptr))}; +} + +nonstd::span Model::lp_branch_cands() const { + int n_vars = 0; + SCIP_VAR** vars = nullptr; + scip::call( + SCIPgetLPBranchCands, const_cast(get_scip_ptr()), &vars, nullptr, nullptr, &n_vars, nullptr, nullptr); + return {vars, static_cast(n_vars)}; +} + +nonstd::span Model::pseudo_branch_cands() const { + int n_vars = 0; + SCIP_VAR** vars = nullptr; + scip::call(SCIPgetPseudoBranchCands, const_cast(get_scip_ptr()), &vars, &n_vars, nullptr); + return {vars, static_cast(n_vars)}; +} + +nonstd::span Model::lp_columns() const { + auto* const scip_ptr = const_cast(get_scip_ptr()); + if (SCIPgetStage(scip_ptr) != SCIP_STAGE_SOLVING) { + throw ScipError::from_retcode(SCIP_INVALIDCALL); + } + return {SCIPgetLPCols(scip_ptr), static_cast(SCIPgetNLPCols(scip_ptr))}; +} + +nonstd::span Model::constraints() const noexcept { + auto* const scip_ptr = const_cast(get_scip_ptr()); + return {SCIPgetConss(scip_ptr), static_cast(SCIPgetNConss(scip_ptr))}; +} + +nonstd::span Model::lp_rows() const { + auto* const scip_ptr = const_cast(get_scip_ptr()); + if (SCIPgetStage(scip_ptr) != SCIP_STAGE_SOLVING) { + throw ScipError::from_retcode(SCIP_INVALIDCALL); + } + return {SCIPgetLPRows(scip_ptr), static_cast(SCIPgetNLPRows(scip_ptr))}; +} + +std::size_t Model::nnz() const noexcept { + return static_cast(SCIPgetNNZs(const_cast(get_scip_ptr()))); +} + +void Model::transform_prob() { + scip::call(SCIPtransformProb, get_scip_ptr()); +} + +void Model::presolve() { + scip::call(SCIPpresolve, get_scip_ptr()); +} + +void Model::solve() { + scip::call(SCIPsolve, get_scip_ptr()); +} + +bool Model::is_solved() const noexcept { + return SCIPgetStage(const_cast(get_scip_ptr())) == SCIP_STAGE_SOLVED; +} + +SCIP_Real Model::primal_bound() const noexcept { + auto* const scip = const_cast(get_scip_ptr()); + switch (SCIPgetStage(scip)) { + case SCIP_STAGE_TRANSFORMED: + case SCIP_STAGE_INITPRESOLVE: + case SCIP_STAGE_PRESOLVING: + case SCIP_STAGE_EXITPRESOLVE: + case SCIP_STAGE_PRESOLVED: + case SCIP_STAGE_INITSOLVE: + case SCIP_STAGE_SOLVING: + case SCIP_STAGE_SOLVED: + return SCIPgetPrimalbound(scip); + default: + return SCIPinfinity(scip); + } +} + +SCIP_Real Model::dual_bound() const noexcept { + auto* const scip = const_cast(get_scip_ptr()); + switch (SCIPgetStage(scip)) { + case SCIP_STAGE_TRANSFORMED: + case SCIP_STAGE_INITPRESOLVE: + case SCIP_STAGE_PRESOLVING: + case SCIP_STAGE_EXITPRESOLVE: + case SCIP_STAGE_PRESOLVED: + case SCIP_STAGE_INITSOLVE: + case SCIP_STAGE_SOLVING: + case SCIP_STAGE_SOLVED: + return SCIPgetDualbound(scip); + default: + return -SCIPinfinity(scip); + } +} + +auto Model::solve_iter(nonstd::span arg_packs) + -> std::optional { + return scimpl->solve_iter(arg_packs); +} + +auto Model::solve_iter(callback::DynamicConstructor arg_pack) -> std::optional { + return solve_iter({&arg_pack, 1}); +} + +auto Model::solve_iter_continue(SCIP_RESULT result) -> std::optional { + return scimpl->solve_iter_continue(result); +} + +} // namespace ecole::scip diff --git a/ecole/libecole/src/scip/row.cpp b/ecole/libecole/src/scip/row.cpp new file mode 100644 index 0000000..2257c33 --- /dev/null +++ b/ecole/libecole/src/scip/row.cpp @@ -0,0 +1,53 @@ +#include + +#include "ecole/scip/row.hpp" + +namespace ecole::scip { + +namespace { + +template auto not_const(T const* ptr) noexcept -> T* { + return const_cast(ptr); +} + +} // namespace + +auto get_unshifted_rhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> std::optional { + auto const rhs_val = SCIProwGetRhs(not_const(row)); + if (SCIPisInfinity(not_const(scip), std::abs(rhs_val))) { + return {}; + } + return rhs_val - SCIProwGetConstant(not_const(row)); +} + +auto get_unshifted_lhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> std::optional { + auto const lhs_val = SCIProwGetLhs(not_const(row)); + if (SCIPisInfinity(not_const(scip), std::abs(lhs_val))) { + return {}; + } + return lhs_val - SCIProwGetConstant(not_const(row)); +} + +auto is_at_rhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> bool { + auto const activity = SCIPgetRowLPActivity(not_const(scip), not_const(row)); + auto const rhs_val = SCIProwGetRhs(not_const(row)); + return SCIPisEQ(not_const(scip), activity, rhs_val); +} + +auto is_at_lhs(SCIP const* scip, SCIP_ROW const* row) noexcept -> bool { + auto const activity = SCIPgetRowLPActivity(not_const(scip), not_const(row)); + auto const lhs_val = SCIProwGetLhs(not_const(row)); + return SCIPisEQ(not_const(scip), activity, lhs_val); +} + +auto get_cols(SCIP_ROW const* row) noexcept -> nonstd::span { + auto const n_cols = SCIProwGetNNonz(not_const(row)); + return {SCIProwGetCols(not_const(row)), static_cast(n_cols)}; +} + +auto get_vals(SCIP_ROW const* row) noexcept -> nonstd::span { + auto const n_cols = SCIProwGetNNonz(not_const(row)); + return {SCIProwGetVals(not_const(row)), static_cast(n_cols)}; +} + +} // namespace ecole::scip diff --git a/ecole/libecole/src/scip/scimpl.cpp b/ecole/libecole/src/scip/scimpl.cpp new file mode 100644 index 0000000..2223531 --- /dev/null +++ b/ecole/libecole/src/scip/scimpl.cpp @@ -0,0 +1,265 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "ecole/scip/callback.hpp" +#include "ecole/scip/scimpl.hpp" +#include "ecole/scip/utils.hpp" +#include "ecole/utility/coroutine.hpp" + +namespace ecole::scip { + +/************************************* + * Definition of reverse Callbacks * + *************************************/ + +namespace { + +using Controller = utility::Coroutine; +using Executor = typename Controller::Executor; + +/** + * Function to add a callback to SCIP. + * + * Needs to be implemented by all reverse callbacks. + */ +template +auto include_reverse_callback(SCIP* scip, std::weak_ptr executor, callback::Constructor args) -> void; + +/** + * In a callback send Callback type and wait for result. + * + * This function is commonly used inside reverse callbacks to wait for user action (the result). + * For user to make the proper action, they need to know on which callback SCIP stoped (the stop location). + * This function will pass the current call function arguments to the coroutine and wait for the result. + */ +template +auto handle_executor(SCIP* scip, std::weak_ptr& weak_executor, callback::Call call) noexcept + -> std::tuple { + if (weak_executor.expired()) { + return {SCIP_OKAY, SCIP_DIDNOTRUN}; + } + try { + return std::visit( + [&](auto result_or_stop) -> std::tuple { + using StopToken = Executor::StopToken; + if constexpr (std::is_same_v) { + return {SCIPinterruptSolve(scip), SCIP_DIDNOTRUN}; + } else { + return {SCIP_OKAY, result_or_stop}; + } + }, + weak_executor.lock()->yield(call)); + } catch (...) { + return {SCIP_ERROR, SCIP_DIDNOTRUN}; + } +} + +class ReverseBranchrule : public ::scip::ObjBranchrule { +public: + ReverseBranchrule( + SCIP* scip, + int priority, + int maxdepth, + SCIP_Real maxbounddist, + std::weak_ptr weak_executor) : + ObjBranchrule{ + scip, + name(callback::Type::Branchrule), + "Branchrule that wait for another thread to make the branching.", + priority, + maxdepth, + maxbounddist}, + m_weak_executor{std::move(weak_executor)} {} + + auto scip_execlp(SCIP* scip, SCIP_BRANCHRULE* /*branchrule*/, SCIP_Bool allow_add_constraints, SCIP_RESULT* result) + -> SCIP_RETCODE override { + using Where = callback::BranchruleCall::Where; + return scip_exec_any(scip, result, {static_cast(allow_add_constraints), Where::LP}); + } + + auto scip_execext(SCIP* scip, SCIP_BRANCHRULE* /*branchrule*/, SCIP_Bool allow_add_constraints, SCIP_RESULT* result) + -> SCIP_RETCODE override { + using Where = callback::BranchruleCall::Where; + return scip_exec_any(scip, result, {static_cast(allow_add_constraints), Where::External}); + } + + auto scip_execps(SCIP* scip, SCIP_BRANCHRULE* /*branchrule*/, SCIP_Bool allow_add_constraints, SCIP_RESULT* result) + -> SCIP_RETCODE override { + using Where = callback::BranchruleCall::Where; + return scip_exec_any(scip, result, {static_cast(allow_add_constraints), Where::Pseudo}); + } + +private: + std::weak_ptr m_weak_executor; + + auto scip_exec_any(SCIP* scip, SCIP_RESULT* result, callback::BranchruleCall call) -> SCIP_RETCODE { + auto retcode = SCIP_OKAY; + std::tie(retcode, *result) = handle_executor(scip, m_weak_executor, call); + return retcode; + } +}; + +template <> +auto include_reverse_callback( + SCIP* scip, + std::weak_ptr executor, + callback::Constructor args) -> void { + scip::call( + SCIPincludeObjBranchrule, + scip, + new ReverseBranchrule(scip, args.priority, args.max_depth, args.max_bound_distance, std::move(executor)), + true); +} // NOLINT + +class ReverseHeur : public ::scip::ObjHeur { +public: + ReverseHeur( + SCIP* scip, + int priority, + int freq, + int freqofs, + int maxdepth, + SCIP_HEURTIMING timingmask, + std::weak_ptr weak_executor) : + ObjHeur{ + scip, + name(callback::Type::Heuristic), + "Primal heuristic that waits for another thread to provide a primal solution.", + 'e', + priority, + freq, + freqofs, + maxdepth, + timingmask, + false}, + m_weak_executor{std::move(weak_executor)} {} + + auto scip_exec( + SCIP* scip, + SCIP_HEUR* /*heur*/, + SCIP_HEURTIMING heuristic_timing, + SCIP_Bool node_infeasible, + SCIP_RESULT* result) -> SCIP_RETCODE override { + auto retcode = SCIP_OKAY; + std::tie(retcode, *result) = handle_executor( + scip, m_weak_executor, callback::HeuristicCall{heuristic_timing, static_cast(node_infeasible)}); + return retcode; + } + +private: + std::weak_ptr m_weak_executor; +}; + +template <> +auto include_reverse_callback( + SCIP* scip, + std::weak_ptr executor, + callback::Constructor args) -> void { + scip::call( + SCIPincludeObjHeur, + scip, + new ReverseHeur( + scip, + args.priority, + args.frequency, + args.frequency_offset, + args.max_depth, + args.timing_mask, + std::move(executor)), + true); +} // NOLINT + +} // namespace + +/**************************** + * Definition of Scimpl * + ****************************/ + +void ScipDeleter::operator()(SCIP* ptr) { + scip::call(SCIPfree, &ptr); +} + +namespace { + +std::unique_ptr create_scip() { + SCIP* scip_raw; + scip::call(SCIPcreate, &scip_raw); + std::unique_ptr scip_ptr = nullptr; + scip_ptr.reset(scip_raw); + return scip_ptr; +} + +} // namespace + +Scimpl::Scimpl() : m_scip{create_scip()} {} + +Scimpl::Scimpl(Scimpl&&) noexcept = default; + +Scimpl::Scimpl(std::unique_ptr&& scip_ptr) noexcept : m_scip(std::move(scip_ptr)) {} + +Scimpl::~Scimpl() = default; + +auto Scimpl::get_scip_ptr() noexcept -> SCIP* { + return m_scip.get(); +} + +auto Scimpl::copy() const -> Scimpl { + if (m_scip == nullptr) { + return {nullptr}; + } + if (SCIPgetStage(m_scip.get()) == SCIP_STAGE_INIT) { + return {create_scip()}; + } + auto dest = create_scip(); + // Copy operation is not thread safe + static auto m = std::mutex{}; + auto g = std::lock_guard{m}; + scip::call(SCIPcopy, m_scip.get(), dest.get(), nullptr, nullptr, "", true, false, false, false, nullptr); + return {std::move(dest)}; +} + +auto Scimpl::copy_orig() const -> Scimpl { + if (m_scip == nullptr) { + return {nullptr}; + } + if (SCIPgetStage(m_scip.get()) == SCIP_STAGE_INIT) { + return {create_scip()}; + } + auto dest = create_scip(); + // Copy operation is not thread safe + static auto m = std::mutex{}; + auto g = std::lock_guard{m}; + scip::call(SCIPcopyOrig, m_scip.get(), dest.get(), nullptr, nullptr, "", false, false, false, nullptr); + return {std::move(dest)}; +} + +auto Scimpl::solve_iter(nonstd::span arg_packs) + -> std::optional { + auto* const scip_ptr = get_scip_ptr(); + m_controller = std::make_unique([=](std::weak_ptr const& executor) { + for (auto const pack : arg_packs) { + std::visit([&](auto args) { include_reverse_callback(scip_ptr, executor, args); }, pack); + } + scip::call(SCIPsolve, scip_ptr); + }); + return m_controller->wait(); +} + +auto Scimpl::solve_iter_continue(SCIP_RESULT result) -> std::optional { + m_controller->resume(result); + return m_controller->wait(); +} + +} // namespace ecole::scip diff --git a/ecole/libecole/src/scip/var.cpp b/ecole/libecole/src/scip/var.cpp new file mode 100644 index 0000000..a72d4a9 --- /dev/null +++ b/ecole/libecole/src/scip/var.cpp @@ -0,0 +1,16 @@ +#include "ecole/scip/var.hpp" + +namespace ecole::scip { + +void VarReleaser::operator()(SCIP_VAR* ptr) { + scip::call(SCIPreleaseVar, scip, &ptr); +} + +auto create_var_basic(SCIP* scip, char const* name, SCIP_Real lb, SCIP_Real ub, SCIP_Real obj, SCIP_VARTYPE vartype) + -> std::unique_ptr { + SCIP_VAR* var = nullptr; + scip::call(SCIPcreateVarBasic, scip, &var, name, lb, ub, obj, vartype); + return {var, VarReleaser{scip}}; +} + +} // namespace ecole::scip diff --git a/ecole/libecole/src/utility/chrono.cpp b/ecole/libecole/src/utility/chrono.cpp new file mode 100644 index 0000000..dc75512 --- /dev/null +++ b/ecole/libecole/src/utility/chrono.cpp @@ -0,0 +1,28 @@ +#include +#include +#include + +#include "ecole/utility/chrono.hpp" + +namespace ecole::utility { + +/** + * There is no standard way to get CPU time. + * + * The following implementation is inspired from + * - https://levelup.gitconnected.com/8-ways-to-measure-execution-time-in-c-c-48634458d0f9 + * - https://stackoverflow.com/a/12480485/5862073 + * - Google Benchmark implementation + * https://github.com/google/benchmark/blob/8df87f6c879cbcabd17c5cfcec7b89687df36953/src/timers.cc#L110 + */ +auto cpu_clock::now() -> time_point { + // Using clock_gettime is not standard but POSIX. It has nanoseconds resolution. + // It works on Linux and MacOS >= 10.12 + struct timespec spec; + if (clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &spec) != 0) { + throw std::system_error{{errno, std::generic_category()}}; + } + return time_point{std::chrono::seconds{spec.tv_sec} + std::chrono::nanoseconds{spec.tv_nsec}}; +} + +} // namespace ecole::utility diff --git a/ecole/libecole/src/utility/graph.cpp b/ecole/libecole/src/utility/graph.cpp new file mode 100644 index 0000000..8226f3f --- /dev/null +++ b/ecole/libecole/src/utility/graph.cpp @@ -0,0 +1,198 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "ecole/utility/random.hpp" + +#include "utility/graph.hpp" + +namespace views = ranges::views; + +namespace ecole::utility { + +auto Graph::Edge::operator==(Edge const& other) const noexcept -> bool { + return ((first == other.first) && (second == other.second)) || ((first == other.second) && (second == other.first)); +} + +auto Graph::Edge::operator!=(Edge const& other) const noexcept -> bool { + return !(*this == other); +} + +auto Graph::n_nodes() const noexcept -> std::size_t { + return edges.size(); +} + +auto Graph::degree(Node n) const noexcept -> std::size_t { + return edges[n].size(); +} + +auto Graph::neighbors(Node n) const noexcept -> robin_hood::unordered_flat_set const& { + return edges[n]; +} + +auto Graph::are_connected(Node popular, Node unpopular) const -> bool { + return neighbors(unpopular).contains(popular); +} + +auto Graph::n_edges() const noexcept -> std::size_t { + auto count = std::size_t{0}; + for (auto const& neighbors : edges) { + count += neighbors.size(); + } + // Each edge is stored twice + assert(count % 2 == 0); + return count / 2; +} + +void Graph::add_edge(Edge edge) { + assert(!are_connected(edge.first, edge.second)); + edges[edge.first].insert(edge.second); + edges[edge.second].insert(edge.first); +} + +void Graph::reserve(std::size_t degree) { + for (auto& neighborhood : edges) { + neighborhood.reserve(degree); + } +} + +auto Graph::erdos_renyi(std::size_t n_nodes, double edge_probability, RandomGenerator& rng) -> Graph { + // Allocate adjacency lists for the expected approximate number of neighbors in an Erdos Renyi graph. + // Computed as the expectation of a Binomial. + auto graph = Graph{n_nodes}; + auto const expected_neighbors = static_cast(std::ceil(static_cast(n_nodes) * edge_probability)); + graph.reserve(expected_neighbors); + + // Flip a (continuous) coin for each edge in the undirected graph + auto rand = std::uniform_real_distribution{0.0, 1.0}; + for (Node n1 = 0; n1 < n_nodes; ++n1) { + for (Node n2 = n1 + 1; n2 < n_nodes; ++n2) { + if (rand(rng) < edge_probability) { + graph.add_edge({n1, n2}); + } + } + } + + return graph; +} + +auto Graph::barabasi_albert(std::size_t n_nodes, std::size_t affinity, RandomGenerator& rng) -> Graph { + if (affinity < 1 || affinity >= n_nodes) { + throw std::invalid_argument{"Affinity must be between 1 and the number of nodes."}; + } + + // Allocate adjacency lists for the expected approximate number of neighbors in an Barabasi Albert graph. + // Computed as the expectation of a power law. + // https://web.archive.org/web/20200615213344/https://barabasi.com/f/622.pdf + auto graph = Graph{n_nodes}; + graph.reserve(2 * affinity); + + // First nodes are all connected to the first one (star shape). + for (Node n = 1; n <= affinity; ++n) { + graph.add_edge({0, n}); + } + + // Function to get Degrees from 0 to k_nodes (exluded) as vector of doubles. + auto get_degrees = [&graph](auto k_nodes) { + auto get_degree = [&graph](auto m) { return static_cast(graph.degree(m)); }; + return views::ints(Node{0}, k_nodes) | views::transform(get_degree) | ranges::to(); + }; + + // Other node grow the graph one by one + for (Node n = affinity + 1; n < n_nodes; ++n) { + // They are linked to `affinity` existing node with probability proportional to degree + for (auto neighbor : utility::arg_choice(affinity, get_degrees(n), rng)) { + graph.add_edge({n, neighbor}); + } + } + + return graph; +} + +template using map = robin_hood::unordered_flat_map; +template using set = robin_hood::unordered_flat_set; + +/** Create a set of mapping of nodes to their degrees. */ +auto create_nodes_degrees(Graph const& g) -> map { + auto nodes = map{}; + nodes.reserve(g.n_nodes()); + for (auto n = Graph::Node{0}; n < Graph::Node{g.n_nodes()}; ++n) { + nodes[n] = g.degree(n); + } + return nodes; +} + +/** Find, remove, and return the node with maximum degree. */ +auto extract_node_with_max_degree(map& nodes_degrees) -> Graph::Node { + assert(!nodes_degrees.empty()); + auto cmp_degrees = [](auto const& nd_1, auto const& nd_2) { return nd_1.second < nd_2.second; }; + auto const max_iter = std::max_element(nodes_degrees.begin(), nodes_degrees.end(), cmp_degrees); + auto node = max_iter->first; + nodes_degrees.erase(max_iter); + return node; +} + +/** Compute intersection between neighborhood and leftovers nodes and sort them by decreasing degree. */ +auto best_clique_candidates(set const& neighborhood, map const& leftover_nodes) + -> std::vector { + auto candidates = std::vector{}; + candidates.reserve(std::min(neighborhood.size(), leftover_nodes.size())); + auto in_leftover_nodes = [&leftover_nodes](auto node) { return leftover_nodes.contains(node); }; + std::copy_if(neighborhood.begin(), neighborhood.end(), std::back_inserter(candidates), in_leftover_nodes); + // Decreasing sort by degree using > comparison. + auto cmp_degrees = [&leftover_nodes](auto node1, auto node2) { + // NOLINTNEXTLINE(bugprone-lambda-function-name) __func__ is used in assert macro. + assert(leftover_nodes.contains(node1) && leftover_nodes.contains(node2)); + return leftover_nodes.find(node1)->second > leftover_nodes.find(node2)->second; + }; + std::sort(candidates.begin(), candidates.end(), cmp_degrees); + return candidates; +} + +/** Return a vector of Node with the center and an allocated size. */ +auto allocate_clique(Graph::Node center, std::size_t n_center_neighors) { + auto clique = std::vector{}; + clique.reserve(n_center_neighors + 1); + clique.push_back(center); + return clique; +} + +auto Graph::greedy_clique_partition() const -> std::vector> { + auto clique_partition = std::vector>{}; + clique_partition.reserve(n_nodes()); + + auto leftover_nodes = create_nodes_degrees(*this); + + // Process all nodes to put them in a new clique + while (!leftover_nodes.empty()) { + // Start clique from the node with most neighbors + auto const clique_center = extract_node_with_max_degree(leftover_nodes); + auto const clique_candidates = best_clique_candidates(neighbors(clique_center), leftover_nodes); + auto clique = allocate_clique(clique_center, clique_candidates.size()); + + // Candidate clique members are among the neighbors + for (auto node : clique_candidates) { + // If clique candidate preserve cliqueness, i.e. connected to every node in clique + if (std::all_of( + clique.begin(), clique.end(), [&](auto clique_node) { return are_connected(node, clique_node); })) { + clique.push_back(node); + [[maybe_unused]] auto const n_removed = leftover_nodes.erase(node); + assert(n_removed == 1); + } + } + + clique_partition.push_back(std::move(clique)); + } + + return clique_partition; +} + +} // namespace ecole::utility diff --git a/ecole/libecole/src/utility/graph.hpp b/ecole/libecole/src/utility/graph.hpp new file mode 100644 index 0000000..33f8b2e --- /dev/null +++ b/ecole/libecole/src/utility/graph.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include + +#include + +#include "ecole/export.hpp" +#include "ecole/random.hpp" + +namespace ecole::utility { + +/** A simple symetric graph based on adjacency lists. */ +class ECOLE_EXPORT Graph { +public: + using Node = std::size_t; + + struct ECOLE_EXPORT Edge : std::pair { + /** All constructors from pair. */ + using std::pair::pair; + + /** Undirected comparison. */ + ECOLE_EXPORT auto operator==(Edge const& other) const noexcept -> bool; + ECOLE_EXPORT auto operator!=(Edge const& other) const noexcept -> bool; + }; + + /** Sample a new graph using Erdos Renyi algorithm. + * + * @param n_nodes The number of nodes in the graph generated. + * @param edge_probability The probability that a given edge is added to the graph. + * @param rng The random number generator used to sample edges. + */ + ECOLE_EXPORT static auto erdos_renyi(std::size_t n_nodes, double edge_probability, RandomGenerator& rng) -> Graph; + + /** Sample a new graph using Barabasi Albert algorithm. + * + * @param n_nodes The number of nodes in the graph generated. + * @param affinity The number of nodes that each node is connected to. + * @param rng The random number generator used to sample edges. + */ + ECOLE_EXPORT static auto barabasi_albert(std::size_t n_nodes, std::size_t affinity, RandomGenerator& rng) -> Graph; + + /** Empty graph with only nodes */ + Graph(std::size_t n_nodes) : edges{n_nodes} {} + + /** Reserve size for each adjacency list. */ + ECOLE_EXPORT void reserve(std::size_t degree); + + [[nodiscard]] ECOLE_EXPORT auto n_nodes() const noexcept -> std::size_t; + [[nodiscard]] ECOLE_EXPORT auto degree(Node n) const noexcept -> std::size_t; + [[nodiscard]] ECOLE_EXPORT auto neighbors(Node n) const noexcept -> robin_hood::unordered_flat_set const&; + [[nodiscard]] ECOLE_EXPORT auto are_connected(Node popular, Node unpopular) const -> bool; + [[nodiscard]] ECOLE_EXPORT auto n_edges() const noexcept -> std::size_t; + + /** Apply a function on all edges in the graph. + * + * Iterators care more complex to implement so we provide a visitor pattern. + */ + template void edges_visit(Func&& func) const; + + ECOLE_EXPORT void add_edge(Edge edge); + + /** Partition the nodes in clique using greedy algorithm. + * + * @return Vector of cliques, each being a vector of nodes. + */ + [[nodiscard]] ECOLE_EXPORT auto greedy_clique_partition() const -> std::vector>; + +private: + // Vector likely more performant than list on small-sized small-count data due to more predictable cache usage + using AdjacencyLists = std::vector>; + + AdjacencyLists edges; +}; + +/***************************** + * Implementation of Graph * + *****************************/ + +template void Graph::edges_visit(Func&& func) const { + auto const n_nodes_ = n_nodes(); + for (auto n1 = Node{0}; n1 < n_nodes_; ++n1) { + for (auto n2 : neighbors(n1)) { + if (n1 <= n2) { // Undirected graph + func(Edge{n1, n2}); + } + } + } +} + +} // namespace ecole::utility diff --git a/ecole/libecole/src/utility/math.hpp b/ecole/libecole/src/utility/math.hpp new file mode 100644 index 0000000..d0ab638 --- /dev/null +++ b/ecole/libecole/src/utility/math.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include + +namespace ecole::utility { + +/** + * Square of a number. + */ +template auto square(T x) noexcept -> T { + return x * x; +} + +/** + * Floating points division that return 0 when the denominator is 0. + * Also ensures that it isn't accidentally called with integers which would lead to euclidian + * division. + */ +template auto safe_div(T x, T y) noexcept -> T { + static_assert(std::is_floating_point_v, "Inputs are not decimals"); + return y != 0. ? x / y : 0.; +} + +namespace internal { + +template +using range_value_type_t = std::remove_cv_t().begin())>>; +//using range_value_type_t = decltype(*std::declval().begin()); +} // namespace internal + +/** + * Compute the sum of and count of elements. + * + * @param range The container to iteratre over. + * @param transform A callable to apply on every element of the container. + * @param filter A callable to filter in elements (after transformation). + */ +template > +auto count_sum(Range range) noexcept -> std::pair { + auto sum = U{0}; + auto count = std::size_t{0}; + + for (auto const element : range) { + count++; + sum += element; + } + return {count, sum}; +} + +/** + * Hold statistics of a range. + */ +template struct StatsFeatures { + T count = 0.; + T sum = 0.; + T mean = 0.; + T stddev = 0.; + T min = 0.; + T max = 0.; +}; + +template < + typename Range, + typename U = internal::range_value_type_t, + typename T = std::conditional_t, U, double>> +auto compute_stats(Range range) noexcept -> StatsFeatures { + auto const [count, sum] = count_sum(range); + + // We can assume count to be always positive after this point and that the (filtered) iteration + // will contain at least one element. + if (count == 0) { + return {}; + } + + auto const mean = safe_div(static_cast(sum), static_cast(count)); // auto const mean + auto stddev = T{0.}; + auto min = std::numeric_limits::max(); //min + auto max = std::numeric_limits::lowest(); //std::numeric_limits::lowest() ?? + + for (auto const element : range) { + min = std::min(min, element); + max = std::max(max, element); + stddev += square(static_cast(element) - mean); + } + stddev = std::sqrt(stddev / static_cast(count)); + + return {static_cast(count), static_cast(sum), mean, stddev, static_cast(min), static_cast(max)}; +} + +} // namespace ecole::utility diff --git a/ecole/libecole/src/version.cpp b/ecole/libecole/src/version.cpp new file mode 100644 index 0000000..e31bee9 --- /dev/null +++ b/ecole/libecole/src/version.cpp @@ -0,0 +1,55 @@ +#include +#include + +#include + +#include "ecole/version.hpp" + +namespace ecole::version { + +auto get_ecole_lib_version() noexcept -> VersionInfo { + return get_ecole_header_version(); +} + +/** + * Non-standard way to extract the path of the current library (libecole). + * + * Inspired by: + * - https://stackoverflow.com/a/57201397/5862073 + * - https://github.com/gpakosz/whereami + * On Windows, see: + * - https://stackoverflow.com/a/55983632/5862073 + */ +auto get_ecole_lib_path() -> std::filesystem::path { + if (Dl_info info; dladdr(reinterpret_cast(get_ecole_lib_path), &info)) { + return info.dli_fname; + } + throw std::runtime_error{"Cannot find path of Ecole library"}; +} + +auto get_scip_lib_version() noexcept -> VersionInfo { + return { + static_cast(SCIPmajorVersion()), + static_cast(SCIPminorVersion()), + static_cast(SCIPtechVersion()), + }; +} + +/** + * Non-standard way to extract the path of the SCIP library. + * + * @see get_ecole_lib_path + */ +auto get_scip_lib_path() -> std::filesystem::path { + // Use any function name + if (Dl_info info; dladdr(reinterpret_cast(SCIPversion), &info)) { + return info.dli_fname; + } + throw std::runtime_error{"Cannot find path of SCIP library"}; +} + +auto get_scip_buildtime_version() noexcept -> VersionInfo { + return get_scip_lib_version(); +} + +} // namespace ecole::version diff --git a/ecole/libecole/tests/CMakeLists.txt b/ecole/libecole/tests/CMakeLists.txt new file mode 100644 index 0000000..81e1a0d --- /dev/null +++ b/ecole/libecole/tests/CMakeLists.txt @@ -0,0 +1,90 @@ +add_executable( + ecole-lib-test + + src/main.cpp + src/conftest.cpp + + src/test-utility/tmp-folder.cpp + + src/test-traits.cpp + src/test-random.cpp + + src/utility/test-chrono.cpp + src/utility/test-coroutine.cpp + src/utility/test-vector.cpp + src/utility/test-random.cpp + src/utility/test-graph.cpp + src/utility/test-sparse-matrix.cpp + + src/scip/test-scimpl.cpp + src/scip/test-model.cpp + + src/instance/unit-tests.cpp + src/instance/test-files.cpp + src/instance/test-set-cover.cpp + src/instance/test-independent-set.cpp + src/instance/test-combinatorial-auction.cpp + src/instance/test-capacitated-facility-location.cpp + + src/data/test-constant.cpp + src/data/test-none.cpp + src/data/test-tuple.cpp + src/data/test-vector.cpp + src/data/test-map.cpp + src/data/test-multiary.cpp + src/data/test-parser.cpp + src/data/test-timed.cpp + src/data/test-dynamic.cpp + + src/reward/test-lp-iterations.cpp + src/reward/test-is-done.cpp + src/reward/test-n-nodes.cpp + src/reward/test-solving-time.cpp + src/reward/test-bound-integral.cpp + + src/observation/test-node-bipartite.cpp + src/observation/test-milp-bipartite.cpp + src/observation/test-strong-branching-scores.cpp + src/observation/test-pseudocosts.cpp + src/observation/test-khalil-2016.cpp + src/observation/test-hutter-2011.cpp + + src/dynamics/test-parts.cpp + src/dynamics/test-branching.cpp + src/dynamics/test-configuring.cpp + src/dynamics/test-primal-search.cpp + + src/environment/test-environment.cpp +) + +target_compile_definitions( + ecole-lib-test PRIVATE TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data" +) + +target_include_directories( + ecole-lib-test + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${${PROJECT_NAME}_SOURCE_DIR}/libecole/src" # Add libecole private include +) + +# File that download the dependencies of libecole +include(dependencies/private.cmake) + +find_package(Catch2 REQUIRED) + +target_link_libraries( + ecole-lib-test + PRIVATE + Ecole::ecole-lib + range-v3::range-v3 + Catch2::Catch2 + libscip +) + +ecole_target_add_compile_warnings(ecole-lib) +ecole_target_add_sanitizers(ecole-lib) + +include("${Catch2_DIR}/Catch.cmake") +enable_testing() +catch_discover_tests(ecole-lib-test) diff --git a/ecole/libecole/tests/data/bppc8-02.mps b/ecole/libecole/tests/data/bppc8-02.mps new file mode 100644 index 0000000..e86bd3d --- /dev/null +++ b/ecole/libecole/tests/data/bppc8-02.mps @@ -0,0 +1,4709 @@ +* ENCODING=ISO-8859-1 +NAME bppc8-02 +ROWS + N obj + E c1 + E c2 + E c3 + E c4 + E c5 + E c6 + E c7 + E c8 + E c9 + E c10 + E c11 + E c12 + E c13 + E c14 + E c15 + E c16 + E c17 + E c18 + E c19 + E c20 + L c21 + L c22 + L c23 + L c24 + L c25 + L c26 + L c27 + L c28 + L c29 + L c30 + L c31 + L c32 + L c33 + L c34 + L c35 + L c36 + L c37 + L c38 + L c39 + L c40 + L c41 + L c42 + L c43 + L c44 + L c45 + L c46 + L c47 + L c48 + L c49 + L c50 + L c51 + L c52 + L c53 + L c54 + L c55 + L c56 + L c57 + L c58 + L c59 +COLUMNS + h obj 1 + h c21 -1 + h c22 -1 + h c23 -1 + h c24 -1 + h c25 -1 + h c26 -1 + h c27 -1 + h c28 -1 + h c29 -1 + h c30 -1 + h c31 -1 + h c32 -1 + h c33 -1 + h c34 -1 + h c35 -1 + h c36 -1 + h c37 -1 + h c38 -1 + h c39 -1 + h c40 -1 + h c41 -1 + h c42 -1 + h c43 -1 + h c44 -1 + h c45 -1 + h c46 -1 + h c47 -1 + h c48 -1 + h c49 -1 + h c50 -1 + h c51 -1 + h c52 -1 + h c53 -1 + h c54 -1 + h c55 -1 + h c56 -1 + h c57 -1 + h c58 -1 + h c59 -1 + x2 obj 1 + MARK0000 'MARKER' 'INTORG' + X0.0 c1 1 + X0.0 c21 19 + X0.0 c22 19 + X0.0 c23 19 + X0.0 c24 19 + X0.0 c25 19 + X0.0 c26 19 + X0.0 c27 19 + X0.0 c28 19 + X0.0 c29 19 + X0.0 c30 19 + X0.0 c31 19 + X0.0 c32 19 + X0.0 c33 19 + X0.0 c34 19 + X0.0 c35 19 + X0.0 c36 19 + X0.0 c37 19 + X0.0 c38 19 + X0.0 c39 19 + X0.0 c40 19 + X0.0 c41 19 + X0.0 c42 19 + X0.0 c43 19 + X0.0 c44 19 + X0.0 c45 19 + X0.0 c46 19 + X0.0 c47 19 + X0.0 c48 19 + X0.0 c49 19 + X0.0 c50 19 + X0.0 c51 19 + X0.0 c52 19 + X0.0 c53 19 + X0.0 c54 19 + X0.0 c55 19 + X0.0 c56 19 + X0.0 c57 19 + X0.0 c58 19 + X1.0 c2 1 + X1.0 c21 4 + X1.0 c22 4 + X1.0 c23 4 + X1.0 c24 4 + X1.0 c25 4 + X1.0 c26 4 + X1.0 c27 4 + X1.0 c28 4 + X1.0 c29 4 + X1.0 c30 4 + X1.0 c31 4 + X1.0 c32 4 + X1.0 c33 4 + X1.0 c34 4 + X1.0 c35 4 + X1.0 c36 4 + X1.0 c37 4 + X1.0 c38 4 + X1.0 c39 4 + X1.0 c40 4 + X1.0 c41 4 + X1.0 c42 4 + X1.0 c43 4 + X1.0 c44 4 + X1.0 c45 4 + X1.0 c46 4 + X1.0 c47 4 + X1.0 c48 4 + X1.0 c49 4 + X1.0 c50 4 + X1.0 c51 4 + X1.0 c52 4 + X1.0 c53 4 + X1.0 c54 4 + X1.0 c55 4 + X1.0 c56 4 + X1.0 c57 4 + X1.11 c2 1 + X1.11 c22 4 + X1.11 c23 4 + X1.11 c24 4 + X1.11 c25 4 + X1.11 c26 4 + X1.11 c27 4 + X1.11 c28 4 + X1.11 c29 4 + X1.11 c30 4 + X1.11 c31 4 + X1.11 c32 4 + X1.11 c33 4 + X1.11 c34 4 + X1.11 c35 4 + X1.11 c36 4 + X1.11 c37 4 + X1.11 c38 4 + X1.11 c39 4 + X1.11 c40 4 + X1.11 c41 4 + X1.11 c42 4 + X1.11 c43 4 + X1.11 c44 4 + X1.11 c45 4 + X1.11 c46 4 + X1.11 c47 4 + X1.11 c48 4 + X1.11 c49 4 + X1.11 c50 4 + X1.11 c51 4 + X1.11 c52 4 + X1.11 c53 4 + X1.11 c54 4 + X1.11 c55 4 + X1.11 c56 4 + X1.11 c57 4 + X1.11 c58 4 + X1.11 c59 4 + X2.0 c3 1 + X2.0 c21 13 + X2.0 c22 13 + X2.0 c23 13 + X2.0 c24 13 + X2.0 c25 13 + X2.0 c26 13 + X2.0 c27 13 + X2.0 c28 13 + X2.0 c29 13 + X2.0 c30 13 + X2.0 c31 13 + X2.0 c32 13 + X2.0 c33 13 + X2.0 c34 13 + X2.0 c35 13 + X2.0 c36 13 + X2.0 c37 13 + X2.0 c38 13 + X2.0 c39 13 + X2.0 c40 13 + X2.0 c41 13 + X2.0 c42 13 + X2.0 c43 13 + X2.0 c44 13 + X2.0 c45 13 + X2.0 c46 13 + X2.0 c47 13 + X2.0 c48 13 + X2.0 c49 13 + X2.0 c50 13 + X2.0 c51 13 + X2.0 c52 13 + X2.0 c53 13 + X2.0 c54 13 + X2.0 c55 13 + X2.0 c56 13 + X2.11 c3 1 + X2.11 c22 13 + X2.11 c23 13 + X2.11 c24 13 + X2.11 c25 13 + X2.11 c26 13 + X2.11 c27 13 + X2.11 c28 13 + X2.11 c29 13 + X2.11 c30 13 + X2.11 c31 13 + X2.11 c32 13 + X2.11 c33 13 + X2.11 c34 13 + X2.11 c35 13 + X2.11 c36 13 + X2.11 c37 13 + X2.11 c38 13 + X2.11 c39 13 + X2.11 c40 13 + X2.11 c41 13 + X2.11 c42 13 + X2.11 c43 13 + X2.11 c44 13 + X2.11 c45 13 + X2.11 c46 13 + X2.11 c47 13 + X2.11 c48 13 + X2.11 c49 13 + X2.11 c50 13 + X2.11 c51 13 + X2.11 c52 13 + X2.11 c53 13 + X2.11 c54 13 + X2.11 c55 13 + X2.11 c56 13 + X2.11 c57 13 + X2.11 c58 13 + X2.11 c59 13 + X2.16 c3 1 + X2.16 c23 13 + X2.16 c24 13 + X2.16 c25 13 + X2.16 c26 13 + X2.16 c27 13 + X2.16 c28 13 + X2.16 c29 13 + X2.16 c30 13 + X2.16 c31 13 + X2.16 c32 13 + X2.16 c33 13 + X2.16 c34 13 + X2.16 c35 13 + X2.16 c36 13 + X2.16 c37 13 + X2.16 c38 13 + X2.16 c39 13 + X2.16 c40 13 + X2.16 c41 13 + X2.16 c42 13 + X2.16 c43 13 + X2.16 c44 13 + X2.16 c45 13 + X2.16 c46 13 + X2.16 c47 13 + X2.16 c48 13 + X2.16 c49 13 + X2.16 c50 13 + X2.16 c51 13 + X2.16 c52 13 + X2.16 c53 13 + X2.16 c54 13 + X2.16 c55 13 + X2.16 c56 13 + X2.16 c57 13 + X2.16 c58 13 + X2.16 c59 13 + X3.0 c4 1 + X3.0 c21 53 + X3.0 c22 53 + X3.0 c23 53 + X3.0 c24 53 + X3.0 c25 53 + X3.0 c26 53 + X3.0 c27 53 + X3.0 c28 53 + X3.0 c29 53 + X3.0 c30 53 + X3.0 c31 53 + X3.0 c32 53 + X3.0 c33 53 + X3.0 c34 53 + X3.0 c35 53 + X3.0 c36 53 + X3.0 c37 53 + X3.0 c38 53 + X3.0 c39 53 + X3.0 c40 53 + X3.0 c41 53 + X3.0 c42 53 + X3.0 c43 53 + X3.0 c44 53 + X3.0 c45 53 + X3.0 c46 53 + X3.0 c47 53 + X3.0 c48 53 + X3.0 c49 53 + X3.0 c50 53 + X3.0 c51 53 + X3.0 c52 53 + X3.0 c53 53 + X3.11 c4 1 + X3.11 c22 53 + X3.11 c23 53 + X3.11 c24 53 + X3.11 c25 53 + X3.11 c26 53 + X3.11 c27 53 + X3.11 c28 53 + X3.11 c29 53 + X3.11 c30 53 + X3.11 c31 53 + X3.11 c32 53 + X3.11 c33 53 + X3.11 c34 53 + X3.11 c35 53 + X3.11 c36 53 + X3.11 c37 53 + X3.11 c38 53 + X3.11 c39 53 + X3.11 c40 53 + X3.11 c41 53 + X3.11 c42 53 + X3.11 c43 53 + X3.11 c44 53 + X3.11 c45 53 + X3.11 c46 53 + X3.11 c47 53 + X3.11 c48 53 + X3.11 c49 53 + X3.11 c50 53 + X3.11 c51 53 + X3.11 c52 53 + X3.11 c53 53 + X3.11 c54 53 + X3.11 c55 53 + X3.11 c56 53 + X3.11 c57 53 + X3.16 c4 1 + X3.16 c23 53 + X3.16 c24 53 + X3.16 c25 53 + X3.16 c26 53 + X3.16 c27 53 + X3.16 c28 53 + X3.16 c29 53 + X3.16 c30 53 + X3.16 c31 53 + X3.16 c32 53 + X3.16 c33 53 + X3.16 c34 53 + X3.16 c35 53 + X3.16 c36 53 + X3.16 c37 53 + X3.16 c38 53 + X3.16 c39 53 + X3.16 c40 53 + X3.16 c41 53 + X3.16 c42 53 + X3.16 c43 53 + X3.16 c44 53 + X3.16 c45 53 + X3.16 c46 53 + X3.16 c47 53 + X3.16 c48 53 + X3.16 c49 53 + X3.16 c50 53 + X3.16 c51 53 + X3.16 c52 53 + X3.16 c53 53 + X3.16 c54 53 + X3.16 c55 53 + X3.16 c56 53 + X3.16 c57 53 + X3.16 c58 53 + X3.19 c4 1 + X3.19 c24 53 + X3.19 c25 53 + X3.19 c26 53 + X3.19 c27 53 + X3.19 c28 53 + X3.19 c29 53 + X3.19 c30 53 + X3.19 c31 53 + X3.19 c32 53 + X3.19 c33 53 + X3.19 c34 53 + X3.19 c35 53 + X3.19 c36 53 + X3.19 c37 53 + X3.19 c38 53 + X3.19 c39 53 + X3.19 c40 53 + X3.19 c41 53 + X3.19 c42 53 + X3.19 c43 53 + X3.19 c44 53 + X3.19 c45 53 + X3.19 c46 53 + X3.19 c47 53 + X3.19 c48 53 + X3.19 c49 53 + X3.19 c50 53 + X3.19 c51 53 + X3.19 c52 53 + X3.19 c53 53 + X3.19 c54 53 + X3.19 c55 53 + X3.19 c56 53 + X3.19 c57 53 + X3.19 c58 53 + X3.19 c59 53 + X4.0 c5 1 + X4.0 c21 50 + X4.0 c22 50 + X4.0 c23 50 + X4.0 c24 50 + X4.0 c25 50 + X4.0 c26 50 + X4.0 c27 50 + X4.0 c28 50 + X4.0 c29 50 + X4.0 c30 50 + X4.0 c31 50 + X4.0 c32 50 + X4.0 c33 50 + X4.0 c34 50 + X4.0 c35 50 + X4.0 c36 50 + X4.0 c37 50 + X4.0 c38 50 + X4.0 c39 50 + X4.0 c40 50 + X4.0 c41 50 + X4.0 c42 50 + X4.0 c43 50 + X4.0 c44 50 + X4.0 c45 50 + X4.0 c46 50 + X4.0 c47 50 + X4.0 c48 50 + X4.0 c49 50 + X4.0 c50 50 + X4.0 c51 50 + X4.0 c52 50 + X4.0 c53 50 + X4.11 c5 1 + X4.11 c22 50 + X4.11 c23 50 + X4.11 c24 50 + X4.11 c25 50 + X4.11 c26 50 + X4.11 c27 50 + X4.11 c28 50 + X4.11 c29 50 + X4.11 c30 50 + X4.11 c31 50 + X4.11 c32 50 + X4.11 c33 50 + X4.11 c34 50 + X4.11 c35 50 + X4.11 c36 50 + X4.11 c37 50 + X4.11 c38 50 + X4.11 c39 50 + X4.11 c40 50 + X4.11 c41 50 + X4.11 c42 50 + X4.11 c43 50 + X4.11 c44 50 + X4.11 c45 50 + X4.11 c46 50 + X4.11 c47 50 + X4.11 c48 50 + X4.11 c49 50 + X4.11 c50 50 + X4.11 c51 50 + X4.11 c52 50 + X4.11 c53 50 + X4.11 c54 50 + X4.11 c55 50 + X4.11 c56 50 + X4.11 c57 50 + X4.16 c5 1 + X4.16 c23 50 + X4.16 c24 50 + X4.16 c25 50 + X4.16 c26 50 + X4.16 c27 50 + X4.16 c28 50 + X4.16 c29 50 + X4.16 c30 50 + X4.16 c31 50 + X4.16 c32 50 + X4.16 c33 50 + X4.16 c34 50 + X4.16 c35 50 + X4.16 c36 50 + X4.16 c37 50 + X4.16 c38 50 + X4.16 c39 50 + X4.16 c40 50 + X4.16 c41 50 + X4.16 c42 50 + X4.16 c43 50 + X4.16 c44 50 + X4.16 c45 50 + X4.16 c46 50 + X4.16 c47 50 + X4.16 c48 50 + X4.16 c49 50 + X4.16 c50 50 + X4.16 c51 50 + X4.16 c52 50 + X4.16 c53 50 + X4.16 c54 50 + X4.16 c55 50 + X4.16 c56 50 + X4.16 c57 50 + X4.16 c58 50 + X4.19 c5 1 + X4.19 c24 50 + X4.19 c25 50 + X4.19 c26 50 + X4.19 c27 50 + X4.19 c28 50 + X4.19 c29 50 + X4.19 c30 50 + X4.19 c31 50 + X4.19 c32 50 + X4.19 c33 50 + X4.19 c34 50 + X4.19 c35 50 + X4.19 c36 50 + X4.19 c37 50 + X4.19 c38 50 + X4.19 c39 50 + X4.19 c40 50 + X4.19 c41 50 + X4.19 c42 50 + X4.19 c43 50 + X4.19 c44 50 + X4.19 c45 50 + X4.19 c46 50 + X4.19 c47 50 + X4.19 c48 50 + X4.19 c49 50 + X4.19 c50 50 + X4.19 c51 50 + X4.19 c52 50 + X4.19 c53 50 + X4.19 c54 50 + X4.19 c55 50 + X4.19 c56 50 + X4.19 c57 50 + X4.19 c58 50 + X4.19 c59 50 + X5.0 c6 1 + X5.0 c21 13 + X5.0 c22 13 + X5.0 c23 13 + X5.0 c24 13 + X5.0 c25 13 + X5.0 c26 13 + X5.0 c27 13 + X5.0 c28 13 + X5.0 c29 13 + X5.0 c30 13 + X5.0 c31 13 + X5.0 c32 13 + X5.0 c33 13 + X5.0 c34 13 + X5.0 c35 13 + X5.0 c36 13 + X5.0 c37 13 + X5.0 c38 13 + X5.0 c39 13 + X5.0 c40 13 + X5.0 c41 13 + X5.0 c42 13 + X5.0 c43 13 + X5.0 c44 13 + X5.0 c45 13 + X5.0 c46 13 + X5.0 c47 13 + X5.0 c48 13 + X5.0 c49 13 + X5.0 c50 13 + X5.0 c51 13 + X5.0 c52 13 + X5.0 c53 13 + X5.11 c6 1 + X5.11 c22 13 + X5.11 c23 13 + X5.11 c24 13 + X5.11 c25 13 + X5.11 c26 13 + X5.11 c27 13 + X5.11 c28 13 + X5.11 c29 13 + X5.11 c30 13 + X5.11 c31 13 + X5.11 c32 13 + X5.11 c33 13 + X5.11 c34 13 + X5.11 c35 13 + X5.11 c36 13 + X5.11 c37 13 + X5.11 c38 13 + X5.11 c39 13 + X5.11 c40 13 + X5.11 c41 13 + X5.11 c42 13 + X5.11 c43 13 + X5.11 c44 13 + X5.11 c45 13 + X5.11 c46 13 + X5.11 c47 13 + X5.11 c48 13 + X5.11 c49 13 + X5.11 c50 13 + X5.11 c51 13 + X5.11 c52 13 + X5.11 c53 13 + X5.11 c54 13 + X5.11 c55 13 + X5.11 c56 13 + X5.11 c57 13 + X5.16 c6 1 + X5.16 c23 13 + X5.16 c24 13 + X5.16 c25 13 + X5.16 c26 13 + X5.16 c27 13 + X5.16 c28 13 + X5.16 c29 13 + X5.16 c30 13 + X5.16 c31 13 + X5.16 c32 13 + X5.16 c33 13 + X5.16 c34 13 + X5.16 c35 13 + X5.16 c36 13 + X5.16 c37 13 + X5.16 c38 13 + X5.16 c39 13 + X5.16 c40 13 + X5.16 c41 13 + X5.16 c42 13 + X5.16 c43 13 + X5.16 c44 13 + X5.16 c45 13 + X5.16 c46 13 + X5.16 c47 13 + X5.16 c48 13 + X5.16 c49 13 + X5.16 c50 13 + X5.16 c51 13 + X5.16 c52 13 + X5.16 c53 13 + X5.16 c54 13 + X5.16 c55 13 + X5.16 c56 13 + X5.16 c57 13 + X5.16 c58 13 + X5.19 c6 1 + X5.19 c24 13 + X5.19 c25 13 + X5.19 c26 13 + X5.19 c27 13 + X5.19 c28 13 + X5.19 c29 13 + X5.19 c30 13 + X5.19 c31 13 + X5.19 c32 13 + X5.19 c33 13 + X5.19 c34 13 + X5.19 c35 13 + X5.19 c36 13 + X5.19 c37 13 + X5.19 c38 13 + X5.19 c39 13 + X5.19 c40 13 + X5.19 c41 13 + X5.19 c42 13 + X5.19 c43 13 + X5.19 c44 13 + X5.19 c45 13 + X5.19 c46 13 + X5.19 c47 13 + X5.19 c48 13 + X5.19 c49 13 + X5.19 c50 13 + X5.19 c51 13 + X5.19 c52 13 + X5.19 c53 13 + X5.19 c54 13 + X5.19 c55 13 + X5.19 c56 13 + X5.19 c57 13 + X5.19 c58 13 + X5.19 c59 13 + X6.0 c7 1 + X6.0 c21 1 + X6.0 c22 1 + X6.0 c23 1 + X6.0 c24 1 + X6.0 c25 1 + X6.0 c26 1 + X6.0 c27 1 + X6.0 c28 1 + X6.0 c29 1 + X6.0 c30 1 + X6.0 c31 1 + X6.0 c32 1 + X6.0 c33 1 + X6.0 c34 1 + X6.0 c35 1 + X6.0 c36 1 + X6.0 c37 1 + X6.0 c38 1 + X6.0 c39 1 + X6.0 c40 1 + X6.0 c41 1 + X6.0 c42 1 + X6.0 c43 1 + X6.0 c44 1 + X6.0 c45 1 + X6.0 c46 1 + X6.0 c47 1 + X6.0 c48 1 + X6.0 c49 1 + X6.0 c50 1 + X6.0 c51 1 + X6.11 c7 1 + X6.11 c22 1 + X6.11 c23 1 + X6.11 c24 1 + X6.11 c25 1 + X6.11 c26 1 + X6.11 c27 1 + X6.11 c28 1 + X6.11 c29 1 + X6.11 c30 1 + X6.11 c31 1 + X6.11 c32 1 + X6.11 c33 1 + X6.11 c34 1 + X6.11 c35 1 + X6.11 c36 1 + X6.11 c37 1 + X6.11 c38 1 + X6.11 c39 1 + X6.11 c40 1 + X6.11 c41 1 + X6.11 c42 1 + X6.11 c43 1 + X6.11 c44 1 + X6.11 c45 1 + X6.11 c46 1 + X6.11 c47 1 + X6.11 c48 1 + X6.11 c49 1 + X6.11 c50 1 + X6.11 c51 1 + X6.11 c52 1 + X6.11 c53 1 + X6.11 c54 1 + X6.11 c55 1 + X6.11 c56 1 + X6.16 c7 1 + X6.16 c23 1 + X6.16 c24 1 + X6.16 c25 1 + X6.16 c26 1 + X6.16 c27 1 + X6.16 c28 1 + X6.16 c29 1 + X6.16 c30 1 + X6.16 c31 1 + X6.16 c32 1 + X6.16 c33 1 + X6.16 c34 1 + X6.16 c35 1 + X6.16 c36 1 + X6.16 c37 1 + X6.16 c38 1 + X6.16 c39 1 + X6.16 c40 1 + X6.16 c41 1 + X6.16 c42 1 + X6.16 c43 1 + X6.16 c44 1 + X6.16 c45 1 + X6.16 c46 1 + X6.16 c47 1 + X6.16 c48 1 + X6.16 c49 1 + X6.16 c50 1 + X6.16 c51 1 + X6.16 c52 1 + X6.16 c53 1 + X6.16 c54 1 + X6.16 c55 1 + X6.16 c56 1 + X6.16 c57 1 + X6.19 c7 1 + X6.19 c24 1 + X6.19 c25 1 + X6.19 c26 1 + X6.19 c27 1 + X6.19 c28 1 + X6.19 c29 1 + X6.19 c30 1 + X6.19 c31 1 + X6.19 c32 1 + X6.19 c33 1 + X6.19 c34 1 + X6.19 c35 1 + X6.19 c36 1 + X6.19 c37 1 + X6.19 c38 1 + X6.19 c39 1 + X6.19 c40 1 + X6.19 c41 1 + X6.19 c42 1 + X6.19 c43 1 + X6.19 c44 1 + X6.19 c45 1 + X6.19 c46 1 + X6.19 c47 1 + X6.19 c48 1 + X6.19 c49 1 + X6.19 c50 1 + X6.19 c51 1 + X6.19 c52 1 + X6.19 c53 1 + X6.19 c54 1 + X6.19 c55 1 + X6.19 c56 1 + X6.19 c57 1 + X6.19 c58 1 + X6.27 c7 1 + X6.27 c25 1 + X6.27 c26 1 + X6.27 c27 1 + X6.27 c28 1 + X6.27 c29 1 + X6.27 c30 1 + X6.27 c31 1 + X6.27 c32 1 + X6.27 c33 1 + X6.27 c34 1 + X6.27 c35 1 + X6.27 c36 1 + X6.27 c37 1 + X6.27 c38 1 + X6.27 c39 1 + X6.27 c40 1 + X6.27 c41 1 + X6.27 c42 1 + X6.27 c43 1 + X6.27 c44 1 + X6.27 c45 1 + X6.27 c46 1 + X6.27 c47 1 + X6.27 c48 1 + X6.27 c49 1 + X6.27 c50 1 + X6.27 c51 1 + X6.27 c52 1 + X6.27 c53 1 + X6.27 c54 1 + X6.27 c55 1 + X6.27 c56 1 + X6.27 c57 1 + X6.27 c58 1 + X6.27 c59 1 + X7.0 c8 1 + X7.0 c21 61 + X7.0 c22 61 + X7.0 c23 61 + X7.0 c24 61 + X7.0 c25 61 + X7.0 c26 61 + X7.0 c27 61 + X7.0 c28 61 + X7.0 c29 61 + X7.0 c30 61 + X7.0 c31 61 + X7.0 c32 61 + X7.0 c33 61 + X7.0 c34 61 + X7.0 c35 61 + X7.0 c36 61 + X7.0 c37 61 + X7.0 c38 61 + X7.0 c39 61 + X7.0 c40 61 + X7.0 c41 61 + X7.0 c42 61 + X7.0 c43 61 + X7.0 c44 61 + X7.0 c45 61 + X7.0 c46 61 + X7.0 c47 61 + X7.0 c48 61 + X7.0 c49 61 + X7.11 c8 1 + X7.11 c22 61 + X7.11 c23 61 + X7.11 c24 61 + X7.11 c25 61 + X7.11 c26 61 + X7.11 c27 61 + X7.11 c28 61 + X7.11 c29 61 + X7.11 c30 61 + X7.11 c31 61 + X7.11 c32 61 + X7.11 c33 61 + X7.11 c34 61 + X7.11 c35 61 + X7.11 c36 61 + X7.11 c37 61 + X7.11 c38 61 + X7.11 c39 61 + X7.11 c40 61 + X7.11 c41 61 + X7.11 c42 61 + X7.11 c43 61 + X7.11 c44 61 + X7.11 c45 61 + X7.11 c46 61 + X7.11 c47 61 + X7.11 c48 61 + X7.11 c49 61 + X7.11 c50 61 + X7.11 c51 61 + X7.11 c52 61 + X7.11 c53 61 + X7.11 c54 61 + X7.16 c8 1 + X7.16 c23 61 + X7.16 c24 61 + X7.16 c25 61 + X7.16 c26 61 + X7.16 c27 61 + X7.16 c28 61 + X7.16 c29 61 + X7.16 c30 61 + X7.16 c31 61 + X7.16 c32 61 + X7.16 c33 61 + X7.16 c34 61 + X7.16 c35 61 + X7.16 c36 61 + X7.16 c37 61 + X7.16 c38 61 + X7.16 c39 61 + X7.16 c40 61 + X7.16 c41 61 + X7.16 c42 61 + X7.16 c43 61 + X7.16 c44 61 + X7.16 c45 61 + X7.16 c46 61 + X7.16 c47 61 + X7.16 c48 61 + X7.16 c49 61 + X7.16 c50 61 + X7.16 c51 61 + X7.16 c52 61 + X7.16 c53 61 + X7.16 c54 61 + X7.16 c55 61 + X7.16 c56 61 + X7.19 c8 1 + X7.19 c24 61 + X7.19 c25 61 + X7.19 c26 61 + X7.19 c27 61 + X7.19 c28 61 + X7.19 c29 61 + X7.19 c30 61 + X7.19 c31 61 + X7.19 c32 61 + X7.19 c33 61 + X7.19 c34 61 + X7.19 c35 61 + X7.19 c36 61 + X7.19 c37 61 + X7.19 c38 61 + X7.19 c39 61 + X7.19 c40 61 + X7.19 c41 61 + X7.19 c42 61 + X7.19 c43 61 + X7.19 c44 61 + X7.19 c45 61 + X7.19 c46 61 + X7.19 c47 61 + X7.19 c48 61 + X7.19 c49 61 + X7.19 c50 61 + X7.19 c51 61 + X7.19 c52 61 + X7.19 c53 61 + X7.19 c54 61 + X7.19 c55 61 + X7.19 c56 61 + X7.19 c57 61 + X7.27 c8 1 + X7.27 c25 61 + X7.27 c26 61 + X7.27 c27 61 + X7.27 c28 61 + X7.27 c29 61 + X7.27 c30 61 + X7.27 c31 61 + X7.27 c32 61 + X7.27 c33 61 + X7.27 c34 61 + X7.27 c35 61 + X7.27 c36 61 + X7.27 c37 61 + X7.27 c38 61 + X7.27 c39 61 + X7.27 c40 61 + X7.27 c41 61 + X7.27 c42 61 + X7.27 c43 61 + X7.27 c44 61 + X7.27 c45 61 + X7.27 c46 61 + X7.27 c47 61 + X7.27 c48 61 + X7.27 c49 61 + X7.27 c50 61 + X7.27 c51 61 + X7.27 c52 61 + X7.27 c53 61 + X7.27 c54 61 + X7.27 c55 61 + X7.27 c56 61 + X7.27 c57 61 + X7.27 c58 61 + X7.27 c59 61 + X7.30 c8 1 + X7.30 c26 61 + X7.30 c27 61 + X7.30 c28 61 + X7.30 c29 61 + X7.30 c30 61 + X7.30 c31 61 + X7.30 c32 61 + X7.30 c33 61 + X7.30 c34 61 + X7.30 c35 61 + X7.30 c36 61 + X7.30 c37 61 + X7.30 c38 61 + X7.30 c39 61 + X7.30 c40 61 + X7.30 c41 61 + X7.30 c42 61 + X7.30 c43 61 + X7.30 c44 61 + X7.30 c45 61 + X7.30 c46 61 + X7.30 c47 61 + X7.30 c48 61 + X7.30 c49 61 + X7.30 c50 61 + X7.30 c51 61 + X7.30 c52 61 + X7.30 c53 61 + X7.30 c54 61 + X7.30 c55 61 + X7.30 c56 61 + X7.30 c57 61 + X7.30 c58 61 + X7.30 c59 61 + X7.32 c8 1 + X7.32 c27 61 + X7.32 c28 61 + X7.32 c29 61 + X7.32 c30 61 + X7.32 c31 61 + X7.32 c32 61 + X7.32 c33 61 + X7.32 c34 61 + X7.32 c35 61 + X7.32 c36 61 + X7.32 c37 61 + X7.32 c38 61 + X7.32 c39 61 + X7.32 c40 61 + X7.32 c41 61 + X7.32 c42 61 + X7.32 c43 61 + X7.32 c44 61 + X7.32 c45 61 + X7.32 c46 61 + X7.32 c47 61 + X7.32 c48 61 + X7.32 c49 61 + X7.32 c50 61 + X7.32 c51 61 + X7.32 c52 61 + X7.32 c53 61 + X7.32 c54 61 + X7.32 c55 61 + X7.32 c56 61 + X7.32 c57 61 + X7.32 c58 61 + X7.32 c59 61 + X8.0 c9 1 + X8.0 c21 81 + X8.0 c22 81 + X8.0 c23 81 + X8.0 c24 81 + X8.0 c25 81 + X8.0 c26 81 + X8.0 c27 81 + X8.0 c28 81 + X8.0 c29 81 + X8.0 c30 81 + X8.0 c31 81 + X8.0 c32 81 + X8.0 c33 81 + X8.0 c34 81 + X8.0 c35 81 + X8.0 c36 81 + X8.0 c37 81 + X8.0 c38 81 + X8.0 c39 81 + X8.0 c40 81 + X8.0 c41 81 + X8.0 c42 81 + X8.0 c43 81 + X8.0 c44 81 + X8.0 c45 81 + X8.0 c46 81 + X8.0 c47 81 + X8.0 c48 81 + X8.11 c9 1 + X8.11 c22 81 + X8.11 c23 81 + X8.11 c24 81 + X8.11 c25 81 + X8.11 c26 81 + X8.11 c27 81 + X8.11 c28 81 + X8.11 c29 81 + X8.11 c30 81 + X8.11 c31 81 + X8.11 c32 81 + X8.11 c33 81 + X8.11 c34 81 + X8.11 c35 81 + X8.11 c36 81 + X8.11 c37 81 + X8.11 c38 81 + X8.11 c39 81 + X8.11 c40 81 + X8.11 c41 81 + X8.11 c42 81 + X8.11 c43 81 + X8.11 c44 81 + X8.11 c45 81 + X8.11 c46 81 + X8.11 c47 81 + X8.11 c48 81 + X8.11 c49 81 + X8.11 c50 81 + X8.11 c51 81 + X8.11 c52 81 + X8.11 c53 81 + X8.11 c54 81 + X8.16 c9 1 + X8.16 c23 81 + X8.16 c24 81 + X8.16 c25 81 + X8.16 c26 81 + X8.16 c27 81 + X8.16 c28 81 + X8.16 c29 81 + X8.16 c30 81 + X8.16 c31 81 + X8.16 c32 81 + X8.16 c33 81 + X8.16 c34 81 + X8.16 c35 81 + X8.16 c36 81 + X8.16 c37 81 + X8.16 c38 81 + X8.16 c39 81 + X8.16 c40 81 + X8.16 c41 81 + X8.16 c42 81 + X8.16 c43 81 + X8.16 c44 81 + X8.16 c45 81 + X8.16 c46 81 + X8.16 c47 81 + X8.16 c48 81 + X8.16 c49 81 + X8.16 c50 81 + X8.16 c51 81 + X8.16 c52 81 + X8.16 c53 81 + X8.16 c54 81 + X8.16 c55 81 + X8.16 c56 81 + X8.19 c9 1 + X8.19 c24 81 + X8.19 c25 81 + X8.19 c26 81 + X8.19 c27 81 + X8.19 c28 81 + X8.19 c29 81 + X8.19 c30 81 + X8.19 c31 81 + X8.19 c32 81 + X8.19 c33 81 + X8.19 c34 81 + X8.19 c35 81 + X8.19 c36 81 + X8.19 c37 81 + X8.19 c38 81 + X8.19 c39 81 + X8.19 c40 81 + X8.19 c41 81 + X8.19 c42 81 + X8.19 c43 81 + X8.19 c44 81 + X8.19 c45 81 + X8.19 c46 81 + X8.19 c47 81 + X8.19 c48 81 + X8.19 c49 81 + X8.19 c50 81 + X8.19 c51 81 + X8.19 c52 81 + X8.19 c53 81 + X8.19 c54 81 + X8.19 c55 81 + X8.19 c56 81 + X8.19 c57 81 + X8.27 c9 1 + X8.27 c25 81 + X8.27 c26 81 + X8.27 c27 81 + X8.27 c28 81 + X8.27 c29 81 + X8.27 c30 81 + X8.27 c31 81 + X8.27 c32 81 + X8.27 c33 81 + X8.27 c34 81 + X8.27 c35 81 + X8.27 c36 81 + X8.27 c37 81 + X8.27 c38 81 + X8.27 c39 81 + X8.27 c40 81 + X8.27 c41 81 + X8.27 c42 81 + X8.27 c43 81 + X8.27 c44 81 + X8.27 c45 81 + X8.27 c46 81 + X8.27 c47 81 + X8.27 c48 81 + X8.27 c49 81 + X8.27 c50 81 + X8.27 c51 81 + X8.27 c52 81 + X8.27 c53 81 + X8.27 c54 81 + X8.27 c55 81 + X8.27 c56 81 + X8.27 c57 81 + X8.27 c58 81 + X8.27 c59 81 + X8.30 c9 1 + X8.30 c26 81 + X8.30 c27 81 + X8.30 c28 81 + X8.30 c29 81 + X8.30 c30 81 + X8.30 c31 81 + X8.30 c32 81 + X8.30 c33 81 + X8.30 c34 81 + X8.30 c35 81 + X8.30 c36 81 + X8.30 c37 81 + X8.30 c38 81 + X8.30 c39 81 + X8.30 c40 81 + X8.30 c41 81 + X8.30 c42 81 + X8.30 c43 81 + X8.30 c44 81 + X8.30 c45 81 + X8.30 c46 81 + X8.30 c47 81 + X8.30 c48 81 + X8.30 c49 81 + X8.30 c50 81 + X8.30 c51 81 + X8.30 c52 81 + X8.30 c53 81 + X8.30 c54 81 + X8.30 c55 81 + X8.30 c56 81 + X8.30 c57 81 + X8.30 c58 81 + X8.30 c59 81 + X8.32 c9 1 + X8.32 c27 81 + X8.32 c28 81 + X8.32 c29 81 + X8.32 c30 81 + X8.32 c31 81 + X8.32 c32 81 + X8.32 c33 81 + X8.32 c34 81 + X8.32 c35 81 + X8.32 c36 81 + X8.32 c37 81 + X8.32 c38 81 + X8.32 c39 81 + X8.32 c40 81 + X8.32 c41 81 + X8.32 c42 81 + X8.32 c43 81 + X8.32 c44 81 + X8.32 c45 81 + X8.32 c46 81 + X8.32 c47 81 + X8.32 c48 81 + X8.32 c49 81 + X8.32 c50 81 + X8.32 c51 81 + X8.32 c52 81 + X8.32 c53 81 + X8.32 c54 81 + X8.32 c55 81 + X8.32 c56 81 + X8.32 c57 81 + X8.32 c58 81 + X8.32 c59 81 + X8.34 c9 1 + X8.34 c28 81 + X8.34 c29 81 + X8.34 c30 81 + X8.34 c31 81 + X8.34 c32 81 + X8.34 c33 81 + X8.34 c34 81 + X8.34 c35 81 + X8.34 c36 81 + X8.34 c37 81 + X8.34 c38 81 + X8.34 c39 81 + X8.34 c40 81 + X8.34 c41 81 + X8.34 c42 81 + X8.34 c43 81 + X8.34 c44 81 + X8.34 c45 81 + X8.34 c46 81 + X8.34 c47 81 + X8.34 c48 81 + X8.34 c49 81 + X8.34 c50 81 + X8.34 c51 81 + X8.34 c52 81 + X8.34 c53 81 + X8.34 c54 81 + X8.34 c55 81 + X8.34 c56 81 + X8.34 c57 81 + X8.34 c58 81 + X8.34 c59 81 + X9.0 c10 1 + X9.0 c21 78 + X9.0 c22 78 + X9.0 c23 78 + X9.0 c24 78 + X9.0 c25 78 + X9.0 c26 78 + X9.0 c27 78 + X9.0 c28 78 + X9.0 c29 78 + X9.0 c30 78 + X9.0 c31 78 + X9.0 c32 78 + X9.0 c33 78 + X9.0 c34 78 + X9.0 c35 78 + X9.0 c36 78 + X9.11 c10 1 + X9.11 c22 78 + X9.11 c23 78 + X9.11 c24 78 + X9.11 c25 78 + X9.11 c26 78 + X9.11 c27 78 + X9.11 c28 78 + X9.11 c29 78 + X9.11 c30 78 + X9.11 c31 78 + X9.11 c32 78 + X9.11 c33 78 + X9.11 c34 78 + X9.11 c35 78 + X9.11 c36 78 + X9.11 c37 78 + X9.11 c38 78 + X9.11 c39 78 + X9.11 c40 78 + X9.11 c41 78 + X9.11 c42 78 + X9.11 c43 78 + X9.11 c44 78 + X9.11 c45 78 + X9.16 c10 1 + X9.16 c23 78 + X9.16 c24 78 + X9.16 c25 78 + X9.16 c26 78 + X9.16 c27 78 + X9.16 c28 78 + X9.16 c29 78 + X9.16 c30 78 + X9.16 c31 78 + X9.16 c32 78 + X9.16 c33 78 + X9.16 c34 78 + X9.16 c35 78 + X9.16 c36 78 + X9.16 c37 78 + X9.16 c38 78 + X9.16 c39 78 + X9.16 c40 78 + X9.16 c41 78 + X9.16 c42 78 + X9.16 c43 78 + X9.16 c44 78 + X9.16 c45 78 + X9.16 c46 78 + X9.16 c47 78 + X9.19 c10 1 + X9.19 c24 78 + X9.19 c25 78 + X9.19 c26 78 + X9.19 c27 78 + X9.19 c28 78 + X9.19 c29 78 + X9.19 c30 78 + X9.19 c31 78 + X9.19 c32 78 + X9.19 c33 78 + X9.19 c34 78 + X9.19 c35 78 + X9.19 c36 78 + X9.19 c37 78 + X9.19 c38 78 + X9.19 c39 78 + X9.19 c40 78 + X9.19 c41 78 + X9.19 c42 78 + X9.19 c43 78 + X9.19 c44 78 + X9.19 c45 78 + X9.19 c46 78 + X9.19 c47 78 + X9.19 c48 78 + X9.19 c49 78 + X9.27 c10 1 + X9.27 c25 78 + X9.27 c26 78 + X9.27 c27 78 + X9.27 c28 78 + X9.27 c29 78 + X9.27 c30 78 + X9.27 c31 78 + X9.27 c32 78 + X9.27 c33 78 + X9.27 c34 78 + X9.27 c35 78 + X9.27 c36 78 + X9.27 c37 78 + X9.27 c38 78 + X9.27 c39 78 + X9.27 c40 78 + X9.27 c41 78 + X9.27 c42 78 + X9.27 c43 78 + X9.27 c44 78 + X9.27 c45 78 + X9.27 c46 78 + X9.27 c47 78 + X9.27 c48 78 + X9.27 c49 78 + X9.27 c50 78 + X9.27 c51 78 + X9.27 c52 78 + X9.27 c53 78 + X9.27 c54 78 + X9.30 c10 1 + X9.30 c26 78 + X9.30 c27 78 + X9.30 c28 78 + X9.30 c29 78 + X9.30 c30 78 + X9.30 c31 78 + X9.30 c32 78 + X9.30 c33 78 + X9.30 c34 78 + X9.30 c35 78 + X9.30 c36 78 + X9.30 c37 78 + X9.30 c38 78 + X9.30 c39 78 + X9.30 c40 78 + X9.30 c41 78 + X9.30 c42 78 + X9.30 c43 78 + X9.30 c44 78 + X9.30 c45 78 + X9.30 c46 78 + X9.30 c47 78 + X9.30 c48 78 + X9.30 c49 78 + X9.30 c50 78 + X9.30 c51 78 + X9.30 c52 78 + X9.30 c53 78 + X9.30 c54 78 + X9.32 c10 1 + X9.32 c27 78 + X9.32 c28 78 + X9.32 c29 78 + X9.32 c30 78 + X9.32 c31 78 + X9.32 c32 78 + X9.32 c33 78 + X9.32 c34 78 + X9.32 c35 78 + X9.32 c36 78 + X9.32 c37 78 + X9.32 c38 78 + X9.32 c39 78 + X9.32 c40 78 + X9.32 c41 78 + X9.32 c42 78 + X9.32 c43 78 + X9.32 c44 78 + X9.32 c45 78 + X9.32 c46 78 + X9.32 c47 78 + X9.32 c48 78 + X9.32 c49 78 + X9.32 c50 78 + X9.32 c51 78 + X9.32 c52 78 + X9.32 c53 78 + X9.32 c54 78 + X9.32 c55 78 + X9.34 c10 1 + X9.34 c28 78 + X9.34 c29 78 + X9.34 c30 78 + X9.34 c31 78 + X9.34 c32 78 + X9.34 c33 78 + X9.34 c34 78 + X9.34 c35 78 + X9.34 c36 78 + X9.34 c37 78 + X9.34 c38 78 + X9.34 c39 78 + X9.34 c40 78 + X9.34 c41 78 + X9.34 c42 78 + X9.34 c43 78 + X9.34 c44 78 + X9.34 c45 78 + X9.34 c46 78 + X9.34 c47 78 + X9.34 c48 78 + X9.34 c49 78 + X9.34 c50 78 + X9.34 c51 78 + X9.34 c52 78 + X9.34 c53 78 + X9.34 c54 78 + X9.34 c55 78 + X9.34 c56 78 + X9.35 c10 1 + X9.35 c29 78 + X9.35 c30 78 + X9.35 c31 78 + X9.35 c32 78 + X9.35 c33 78 + X9.35 c34 78 + X9.35 c35 78 + X9.35 c36 78 + X9.35 c37 78 + X9.35 c38 78 + X9.35 c39 78 + X9.35 c40 78 + X9.35 c41 78 + X9.35 c42 78 + X9.35 c43 78 + X9.35 c44 78 + X9.35 c45 78 + X9.35 c46 78 + X9.35 c47 78 + X9.35 c48 78 + X9.35 c49 78 + X9.35 c50 78 + X9.35 c51 78 + X9.35 c52 78 + X9.35 c53 78 + X9.35 c54 78 + X9.35 c55 78 + X9.35 c56 78 + X9.36 c10 1 + X9.36 c30 78 + X9.36 c31 78 + X9.36 c32 78 + X9.36 c33 78 + X9.36 c34 78 + X9.36 c35 78 + X9.36 c36 78 + X9.36 c37 78 + X9.36 c38 78 + X9.36 c39 78 + X9.36 c40 78 + X9.36 c41 78 + X9.36 c42 78 + X9.36 c43 78 + X9.36 c44 78 + X9.36 c45 78 + X9.36 c46 78 + X9.36 c47 78 + X9.36 c48 78 + X9.36 c49 78 + X9.36 c50 78 + X9.36 c51 78 + X9.36 c52 78 + X9.36 c53 78 + X9.36 c54 78 + X9.36 c55 78 + X9.36 c56 78 + X9.36 c57 78 + X9.41 c10 1 + X9.41 c31 78 + X9.41 c32 78 + X9.41 c33 78 + X9.41 c34 78 + X9.41 c35 78 + X9.41 c36 78 + X9.41 c37 78 + X9.41 c38 78 + X9.41 c39 78 + X9.41 c40 78 + X9.41 c41 78 + X9.41 c42 78 + X9.41 c43 78 + X9.41 c44 78 + X9.41 c45 78 + X9.41 c46 78 + X9.41 c47 78 + X9.41 c48 78 + X9.41 c49 78 + X9.41 c50 78 + X9.41 c51 78 + X9.41 c52 78 + X9.41 c53 78 + X9.41 c54 78 + X9.41 c55 78 + X9.41 c56 78 + X9.41 c57 78 + X9.41 c58 78 + X9.43 c10 1 + X9.43 c32 78 + X9.43 c33 78 + X9.43 c34 78 + X9.43 c35 78 + X9.43 c36 78 + X9.43 c37 78 + X9.43 c38 78 + X9.43 c39 78 + X9.43 c40 78 + X9.43 c41 78 + X9.43 c42 78 + X9.43 c43 78 + X9.43 c44 78 + X9.43 c45 78 + X9.43 c46 78 + X9.43 c47 78 + X9.43 c48 78 + X9.43 c49 78 + X9.43 c50 78 + X9.43 c51 78 + X9.43 c52 78 + X9.43 c53 78 + X9.43 c54 78 + X9.43 c55 78 + X9.43 c56 78 + X9.43 c57 78 + X9.43 c58 78 + X9.43 c59 78 + X9.45 c10 1 + X9.45 c33 78 + X9.45 c34 78 + X9.45 c35 78 + X9.45 c36 78 + X9.45 c37 78 + X9.45 c38 78 + X9.45 c39 78 + X9.45 c40 78 + X9.45 c41 78 + X9.45 c42 78 + X9.45 c43 78 + X9.45 c44 78 + X9.45 c45 78 + X9.45 c46 78 + X9.45 c47 78 + X9.45 c48 78 + X9.45 c49 78 + X9.45 c50 78 + X9.45 c51 78 + X9.45 c52 78 + X9.45 c53 78 + X9.45 c54 78 + X9.45 c55 78 + X9.45 c56 78 + X9.45 c57 78 + X9.45 c58 78 + X9.45 c59 78 + X9.51 c10 1 + X9.51 c39 78 + X9.51 c40 78 + X9.51 c41 78 + X9.51 c42 78 + X9.51 c43 78 + X9.51 c44 78 + X9.51 c45 78 + X9.51 c46 78 + X9.51 c47 78 + X9.51 c48 78 + X9.51 c49 78 + X9.51 c50 78 + X9.51 c51 78 + X9.51 c52 78 + X9.51 c53 78 + X9.51 c54 78 + X9.51 c55 78 + X9.51 c56 78 + X9.51 c57 78 + X9.51 c58 78 + X9.51 c59 78 + X10.0 c11 1 + X10.0 c21 69 + X10.0 c22 69 + X10.0 c23 69 + X10.0 c24 69 + X10.0 c25 69 + X10.0 c26 69 + X10.0 c27 69 + X10.0 c28 69 + X10.0 c29 69 + X10.0 c30 69 + X10.0 c31 69 + X10.0 c32 69 + X10.0 c33 69 + X10.0 c34 69 + X10.0 c35 69 + X10.11 c11 1 + X10.11 c22 69 + X10.11 c23 69 + X10.11 c24 69 + X10.11 c25 69 + X10.11 c26 69 + X10.11 c27 69 + X10.11 c28 69 + X10.11 c29 69 + X10.11 c30 69 + X10.11 c31 69 + X10.11 c32 69 + X10.11 c33 69 + X10.11 c34 69 + X10.11 c35 69 + X10.11 c36 69 + X10.11 c37 69 + X10.11 c38 69 + X10.11 c39 69 + X10.11 c40 69 + X10.11 c41 69 + X10.11 c42 69 + X10.11 c43 69 + X10.11 c44 69 + X10.16 c11 1 + X10.16 c23 69 + X10.16 c24 69 + X10.16 c25 69 + X10.16 c26 69 + X10.16 c27 69 + X10.16 c28 69 + X10.16 c29 69 + X10.16 c30 69 + X10.16 c31 69 + X10.16 c32 69 + X10.16 c33 69 + X10.16 c34 69 + X10.16 c35 69 + X10.16 c36 69 + X10.16 c37 69 + X10.16 c38 69 + X10.16 c39 69 + X10.16 c40 69 + X10.16 c41 69 + X10.16 c42 69 + X10.16 c43 69 + X10.16 c44 69 + X10.16 c45 69 + X10.16 c46 69 + X10.19 c11 1 + X10.19 c24 69 + X10.19 c25 69 + X10.19 c26 69 + X10.19 c27 69 + X10.19 c28 69 + X10.19 c29 69 + X10.19 c30 69 + X10.19 c31 69 + X10.19 c32 69 + X10.19 c33 69 + X10.19 c34 69 + X10.19 c35 69 + X10.19 c36 69 + X10.19 c37 69 + X10.19 c38 69 + X10.19 c39 69 + X10.19 c40 69 + X10.19 c41 69 + X10.19 c42 69 + X10.19 c43 69 + X10.19 c44 69 + X10.19 c45 69 + X10.19 c46 69 + X10.19 c47 69 + X10.19 c48 69 + X10.19 c49 69 + X10.27 c11 1 + X10.27 c25 69 + X10.27 c26 69 + X10.27 c27 69 + X10.27 c28 69 + X10.27 c29 69 + X10.27 c30 69 + X10.27 c31 69 + X10.27 c32 69 + X10.27 c33 69 + X10.27 c34 69 + X10.27 c35 69 + X10.27 c36 69 + X10.27 c37 69 + X10.27 c38 69 + X10.27 c39 69 + X10.27 c40 69 + X10.27 c41 69 + X10.27 c42 69 + X10.27 c43 69 + X10.27 c44 69 + X10.27 c45 69 + X10.27 c46 69 + X10.27 c47 69 + X10.27 c48 69 + X10.27 c49 69 + X10.27 c50 69 + X10.27 c51 69 + X10.27 c52 69 + X10.27 c53 69 + X10.30 c11 1 + X10.30 c26 69 + X10.30 c27 69 + X10.30 c28 69 + X10.30 c29 69 + X10.30 c30 69 + X10.30 c31 69 + X10.30 c32 69 + X10.30 c33 69 + X10.30 c34 69 + X10.30 c35 69 + X10.30 c36 69 + X10.30 c37 69 + X10.30 c38 69 + X10.30 c39 69 + X10.30 c40 69 + X10.30 c41 69 + X10.30 c42 69 + X10.30 c43 69 + X10.30 c44 69 + X10.30 c45 69 + X10.30 c46 69 + X10.30 c47 69 + X10.30 c48 69 + X10.30 c49 69 + X10.30 c50 69 + X10.30 c51 69 + X10.30 c52 69 + X10.30 c53 69 + X10.30 c54 69 + X10.32 c11 1 + X10.32 c27 69 + X10.32 c28 69 + X10.32 c29 69 + X10.32 c30 69 + X10.32 c31 69 + X10.32 c32 69 + X10.32 c33 69 + X10.32 c34 69 + X10.32 c35 69 + X10.32 c36 69 + X10.32 c37 69 + X10.32 c38 69 + X10.32 c39 69 + X10.32 c40 69 + X10.32 c41 69 + X10.32 c42 69 + X10.32 c43 69 + X10.32 c44 69 + X10.32 c45 69 + X10.32 c46 69 + X10.32 c47 69 + X10.32 c48 69 + X10.32 c49 69 + X10.32 c50 69 + X10.32 c51 69 + X10.32 c52 69 + X10.32 c53 69 + X10.32 c54 69 + X10.34 c11 1 + X10.34 c28 69 + X10.34 c29 69 + X10.34 c30 69 + X10.34 c31 69 + X10.34 c32 69 + X10.34 c33 69 + X10.34 c34 69 + X10.34 c35 69 + X10.34 c36 69 + X10.34 c37 69 + X10.34 c38 69 + X10.34 c39 69 + X10.34 c40 69 + X10.34 c41 69 + X10.34 c42 69 + X10.34 c43 69 + X10.34 c44 69 + X10.34 c45 69 + X10.34 c46 69 + X10.34 c47 69 + X10.34 c48 69 + X10.34 c49 69 + X10.34 c50 69 + X10.34 c51 69 + X10.34 c52 69 + X10.34 c53 69 + X10.34 c54 69 + X10.34 c55 69 + X10.34 c56 69 + X10.35 c11 1 + X10.35 c29 69 + X10.35 c30 69 + X10.35 c31 69 + X10.35 c32 69 + X10.35 c33 69 + X10.35 c34 69 + X10.35 c35 69 + X10.35 c36 69 + X10.35 c37 69 + X10.35 c38 69 + X10.35 c39 69 + X10.35 c40 69 + X10.35 c41 69 + X10.35 c42 69 + X10.35 c43 69 + X10.35 c44 69 + X10.35 c45 69 + X10.35 c46 69 + X10.35 c47 69 + X10.35 c48 69 + X10.35 c49 69 + X10.35 c50 69 + X10.35 c51 69 + X10.35 c52 69 + X10.35 c53 69 + X10.35 c54 69 + X10.35 c55 69 + X10.35 c56 69 + X10.36 c11 1 + X10.36 c30 69 + X10.36 c31 69 + X10.36 c32 69 + X10.36 c33 69 + X10.36 c34 69 + X10.36 c35 69 + X10.36 c36 69 + X10.36 c37 69 + X10.36 c38 69 + X10.36 c39 69 + X10.36 c40 69 + X10.36 c41 69 + X10.36 c42 69 + X10.36 c43 69 + X10.36 c44 69 + X10.36 c45 69 + X10.36 c46 69 + X10.36 c47 69 + X10.36 c48 69 + X10.36 c49 69 + X10.36 c50 69 + X10.36 c51 69 + X10.36 c52 69 + X10.36 c53 69 + X10.36 c54 69 + X10.36 c55 69 + X10.36 c56 69 + X10.41 c11 1 + X10.41 c31 69 + X10.41 c32 69 + X10.41 c33 69 + X10.41 c34 69 + X10.41 c35 69 + X10.41 c36 69 + X10.41 c37 69 + X10.41 c38 69 + X10.41 c39 69 + X10.41 c40 69 + X10.41 c41 69 + X10.41 c42 69 + X10.41 c43 69 + X10.41 c44 69 + X10.41 c45 69 + X10.41 c46 69 + X10.41 c47 69 + X10.41 c48 69 + X10.41 c49 69 + X10.41 c50 69 + X10.41 c51 69 + X10.41 c52 69 + X10.41 c53 69 + X10.41 c54 69 + X10.41 c55 69 + X10.41 c56 69 + X10.41 c57 69 + X10.43 c11 1 + X10.43 c32 69 + X10.43 c33 69 + X10.43 c34 69 + X10.43 c35 69 + X10.43 c36 69 + X10.43 c37 69 + X10.43 c38 69 + X10.43 c39 69 + X10.43 c40 69 + X10.43 c41 69 + X10.43 c42 69 + X10.43 c43 69 + X10.43 c44 69 + X10.43 c45 69 + X10.43 c46 69 + X10.43 c47 69 + X10.43 c48 69 + X10.43 c49 69 + X10.43 c50 69 + X10.43 c51 69 + X10.43 c52 69 + X10.43 c53 69 + X10.43 c54 69 + X10.43 c55 69 + X10.43 c56 69 + X10.43 c57 69 + X10.43 c58 69 + X10.45 c11 1 + X10.45 c33 69 + X10.45 c34 69 + X10.45 c35 69 + X10.45 c36 69 + X10.45 c37 69 + X10.45 c38 69 + X10.45 c39 69 + X10.45 c40 69 + X10.45 c41 69 + X10.45 c42 69 + X10.45 c43 69 + X10.45 c44 69 + X10.45 c45 69 + X10.45 c46 69 + X10.45 c47 69 + X10.45 c48 69 + X10.45 c49 69 + X10.45 c50 69 + X10.45 c51 69 + X10.45 c52 69 + X10.45 c53 69 + X10.45 c54 69 + X10.45 c55 69 + X10.45 c56 69 + X10.45 c57 69 + X10.45 c58 69 + X10.45 c59 69 + X10.52 c11 1 + X10.52 c40 69 + X10.52 c41 69 + X10.52 c42 69 + X10.52 c43 69 + X10.52 c44 69 + X10.52 c45 69 + X10.52 c46 69 + X10.52 c47 69 + X10.52 c48 69 + X10.52 c49 69 + X10.52 c50 69 + X10.52 c51 69 + X10.52 c52 69 + X10.52 c53 69 + X10.52 c54 69 + X10.52 c55 69 + X10.52 c56 69 + X10.52 c57 69 + X10.52 c58 69 + X10.52 c59 69 + X11.0 c12 1 + X11.0 c21 71 + X11.0 c22 71 + X11.0 c23 71 + X11.0 c24 71 + X11.0 c25 71 + X11.0 c26 71 + X11.0 c27 71 + X11.0 c28 71 + X11.0 c29 71 + X11.0 c30 71 + X11.0 c31 71 + X11.0 c32 71 + X11.11 c12 1 + X11.11 c22 71 + X11.11 c23 71 + X11.11 c24 71 + X11.11 c25 71 + X11.11 c26 71 + X11.11 c27 71 + X11.11 c28 71 + X11.11 c29 71 + X11.11 c30 71 + X11.11 c31 71 + X11.11 c32 71 + X11.11 c33 71 + X11.11 c34 71 + X11.11 c35 71 + X11.11 c36 71 + X11.11 c37 71 + X11.11 c38 71 + X11.11 c39 71 + X11.11 c40 71 + X11.11 c41 71 + X11.11 c42 71 + X11.16 c12 1 + X11.16 c23 71 + X11.16 c24 71 + X11.16 c25 71 + X11.16 c26 71 + X11.16 c27 71 + X11.16 c28 71 + X11.16 c29 71 + X11.16 c30 71 + X11.16 c31 71 + X11.16 c32 71 + X11.16 c33 71 + X11.16 c34 71 + X11.16 c35 71 + X11.16 c36 71 + X11.16 c37 71 + X11.16 c38 71 + X11.16 c39 71 + X11.16 c40 71 + X11.16 c41 71 + X11.16 c42 71 + X11.16 c43 71 + X11.16 c44 71 + X11.16 c45 71 + X11.19 c12 1 + X11.19 c24 71 + X11.19 c25 71 + X11.19 c26 71 + X11.19 c27 71 + X11.19 c28 71 + X11.19 c29 71 + X11.19 c30 71 + X11.19 c31 71 + X11.19 c32 71 + X11.19 c33 71 + X11.19 c34 71 + X11.19 c35 71 + X11.19 c36 71 + X11.19 c37 71 + X11.19 c38 71 + X11.19 c39 71 + X11.19 c40 71 + X11.19 c41 71 + X11.19 c42 71 + X11.19 c43 71 + X11.19 c44 71 + X11.19 c45 71 + X11.19 c46 71 + X11.27 c12 1 + X11.27 c25 71 + X11.27 c26 71 + X11.27 c27 71 + X11.27 c28 71 + X11.27 c29 71 + X11.27 c30 71 + X11.27 c31 71 + X11.27 c32 71 + X11.27 c33 71 + X11.27 c34 71 + X11.27 c35 71 + X11.27 c36 71 + X11.27 c37 71 + X11.27 c38 71 + X11.27 c39 71 + X11.27 c40 71 + X11.27 c41 71 + X11.27 c42 71 + X11.27 c43 71 + X11.27 c44 71 + X11.27 c45 71 + X11.27 c46 71 + X11.27 c47 71 + X11.27 c48 71 + X11.27 c49 71 + X11.27 c50 71 + X11.27 c51 71 + X11.30 c12 1 + X11.30 c26 71 + X11.30 c27 71 + X11.30 c28 71 + X11.30 c29 71 + X11.30 c30 71 + X11.30 c31 71 + X11.30 c32 71 + X11.30 c33 71 + X11.30 c34 71 + X11.30 c35 71 + X11.30 c36 71 + X11.30 c37 71 + X11.30 c38 71 + X11.30 c39 71 + X11.30 c40 71 + X11.30 c41 71 + X11.30 c42 71 + X11.30 c43 71 + X11.30 c44 71 + X11.30 c45 71 + X11.30 c46 71 + X11.30 c47 71 + X11.30 c48 71 + X11.30 c49 71 + X11.30 c50 71 + X11.30 c51 71 + X11.30 c52 71 + X11.30 c53 71 + X11.32 c12 1 + X11.32 c27 71 + X11.32 c28 71 + X11.32 c29 71 + X11.32 c30 71 + X11.32 c31 71 + X11.32 c32 71 + X11.32 c33 71 + X11.32 c34 71 + X11.32 c35 71 + X11.32 c36 71 + X11.32 c37 71 + X11.32 c38 71 + X11.32 c39 71 + X11.32 c40 71 + X11.32 c41 71 + X11.32 c42 71 + X11.32 c43 71 + X11.32 c44 71 + X11.32 c45 71 + X11.32 c46 71 + X11.32 c47 71 + X11.32 c48 71 + X11.32 c49 71 + X11.32 c50 71 + X11.32 c51 71 + X11.32 c52 71 + X11.32 c53 71 + X11.32 c54 71 + X11.34 c12 1 + X11.34 c28 71 + X11.34 c29 71 + X11.34 c30 71 + X11.34 c31 71 + X11.34 c32 71 + X11.34 c33 71 + X11.34 c34 71 + X11.34 c35 71 + X11.34 c36 71 + X11.34 c37 71 + X11.34 c38 71 + X11.34 c39 71 + X11.34 c40 71 + X11.34 c41 71 + X11.34 c42 71 + X11.34 c43 71 + X11.34 c44 71 + X11.34 c45 71 + X11.34 c46 71 + X11.34 c47 71 + X11.34 c48 71 + X11.34 c49 71 + X11.34 c50 71 + X11.34 c51 71 + X11.34 c52 71 + X11.34 c53 71 + X11.34 c54 71 + X11.35 c12 1 + X11.35 c29 71 + X11.35 c30 71 + X11.35 c31 71 + X11.35 c32 71 + X11.35 c33 71 + X11.35 c34 71 + X11.35 c35 71 + X11.35 c36 71 + X11.35 c37 71 + X11.35 c38 71 + X11.35 c39 71 + X11.35 c40 71 + X11.35 c41 71 + X11.35 c42 71 + X11.35 c43 71 + X11.35 c44 71 + X11.35 c45 71 + X11.35 c46 71 + X11.35 c47 71 + X11.35 c48 71 + X11.35 c49 71 + X11.35 c50 71 + X11.35 c51 71 + X11.35 c52 71 + X11.35 c53 71 + X11.35 c54 71 + X11.36 c12 1 + X11.36 c30 71 + X11.36 c31 71 + X11.36 c32 71 + X11.36 c33 71 + X11.36 c34 71 + X11.36 c35 71 + X11.36 c36 71 + X11.36 c37 71 + X11.36 c38 71 + X11.36 c39 71 + X11.36 c40 71 + X11.36 c41 71 + X11.36 c42 71 + X11.36 c43 71 + X11.36 c44 71 + X11.36 c45 71 + X11.36 c46 71 + X11.36 c47 71 + X11.36 c48 71 + X11.36 c49 71 + X11.36 c50 71 + X11.36 c51 71 + X11.36 c52 71 + X11.36 c53 71 + X11.36 c54 71 + X11.36 c55 71 + X11.41 c12 1 + X11.41 c31 71 + X11.41 c32 71 + X11.41 c33 71 + X11.41 c34 71 + X11.41 c35 71 + X11.41 c36 71 + X11.41 c37 71 + X11.41 c38 71 + X11.41 c39 71 + X11.41 c40 71 + X11.41 c41 71 + X11.41 c42 71 + X11.41 c43 71 + X11.41 c44 71 + X11.41 c45 71 + X11.41 c46 71 + X11.41 c47 71 + X11.41 c48 71 + X11.41 c49 71 + X11.41 c50 71 + X11.41 c51 71 + X11.41 c52 71 + X11.41 c53 71 + X11.41 c54 71 + X11.41 c55 71 + X11.41 c56 71 + X11.41 c57 71 + X11.43 c12 1 + X11.43 c32 71 + X11.43 c33 71 + X11.43 c34 71 + X11.43 c35 71 + X11.43 c36 71 + X11.43 c37 71 + X11.43 c38 71 + X11.43 c39 71 + X11.43 c40 71 + X11.43 c41 71 + X11.43 c42 71 + X11.43 c43 71 + X11.43 c44 71 + X11.43 c45 71 + X11.43 c46 71 + X11.43 c47 71 + X11.43 c48 71 + X11.43 c49 71 + X11.43 c50 71 + X11.43 c51 71 + X11.43 c52 71 + X11.43 c53 71 + X11.43 c54 71 + X11.43 c55 71 + X11.43 c56 71 + X11.43 c57 71 + X11.45 c12 1 + X11.45 c33 71 + X11.45 c34 71 + X11.45 c35 71 + X11.45 c36 71 + X11.45 c37 71 + X11.45 c38 71 + X11.45 c39 71 + X11.45 c40 71 + X11.45 c41 71 + X11.45 c42 71 + X11.45 c43 71 + X11.45 c44 71 + X11.45 c45 71 + X11.45 c46 71 + X11.45 c47 71 + X11.45 c48 71 + X11.45 c49 71 + X11.45 c50 71 + X11.45 c51 71 + X11.45 c52 71 + X11.45 c53 71 + X11.45 c54 71 + X11.45 c55 71 + X11.45 c56 71 + X11.45 c57 71 + X11.45 c58 71 + X11.46 c12 1 + X11.46 c34 71 + X11.46 c35 71 + X11.46 c36 71 + X11.46 c37 71 + X11.46 c38 71 + X11.46 c39 71 + X11.46 c40 71 + X11.46 c41 71 + X11.46 c42 71 + X11.46 c43 71 + X11.46 c44 71 + X11.46 c45 71 + X11.46 c46 71 + X11.46 c47 71 + X11.46 c48 71 + X11.46 c49 71 + X11.46 c50 71 + X11.46 c51 71 + X11.46 c52 71 + X11.46 c53 71 + X11.46 c54 71 + X11.46 c55 71 + X11.46 c56 71 + X11.46 c57 71 + X11.46 c58 71 + X11.55 c12 1 + X11.55 c42 71 + X11.55 c43 71 + X11.55 c44 71 + X11.55 c45 71 + X11.55 c46 71 + X11.55 c47 71 + X11.55 c48 71 + X11.55 c49 71 + X11.55 c50 71 + X11.55 c51 71 + X11.55 c52 71 + X11.55 c53 71 + X11.55 c54 71 + X11.55 c55 71 + X11.55 c56 71 + X11.55 c57 71 + X11.55 c58 71 + X11.55 c59 71 + X12.0 c13 1 + X12.0 c21 88 + X12.0 c22 88 + X12.0 c23 88 + X12.0 c24 88 + X12.0 c25 88 + X12.0 c26 88 + X12.0 c27 88 + X12.0 c28 88 + X12.0 c29 88 + X12.0 c30 88 + X12.11 c13 1 + X12.11 c22 88 + X12.11 c23 88 + X12.11 c24 88 + X12.11 c25 88 + X12.11 c26 88 + X12.11 c27 88 + X12.11 c28 88 + X12.11 c29 88 + X12.11 c30 88 + X12.11 c31 88 + X12.11 c32 88 + X12.11 c33 88 + X12.11 c34 88 + X12.11 c35 88 + X12.11 c36 88 + X12.11 c37 88 + X12.11 c38 88 + X12.11 c39 88 + X12.16 c13 1 + X12.16 c23 88 + X12.16 c24 88 + X12.16 c25 88 + X12.16 c26 88 + X12.16 c27 88 + X12.16 c28 88 + X12.16 c29 88 + X12.16 c30 88 + X12.16 c31 88 + X12.16 c32 88 + X12.16 c33 88 + X12.16 c34 88 + X12.16 c35 88 + X12.16 c36 88 + X12.16 c37 88 + X12.16 c38 88 + X12.16 c39 88 + X12.16 c40 88 + X12.16 c41 88 + X12.16 c42 88 + X12.16 c43 88 + X12.19 c13 1 + X12.19 c24 88 + X12.19 c25 88 + X12.19 c26 88 + X12.19 c27 88 + X12.19 c28 88 + X12.19 c29 88 + X12.19 c30 88 + X12.19 c31 88 + X12.19 c32 88 + X12.19 c33 88 + X12.19 c34 88 + X12.19 c35 88 + X12.19 c36 88 + X12.19 c37 88 + X12.19 c38 88 + X12.19 c39 88 + X12.19 c40 88 + X12.19 c41 88 + X12.19 c42 88 + X12.19 c43 88 + X12.19 c44 88 + X12.19 c45 88 + X12.27 c13 1 + X12.27 c25 88 + X12.27 c26 88 + X12.27 c27 88 + X12.27 c28 88 + X12.27 c29 88 + X12.27 c30 88 + X12.27 c31 88 + X12.27 c32 88 + X12.27 c33 88 + X12.27 c34 88 + X12.27 c35 88 + X12.27 c36 88 + X12.27 c37 88 + X12.27 c38 88 + X12.27 c39 88 + X12.27 c40 88 + X12.27 c41 88 + X12.27 c42 88 + X12.27 c43 88 + X12.27 c44 88 + X12.27 c45 88 + X12.27 c46 88 + X12.27 c47 88 + X12.27 c48 88 + X12.27 c49 88 + X12.30 c13 1 + X12.30 c26 88 + X12.30 c27 88 + X12.30 c28 88 + X12.30 c29 88 + X12.30 c30 88 + X12.30 c31 88 + X12.30 c32 88 + X12.30 c33 88 + X12.30 c34 88 + X12.30 c35 88 + X12.30 c36 88 + X12.30 c37 88 + X12.30 c38 88 + X12.30 c39 88 + X12.30 c40 88 + X12.30 c41 88 + X12.30 c42 88 + X12.30 c43 88 + X12.30 c44 88 + X12.30 c45 88 + X12.30 c46 88 + X12.30 c47 88 + X12.30 c48 88 + X12.30 c49 88 + X12.30 c50 88 + X12.30 c51 88 + X12.32 c13 1 + X12.32 c27 88 + X12.32 c28 88 + X12.32 c29 88 + X12.32 c30 88 + X12.32 c31 88 + X12.32 c32 88 + X12.32 c33 88 + X12.32 c34 88 + X12.32 c35 88 + X12.32 c36 88 + X12.32 c37 88 + X12.32 c38 88 + X12.32 c39 88 + X12.32 c40 88 + X12.32 c41 88 + X12.32 c42 88 + X12.32 c43 88 + X12.32 c44 88 + X12.32 c45 88 + X12.32 c46 88 + X12.32 c47 88 + X12.32 c48 88 + X12.32 c49 88 + X12.32 c50 88 + X12.32 c51 88 + X12.32 c52 88 + X12.34 c13 1 + X12.34 c28 88 + X12.34 c29 88 + X12.34 c30 88 + X12.34 c31 88 + X12.34 c32 88 + X12.34 c33 88 + X12.34 c34 88 + X12.34 c35 88 + X12.34 c36 88 + X12.34 c37 88 + X12.34 c38 88 + X12.34 c39 88 + X12.34 c40 88 + X12.34 c41 88 + X12.34 c42 88 + X12.34 c43 88 + X12.34 c44 88 + X12.34 c45 88 + X12.34 c46 88 + X12.34 c47 88 + X12.34 c48 88 + X12.34 c49 88 + X12.34 c50 88 + X12.34 c51 88 + X12.34 c52 88 + X12.34 c53 88 + X12.35 c13 1 + X12.35 c29 88 + X12.35 c30 88 + X12.35 c31 88 + X12.35 c32 88 + X12.35 c33 88 + X12.35 c34 88 + X12.35 c35 88 + X12.35 c36 88 + X12.35 c37 88 + X12.35 c38 88 + X12.35 c39 88 + X12.35 c40 88 + X12.35 c41 88 + X12.35 c42 88 + X12.35 c43 88 + X12.35 c44 88 + X12.35 c45 88 + X12.35 c46 88 + X12.35 c47 88 + X12.35 c48 88 + X12.35 c49 88 + X12.35 c50 88 + X12.35 c51 88 + X12.35 c52 88 + X12.35 c53 88 + X12.35 c54 88 + X12.36 c13 1 + X12.36 c30 88 + X12.36 c31 88 + X12.36 c32 88 + X12.36 c33 88 + X12.36 c34 88 + X12.36 c35 88 + X12.36 c36 88 + X12.36 c37 88 + X12.36 c38 88 + X12.36 c39 88 + X12.36 c40 88 + X12.36 c41 88 + X12.36 c42 88 + X12.36 c43 88 + X12.36 c44 88 + X12.36 c45 88 + X12.36 c46 88 + X12.36 c47 88 + X12.36 c48 88 + X12.36 c49 88 + X12.36 c50 88 + X12.36 c51 88 + X12.36 c52 88 + X12.36 c53 88 + X12.36 c54 88 + X12.43 c13 1 + X12.43 c32 88 + X12.43 c33 88 + X12.43 c34 88 + X12.43 c35 88 + X12.43 c36 88 + X12.43 c37 88 + X12.43 c38 88 + X12.43 c39 88 + X12.43 c40 88 + X12.43 c41 88 + X12.43 c42 88 + X12.43 c43 88 + X12.43 c44 88 + X12.43 c45 88 + X12.43 c46 88 + X12.43 c47 88 + X12.43 c48 88 + X12.43 c49 88 + X12.43 c50 88 + X12.43 c51 88 + X12.43 c52 88 + X12.43 c53 88 + X12.43 c54 88 + X12.43 c55 88 + X12.43 c56 88 + X12.45 c13 1 + X12.45 c33 88 + X12.45 c34 88 + X12.45 c35 88 + X12.45 c36 88 + X12.45 c37 88 + X12.45 c38 88 + X12.45 c39 88 + X12.45 c40 88 + X12.45 c41 88 + X12.45 c42 88 + X12.45 c43 88 + X12.45 c44 88 + X12.45 c45 88 + X12.45 c46 88 + X12.45 c47 88 + X12.45 c48 88 + X12.45 c49 88 + X12.45 c50 88 + X12.45 c51 88 + X12.45 c52 88 + X12.45 c53 88 + X12.45 c54 88 + X12.45 c55 88 + X12.45 c56 88 + X12.45 c57 88 + X12.48 c13 1 + X12.48 c36 88 + X12.48 c37 88 + X12.48 c38 88 + X12.48 c39 88 + X12.48 c40 88 + X12.48 c41 88 + X12.48 c42 88 + X12.48 c43 88 + X12.48 c44 88 + X12.48 c45 88 + X12.48 c46 88 + X12.48 c47 88 + X12.48 c48 88 + X12.48 c49 88 + X12.48 c50 88 + X12.48 c51 88 + X12.48 c52 88 + X12.48 c53 88 + X12.48 c54 88 + X12.48 c55 88 + X12.48 c56 88 + X12.48 c57 88 + X12.50 c13 1 + X12.50 c38 88 + X12.50 c39 88 + X12.50 c40 88 + X12.50 c41 88 + X12.50 c42 88 + X12.50 c43 88 + X12.50 c44 88 + X12.50 c45 88 + X12.50 c46 88 + X12.50 c47 88 + X12.50 c48 88 + X12.50 c49 88 + X12.50 c50 88 + X12.50 c51 88 + X12.50 c52 88 + X12.50 c53 88 + X12.50 c54 88 + X12.50 c55 88 + X12.50 c56 88 + X12.50 c57 88 + X12.50 c58 88 + X12.59 c13 1 + X12.59 c45 88 + X12.59 c46 88 + X12.59 c47 88 + X12.59 c48 88 + X12.59 c49 88 + X12.59 c50 88 + X12.59 c51 88 + X12.59 c52 88 + X12.59 c53 88 + X12.59 c54 88 + X12.59 c55 88 + X12.59 c56 88 + X12.59 c57 88 + X12.59 c58 88 + X12.59 c59 88 + X13.0 c14 1 + X13.0 c21 72 + X13.0 c22 72 + X13.0 c23 72 + X13.0 c24 72 + X13.0 c25 72 + X13.0 c26 72 + X13.0 c27 72 + X13.0 c28 72 + X13.0 c29 72 + X13.11 c14 1 + X13.11 c22 72 + X13.11 c23 72 + X13.11 c24 72 + X13.11 c25 72 + X13.11 c26 72 + X13.11 c27 72 + X13.11 c28 72 + X13.11 c29 72 + X13.11 c30 72 + X13.11 c31 72 + X13.11 c32 72 + X13.11 c33 72 + X13.11 c34 72 + X13.16 c14 1 + X13.16 c23 72 + X13.16 c24 72 + X13.16 c25 72 + X13.16 c26 72 + X13.16 c27 72 + X13.16 c28 72 + X13.16 c29 72 + X13.16 c30 72 + X13.16 c31 72 + X13.16 c32 72 + X13.16 c33 72 + X13.16 c34 72 + X13.16 c35 72 + X13.16 c36 72 + X13.16 c37 72 + X13.16 c38 72 + X13.16 c39 72 + X13.19 c14 1 + X13.19 c24 72 + X13.19 c25 72 + X13.19 c26 72 + X13.19 c27 72 + X13.19 c28 72 + X13.19 c29 72 + X13.19 c30 72 + X13.19 c31 72 + X13.19 c32 72 + X13.19 c33 72 + X13.19 c34 72 + X13.19 c35 72 + X13.19 c36 72 + X13.19 c37 72 + X13.19 c38 72 + X13.19 c39 72 + X13.19 c40 72 + X13.19 c41 72 + X13.27 c14 1 + X13.27 c25 72 + X13.27 c26 72 + X13.27 c27 72 + X13.27 c28 72 + X13.27 c29 72 + X13.27 c30 72 + X13.27 c31 72 + X13.27 c32 72 + X13.27 c33 72 + X13.27 c34 72 + X13.27 c35 72 + X13.27 c36 72 + X13.27 c37 72 + X13.27 c38 72 + X13.27 c39 72 + X13.27 c40 72 + X13.27 c41 72 + X13.27 c42 72 + X13.27 c43 72 + X13.27 c44 72 + X13.27 c45 72 + X13.27 c46 72 + X13.30 c14 1 + X13.30 c26 72 + X13.30 c27 72 + X13.30 c28 72 + X13.30 c29 72 + X13.30 c30 72 + X13.30 c31 72 + X13.30 c32 72 + X13.30 c33 72 + X13.30 c34 72 + X13.30 c35 72 + X13.30 c36 72 + X13.30 c37 72 + X13.30 c38 72 + X13.30 c39 72 + X13.30 c40 72 + X13.30 c41 72 + X13.30 c42 72 + X13.30 c43 72 + X13.30 c44 72 + X13.30 c45 72 + X13.30 c46 72 + X13.30 c47 72 + X13.30 c48 72 + X13.32 c14 1 + X13.32 c27 72 + X13.32 c28 72 + X13.32 c29 72 + X13.32 c30 72 + X13.32 c31 72 + X13.32 c32 72 + X13.32 c33 72 + X13.32 c34 72 + X13.32 c35 72 + X13.32 c36 72 + X13.32 c37 72 + X13.32 c38 72 + X13.32 c39 72 + X13.32 c40 72 + X13.32 c41 72 + X13.32 c42 72 + X13.32 c43 72 + X13.32 c44 72 + X13.32 c45 72 + X13.32 c46 72 + X13.32 c47 72 + X13.32 c48 72 + X13.32 c49 72 + X13.34 c14 1 + X13.34 c28 72 + X13.34 c29 72 + X13.34 c30 72 + X13.34 c31 72 + X13.34 c32 72 + X13.34 c33 72 + X13.34 c34 72 + X13.34 c35 72 + X13.34 c36 72 + X13.34 c37 72 + X13.34 c38 72 + X13.34 c39 72 + X13.34 c40 72 + X13.34 c41 72 + X13.34 c42 72 + X13.34 c43 72 + X13.34 c44 72 + X13.34 c45 72 + X13.34 c46 72 + X13.34 c47 72 + X13.34 c48 72 + X13.34 c49 72 + X13.34 c50 72 + X13.35 c14 1 + X13.35 c29 72 + X13.35 c30 72 + X13.35 c31 72 + X13.35 c32 72 + X13.35 c33 72 + X13.35 c34 72 + X13.35 c35 72 + X13.35 c36 72 + X13.35 c37 72 + X13.35 c38 72 + X13.35 c39 72 + X13.35 c40 72 + X13.35 c41 72 + X13.35 c42 72 + X13.35 c43 72 + X13.35 c44 72 + X13.35 c45 72 + X13.35 c46 72 + X13.35 c47 72 + X13.35 c48 72 + X13.35 c49 72 + X13.35 c50 72 + X13.35 c51 72 + X13.41 c14 1 + X13.41 c31 72 + X13.41 c32 72 + X13.41 c33 72 + X13.41 c34 72 + X13.41 c35 72 + X13.41 c36 72 + X13.41 c37 72 + X13.41 c38 72 + X13.41 c39 72 + X13.41 c40 72 + X13.41 c41 72 + X13.41 c42 72 + X13.41 c43 72 + X13.41 c44 72 + X13.41 c45 72 + X13.41 c46 72 + X13.41 c47 72 + X13.41 c48 72 + X13.41 c49 72 + X13.41 c50 72 + X13.41 c51 72 + X13.41 c52 72 + X13.41 c53 72 + X13.41 c54 72 + X13.43 c14 1 + X13.43 c32 72 + X13.43 c33 72 + X13.43 c34 72 + X13.43 c35 72 + X13.43 c36 72 + X13.43 c37 72 + X13.43 c38 72 + X13.43 c39 72 + X13.43 c40 72 + X13.43 c41 72 + X13.43 c42 72 + X13.43 c43 72 + X13.43 c44 72 + X13.43 c45 72 + X13.43 c46 72 + X13.43 c47 72 + X13.43 c48 72 + X13.43 c49 72 + X13.43 c50 72 + X13.43 c51 72 + X13.43 c52 72 + X13.43 c53 72 + X13.43 c54 72 + X13.45 c14 1 + X13.45 c33 72 + X13.45 c34 72 + X13.45 c35 72 + X13.45 c36 72 + X13.45 c37 72 + X13.45 c38 72 + X13.45 c39 72 + X13.45 c40 72 + X13.45 c41 72 + X13.45 c42 72 + X13.45 c43 72 + X13.45 c44 72 + X13.45 c45 72 + X13.45 c46 72 + X13.45 c47 72 + X13.45 c48 72 + X13.45 c49 72 + X13.45 c50 72 + X13.45 c51 72 + X13.45 c52 72 + X13.45 c53 72 + X13.45 c54 72 + X13.45 c55 72 + X13.48 c14 1 + X13.48 c36 72 + X13.48 c37 72 + X13.48 c38 72 + X13.48 c39 72 + X13.48 c40 72 + X13.48 c41 72 + X13.48 c42 72 + X13.48 c43 72 + X13.48 c44 72 + X13.48 c45 72 + X13.48 c46 72 + X13.48 c47 72 + X13.48 c48 72 + X13.48 c49 72 + X13.48 c50 72 + X13.48 c51 72 + X13.48 c52 72 + X13.48 c53 72 + X13.48 c54 72 + X13.48 c55 72 + X13.48 c56 72 + X13.53 c14 1 + X13.53 c41 72 + X13.53 c42 72 + X13.53 c43 72 + X13.53 c44 72 + X13.53 c45 72 + X13.53 c46 72 + X13.53 c47 72 + X13.53 c48 72 + X13.53 c49 72 + X13.53 c50 72 + X13.53 c51 72 + X13.53 c52 72 + X13.53 c53 72 + X13.53 c54 72 + X13.53 c55 72 + X13.53 c56 72 + X13.53 c57 72 + X13.55 c14 1 + X13.55 c42 72 + X13.55 c43 72 + X13.55 c44 72 + X13.55 c45 72 + X13.55 c46 72 + X13.55 c47 72 + X13.55 c48 72 + X13.55 c49 72 + X13.55 c50 72 + X13.55 c51 72 + X13.55 c52 72 + X13.55 c53 72 + X13.55 c54 72 + X13.55 c55 72 + X13.55 c56 72 + X13.55 c57 72 + X13.55 c58 72 + X13.64 c14 1 + X13.64 c47 72 + X13.64 c48 72 + X13.64 c49 72 + X13.64 c50 72 + X13.64 c51 72 + X13.64 c52 72 + X13.64 c53 72 + X13.64 c54 72 + X13.64 c55 72 + X13.64 c56 72 + X13.64 c57 72 + X13.64 c58 72 + X13.64 c59 72 + X14.0 c15 1 + X14.0 c21 86 + X14.0 c22 86 + X14.0 c23 86 + X14.0 c24 86 + X14.0 c25 86 + X14.0 c26 86 + X14.0 c27 86 + X14.11 c15 1 + X14.11 c22 86 + X14.11 c23 86 + X14.11 c24 86 + X14.11 c25 86 + X14.11 c26 86 + X14.11 c27 86 + X14.11 c28 86 + X14.11 c29 86 + X14.11 c30 86 + X14.11 c31 86 + X14.11 c32 86 + X14.16 c15 1 + X14.16 c23 86 + X14.16 c24 86 + X14.16 c25 86 + X14.16 c26 86 + X14.16 c27 86 + X14.16 c28 86 + X14.16 c29 86 + X14.16 c30 86 + X14.16 c31 86 + X14.16 c32 86 + X14.16 c33 86 + X14.16 c34 86 + X14.16 c35 86 + X14.16 c36 86 + X14.16 c37 86 + X14.19 c15 1 + X14.19 c24 86 + X14.19 c25 86 + X14.19 c26 86 + X14.19 c27 86 + X14.19 c28 86 + X14.19 c29 86 + X14.19 c30 86 + X14.19 c31 86 + X14.19 c32 86 + X14.19 c33 86 + X14.19 c34 86 + X14.19 c35 86 + X14.19 c36 86 + X14.19 c37 86 + X14.19 c38 86 + X14.19 c39 86 + X14.19 c40 86 + X14.27 c15 1 + X14.27 c25 86 + X14.27 c26 86 + X14.27 c27 86 + X14.27 c28 86 + X14.27 c29 86 + X14.27 c30 86 + X14.27 c31 86 + X14.27 c32 86 + X14.27 c33 86 + X14.27 c34 86 + X14.27 c35 86 + X14.27 c36 86 + X14.27 c37 86 + X14.27 c38 86 + X14.27 c39 86 + X14.27 c40 86 + X14.27 c41 86 + X14.27 c42 86 + X14.27 c43 86 + X14.27 c44 86 + X14.27 c45 86 + X14.30 c15 1 + X14.30 c26 86 + X14.30 c27 86 + X14.30 c28 86 + X14.30 c29 86 + X14.30 c30 86 + X14.30 c31 86 + X14.30 c32 86 + X14.30 c33 86 + X14.30 c34 86 + X14.30 c35 86 + X14.30 c36 86 + X14.30 c37 86 + X14.30 c38 86 + X14.30 c39 86 + X14.30 c40 86 + X14.30 c41 86 + X14.30 c42 86 + X14.30 c43 86 + X14.30 c44 86 + X14.30 c45 86 + X14.30 c46 86 + X14.32 c15 1 + X14.32 c27 86 + X14.32 c28 86 + X14.32 c29 86 + X14.32 c30 86 + X14.32 c31 86 + X14.32 c32 86 + X14.32 c33 86 + X14.32 c34 86 + X14.32 c35 86 + X14.32 c36 86 + X14.32 c37 86 + X14.32 c38 86 + X14.32 c39 86 + X14.32 c40 86 + X14.32 c41 86 + X14.32 c42 86 + X14.32 c43 86 + X14.32 c44 86 + X14.32 c45 86 + X14.32 c46 86 + X14.32 c47 86 + X14.32 c48 86 + X14.35 c15 1 + X14.35 c29 86 + X14.35 c30 86 + X14.35 c31 86 + X14.35 c32 86 + X14.35 c33 86 + X14.35 c34 86 + X14.35 c35 86 + X14.35 c36 86 + X14.35 c37 86 + X14.35 c38 86 + X14.35 c39 86 + X14.35 c40 86 + X14.35 c41 86 + X14.35 c42 86 + X14.35 c43 86 + X14.35 c44 86 + X14.35 c45 86 + X14.35 c46 86 + X14.35 c47 86 + X14.35 c48 86 + X14.35 c49 86 + X14.35 c50 86 + X14.36 c15 1 + X14.36 c30 86 + X14.36 c31 86 + X14.36 c32 86 + X14.36 c33 86 + X14.36 c34 86 + X14.36 c35 86 + X14.36 c36 86 + X14.36 c37 86 + X14.36 c38 86 + X14.36 c39 86 + X14.36 c40 86 + X14.36 c41 86 + X14.36 c42 86 + X14.36 c43 86 + X14.36 c44 86 + X14.36 c45 86 + X14.36 c46 86 + X14.36 c47 86 + X14.36 c48 86 + X14.36 c49 86 + X14.36 c50 86 + X14.41 c15 1 + X14.41 c31 86 + X14.41 c32 86 + X14.41 c33 86 + X14.41 c34 86 + X14.41 c35 86 + X14.41 c36 86 + X14.41 c37 86 + X14.41 c38 86 + X14.41 c39 86 + X14.41 c40 86 + X14.41 c41 86 + X14.41 c42 86 + X14.41 c43 86 + X14.41 c44 86 + X14.41 c45 86 + X14.41 c46 86 + X14.41 c47 86 + X14.41 c48 86 + X14.41 c49 86 + X14.41 c50 86 + X14.41 c51 86 + X14.41 c52 86 + X14.41 c53 86 + X14.43 c15 1 + X14.43 c32 86 + X14.43 c33 86 + X14.43 c34 86 + X14.43 c35 86 + X14.43 c36 86 + X14.43 c37 86 + X14.43 c38 86 + X14.43 c39 86 + X14.43 c40 86 + X14.43 c41 86 + X14.43 c42 86 + X14.43 c43 86 + X14.43 c44 86 + X14.43 c45 86 + X14.43 c46 86 + X14.43 c47 86 + X14.43 c48 86 + X14.43 c49 86 + X14.43 c50 86 + X14.43 c51 86 + X14.43 c52 86 + X14.43 c53 86 + X14.43 c54 86 + X14.45 c15 1 + X14.45 c33 86 + X14.45 c34 86 + X14.45 c35 86 + X14.45 c36 86 + X14.45 c37 86 + X14.45 c38 86 + X14.45 c39 86 + X14.45 c40 86 + X14.45 c41 86 + X14.45 c42 86 + X14.45 c43 86 + X14.45 c44 86 + X14.45 c45 86 + X14.45 c46 86 + X14.45 c47 86 + X14.45 c48 86 + X14.45 c49 86 + X14.45 c50 86 + X14.45 c51 86 + X14.45 c52 86 + X14.45 c53 86 + X14.45 c54 86 + X14.46 c15 1 + X14.46 c34 86 + X14.46 c35 86 + X14.46 c36 86 + X14.46 c37 86 + X14.46 c38 86 + X14.46 c39 86 + X14.46 c40 86 + X14.46 c41 86 + X14.46 c42 86 + X14.46 c43 86 + X14.46 c44 86 + X14.46 c45 86 + X14.46 c46 86 + X14.46 c47 86 + X14.46 c48 86 + X14.46 c49 86 + X14.46 c50 86 + X14.46 c51 86 + X14.46 c52 86 + X14.46 c53 86 + X14.46 c54 86 + X14.47 c15 1 + X14.47 c35 86 + X14.47 c36 86 + X14.47 c37 86 + X14.47 c38 86 + X14.47 c39 86 + X14.47 c40 86 + X14.47 c41 86 + X14.47 c42 86 + X14.47 c43 86 + X14.47 c44 86 + X14.47 c45 86 + X14.47 c46 86 + X14.47 c47 86 + X14.47 c48 86 + X14.47 c49 86 + X14.47 c50 86 + X14.47 c51 86 + X14.47 c52 86 + X14.47 c53 86 + X14.47 c54 86 + X14.47 c55 86 + X14.50 c15 1 + X14.50 c38 86 + X14.50 c39 86 + X14.50 c40 86 + X14.50 c41 86 + X14.50 c42 86 + X14.50 c43 86 + X14.50 c44 86 + X14.50 c45 86 + X14.50 c46 86 + X14.50 c47 86 + X14.50 c48 86 + X14.50 c49 86 + X14.50 c50 86 + X14.50 c51 86 + X14.50 c52 86 + X14.50 c53 86 + X14.50 c54 86 + X14.50 c55 86 + X14.50 c56 86 + X14.55 c15 1 + X14.55 c42 86 + X14.55 c43 86 + X14.55 c44 86 + X14.55 c45 86 + X14.55 c46 86 + X14.55 c47 86 + X14.55 c48 86 + X14.55 c49 86 + X14.55 c50 86 + X14.55 c51 86 + X14.55 c52 86 + X14.55 c53 86 + X14.55 c54 86 + X14.55 c55 86 + X14.55 c56 86 + X14.55 c57 86 + X14.57 c15 1 + X14.57 c44 86 + X14.57 c45 86 + X14.57 c46 86 + X14.57 c47 86 + X14.57 c48 86 + X14.57 c49 86 + X14.57 c50 86 + X14.57 c51 86 + X14.57 c52 86 + X14.57 c53 86 + X14.57 c54 86 + X14.57 c55 86 + X14.57 c56 86 + X14.57 c57 86 + X14.57 c58 86 + X14.66 c15 1 + X14.66 c49 86 + X14.66 c50 86 + X14.66 c51 86 + X14.66 c52 86 + X14.66 c53 86 + X14.66 c54 86 + X14.66 c55 86 + X14.66 c56 86 + X14.66 c57 86 + X14.66 c58 86 + X14.66 c59 86 + X15.0 c16 1 + X15.0 c21 77 + X15.0 c22 77 + X15.0 c23 77 + X15.11 c16 1 + X15.11 c22 77 + X15.11 c23 77 + X15.11 c24 77 + X15.11 c25 77 + X15.16 c16 1 + X15.16 c23 77 + X15.16 c24 77 + X15.16 c25 77 + X15.16 c26 77 + X15.16 c27 77 + X15.16 c28 77 + X15.27 c16 1 + X15.27 c25 77 + X15.27 c26 77 + X15.27 c27 77 + X15.27 c28 77 + X15.27 c29 77 + X15.27 c30 77 + X15.27 c31 77 + X15.27 c32 77 + X15.27 c33 77 + X15.32 c16 1 + X15.32 c27 77 + X15.32 c28 77 + X15.32 c29 77 + X15.32 c30 77 + X15.32 c31 77 + X15.32 c32 77 + X15.32 c33 77 + X15.32 c34 77 + X15.32 c35 77 + X15.32 c36 77 + X15.32 c37 77 + X15.32 c38 77 + X15.34 c16 1 + X15.34 c28 77 + X15.34 c29 77 + X15.34 c30 77 + X15.34 c31 77 + X15.34 c32 77 + X15.34 c33 77 + X15.34 c34 77 + X15.34 c35 77 + X15.34 c36 77 + X15.34 c37 77 + X15.34 c38 77 + X15.34 c39 77 + X15.34 c40 77 + X15.36 c16 1 + X15.36 c30 77 + X15.36 c31 77 + X15.36 c32 77 + X15.36 c33 77 + X15.36 c34 77 + X15.36 c35 77 + X15.36 c36 77 + X15.36 c37 77 + X15.36 c38 77 + X15.36 c39 77 + X15.36 c40 77 + X15.36 c41 77 + X15.41 c16 1 + X15.41 c31 77 + X15.41 c32 77 + X15.41 c33 77 + X15.41 c34 77 + X15.41 c35 77 + X15.41 c36 77 + X15.41 c37 77 + X15.41 c38 77 + X15.41 c39 77 + X15.41 c40 77 + X15.41 c41 77 + X15.41 c42 77 + X15.41 c43 77 + X15.41 c44 77 + X15.41 c45 77 + X15.43 c16 1 + X15.43 c32 77 + X15.43 c33 77 + X15.43 c34 77 + X15.43 c35 77 + X15.43 c36 77 + X15.43 c37 77 + X15.43 c38 77 + X15.43 c39 77 + X15.43 c40 77 + X15.43 c41 77 + X15.43 c42 77 + X15.43 c43 77 + X15.43 c44 77 + X15.43 c45 77 + X15.43 c46 77 + X15.45 c16 1 + X15.45 c33 77 + X15.45 c34 77 + X15.45 c35 77 + X15.45 c36 77 + X15.45 c37 77 + X15.45 c38 77 + X15.45 c39 77 + X15.45 c40 77 + X15.45 c41 77 + X15.45 c42 77 + X15.45 c43 77 + X15.45 c44 77 + X15.45 c45 77 + X15.45 c46 77 + X15.47 c16 1 + X15.47 c35 77 + X15.47 c36 77 + X15.47 c37 77 + X15.47 c38 77 + X15.47 c39 77 + X15.47 c40 77 + X15.47 c41 77 + X15.47 c42 77 + X15.47 c43 77 + X15.47 c44 77 + X15.47 c45 77 + X15.47 c46 77 + X15.47 c47 77 + X15.47 c48 77 + X15.49 c16 1 + X15.49 c37 77 + X15.49 c38 77 + X15.49 c39 77 + X15.49 c40 77 + X15.49 c41 77 + X15.49 c42 77 + X15.49 c43 77 + X15.49 c44 77 + X15.49 c45 77 + X15.49 c46 77 + X15.49 c47 77 + X15.49 c48 77 + X15.49 c49 77 + X15.53 c16 1 + X15.53 c41 77 + X15.53 c42 77 + X15.53 c43 77 + X15.53 c44 77 + X15.53 c45 77 + X15.53 c46 77 + X15.53 c47 77 + X15.53 c48 77 + X15.53 c49 77 + X15.53 c50 77 + X15.53 c51 77 + X15.56 c16 1 + X15.56 c43 77 + X15.56 c44 77 + X15.56 c45 77 + X15.56 c46 77 + X15.56 c47 77 + X15.56 c48 77 + X15.56 c49 77 + X15.56 c50 77 + X15.56 c51 77 + X15.56 c52 77 + X15.56 c53 77 + X15.61 c16 1 + X15.61 c46 77 + X15.61 c47 77 + X15.61 c48 77 + X15.61 c49 77 + X15.61 c50 77 + X15.61 c51 77 + X15.61 c52 77 + X15.61 c53 77 + X15.61 c54 77 + X15.65 c16 1 + X15.65 c48 77 + X15.65 c49 77 + X15.65 c50 77 + X15.65 c51 77 + X15.65 c52 77 + X15.65 c53 77 + X15.65 c54 77 + X15.65 c55 77 + X15.65 c56 77 + X15.70 c16 1 + X15.70 c51 77 + X15.70 c52 77 + X15.70 c53 77 + X15.70 c54 77 + X15.70 c55 77 + X15.70 c56 77 + X15.70 c57 77 + X15.72 c16 1 + X15.72 c52 77 + X15.72 c53 77 + X15.72 c54 77 + X15.72 c55 77 + X15.72 c56 77 + X15.72 c57 77 + X15.72 c58 77 + X15.81 c16 1 + X15.81 c56 77 + X15.81 c57 77 + X15.81 c58 77 + X15.81 c59 77 + X16.0 c17 1 + X16.0 c21 72 + X16.0 c22 72 + X16.11 c17 1 + X16.11 c22 72 + X16.11 c23 72 + X16.11 c24 72 + X16.16 c17 1 + X16.16 c23 72 + X16.16 c24 72 + X16.16 c25 72 + X16.16 c26 72 + X16.19 c17 1 + X16.19 c24 72 + X16.19 c25 72 + X16.19 c26 72 + X16.19 c27 72 + X16.19 c28 72 + X16.27 c17 1 + X16.27 c25 72 + X16.27 c26 72 + X16.27 c27 72 + X16.27 c28 72 + X16.27 c29 72 + X16.27 c30 72 + X16.27 c31 72 + X16.30 c17 1 + X16.30 c26 72 + X16.30 c27 72 + X16.30 c28 72 + X16.30 c29 72 + X16.30 c30 72 + X16.30 c31 72 + X16.30 c32 72 + X16.30 c33 72 + X16.34 c17 1 + X16.34 c28 72 + X16.34 c29 72 + X16.34 c30 72 + X16.34 c31 72 + X16.34 c32 72 + X16.34 c33 72 + X16.34 c34 72 + X16.34 c35 72 + X16.34 c36 72 + X16.34 c37 72 + X16.35 c17 1 + X16.35 c29 72 + X16.35 c30 72 + X16.35 c31 72 + X16.35 c32 72 + X16.35 c33 72 + X16.35 c34 72 + X16.35 c35 72 + X16.35 c36 72 + X16.35 c37 72 + X16.35 c38 72 + X16.36 c17 1 + X16.36 c30 72 + X16.36 c31 72 + X16.36 c32 72 + X16.36 c33 72 + X16.36 c34 72 + X16.36 c35 72 + X16.36 c36 72 + X16.36 c37 72 + X16.36 c38 72 + X16.36 c39 72 + X16.41 c17 1 + X16.41 c31 72 + X16.41 c32 72 + X16.41 c33 72 + X16.41 c34 72 + X16.41 c35 72 + X16.41 c36 72 + X16.41 c37 72 + X16.41 c38 72 + X16.41 c39 72 + X16.41 c40 72 + X16.41 c41 72 + X16.41 c42 72 + X16.41 c43 72 + X16.45 c17 1 + X16.45 c33 72 + X16.45 c34 72 + X16.45 c35 72 + X16.45 c36 72 + X16.45 c37 72 + X16.45 c38 72 + X16.45 c39 72 + X16.45 c40 72 + X16.45 c41 72 + X16.45 c42 72 + X16.45 c43 72 + X16.45 c44 72 + X16.45 c45 72 + X16.48 c17 1 + X16.48 c36 72 + X16.48 c37 72 + X16.48 c38 72 + X16.48 c39 72 + X16.48 c40 72 + X16.48 c41 72 + X16.48 c42 72 + X16.48 c43 72 + X16.48 c44 72 + X16.48 c45 72 + X16.48 c46 72 + X16.49 c17 1 + X16.49 c37 72 + X16.49 c38 72 + X16.49 c39 72 + X16.49 c40 72 + X16.49 c41 72 + X16.49 c42 72 + X16.49 c43 72 + X16.49 c44 72 + X16.49 c45 72 + X16.49 c46 72 + X16.49 c47 72 + X16.50 c17 1 + X16.50 c38 72 + X16.50 c39 72 + X16.50 c40 72 + X16.50 c41 72 + X16.50 c42 72 + X16.50 c43 72 + X16.50 c44 72 + X16.50 c45 72 + X16.50 c46 72 + X16.50 c47 72 + X16.50 c48 72 + X16.53 c17 1 + X16.53 c41 72 + X16.53 c42 72 + X16.53 c43 72 + X16.53 c44 72 + X16.53 c45 72 + X16.53 c46 72 + X16.53 c47 72 + X16.53 c48 72 + X16.53 c49 72 + X16.53 c50 72 + X16.56 c17 1 + X16.56 c43 72 + X16.56 c44 72 + X16.56 c45 72 + X16.56 c46 72 + X16.56 c47 72 + X16.56 c48 72 + X16.56 c49 72 + X16.56 c50 72 + X16.56 c51 72 + X16.57 c17 1 + X16.57 c44 72 + X16.57 c45 72 + X16.57 c46 72 + X16.57 c47 72 + X16.57 c48 72 + X16.57 c49 72 + X16.57 c50 72 + X16.57 c51 72 + X16.57 c52 72 + X16.59 c17 1 + X16.59 c45 72 + X16.59 c46 72 + X16.59 c47 72 + X16.59 c48 72 + X16.59 c49 72 + X16.59 c50 72 + X16.59 c51 72 + X16.59 c52 72 + X16.59 c53 72 + X16.64 c17 1 + X16.64 c47 72 + X16.64 c48 72 + X16.64 c49 72 + X16.64 c50 72 + X16.64 c51 72 + X16.64 c52 72 + X16.64 c53 72 + X16.64 c54 72 + X16.65 c17 1 + X16.65 c48 72 + X16.65 c49 72 + X16.65 c50 72 + X16.65 c51 72 + X16.65 c52 72 + X16.65 c53 72 + X16.65 c54 72 + X16.65 c55 72 + X16.68 c17 1 + X16.68 c50 72 + X16.68 c51 72 + X16.68 c52 72 + X16.68 c53 72 + X16.68 c54 72 + X16.68 c55 72 + X16.68 c56 72 + X16.73 c17 1 + X16.73 c53 72 + X16.73 c54 72 + X16.73 c55 72 + X16.73 c56 72 + X16.73 c57 72 + X16.75 c17 1 + X16.75 c54 72 + X16.75 c55 72 + X16.75 c56 72 + X16.75 c57 72 + X16.75 c58 72 + X16.84 c17 1 + X16.84 c57 72 + X16.84 c58 72 + X16.84 c59 72 + X17.0 c18 1 + X17.0 c21 68 + X17.0 c22 68 + X17.11 c18 1 + X17.11 c22 68 + X17.11 c23 68 + X17.11 c24 68 + X17.16 c18 1 + X17.16 c23 68 + X17.16 c24 68 + X17.16 c25 68 + X17.16 c26 68 + X17.19 c18 1 + X17.19 c24 68 + X17.19 c25 68 + X17.19 c26 68 + X17.19 c27 68 + X17.19 c28 68 + X17.27 c18 1 + X17.27 c25 68 + X17.27 c26 68 + X17.27 c27 68 + X17.27 c28 68 + X17.27 c29 68 + X17.27 c30 68 + X17.27 c31 68 + X17.30 c18 1 + X17.30 c26 68 + X17.30 c27 68 + X17.30 c28 68 + X17.30 c29 68 + X17.30 c30 68 + X17.30 c31 68 + X17.30 c32 68 + X17.30 c33 68 + X17.34 c18 1 + X17.34 c28 68 + X17.34 c29 68 + X17.34 c30 68 + X17.34 c31 68 + X17.34 c32 68 + X17.34 c33 68 + X17.34 c34 68 + X17.34 c35 68 + X17.34 c36 68 + X17.34 c37 68 + X17.35 c18 1 + X17.35 c29 68 + X17.35 c30 68 + X17.35 c31 68 + X17.35 c32 68 + X17.35 c33 68 + X17.35 c34 68 + X17.35 c35 68 + X17.35 c36 68 + X17.35 c37 68 + X17.35 c38 68 + X17.36 c18 1 + X17.36 c30 68 + X17.36 c31 68 + X17.36 c32 68 + X17.36 c33 68 + X17.36 c34 68 + X17.36 c35 68 + X17.36 c36 68 + X17.36 c37 68 + X17.36 c38 68 + X17.36 c39 68 + X17.41 c18 1 + X17.41 c31 68 + X17.41 c32 68 + X17.41 c33 68 + X17.41 c34 68 + X17.41 c35 68 + X17.41 c36 68 + X17.41 c37 68 + X17.41 c38 68 + X17.41 c39 68 + X17.41 c40 68 + X17.41 c41 68 + X17.41 c42 68 + X17.41 c43 68 + X17.45 c18 1 + X17.45 c33 68 + X17.45 c34 68 + X17.45 c35 68 + X17.45 c36 68 + X17.45 c37 68 + X17.45 c38 68 + X17.45 c39 68 + X17.45 c40 68 + X17.45 c41 68 + X17.45 c42 68 + X17.45 c43 68 + X17.45 c44 68 + X17.45 c45 68 + X17.48 c18 1 + X17.48 c36 68 + X17.48 c37 68 + X17.48 c38 68 + X17.48 c39 68 + X17.48 c40 68 + X17.48 c41 68 + X17.48 c42 68 + X17.48 c43 68 + X17.48 c44 68 + X17.48 c45 68 + X17.48 c46 68 + X17.49 c18 1 + X17.49 c37 68 + X17.49 c38 68 + X17.49 c39 68 + X17.49 c40 68 + X17.49 c41 68 + X17.49 c42 68 + X17.49 c43 68 + X17.49 c44 68 + X17.49 c45 68 + X17.49 c46 68 + X17.49 c47 68 + X17.50 c18 1 + X17.50 c38 68 + X17.50 c39 68 + X17.50 c40 68 + X17.50 c41 68 + X17.50 c42 68 + X17.50 c43 68 + X17.50 c44 68 + X17.50 c45 68 + X17.50 c46 68 + X17.50 c47 68 + X17.50 c48 68 + X17.53 c18 1 + X17.53 c41 68 + X17.53 c42 68 + X17.53 c43 68 + X17.53 c44 68 + X17.53 c45 68 + X17.53 c46 68 + X17.53 c47 68 + X17.53 c48 68 + X17.53 c49 68 + X17.53 c50 68 + X17.56 c18 1 + X17.56 c43 68 + X17.56 c44 68 + X17.56 c45 68 + X17.56 c46 68 + X17.56 c47 68 + X17.56 c48 68 + X17.56 c49 68 + X17.56 c50 68 + X17.56 c51 68 + X17.57 c18 1 + X17.57 c44 68 + X17.57 c45 68 + X17.57 c46 68 + X17.57 c47 68 + X17.57 c48 68 + X17.57 c49 68 + X17.57 c50 68 + X17.57 c51 68 + X17.57 c52 68 + X17.59 c18 1 + X17.59 c45 68 + X17.59 c46 68 + X17.59 c47 68 + X17.59 c48 68 + X17.59 c49 68 + X17.59 c50 68 + X17.59 c51 68 + X17.59 c52 68 + X17.59 c53 68 + X17.64 c18 1 + X17.64 c47 68 + X17.64 c48 68 + X17.64 c49 68 + X17.64 c50 68 + X17.64 c51 68 + X17.64 c52 68 + X17.64 c53 68 + X17.64 c54 68 + X17.65 c18 1 + X17.65 c48 68 + X17.65 c49 68 + X17.65 c50 68 + X17.65 c51 68 + X17.65 c52 68 + X17.65 c53 68 + X17.65 c54 68 + X17.65 c55 68 + X17.68 c18 1 + X17.68 c50 68 + X17.68 c51 68 + X17.68 c52 68 + X17.68 c53 68 + X17.68 c54 68 + X17.68 c55 68 + X17.68 c56 68 + X17.73 c18 1 + X17.73 c53 68 + X17.73 c54 68 + X17.73 c55 68 + X17.73 c56 68 + X17.73 c57 68 + X17.75 c18 1 + X17.75 c54 68 + X17.75 c55 68 + X17.75 c56 68 + X17.75 c57 68 + X17.75 c58 68 + X17.84 c18 1 + X17.84 c57 68 + X17.84 c58 68 + X17.84 c59 68 + X18.0 c19 1 + X18.0 c21 97 + X18.16 c19 1 + X18.16 c23 97 + X18.16 c24 97 + X18.19 c19 1 + X18.19 c24 97 + X18.19 c25 97 + X18.32 c19 1 + X18.32 c27 97 + X18.32 c28 97 + X18.32 c29 97 + X18.32 c30 97 + X18.32 c31 97 + X18.34 c19 1 + X18.34 c28 97 + X18.34 c29 97 + X18.34 c30 97 + X18.34 c31 97 + X18.34 c32 97 + X18.35 c19 1 + X18.35 c29 97 + X18.35 c30 97 + X18.35 c31 97 + X18.35 c32 97 + X18.35 c33 97 + X18.36 c19 1 + X18.36 c30 97 + X18.36 c31 97 + X18.36 c32 97 + X18.36 c33 97 + X18.36 c34 97 + X18.41 c19 1 + X18.41 c31 97 + X18.41 c32 97 + X18.41 c33 97 + X18.41 c34 97 + X18.41 c35 97 + X18.41 c36 97 + X18.41 c37 97 + X18.41 c38 97 + X18.41 c39 97 + X18.45 c19 1 + X18.45 c33 97 + X18.45 c34 97 + X18.45 c35 97 + X18.45 c36 97 + X18.45 c37 97 + X18.45 c38 97 + X18.45 c39 97 + X18.45 c40 97 + X18.45 c41 97 + X18.45 c42 97 + X18.46 c19 1 + X18.46 c34 97 + X18.46 c35 97 + X18.46 c36 97 + X18.46 c37 97 + X18.46 c38 97 + X18.46 c39 97 + X18.46 c40 97 + X18.46 c41 97 + X18.46 c42 97 + X18.46 c43 97 + X18.48 c19 1 + X18.48 c36 97 + X18.48 c37 97 + X18.48 c38 97 + X18.48 c39 97 + X18.48 c40 97 + X18.48 c41 97 + X18.48 c42 97 + X18.48 c43 97 + X18.48 c44 97 + X18.53 c19 1 + X18.53 c41 97 + X18.53 c42 97 + X18.53 c43 97 + X18.53 c44 97 + X18.53 c45 97 + X18.53 c46 97 + X18.55 c19 1 + X18.55 c42 97 + X18.55 c43 97 + X18.55 c44 97 + X18.55 c45 97 + X18.55 c46 97 + X18.55 c47 97 + X18.55 c48 97 + X18.57 c19 1 + X18.57 c44 97 + X18.57 c45 97 + X18.57 c46 97 + X18.57 c47 97 + X18.57 c48 97 + X18.57 c49 97 + X18.61 c19 1 + X18.61 c46 97 + X18.61 c47 97 + X18.61 c48 97 + X18.61 c49 97 + X18.61 c50 97 + X18.61 c51 97 + X18.64 c19 1 + X18.64 c47 97 + X18.64 c48 97 + X18.64 c49 97 + X18.64 c50 97 + X18.64 c51 97 + X18.64 c52 97 + X18.64 c53 97 + X18.70 c19 1 + X18.70 c51 97 + X18.70 c52 97 + X18.70 c53 97 + X18.70 c54 97 + X18.70 c55 97 + X18.73 c19 1 + X18.73 c53 97 + X18.73 c54 97 + X18.73 c55 97 + X18.73 c56 97 + X18.80 c19 1 + X18.80 c55 97 + X18.80 c56 97 + X18.80 c57 97 + X18.80 c58 97 + X18.89 c19 1 + X18.89 c58 97 + X18.89 c59 97 + X19.46 c20 1 + X19.46 c34 97 + X19.46 c35 97 + X19.46 c36 97 + X19.46 c37 97 + X19.46 c38 97 + X19.46 c39 97 + X19.46 c40 97 + X19.46 c41 97 + X19.48 c20 1 + X19.48 c36 97 + X19.48 c37 97 + X19.48 c38 97 + X19.48 c39 97 + X19.48 c40 97 + X19.48 c41 97 + X19.48 c42 97 + X19.48 c43 97 + X19.50 c20 1 + X19.50 c38 97 + X19.50 c39 97 + X19.50 c40 97 + X19.50 c41 97 + X19.50 c42 97 + X19.50 c43 97 + X19.50 c44 97 + X19.55 c20 1 + X19.55 c42 97 + X19.55 c43 97 + X19.55 c44 97 + X19.55 c45 97 + X19.55 c46 97 + X19.56 c20 1 + X19.56 c43 97 + X19.56 c44 97 + X19.56 c45 97 + X19.56 c46 97 + X19.56 c47 97 + X19.57 c20 1 + X19.57 c44 97 + X19.57 c45 97 + X19.57 c46 97 + X19.57 c47 97 + X19.57 c48 97 + X19.59 c20 1 + X19.59 c45 97 + X19.59 c46 97 + X19.59 c47 97 + X19.59 c48 97 + X19.59 c49 97 + X19.61 c20 1 + X19.61 c46 97 + X19.61 c47 97 + X19.61 c48 97 + X19.61 c49 97 + X19.61 c50 97 + X19.64 c20 1 + X19.64 c47 97 + X19.64 c48 97 + X19.64 c49 97 + X19.64 c50 97 + X19.64 c51 97 + X19.64 c52 97 + X19.72 c20 1 + X19.72 c52 97 + X19.72 c53 97 + X19.72 c54 97 + X19.72 c55 97 + X19.75 c20 1 + X19.75 c54 97 + X19.75 c55 97 + X19.75 c56 97 + X19.80 c20 1 + X19.80 c55 97 + X19.80 c56 97 + X19.80 c57 97 + X19.91 c20 1 + X19.91 c59 97 + MARK0001 'MARKER' 'INTEND' +RHS + rhs c1 1 + rhs c2 1 + rhs c3 1 + rhs c4 1 + rhs c5 1 + rhs c6 1 + rhs c7 1 + rhs c8 1 + rhs c9 1 + rhs c10 1 + rhs c11 1 + rhs c12 1 + rhs c13 1 + rhs c14 1 + rhs c15 1 + rhs c16 1 + rhs c17 1 + rhs c18 1 + rhs c19 1 + rhs c20 1 +BOUNDS + FX bnd x2 0 + UP bnd X0.0 1 + UP bnd X1.0 1 + UP bnd X1.11 1 + UP bnd X2.0 1 + UP bnd X2.11 1 + UP bnd X2.16 1 + UP bnd X3.0 1 + UP bnd X3.11 1 + UP bnd X3.16 1 + UP bnd X3.19 1 + UP bnd X4.0 1 + UP bnd X4.11 1 + UP bnd X4.16 1 + UP bnd X4.19 1 + UP bnd X5.0 1 + UP bnd X5.11 1 + UP bnd X5.16 1 + UP bnd X5.19 1 + UP bnd X6.0 1 + UP bnd X6.11 1 + UP bnd X6.16 1 + UP bnd X6.19 1 + UP bnd X6.27 1 + UP bnd X7.0 1 + UP bnd X7.11 1 + UP bnd X7.16 1 + UP bnd X7.19 1 + UP bnd X7.27 1 + UP bnd X7.30 1 + UP bnd X7.32 1 + UP bnd X8.0 1 + UP bnd X8.11 1 + UP bnd X8.16 1 + UP bnd X8.19 1 + UP bnd X8.27 1 + UP bnd X8.30 1 + UP bnd X8.32 1 + UP bnd X8.34 1 + UP bnd X9.0 1 + UP bnd X9.11 1 + UP bnd X9.16 1 + UP bnd X9.19 1 + UP bnd X9.27 1 + UP bnd X9.30 1 + UP bnd X9.32 1 + UP bnd X9.34 1 + UP bnd X9.35 1 + UP bnd X9.36 1 + UP bnd X9.41 1 + UP bnd X9.43 1 + UP bnd X9.45 1 + UP bnd X9.51 1 + UP bnd X10.0 1 + UP bnd X10.11 1 + UP bnd X10.16 1 + UP bnd X10.19 1 + UP bnd X10.27 1 + UP bnd X10.30 1 + UP bnd X10.32 1 + UP bnd X10.34 1 + UP bnd X10.35 1 + UP bnd X10.36 1 + UP bnd X10.41 1 + UP bnd X10.43 1 + UP bnd X10.45 1 + UP bnd X10.52 1 + UP bnd X11.0 1 + UP bnd X11.11 1 + UP bnd X11.16 1 + UP bnd X11.19 1 + UP bnd X11.27 1 + UP bnd X11.30 1 + UP bnd X11.32 1 + UP bnd X11.34 1 + UP bnd X11.35 1 + UP bnd X11.36 1 + UP bnd X11.41 1 + UP bnd X11.43 1 + UP bnd X11.45 1 + UP bnd X11.46 1 + UP bnd X11.55 1 + UP bnd X12.0 1 + UP bnd X12.11 1 + UP bnd X12.16 1 + UP bnd X12.19 1 + UP bnd X12.27 1 + UP bnd X12.30 1 + UP bnd X12.32 1 + UP bnd X12.34 1 + UP bnd X12.35 1 + UP bnd X12.36 1 + UP bnd X12.43 1 + UP bnd X12.45 1 + UP bnd X12.48 1 + UP bnd X12.50 1 + UP bnd X12.59 1 + UP bnd X13.0 1 + UP bnd X13.11 1 + UP bnd X13.16 1 + UP bnd X13.19 1 + UP bnd X13.27 1 + UP bnd X13.30 1 + UP bnd X13.32 1 + UP bnd X13.34 1 + UP bnd X13.35 1 + UP bnd X13.41 1 + UP bnd X13.43 1 + UP bnd X13.45 1 + UP bnd X13.48 1 + UP bnd X13.53 1 + UP bnd X13.55 1 + UP bnd X13.64 1 + UP bnd X14.0 1 + UP bnd X14.11 1 + UP bnd X14.16 1 + UP bnd X14.19 1 + UP bnd X14.27 1 + UP bnd X14.30 1 + UP bnd X14.32 1 + UP bnd X14.35 1 + UP bnd X14.36 1 + UP bnd X14.41 1 + UP bnd X14.43 1 + UP bnd X14.45 1 + UP bnd X14.46 1 + UP bnd X14.47 1 + UP bnd X14.50 1 + UP bnd X14.55 1 + UP bnd X14.57 1 + UP bnd X14.66 1 + UP bnd X15.0 1 + UP bnd X15.11 1 + UP bnd X15.16 1 + UP bnd X15.27 1 + UP bnd X15.32 1 + UP bnd X15.34 1 + UP bnd X15.36 1 + UP bnd X15.41 1 + UP bnd X15.43 1 + UP bnd X15.45 1 + UP bnd X15.47 1 + UP bnd X15.49 1 + UP bnd X15.53 1 + UP bnd X15.56 1 + UP bnd X15.61 1 + UP bnd X15.65 1 + UP bnd X15.70 1 + UP bnd X15.72 1 + UP bnd X15.81 1 + UP bnd X16.0 1 + UP bnd X16.11 1 + UP bnd X16.16 1 + UP bnd X16.19 1 + UP bnd X16.27 1 + UP bnd X16.30 1 + UP bnd X16.34 1 + UP bnd X16.35 1 + UP bnd X16.36 1 + UP bnd X16.41 1 + UP bnd X16.45 1 + UP bnd X16.48 1 + UP bnd X16.49 1 + UP bnd X16.50 1 + UP bnd X16.53 1 + UP bnd X16.56 1 + UP bnd X16.57 1 + UP bnd X16.59 1 + UP bnd X16.64 1 + UP bnd X16.65 1 + UP bnd X16.68 1 + UP bnd X16.73 1 + UP bnd X16.75 1 + UP bnd X16.84 1 + UP bnd X17.0 1 + UP bnd X17.11 1 + UP bnd X17.16 1 + UP bnd X17.19 1 + UP bnd X17.27 1 + UP bnd X17.30 1 + UP bnd X17.34 1 + UP bnd X17.35 1 + UP bnd X17.36 1 + UP bnd X17.41 1 + UP bnd X17.45 1 + UP bnd X17.48 1 + UP bnd X17.49 1 + UP bnd X17.50 1 + UP bnd X17.53 1 + UP bnd X17.56 1 + UP bnd X17.57 1 + UP bnd X17.59 1 + UP bnd X17.64 1 + UP bnd X17.65 1 + UP bnd X17.68 1 + UP bnd X17.73 1 + UP bnd X17.75 1 + UP bnd X17.84 1 + UP bnd X18.0 1 + UP bnd X18.16 1 + UP bnd X18.19 1 + UP bnd X18.32 1 + UP bnd X18.34 1 + UP bnd X18.35 1 + UP bnd X18.36 1 + UP bnd X18.41 1 + UP bnd X18.45 1 + UP bnd X18.46 1 + UP bnd X18.48 1 + UP bnd X18.53 2 + UP bnd X18.55 1 + UP bnd X18.57 1 + UP bnd X18.61 1 + UP bnd X18.64 1 + UP bnd X18.70 1 + UP bnd X18.73 1 + UP bnd X18.80 1 + UP bnd X18.89 1 + UP bnd X19.46 1 + UP bnd X19.48 1 + UP bnd X19.50 1 + UP bnd X19.55 1 + UP bnd X19.56 1 + UP bnd X19.57 1 + UP bnd X19.59 1 + UP bnd X19.61 1 + UP bnd X19.64 1 + UP bnd X19.72 1 + UP bnd X19.75 1 + UP bnd X19.80 1 + UP bnd X19.91 1 +ENDATA diff --git a/ecole/libecole/tests/data/enlight8.mps b/ecole/libecole/tests/data/enlight8.mps new file mode 100644 index 0000000..8b6610c --- /dev/null +++ b/ecole/libecole/tests/data/enlight8.mps @@ -0,0 +1,618 @@ +NAME enlight8 +ROWS + N moves + E inner_area_1 + E inner_area_2 + E inner_area_3 + E inner_area_4 + E inner_area_5 + E inner_area_6 + E inner_area_7 + E inner_area_8 + E inner_area_9 + E inner_area_10 + E inner_area_11 + E inner_area_12 + E inner_area_13 + E inner_area_14 + E inner_area_15 + E inner_area_16 + E inner_area_17 + E inner_area_18 + E inner_area_19 + E inner_area_20 + E inner_area_21 + E inner_area_22 + E inner_area_23 + E inner_area_24 + E inner_area_25 + E inner_area_26 + E inner_area_27 + E inner_area_28 + E inner_area_29 + E inner_area_30 + E inner_area_31 + E inner_area_32 + E inner_area_33 + E inner_area_34 + E inner_area_35 + E inner_area_36 + E upper_border_1 + E upper_border_2 + E upper_border_3 + E upper_border_4 + E upper_border_5 + E upper_border_6 + E lower_border_1 + E lower_border_2 + E lower_border_3 + E lower_border_4 + E lower_border_5 + E lower_border_6 + E left_border_1 + E left_border_2 + E left_border_3 + E left_border_4 + E left_border_5 + E left_border_6 + E right_border_1 + E right_border_2 + E right_border_3 + E right_border_4 + E right_border_5 + E right_border_6 + E left_upper_co@3c + E left_lower_co@3d + E right_upper_c@3e + E right_lower_c@3f +COLUMNS + MARK0000 'MARKER' 'INTORG' + x#1#1 moves 1 + x#1#1 upper_border_1 1 + x#1#1 left_border_1 1 + x#1#1 left_upper_co@3c 1 + x#1#2 moves 1 + x#1#2 inner_area_1 1 + x#1#2 upper_border_1 1 + x#1#2 upper_border_2 1 + x#1#2 left_upper_co@3c 1 + x#1#3 moves 1 + x#1#3 inner_area_2 1 + x#1#3 upper_border_1 1 + x#1#3 upper_border_2 1 + x#1#3 upper_border_3 1 + x#1#4 moves 1 + x#1#4 inner_area_3 1 + x#1#4 upper_border_2 1 + x#1#4 upper_border_3 1 + x#1#4 upper_border_4 1 + x#1#5 moves 1 + x#1#5 inner_area_4 1 + x#1#5 upper_border_3 1 + x#1#5 upper_border_4 1 + x#1#5 upper_border_5 1 + x#1#6 moves 1 + x#1#6 inner_area_5 1 + x#1#6 upper_border_4 1 + x#1#6 upper_border_5 1 + x#1#6 upper_border_6 1 + x#1#7 moves 1 + x#1#7 inner_area_6 1 + x#1#7 upper_border_5 1 + x#1#7 upper_border_6 1 + x#1#7 right_upper_c@3e 1 + x#1#8 moves 1 + x#1#8 upper_border_6 1 + x#1#8 right_border_1 1 + x#1#8 right_upper_c@3e 1 + x#2#1 moves 1 + x#2#1 inner_area_1 1 + x#2#1 left_border_1 1 + x#2#1 left_border_2 1 + x#2#1 left_upper_co@3c 1 + x#2#2 moves 1 + x#2#2 inner_area_1 1 + x#2#2 inner_area_2 1 + x#2#2 inner_area_7 1 + x#2#2 upper_border_1 1 + x#2#2 left_border_1 1 + x#2#3 moves 1 + x#2#3 inner_area_1 1 + x#2#3 inner_area_2 1 + x#2#3 inner_area_3 1 + x#2#3 inner_area_8 1 + x#2#3 upper_border_2 1 + x#2#4 moves 1 + x#2#4 inner_area_2 1 + x#2#4 inner_area_3 1 + x#2#4 inner_area_4 1 + x#2#4 inner_area_9 1 + x#2#4 upper_border_3 1 + x#2#5 moves 1 + x#2#5 inner_area_3 1 + x#2#5 inner_area_4 1 + x#2#5 inner_area_5 1 + x#2#5 inner_area_10 1 + x#2#5 upper_border_4 1 + x#2#6 moves 1 + x#2#6 inner_area_4 1 + x#2#6 inner_area_5 1 + x#2#6 inner_area_6 1 + x#2#6 inner_area_11 1 + x#2#6 upper_border_5 1 + x#2#7 moves 1 + x#2#7 inner_area_5 1 + x#2#7 inner_area_6 1 + x#2#7 inner_area_12 1 + x#2#7 upper_border_6 1 + x#2#7 right_border_1 1 + x#2#8 moves 1 + x#2#8 inner_area_6 1 + x#2#8 right_border_1 1 + x#2#8 right_border_2 1 + x#2#8 right_upper_c@3e 1 + x#3#1 moves 1 + x#3#1 inner_area_7 1 + x#3#1 left_border_1 1 + x#3#1 left_border_2 1 + x#3#1 left_border_3 1 + x#3#2 moves 1 + x#3#2 inner_area_1 1 + x#3#2 inner_area_7 1 + x#3#2 inner_area_8 1 + x#3#2 inner_area_13 1 + x#3#2 left_border_2 1 + x#3#3 moves 1 + x#3#3 inner_area_2 1 + x#3#3 inner_area_7 1 + x#3#3 inner_area_8 1 + x#3#3 inner_area_9 1 + x#3#3 inner_area_14 1 + x#3#4 moves 1 + x#3#4 inner_area_3 1 + x#3#4 inner_area_8 1 + x#3#4 inner_area_9 1 + x#3#4 inner_area_10 1 + x#3#4 inner_area_15 1 + x#3#5 moves 1 + x#3#5 inner_area_4 1 + x#3#5 inner_area_9 1 + x#3#5 inner_area_10 1 + x#3#5 inner_area_11 1 + x#3#5 inner_area_16 1 + x#3#6 moves 1 + x#3#6 inner_area_5 1 + x#3#6 inner_area_10 1 + x#3#6 inner_area_11 1 + x#3#6 inner_area_12 1 + x#3#6 inner_area_17 1 + x#3#7 moves 1 + x#3#7 inner_area_6 1 + x#3#7 inner_area_11 1 + x#3#7 inner_area_12 1 + x#3#7 inner_area_18 1 + x#3#7 right_border_2 1 + x#3#8 moves 1 + x#3#8 inner_area_12 1 + x#3#8 right_border_1 1 + x#3#8 right_border_2 1 + x#3#8 right_border_3 1 + x#4#1 moves 1 + x#4#1 inner_area_13 1 + x#4#1 left_border_2 1 + x#4#1 left_border_3 1 + x#4#1 left_border_4 1 + x#4#2 moves 1 + x#4#2 inner_area_7 1 + x#4#2 inner_area_13 1 + x#4#2 inner_area_14 1 + x#4#2 inner_area_19 1 + x#4#2 left_border_3 1 + x#4#3 moves 1 + x#4#3 inner_area_8 1 + x#4#3 inner_area_13 1 + x#4#3 inner_area_14 1 + x#4#3 inner_area_15 1 + x#4#3 inner_area_20 1 + x#4#4 moves 1 + x#4#4 inner_area_9 1 + x#4#4 inner_area_14 1 + x#4#4 inner_area_15 1 + x#4#4 inner_area_16 1 + x#4#4 inner_area_21 1 + x#4#5 moves 1 + x#4#5 inner_area_10 1 + x#4#5 inner_area_15 1 + x#4#5 inner_area_16 1 + x#4#5 inner_area_17 1 + x#4#5 inner_area_22 1 + x#4#6 moves 1 + x#4#6 inner_area_11 1 + x#4#6 inner_area_16 1 + x#4#6 inner_area_17 1 + x#4#6 inner_area_18 1 + x#4#6 inner_area_23 1 + x#4#7 moves 1 + x#4#7 inner_area_12 1 + x#4#7 inner_area_17 1 + x#4#7 inner_area_18 1 + x#4#7 inner_area_24 1 + x#4#7 right_border_3 1 + x#4#8 moves 1 + x#4#8 inner_area_18 1 + x#4#8 right_border_2 1 + x#4#8 right_border_3 1 + x#4#8 right_border_4 1 + x#5#1 moves 1 + x#5#1 inner_area_19 1 + x#5#1 left_border_3 1 + x#5#1 left_border_4 1 + x#5#1 left_border_5 1 + x#5#2 moves 1 + x#5#2 inner_area_13 1 + x#5#2 inner_area_19 1 + x#5#2 inner_area_20 1 + x#5#2 inner_area_25 1 + x#5#2 left_border_4 1 + x#5#3 moves 1 + x#5#3 inner_area_14 1 + x#5#3 inner_area_19 1 + x#5#3 inner_area_20 1 + x#5#3 inner_area_21 1 + x#5#3 inner_area_26 1 + x#5#4 moves 1 + x#5#4 inner_area_15 1 + x#5#4 inner_area_20 1 + x#5#4 inner_area_21 1 + x#5#4 inner_area_22 1 + x#5#4 inner_area_27 1 + x#5#5 moves 1 + x#5#5 inner_area_16 1 + x#5#5 inner_area_21 1 + x#5#5 inner_area_22 1 + x#5#5 inner_area_23 1 + x#5#5 inner_area_28 1 + x#5#6 moves 1 + x#5#6 inner_area_17 1 + x#5#6 inner_area_22 1 + x#5#6 inner_area_23 1 + x#5#6 inner_area_24 1 + x#5#6 inner_area_29 1 + x#5#7 moves 1 + x#5#7 inner_area_18 1 + x#5#7 inner_area_23 1 + x#5#7 inner_area_24 1 + x#5#7 inner_area_30 1 + x#5#7 right_border_4 1 + x#5#8 moves 1 + x#5#8 inner_area_24 1 + x#5#8 right_border_3 1 + x#5#8 right_border_4 1 + x#5#8 right_border_5 1 + x#6#1 moves 1 + x#6#1 inner_area_25 1 + x#6#1 left_border_4 1 + x#6#1 left_border_5 1 + x#6#1 left_border_6 1 + x#6#2 moves 1 + x#6#2 inner_area_19 1 + x#6#2 inner_area_25 1 + x#6#2 inner_area_26 1 + x#6#2 inner_area_31 1 + x#6#2 left_border_5 1 + x#6#3 moves 1 + x#6#3 inner_area_20 1 + x#6#3 inner_area_25 1 + x#6#3 inner_area_26 1 + x#6#3 inner_area_27 1 + x#6#3 inner_area_32 1 + x#6#4 moves 1 + x#6#4 inner_area_21 1 + x#6#4 inner_area_26 1 + x#6#4 inner_area_27 1 + x#6#4 inner_area_28 1 + x#6#4 inner_area_33 1 + x#6#5 moves 1 + x#6#5 inner_area_22 1 + x#6#5 inner_area_27 1 + x#6#5 inner_area_28 1 + x#6#5 inner_area_29 1 + x#6#5 inner_area_34 1 + x#6#6 moves 1 + x#6#6 inner_area_23 1 + x#6#6 inner_area_28 1 + x#6#6 inner_area_29 1 + x#6#6 inner_area_30 1 + x#6#6 inner_area_35 1 + x#6#7 moves 1 + x#6#7 inner_area_24 1 + x#6#7 inner_area_29 1 + x#6#7 inner_area_30 1 + x#6#7 inner_area_36 1 + x#6#7 right_border_5 1 + x#6#8 moves 1 + x#6#8 inner_area_30 1 + x#6#8 right_border_4 1 + x#6#8 right_border_5 1 + x#6#8 right_border_6 1 + x#7#1 moves 1 + x#7#1 inner_area_31 1 + x#7#1 left_border_5 1 + x#7#1 left_border_6 1 + x#7#1 left_lower_co@3d 1 + x#7#2 moves 1 + x#7#2 inner_area_25 1 + x#7#2 inner_area_31 1 + x#7#2 inner_area_32 1 + x#7#2 lower_border_1 1 + x#7#2 left_border_6 1 + x#7#3 moves 1 + x#7#3 inner_area_26 1 + x#7#3 inner_area_31 1 + x#7#3 inner_area_32 1 + x#7#3 inner_area_33 1 + x#7#3 lower_border_2 1 + x#7#4 moves 1 + x#7#4 inner_area_27 1 + x#7#4 inner_area_32 1 + x#7#4 inner_area_33 1 + x#7#4 inner_area_34 1 + x#7#4 lower_border_3 1 + x#7#5 moves 1 + x#7#5 inner_area_28 1 + x#7#5 inner_area_33 1 + x#7#5 inner_area_34 1 + x#7#5 inner_area_35 1 + x#7#5 lower_border_4 1 + x#7#6 moves 1 + x#7#6 inner_area_29 1 + x#7#6 inner_area_34 1 + x#7#6 inner_area_35 1 + x#7#6 inner_area_36 1 + x#7#6 lower_border_5 1 + x#7#7 moves 1 + x#7#7 inner_area_30 1 + x#7#7 inner_area_35 1 + x#7#7 inner_area_36 1 + x#7#7 lower_border_6 1 + x#7#7 right_border_6 1 + x#7#8 moves 1 + x#7#8 inner_area_36 1 + x#7#8 right_border_5 1 + x#7#8 right_border_6 1 + x#7#8 right_lower_c@3f 1 + x#8#1 moves 1 + x#8#1 lower_border_1 1 + x#8#1 left_border_6 1 + x#8#1 left_lower_co@3d 1 + x#8#2 moves 1 + x#8#2 inner_area_31 1 + x#8#2 lower_border_1 1 + x#8#2 lower_border_2 1 + x#8#2 left_lower_co@3d 1 + x#8#3 moves 1 + x#8#3 inner_area_32 1 + x#8#3 lower_border_1 1 + x#8#3 lower_border_2 1 + x#8#3 lower_border_3 1 + x#8#4 moves 1 + x#8#4 inner_area_33 1 + x#8#4 lower_border_2 1 + x#8#4 lower_border_3 1 + x#8#4 lower_border_4 1 + x#8#5 moves 1 + x#8#5 inner_area_34 1 + x#8#5 lower_border_3 1 + x#8#5 lower_border_4 1 + x#8#5 lower_border_5 1 + x#8#6 moves 1 + x#8#6 inner_area_35 1 + x#8#6 lower_border_4 1 + x#8#6 lower_border_5 1 + x#8#6 lower_border_6 1 + x#8#7 moves 1 + x#8#7 inner_area_36 1 + x#8#7 lower_border_5 1 + x#8#7 lower_border_6 1 + x#8#7 right_lower_c@3f 1 + x#8#8 moves 1 + x#8#8 lower_border_6 1 + x#8#8 right_border_6 1 + x#8#8 right_lower_c@3f 1 + y#2#2 inner_area_1 -2 + y#2#3 inner_area_2 -2 + y#2#4 inner_area_3 -2 + y#2#5 inner_area_4 -2 + y#2#6 inner_area_5 -2 + y#2#7 inner_area_6 -2 + y#3#2 inner_area_7 -2 + y#3#3 inner_area_8 -2 + y#3#4 inner_area_9 -2 + y#3#5 inner_area_10 -2 + y#3#6 inner_area_11 -2 + y#3#7 inner_area_12 -2 + y#4#2 inner_area_13 -2 + y#4#3 inner_area_14 -2 + y#4#4 inner_area_15 -2 + y#4#5 inner_area_16 -2 + y#4#6 inner_area_17 -2 + y#4#7 inner_area_18 -2 + y#5#2 inner_area_19 -2 + y#5#3 inner_area_20 -2 + y#5#4 inner_area_21 -2 + y#5#5 inner_area_22 -2 + y#5#6 inner_area_23 -2 + y#5#7 inner_area_24 -2 + y#6#2 inner_area_25 -2 + y#6#3 inner_area_26 -2 + y#6#4 inner_area_27 -2 + y#6#5 inner_area_28 -2 + y#6#6 inner_area_29 -2 + y#6#7 inner_area_30 -2 + y#7#2 inner_area_31 -2 + y#7#3 inner_area_32 -2 + y#7#4 inner_area_33 -2 + y#7#5 inner_area_34 -2 + y#7#6 inner_area_35 -2 + y#7#7 inner_area_36 -2 + y#1#2 upper_border_1 -2 + y#1#3 upper_border_2 -2 + y#1#4 upper_border_3 -2 + y#1#5 upper_border_4 -2 + y#1#6 upper_border_5 -2 + y#1#7 upper_border_6 -2 + y#8#2 lower_border_1 -2 + y#8#3 lower_border_2 -2 + y#8#4 lower_border_3 -2 + y#8#5 lower_border_4 -2 + y#8#6 lower_border_5 -2 + y#8#7 lower_border_6 -2 + y#2#1 left_border_1 -2 + y#3#1 left_border_2 -2 + y#4#1 left_border_3 -2 + y#5#1 left_border_4 -2 + y#6#1 left_border_5 -2 + y#7#1 left_border_6 -2 + y#2#8 right_border_1 -2 + y#3#8 right_border_2 -2 + y#4#8 right_border_3 -2 + y#5#8 right_border_4 -2 + y#6#8 right_border_5 -2 + y#7#8 right_border_6 -2 + y#1#1 left_upper_co@3c -2 + y#8#1 left_lower_co@3d -2 + y#1#8 right_upper_c@3e -2 + y#8#8 right_lower_c@3f -2 + MARK0001 'MARKER' 'INTEND' +RHS + rhs left_upper_co@3c -1 +BOUNDS + UP bnd x#1#1 1 + UP bnd x#1#2 1 + UP bnd x#1#3 1 + UP bnd x#1#4 1 + UP bnd x#1#5 1 + UP bnd x#1#6 1 + UP bnd x#1#7 1 + UP bnd x#1#8 1 + UP bnd x#2#1 1 + UP bnd x#2#2 1 + UP bnd x#2#3 1 + UP bnd x#2#4 1 + UP bnd x#2#5 1 + UP bnd x#2#6 1 + UP bnd x#2#7 1 + UP bnd x#2#8 1 + UP bnd x#3#1 1 + UP bnd x#3#2 1 + UP bnd x#3#3 1 + UP bnd x#3#4 1 + UP bnd x#3#5 1 + UP bnd x#3#6 1 + UP bnd x#3#7 1 + UP bnd x#3#8 1 + UP bnd x#4#1 1 + UP bnd x#4#2 1 + UP bnd x#4#3 1 + UP bnd x#4#4 1 + UP bnd x#4#5 1 + UP bnd x#4#6 1 + UP bnd x#4#7 1 + UP bnd x#4#8 1 + UP bnd x#5#1 1 + UP bnd x#5#2 1 + UP bnd x#5#3 1 + UP bnd x#5#4 1 + UP bnd x#5#5 1 + UP bnd x#5#6 1 + UP bnd x#5#7 1 + UP bnd x#5#8 1 + UP bnd x#6#1 1 + UP bnd x#6#2 1 + UP bnd x#6#3 1 + UP bnd x#6#4 1 + UP bnd x#6#5 1 + UP bnd x#6#6 1 + UP bnd x#6#7 1 + UP bnd x#6#8 1 + UP bnd x#7#1 1 + UP bnd x#7#2 1 + UP bnd x#7#3 1 + UP bnd x#7#4 1 + UP bnd x#7#5 1 + UP bnd x#7#6 1 + UP bnd x#7#7 1 + UP bnd x#7#8 1 + UP bnd x#8#1 1 + UP bnd x#8#2 1 + UP bnd x#8#3 1 + UP bnd x#8#4 1 + UP bnd x#8#5 1 + UP bnd x#8#6 1 + UP bnd x#8#7 1 + UP bnd x#8#8 1 + LI bnd y#2#2 0 + LI bnd y#2#3 0 + LI bnd y#2#4 0 + LI bnd y#2#5 0 + LI bnd y#2#6 0 + LI bnd y#2#7 0 + LI bnd y#3#2 0 + LI bnd y#3#3 0 + LI bnd y#3#4 0 + LI bnd y#3#5 0 + LI bnd y#3#6 0 + LI bnd y#3#7 0 + LI bnd y#4#2 0 + LI bnd y#4#3 0 + LI bnd y#4#4 0 + LI bnd y#4#5 0 + LI bnd y#4#6 0 + LI bnd y#4#7 0 + LI bnd y#5#2 0 + LI bnd y#5#3 0 + LI bnd y#5#4 0 + LI bnd y#5#5 0 + LI bnd y#5#6 0 + LI bnd y#5#7 0 + LI bnd y#6#2 0 + LI bnd y#6#3 0 + LI bnd y#6#4 0 + LI bnd y#6#5 0 + LI bnd y#6#6 0 + LI bnd y#6#7 0 + LI bnd y#7#2 0 + LI bnd y#7#3 0 + LI bnd y#7#4 0 + LI bnd y#7#5 0 + LI bnd y#7#6 0 + LI bnd y#7#7 0 + LI bnd y#1#2 0 + LI bnd y#1#3 0 + LI bnd y#1#4 0 + LI bnd y#1#5 0 + LI bnd y#1#6 0 + LI bnd y#1#7 0 + LI bnd y#8#2 0 + LI bnd y#8#3 0 + LI bnd y#8#4 0 + LI bnd y#8#5 0 + LI bnd y#8#6 0 + LI bnd y#8#7 0 + LI bnd y#2#1 0 + LI bnd y#3#1 0 + LI bnd y#4#1 0 + LI bnd y#5#1 0 + LI bnd y#6#1 0 + LI bnd y#7#1 0 + LI bnd y#2#8 0 + LI bnd y#3#8 0 + LI bnd y#4#8 0 + LI bnd y#5#8 0 + LI bnd y#6#8 0 + LI bnd y#7#8 0 + LI bnd y#1#1 0 + LI bnd y#8#1 0 + LI bnd y#1#8 0 + LI bnd y#8#8 0 +ENDATA diff --git a/ecole/libecole/tests/dependencies/private.cmake b/ecole/libecole/tests/dependencies/private.cmake new file mode 100644 index 0000000..73f61e1 --- /dev/null +++ b/ecole/libecole/tests/dependencies/private.cmake @@ -0,0 +1,6 @@ +find_or_download_package( + NAME Catch2 + URL https://github.com/catchorg/Catch2/archive/v2.13.4.tar.gz + URL_HASH SHA256=e7eb70b3d0ac2ed7dcf14563ad808740c29e628edde99e973adad373a2b5e4df + CONFIGURE_ARGS -D CMAKE_BUILD_TYPE=Release +) diff --git a/ecole/libecole/tests/src/conftest.cpp b/ecole/libecole/tests/src/conftest.cpp new file mode 100644 index 0000000..f5574d8 --- /dev/null +++ b/ecole/libecole/tests/src/conftest.cpp @@ -0,0 +1,35 @@ +#include + +#include + +#include "conftest.hpp" + +ecole::scip::Model get_model(SCIP_STAGE stage) { + auto model = ecole::scip::Model::from_file(problem_file); + model.disable_cuts(); + model.disable_presolve(); + advance_to_stage(model, stage); + return model; +} + +void advance_to_stage(ecole::scip::Model& model, SCIP_STAGE stage) { + switch (stage) { + case SCIP_STAGE_PROBLEM: + break; + case SCIP_STAGE_TRANSFORMED: + model.transform_prob(); + break; + case SCIP_STAGE_PRESOLVED: + model.presolve(); + break; + case SCIP_STAGE_SOLVING: { + model.solve_iter(ecole::scip::callback::BranchruleConstructor{}); + break; + } + case SCIP_STAGE_SOLVED: + model.solve(); + break; + default: + throw std::logic_error{"Function get_model is not implemented for given stage stage "}; + } +} diff --git a/ecole/libecole/tests/src/conftest.hpp b/ecole/libecole/tests/src/conftest.hpp new file mode 100644 index 0000000..f51bffd --- /dev/null +++ b/ecole/libecole/tests/src/conftest.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include + +#include "ecole/scip/model.hpp" + +#ifndef TEST_DATA_DIR +#error "Need to define TEST_DATA_DIR." +#endif +constexpr auto problem_file = (TEST_DATA_DIR "/bppc8-02.mps"); + +/** + * Return a Model that is not trivially solved in the deisred stage. + */ +ecole::scip::Model get_model(SCIP_STAGE stage = SCIP_STAGE_PROBLEM); + +/* + * Advance an unsolved Model to the root node. + */ +void advance_to_stage(ecole::scip::Model& model, SCIP_STAGE stage); diff --git a/ecole/libecole/tests/src/data/mock-function.hpp b/ecole/libecole/tests/src/data/mock-function.hpp new file mode 100644 index 0000000..c87ba7f --- /dev/null +++ b/ecole/libecole/tests/src/data/mock-function.hpp @@ -0,0 +1,20 @@ +#include "ecole/data/abstract.hpp" + +namespace ecole::data { + +/** Dummy data function to monitor what is happening. */ +template struct MockFunction { + T val; + + MockFunction() = default; + MockFunction(T val_) : val{val_} {} + + auto before_reset(scip::Model const& /* model */) -> void { ++val; }; + + [[nodiscard]] auto extract(scip::Model const& /* model */, bool /* done */) const -> T { return val; } +}; + +using IntDataFunc = MockFunction; +using DoubleDataFunc = MockFunction; + +} // namespace ecole::data diff --git a/ecole/libecole/tests/src/data/test-constant.cpp b/ecole/libecole/tests/src/data/test-constant.cpp new file mode 100644 index 0000000..c42af0c --- /dev/null +++ b/ecole/libecole/tests/src/data/test-constant.cpp @@ -0,0 +1,27 @@ +#include + +#include "ecole/data/constant.hpp" + +#include "conftest.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("Constant reward unit tests", "[unit][data]") { + constexpr auto some_constant = 3.0; + data::unit_tests(data::ConstantFunction{some_constant}); +} + +TEST_CASE("Constant reward always return the same value", "[data]") { + auto const done = GENERATE(true, false); + auto const constant = GENERATE(-1., 0., 55); + auto data_func = data::ConstantFunction{constant}; + auto model = get_model(); + + data_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + + REQUIRE(data_func.extract(model, done) == constant); + + SECTION("On successive calls") { REQUIRE(data_func.extract(model, done) == constant); } +} diff --git a/ecole/libecole/tests/src/data/test-dynamic.cpp b/ecole/libecole/tests/src/data/test-dynamic.cpp new file mode 100644 index 0000000..0b4bdf3 --- /dev/null +++ b/ecole/libecole/tests/src/data/test-dynamic.cpp @@ -0,0 +1,36 @@ +#include + +#include "ecole/data/dynamic.hpp" + +#include "conftest.hpp" +#include "data/mock-function.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole::data; + +TEST_CASE("Dynamic function unit tests", "[unit][data]") { + unit_tests(DynamicFunction{IntDataFunc{}}); +} + +TEST_CASE("Dynamic function is polymorphic", "[unit][data]") { + using Data = double; + static constexpr auto int_val = 33; + auto data_func = DynamicFunction{IntDataFunc{int_val}}; + auto model = get_model(); + + SECTION("Extract correct data") { + data_func.before_reset(model); + auto const data = data_func.extract(model, false); + STATIC_REQUIRE(std::is_same_v, Data>); + REQUIRE(data == Data{int_val + 1}); + } + + SECTION("Extract correct data when muted to new data function") { + static constexpr auto double_val = 42.; + data_func = DynamicFunction{DoubleDataFunc{double_val}}; + data_func.before_reset(model); + auto const data = data_func.extract(model, false); + STATIC_REQUIRE(std::is_same_v, Data>); + REQUIRE(data == double_val + 1); + } +} diff --git a/ecole/libecole/tests/src/data/test-map.cpp b/ecole/libecole/tests/src/data/test-map.cpp new file mode 100644 index 0000000..48b90e8 --- /dev/null +++ b/ecole/libecole/tests/src/data/test-map.cpp @@ -0,0 +1,28 @@ +#include +#include + +#include + +#include "ecole/data/map.hpp" + +#include "conftest.hpp" +#include "data/mock-function.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole::data; + +TEST_CASE("Data MapFunction unit tests", "[unit][data]") { + ecole::data::unit_tests(MapFunction{{{"a", {}}, {"b", {}}}}); +} + +TEST_CASE("Combine data extraction functions into a map", "[data]") { + auto data_func = MapFunction{{{"a", {1}}, {"b", {2}}}}; + auto model = get_model(); + + data_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const data = data_func.extract(model, false); + STATIC_REQUIRE(std::is_same_v, std::map>); + REQUIRE(data.at("a") == 2); + REQUIRE(data.at("b") == 3); +} diff --git a/ecole/libecole/tests/src/data/test-multiary.cpp b/ecole/libecole/tests/src/data/test-multiary.cpp new file mode 100644 index 0000000..44c2a97 --- /dev/null +++ b/ecole/libecole/tests/src/data/test-multiary.cpp @@ -0,0 +1,41 @@ +#include + +#include + +#include "ecole/data/multiary.hpp" + +#include "conftest.hpp" +#include "data/mock-function.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole::data; + +TEST_CASE("UnaryFunction unit tests", "[unit][data]") { + // FIXME C++20: No type deduction for aliases + ecole::data::unit_tests(MultiaryFunction{std::negate{}, IntDataFunc{}}); +} + +TEST_CASE("BinaryFunction unit tests", "[unit][data]") { + // FIXME C++20: No type deduction for aliases + ecole::data::unit_tests(MultiaryFunction{std::plus{}, IntDataFunc{}, IntDataFunc{}}); +} + +TEST_CASE("UnaryFunction negate the number", "[data]") { + auto reward_func = MultiaryFunction{std::negate{}, IntDataFunc{}}; + auto model = get_model(); + + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + + REQUIRE(reward_func.extract(model) < 0); +} + +TEST_CASE("BinaryFunction substract two numbers", "[data]") { + auto reward_func = MultiaryFunction{std::minus{}, IntDataFunc{}, IntDataFunc{}}; + auto model = get_model(); + + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + + REQUIRE(reward_func.extract(model) == 0); +} diff --git a/ecole/libecole/tests/src/data/test-none.cpp b/ecole/libecole/tests/src/data/test-none.cpp new file mode 100644 index 0000000..ffe1a27 --- /dev/null +++ b/ecole/libecole/tests/src/data/test-none.cpp @@ -0,0 +1,22 @@ +#include + +#include "ecole/data/none.hpp" + +#include "conftest.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole::data; + +TEST_CASE("NoneFunction unit tests", "[unit][data]") { + unit_tests(NoneFunction{}); +} + +TEST_CASE("NoneFunction return None as data", "[data]") { + auto const done = GENERATE(true, false); + auto data_func = NoneFunction{}; + auto model = get_model(); + data_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + + REQUIRE(data_func.extract(model, done) == ecole::None); +} diff --git a/ecole/libecole/tests/src/data/test-parser.cpp b/ecole/libecole/tests/src/data/test-parser.cpp new file mode 100644 index 0000000..a8b8d0d --- /dev/null +++ b/ecole/libecole/tests/src/data/test-parser.cpp @@ -0,0 +1,39 @@ +#include +#include +#include +#include +#include + +#include + +#include "ecole/data/parser.hpp" +#include "ecole/none.hpp" + +#include "conftest.hpp" +#include "data/mock-function.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole::data; + +auto make_function_aggregate() { + return std::tuple{std::map{{"0", {}}}, std::vector{1.0}, ecole::None}; +} + +TEST_CASE("Data function parser passes unit tests", "[unit][data]") { + ecole::data::unit_tests(parse(make_function_aggregate())); +} + +TEST_CASE("Recursively parse data functions", "[data]") { + auto aggregate_func = parse(make_function_aggregate()); + auto model = get_model(); + + aggregate_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const aggregate_obs = aggregate_func.extract(model, false); + + using AggregateObs = std::remove_const_t; + STATIC_REQUIRE( + std::is_same_v, std::vector, ecole::NoneType>>); + REQUIRE(std::get<0>(aggregate_obs).at("0") == 1); + REQUIRE(std::get<1>(aggregate_obs).at(0) == 1.0); +} diff --git a/ecole/libecole/tests/src/data/test-timed.cpp b/ecole/libecole/tests/src/data/test-timed.cpp new file mode 100644 index 0000000..f9780d7 --- /dev/null +++ b/ecole/libecole/tests/src/data/test-timed.cpp @@ -0,0 +1,25 @@ +#include + +#include "ecole/data/timed.hpp" + +#include "conftest.hpp" +#include "data/mock-function.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("Data TimedFunction unit tests", "[unit][data]") { + bool const wall = GENERATE(true, false); + data::unit_tests(data::TimedFunction{wall}); +} + +TEST_CASE("Timed data function is positive", "[data]") { + bool const wall = GENERATE(true, false); + auto timed_func = data::TimedFunction{wall}; + auto model = get_model(); + + timed_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const time = timed_func.extract(model, false); + REQUIRE(time >= 0.); +} diff --git a/ecole/libecole/tests/src/data/test-tuple.cpp b/ecole/libecole/tests/src/data/test-tuple.cpp new file mode 100644 index 0000000..4783ba0 --- /dev/null +++ b/ecole/libecole/tests/src/data/test-tuple.cpp @@ -0,0 +1,27 @@ +#include + +#include + +#include "ecole/data/tuple.hpp" + +#include "conftest.hpp" +#include "data/mock-function.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole::data; + +TEST_CASE("Data TupleFunction unit tests", "[unit][data]") { + ecole::data::unit_tests(TupleFunction{IntDataFunc{}, DoubleDataFunc{}}); +} + +TEST_CASE("Combine data functions into a tuple", "[data]") { + auto data_func = TupleFunction{IntDataFunc{0}, DoubleDataFunc{1}}; + auto model = get_model(); + + data_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const data = data_func.extract(model, false); + STATIC_REQUIRE(std::is_same_v, std::tuple>); + REQUIRE(std::get<0>(data) == 1); + REQUIRE(std::get<1>(data) == 2.0); // NOLINT(readability-magic-numbers) +} diff --git a/ecole/libecole/tests/src/data/test-vector.cpp b/ecole/libecole/tests/src/data/test-vector.cpp new file mode 100644 index 0000000..b6b4f87 --- /dev/null +++ b/ecole/libecole/tests/src/data/test-vector.cpp @@ -0,0 +1,27 @@ +#include + +#include + +#include "ecole/data/vector.hpp" + +#include "conftest.hpp" +#include "data/mock-function.hpp" +#include "data/unit-tests.hpp" + +using namespace ecole::data; + +TEST_CASE("Data VectorFunction unit tests", "[unit][data]") { + ecole::data::unit_tests(VectorFunction{std::vector{IntDataFunc{}, IntDataFunc{}}}); +} + +TEST_CASE("Combine data extraction functions into a vector", "[data]") { + auto data_func = VectorFunction{{{1}, {2}}}; + auto model = get_model(); + + data_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const data = data_func.extract(model, false); + STATIC_REQUIRE(std::is_same_v, std::vector>); + REQUIRE(data[0] == 2); + REQUIRE(data[1] == 3); +} diff --git a/ecole/libecole/tests/src/data/unit-tests.hpp b/ecole/libecole/tests/src/data/unit-tests.hpp new file mode 100644 index 0000000..aeccde5 --- /dev/null +++ b/ecole/libecole/tests/src/data/unit-tests.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include + +#include "ecole/traits.hpp" + +#include "conftest.hpp" + +namespace ecole::data { + +template void unit_tests(DataFunc&& data_func) { + SECTION("Interface is valid") { STATIC_REQUIRE(trait::is_data_function_v); }; + + SECTION("Has valid constructors") { + if constexpr (std::is_default_constructible_v) { + [[maybe_unused]] auto const func = DataFunc{}; // NOLINT + } + if constexpr (std::is_copy_constructible_v) { + [[maybe_unused]] auto const func = DataFunc{data_func}; // NOLINT + } + if constexpr (std::is_copy_assignable_v) { + [[maybe_unused]] auto const& func = data_func; // NOLINT + } + } + + SECTION("Function before_reset, before_reset, and delete") { + auto model1 = get_model(); + data_func.before_reset(model1); + auto model2 = get_model(); + data_func.before_reset(model2); + } + + SECTION("Function before_reset, obtain data, and delete") { + auto model = get_model(); + data_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const done = GENERATE(true, false); + [[maybe_unused]] auto const data = data_func.extract(model, done); + } +} + +} // namespace ecole::data diff --git a/ecole/libecole/tests/src/dynamics/test-branching.cpp b/ecole/libecole/tests/src/dynamics/test-branching.cpp new file mode 100644 index 0000000..854982c --- /dev/null +++ b/ecole/libecole/tests/src/dynamics/test-branching.cpp @@ -0,0 +1,88 @@ +#include +#include + +#include +#include +#include + +#include "ecole/dynamics/branching.hpp" +#include "ecole/exception.hpp" + +#include "conftest.hpp" +#include "dynamics/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("BranchingDynamics unit tests", "[unit][dynamics]") { + bool const pseudo_candidates = GENERATE(true, false); + bool const branch_first = GENERATE(true, false); + auto const policy = [branch_first](auto const& action_set, auto const& /*model*/) { + auto const branch_idx = branch_first ? 0 : action_set.value().size() - 1; + return action_set.value()[branch_idx]; + }; + dynamics::unit_tests(dynamics::BranchingDynamics{pseudo_candidates}, policy); +} + +TEST_CASE("BranchingDynamics functional tests", "[dynamics]") { + bool const pseudo_candidates = GENERATE(true, false); + auto dyn = dynamics::BranchingDynamics{pseudo_candidates}; + auto model = get_model(); + + SECTION("Return valid action set") { + auto const [done, action_set] = dyn.reset_dynamics(model); + REQUIRE(action_set.has_value()); + auto const& branch_cands = action_set.value(); + REQUIRE(branch_cands.size() > 0); + REQUIRE(branch_cands.size() <= model.lp_columns().size()); + REQUIRE(xt::all(branch_cands >= 0)); + REQUIRE(xt::all(branch_cands < model.lp_columns().size())); + REQUIRE(xt::unique(branch_cands).size() == branch_cands.size()); + } + + SECTION("Solve instance") { + auto [done, action_set] = dyn.reset_dynamics(model); + while (!done) { + REQUIRE(action_set.has_value()); + std::tie(done, action_set) = dyn.step_dynamics(model, action_set.value()[0]); + } + REQUIRE(model.is_solved()); + } + + SECTION("Throw on invalid branching variable") { + auto const [done, action_set] = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + REQUIRE(action_set.has_value()); + auto const action = model.lp_columns().size() + 1; + REQUIRE_THROWS_AS(dyn.step_dynamics(model, action), std::invalid_argument); + } + + SECTION("Provides default branching") { + auto [done, _] = dyn.reset_dynamics(model); + while (!done) { + std::tie(done, _) = dyn.step_dynamics(model, ecole::Default); + } + REQUIRE(model.is_solved()); + } +} + +TEST_CASE("BranchingDynamics handles limits", "[dynamics]") { + bool const pseudo_candidates = GENERATE(true, false); + auto dyn = dynamics::BranchingDynamics{pseudo_candidates}; + auto model = get_model(); + + SECTION("Node limit") { + auto const node_limit = GENERATE(0, 1, 2); + model.set_param("limits/totalnodes", node_limit); + } + + SECTION("Time limit") { + auto const time_limit = GENERATE(0, 1, 2); + model.set_param("limits/time", time_limit); + } + + auto [done, action_set] = dyn.reset_dynamics(model); + while (!done) { + REQUIRE(action_set.has_value()); + std::tie(done, action_set) = dyn.step_dynamics(model, action_set.value()[0]); + } +} diff --git a/ecole/libecole/tests/src/dynamics/test-configuring.cpp b/ecole/libecole/tests/src/dynamics/test-configuring.cpp new file mode 100644 index 0000000..a196aff --- /dev/null +++ b/ecole/libecole/tests/src/dynamics/test-configuring.cpp @@ -0,0 +1,53 @@ +#include +#include + +#include + +#include "ecole/dynamics/configuring.hpp" + +#include "conftest.hpp" +#include "dynamics/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("ConfiguringDynamics unit tests", "[unit][dynamics]") { + auto const policy = + [](auto const& /*action_set*/, auto const& /*model*/) -> trait::action_of_t { + return {{"branching/scorefunc", 's'}}; + }; + dynamics::unit_tests(dynamics::ConfiguringDynamics{}, policy); +} + +TEST_CASE("ConfiguringDynamics functional tests", "[dynamics]") { + dynamics::ConfiguringDynamics dyn{}; + auto model = get_model(); + + SECTION("Episodes have length one") { + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + std::tie(done, std::ignore) = dyn.step_dynamics(model, {}); + REQUIRE(done); + } + + SECTION("Solve instance") { + dyn.reset_dynamics(model); + dyn.step_dynamics(model, {}); + REQUIRE(model.is_solved()); + } + + SECTION("Accept multiple parameters") { + trait::action_of_t const params = { + {"branching/scorefunc", 's'}, + {"branching/scorefac", 0.1}, + {"branching/divingpscost", false}, + {"conflict/lpiterations", 0}, + // std::string has lower priority than bool for converting const char* + {"heuristics/undercover/fixingalts", std::string("ln")}, + }; + dyn.reset_dynamics(model); + dyn.step_dynamics(model, params); + for (auto const& name_val : params) { + REQUIRE(name_val.second == model.get_param(name_val.first)); + } + } +} diff --git a/ecole/libecole/tests/src/dynamics/test-parts.cpp b/ecole/libecole/tests/src/dynamics/test-parts.cpp new file mode 100644 index 0000000..8ad7a3f --- /dev/null +++ b/ecole/libecole/tests/src/dynamics/test-parts.cpp @@ -0,0 +1,27 @@ +#include + +#include "ecole/dynamics/parts.hpp" +#include "ecole/random.hpp" +#include "ecole/scip/model.hpp" + +using namespace ecole; + +TEST_CASE("Test default dynamics seeding", "[dynamics]") { + auto dyn = dynamics::DefaultSetDynamicsRandomState{}; + auto rng = RandomGenerator{}; // NOLINT This is deterministic for the test + auto model = scip::Model::prob_basic(); + + SECTION("Random generator is consumed") { + auto const rng_copy = rng; + dyn.set_dynamics_random_state(model, rng); + REQUIRE(rng != rng_copy); + } + + SECTION("Defaut Dynamics change seed every episode") { + dyn.set_dynamics_random_state(model, rng); + auto const seed1 = model.get_param("randomization/randomseedshift"); + dyn.set_dynamics_random_state(model, rng); + auto const seed2 = model.get_param("randomization/randomseedshift"); + REQUIRE(seed1 != seed2); + } +} diff --git a/ecole/libecole/tests/src/dynamics/test-primal-search.cpp b/ecole/libecole/tests/src/dynamics/test-primal-search.cpp new file mode 100644 index 0000000..a542c58 --- /dev/null +++ b/ecole/libecole/tests/src/dynamics/test-primal-search.cpp @@ -0,0 +1,173 @@ +#include +#include + +#include +#include +#include + +#include + +#include "ecole/dynamics/primal-search.hpp" +#include "ecole/exception.hpp" + +#include "conftest.hpp" +#include "dynamics/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("PrimalSearchDynamics unit tests", "[unit][dynamics]") { + dynamics::unit_tests( + dynamics::PrimalSearchDynamics{}, + [](auto const& /*action_set*/, auto const& /*model*/) -> dynamics::PrimalSearchDynamics::Action { + return {{}, {}}; + }); +} + +TEST_CASE("PrimalSearchDynamics functional tests", "[dynamics]") { + const auto trials_per_node = 5; + auto dyn = dynamics::PrimalSearchDynamics{trials_per_node}; + auto model = get_model(); + + SECTION("Return valid action set") { + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE(action_set.has_value()); + auto var_ids = action_set.value(); + REQUIRE(var_ids.size() > 0); + REQUIRE(var_ids.size() <= model.variables().size()); + REQUIRE(xt::all(var_ids >= 0)); + REQUIRE(xt::all(var_ids < model.variables().size())); + REQUIRE(xt::unique(var_ids).size() == var_ids.size()); + } + + SECTION("Handle extreme action - empty") { + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE(action_set.has_value()); + auto var_ids = action_set.value(); + REQUIRE(var_ids.size() > 0); + std::tie(done, action_set) = dyn.step_dynamics(model, {{}, {}}); + REQUIRE_FALSE(done); + } + + SECTION("Handle extreme values") { + auto const scip_inf = SCIPinfinity(model.get_scip_ptr()); + auto const scip_unknown = SCIP_UNKNOWN; + auto const scip_invalid = SCIP_INVALID; + auto const scip_min = std::numeric_limits::min(); + auto const scip_max = std::numeric_limits::max(); + + auto const val = GENERATE_COPY(scip_min, scip_max, scip_unknown, scip_invalid, -scip_inf, scip_inf); + + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE(action_set.has_value()); + auto var_ids = action_set.value(); + REQUIRE(var_ids.size() > 0); + auto var_vals = std::vector(var_ids.size(), val); + auto action = dynamics::PrimalSearchDynamics::Action{ + nonstd::span{var_ids.data(), var_ids.size()}, + nonstd::span{var_vals.data(), var_vals.size()}}; + std::tie(done, action_set) = dyn.step_dynamics(model, action); + + REQUIRE_FALSE(done); + } + + SECTION("Solve instance") { + auto [done, action_set] = dyn.reset_dynamics(model); + while (!done) { + REQUIRE(action_set.has_value()); + std::tie(done, action_set) = dyn.step_dynamics(model, {{}, {}}); + } + REQUIRE(model.is_solved()); + } + + SECTION("Throw on invalid variable id") { + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + REQUIRE(action_set.has_value()); + auto var_ids = std::vector{model.variables().size()}; + auto var_vals = std::vector{0.0}; + auto action = dynamics::PrimalSearchDynamics::Action{ + nonstd::span{var_ids.data(), var_ids.size()}, + nonstd::span{var_vals.data(), var_vals.size()}}; + REQUIRE_THROWS_AS(dyn.step_dynamics(model, action), std::exception); + } +} + +TEST_CASE("PrimalSearchDynamics handles limits", "[dynamics]") { + auto dyn = dynamics::PrimalSearchDynamics{1}; + auto model = get_model(); + + SECTION("Node limit") { + auto const node_limit = GENERATE(0, 1, 2); + model.set_param("limits/totalnodes", node_limit); + } + + SECTION("Time limit") { + auto const time_limit = GENERATE(0, 1, 2); + model.set_param("limits/time", time_limit); + } + + auto [done, action_set] = dyn.reset_dynamics(model); + while (!done) { + REQUIRE(action_set.has_value()); + std::tie(done, action_set) = dyn.step_dynamics(model, {{}, {}}); + } +} + +TEST_CASE("PrimalSearchDynamics extreme parameterizations", "[dynamics]") { + + SECTION("Infinite trial loop") { + auto dyn = dynamics::PrimalSearchDynamics{-1}; + auto model = get_model(); + + auto const time_limit = GENERATE(0, 1, 2); + model.set_param("limits/time", time_limit); // we must set a time limit + auto [done, action_set] = dyn.reset_dynamics(model); + while (!done) { + REQUIRE(action_set.has_value()); + std::tie(done, action_set) = dyn.step_dynamics(model, {{}, {}}); + } + } + + SECTION("Zero trial") { + auto dyn = dynamics::PrimalSearchDynamics{0}; + auto model = get_model(); + + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE(done); + } + + SECTION("Single trial at root node") { + auto dyn = dynamics::PrimalSearchDynamics{1, 0}; + auto model = get_model(); + + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + std::tie(done, action_set) = dyn.step_dynamics(model, {{}, {}}); + REQUIRE(done); + } + + SECTION("Single trial at root node (alternative)") { + auto dyn = dynamics::PrimalSearchDynamics{1, 1, 0, 0}; // trials, freq, start, stop + auto model = get_model(); + + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + std::tie(done, action_set) = dyn.step_dynamics(model, {{}, {}}); + REQUIRE(done); + } + + SECTION("X trial per node") { + auto const trials_per_node = GENERATE(1, 2, 3); + auto dyn = dynamics::PrimalSearchDynamics{trials_per_node}; + auto model = get_model(); + model.set_param("limits/totalnodes", 3); + int nsteps = 0; + + auto [done, action_set] = dyn.reset_dynamics(model); + while (!done) { + std::tie(done, action_set) = dyn.step_dynamics(model, {{}, {}}); + nsteps++; + } + REQUIRE(nsteps % trials_per_node == 0); + } +} diff --git a/ecole/libecole/tests/src/dynamics/unit-tests.hpp b/ecole/libecole/tests/src/dynamics/unit-tests.hpp new file mode 100644 index 0000000..b1ae4d3 --- /dev/null +++ b/ecole/libecole/tests/src/dynamics/unit-tests.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include + +#include "ecole/random.hpp" +#include "ecole/traits.hpp" + +#include "conftest.hpp" + +namespace ecole::dynamics { + +template void unit_tests(Dynamics&& dyn, Func policy) { + auto model = get_model(); + + SECTION("Has default constructor") { [[maybe_unused]] auto const d = Dynamics{}; } + + SECTION("Perfom seeding") { + auto rng = RandomGenerator{std::random_device{}()}; + auto rng_copy = rng; + dyn.set_dynamics_random_state(model, rng); + REQUIRE(rng != rng_copy); + } + + SECTION("Reset, reset, and delete") { + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + model = get_model(); + std::tie(done, action_set) = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + } + + SECTION("Reset, step, and delete") { + auto [done, action_set] = dyn.reset_dynamics(model); + REQUIRE_FALSE(done); + std::tie(done, action_set) = dyn.step_dynamics(model, policy(action_set, model)); + } + + SECTION("Run full trajectory") { + auto [done, action_set] = dyn.reset_dynamics(model); + while (!done) { + std::tie(done, action_set) = dyn.step_dynamics(model, policy(action_set, model)); + } + + SECTION("Run another trajectory") { + model = get_model(); + std::tie(done, action_set) = dyn.reset_dynamics(model); + while (!done) { + std::tie(done, action_set) = dyn.step_dynamics(model, policy(action_set, model)); + } + } + } +} + +} // namespace ecole::dynamics diff --git a/ecole/libecole/tests/src/environment/test-environment.cpp b/ecole/libecole/tests/src/environment/test-environment.cpp new file mode 100644 index 0000000..17d8d0b --- /dev/null +++ b/ecole/libecole/tests/src/environment/test-environment.cpp @@ -0,0 +1,113 @@ +#include +#include + +#include + +#include "ecole/environment/environment.hpp" +#include "ecole/exception.hpp" +#include "ecole/information/nothing.hpp" +#include "ecole/none.hpp" +#include "ecole/observation/nothing.hpp" +#include "ecole/random.hpp" +#include "ecole/reward/constant.hpp" +#include "ecole/traits.hpp" + +#include "conftest.hpp" + +/**************************************** + * Mocking some classes for unit test * + ****************************************/ + +namespace ecole { +namespace dynamics { + +/** + * Dummy dynamics that record calls for testing purposes. + */ +struct TestDynamics { + using Action = double; + + enum class Calls { seed, reset, step }; + + static std::size_t constexpr max_call_lenght = 10; + std::vector calls; + Action last_action = 0.; + + auto set_dynamics_random_state(scip::Model& /*model*/, RandomGenerator& /*rng*/) -> void { + calls.push_back(Calls::seed); + } + + auto reset_dynamics(scip::Model& /*model*/) -> std::tuple { + calls.push_back(Calls::reset); + return {calls.size() >= max_call_lenght, None}; + } + + auto step_dynamics(scip::Model& /*model*/, double const& action) -> std::tuple { + calls.push_back(Calls::step); + last_action = action; + return {calls.size() >= max_call_lenght, None}; + } +}; + +} // namespace dynamics + +namespace environment { + +using TestEnv = Environment; + +} // namespace environment +} // namespace ecole + +/********************** + * Test Environment * + **********************/ + +using namespace ecole; + +TEST_CASE("Environments accept SCIP parameters", "[env]") { + auto constexpr name = "concurrent/paramsetprefix"; + auto const value = std::string("testname"); + auto env = environment::TestEnv{{}, {}, {}, {{name, value}}}; + + env.reset(problem_file); + REQUIRE(env.model().get_param(name) == std::string(value)); +} + +TEST_CASE("Environments have MDP API", "[env]") { + auto env = environment::TestEnv{}; + constexpr double some_action = 3.0; + using Calls = dynamics::TestDynamics::Calls; + + SECTION("Call reset, reset, and delete") { + auto [obs, action_set, reward, done, info] = env.reset(problem_file); + std::tie(obs, action_set, reward, done, info) = env.reset(problem_file); + REQUIRE(env.dynamics().calls == std::vector{Calls::seed, Calls::reset, Calls::seed, Calls::reset}); + } + + SECTION("Call reset, step, and delete") { + auto [obs, action_set, reward, done, info] = env.reset(problem_file); + std::tie(obs, action_set, reward, done, info) = env.step(some_action); + REQUIRE(env.dynamics().calls == std::vector{Calls::seed, Calls::reset, Calls::step}); + REQUIRE(env.dynamics().last_action == some_action); + } + + SECTION("Run full episodes") { + for (auto i = 0UL; i < 2; ++i) { + auto [obs, action_set, reward, done, info] = env.reset(problem_file); + REQUIRE(env.dynamics().calls.back() == Calls::reset); + while (!done) { + std::tie(obs, action_set, reward, done, info) = env.step(some_action); + } + } + } + + SECTION("Cannot transition without reseting") { REQUIRE_THROWS_AS(env.step(some_action), MarkovError); } + + SECTION("Cannot transition past termination") { + auto [obs, action_set, reward, done, info] = env.reset(problem_file); + while (!done) { + std::tie(std::ignore, std::ignore, std::ignore, done, std::ignore) = env.step(some_action); + } + REQUIRE_THROWS_AS(env.step(some_action), MarkovError); + } +} diff --git a/ecole/libecole/tests/src/main.cpp b/ecole/libecole/tests/src/main.cpp new file mode 100644 index 0000000..f481b16 --- /dev/null +++ b/ecole/libecole/tests/src/main.cpp @@ -0,0 +1,3 @@ +#define CATCH_CONFIG_MAIN + +#include diff --git a/ecole/libecole/tests/src/observation/test-hutter-2011.cpp b/ecole/libecole/tests/src/observation/test-hutter-2011.cpp new file mode 100644 index 0000000..a61c2b7 --- /dev/null +++ b/ecole/libecole/tests/src/observation/test-hutter-2011.cpp @@ -0,0 +1,129 @@ +#include +#include +#include + +#include +#include + +#include "ecole/observation/hutter-2011.hpp" + +#include "conftest.hpp" +#include "observation/unit-tests.hpp" + +using namespace ecole; + +template auto is_integer(T val) -> bool { + if constexpr (std::is_integral_v) { + return true; + } else { + return std::floor(val) == val; + } +} + +template auto is_positive_integer(T val) -> bool { + return is_integer(val) && (val >= 0); +} + +template auto is_sorted(T... vals) -> bool { + auto arr = std::array, sizeof...(vals)>{vals...}; + return std::is_sorted(arr.begin(), arr.end()); +} + +TEST_CASE("Hutter2011 unit tests", "[unit][obs]") { + observation::unit_tests(observation::Hutter2011{}); +} + +TEST_CASE("Hutter2011 return correct observation", "[obs]") { + using Features = observation::Hutter2011Obs::Features; + + auto obs_func = observation::Hutter2011{}; + auto model = get_model(); + obs_func.before_reset(model); + auto const optional_obs = obs_func.extract(model, false); + + SECTION("Observation is not empty on non terminal state") { REQUIRE(optional_obs.has_value()); } + + SECTION("Observation features has correct shape") { + auto const& obs = optional_obs.value(); + REQUIRE(obs.features.shape(0) == observation::Hutter2011Obs::n_features); + } + + SECTION("No features are NaN or infinite") { + auto const& obs = optional_obs.value(); + REQUIRE_FALSE(xt::any(xt::isnan(obs.features))); + REQUIRE_FALSE(xt::any(xt::isinf(obs.features))); + } + + SECTION("Observation has correct values") { + auto const& obs = optional_obs.value(); + auto get_feature = [&obs](auto feat) { return obs.features[static_cast(feat)]; }; + + SECTION("Problem size features") { + REQUIRE(is_positive_integer(get_feature(Features::nb_variables))); + REQUIRE(is_positive_integer(get_feature(Features::nb_constraints))); + REQUIRE(is_positive_integer(get_feature(Features::nb_nonzero_coefs))); + } + + SECTION("Variable-constraint graph features") { + { // Variables + REQUIRE(0. <= get_feature(Features::variable_node_degree_std)); + auto const min_degree = get_feature(Features::variable_node_degree_min); + auto const max_degree = get_feature(Features::variable_node_degree_max); + auto const mean_degree = get_feature(Features::variable_node_degree_mean); + auto const nb_cons = get_feature(Features::nb_constraints); + REQUIRE(is_integer(min_degree)); + REQUIRE(is_integer(max_degree)); + REQUIRE(is_sorted(0., min_degree, mean_degree, max_degree, nb_cons)); + } + { // Constraints + REQUIRE(0. <= get_feature(Features::constraint_node_degree_std)); + auto const min_degree = get_feature(Features::constraint_node_degree_min); + auto const max_degree = get_feature(Features::constraint_node_degree_max); + auto const mean_degree = get_feature(Features::constraint_node_degree_mean); + auto const nb_var = get_feature(Features::nb_variables); + REQUIRE(is_integer(min_degree)); + REQUIRE(is_integer(max_degree)); + REQUIRE(is_sorted(0., min_degree, mean_degree, max_degree, nb_var)); + } + } + + SECTION("Variable graph features") { + REQUIRE(0. <= get_feature(Features::node_degree_std)); + auto const min_degree = get_feature(Features::node_degree_min); + auto const max_degree = get_feature(Features::node_degree_max); + auto const mean_degree = get_feature(Features::node_degree_mean); + auto const nb_var = get_feature(Features::nb_variables); + REQUIRE(is_integer(min_degree)); + REQUIRE(is_integer(max_degree)); + REQUIRE(is_sorted(0., min_degree, mean_degree, max_degree, nb_var)); + auto const q25_degree = get_feature(Features::node_degree_25q); + auto const q75_degree = get_feature(Features::node_degree_75q); + REQUIRE(is_sorted(min_degree, q25_degree, q75_degree, max_degree)); + REQUIRE(is_sorted(0., get_feature(Features::edge_density), 1.)); + } + + SECTION("LP based features") { + REQUIRE(get_feature(Features::lp_slack_mean) <= get_feature(Features::lp_slack_max)); + REQUIRE(0. <= get_feature(Features::lp_slack_l2)); + } + + SECTION("Objective function features") { + REQUIRE(0. <= get_feature(Features::objective_coef_m_std)); + REQUIRE(0. <= get_feature(Features::objective_coef_n_std)); + REQUIRE(0. <= get_feature(Features::objective_coef_sqrtn_std)); + } + + SECTION("Linear constraint matrix features") { + REQUIRE(0. <= get_feature(Features::constraint_coef_std)); + REQUIRE(0. <= get_feature(Features::constraint_var_coef_mean)); + REQUIRE(0. <= get_feature(Features::constraint_var_coef_std)); + } + + SECTION("Variable type features") { + REQUIRE(0. <= get_feature(Features::discrete_vars_support_size_mean)); + REQUIRE(0. <= get_feature(Features::discrete_vars_support_size_std)); + REQUIRE(is_sorted(0., get_feature(Features::ratio_unbounded_discrete_vars), 1.)); + REQUIRE(is_sorted(0., get_feature(Features::ratio_continuous_vars), 1.)); + } + } +} diff --git a/ecole/libecole/tests/src/observation/test-khalil-2016.cpp b/ecole/libecole/tests/src/observation/test-khalil-2016.cpp new file mode 100644 index 0000000..1faeb04 --- /dev/null +++ b/ecole/libecole/tests/src/observation/test-khalil-2016.cpp @@ -0,0 +1,165 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ecole/observation/khalil-2016.hpp" +#include "ecole/tweak/range.hpp" + +#include "conftest.hpp" +#include "observation/unit-tests.hpp" + +namespace views = ranges::views; + +using namespace ecole; + +TEST_CASE("Khalil2016 unit tests", "[unit][obs]") { + auto const pseudo = GENERATE(true, false); + observation::unit_tests(observation::Khalil2016{pseudo}); +} + +template +auto in_interval(Tensor const& tensor, T const& lower, T const& upper) { + // Must take bounds by reference because they are captured by reference in the xexpression + return (lower <= tensor) && (tensor <= upper); +} + +/** Get the features of the pseudo candidate only. */ +template +auto obs_pseudo_cands(Tensor const& obs_features, Range const& pseudo_cands_idx) -> Tensor { + auto filtered_features = Tensor::from_shape({pseudo_cands_idx.size(), obs_features.shape()[1]}); + for (auto const [idx, var_idx] : views::enumerate(pseudo_cands_idx)) { + xt::row(filtered_features, static_cast(idx)) = xt::row(obs_features, var_idx); + } + return filtered_features; +} + +TEST_CASE("Khalil2016 return correct observation", "[obs]") { + using Features = observation::Khalil2016Obs::Features; + + auto const pseudo = GENERATE(true, false); + auto obs_func = observation::Khalil2016{pseudo}; + auto model = get_model(); + obs_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const optional_obs = obs_func.extract(model, false); + + SECTION("Observation is not empty on non terminal state") { REQUIRE(optional_obs.has_value()); } + + SECTION("Observation features has correct shape") { + auto const& obs = optional_obs.value(); + REQUIRE(obs.features.shape(0) == model.variables().size()); + REQUIRE(obs.features.shape(1) == observation::Khalil2016Obs::n_features); + } + + SECTION("Observation has correct values") { + auto const& obs = optional_obs.value(); + auto const branch_cands = pseudo ? model.pseudo_branch_cands() : model.lp_branch_cands(); + auto obs_pseudo = obs_pseudo_cands(obs.features, views::transform(branch_cands, SCIPvarGetProbindex)); + auto col = [&obs_pseudo](auto feat) { return xt::col(obs_pseudo, static_cast(feat)); }; + + SECTION("No pseudo_candidate features are NaN or infinite") { + for (auto* var : branch_cands) { + auto const var_idx = SCIPvarGetProbindex(var); + REQUIRE_FALSE(xt::any(xt::isnan(xt::row(obs.features, var_idx)))); + REQUIRE_FALSE(xt::any(xt::isinf(xt::row(obs.features, var_idx)))); + } + } + + SECTION("Objective function coefficients") { + REQUIRE(xt::all(col(Features::obj_coef_pos_part) >= 0)); + REQUIRE(xt::all(col(Features::obj_coef_neg_part) >= 0)); + auto const& pos_minus_neg = col(Features::obj_coef_pos_part) - col(Features::obj_coef_pos_part); + REQUIRE(xt::all(xt::equal(col(Features::obj_coef), pos_minus_neg))); + } + + SECTION("Number of constraint") { REQUIRE(xt::all(col(Features::n_rows) >= 0)); } + + SECTION("Static stats for constraint degree") { + REQUIRE(xt::all(col(Features::rows_deg_mean) >= 0)); + REQUIRE(xt::all(col(Features::rows_deg_stddev) >= 0)); + REQUIRE(xt::all(col(Features::rows_deg_min) >= 0)); + REQUIRE(xt::all(col(Features::rows_deg_max) >= 0)); + REQUIRE(xt::all(col(Features::rows_deg_mean) >= col(Features::rows_deg_min))); + REQUIRE(xt::all(col(Features::rows_deg_mean) <= col(Features::rows_deg_max))); + } + + SECTION("Stats for constraint positive coefficients") { + REQUIRE(xt::all(col(Features::rows_pos_coefs_count) >= 0)); + REQUIRE(xt::all(col(Features::rows_pos_coefs_mean) >= 0)); + REQUIRE(xt::all(col(Features::rows_pos_coefs_stddev) >= 0)); + REQUIRE(xt::all(col(Features::rows_pos_coefs_min) >= 0)); + REQUIRE(xt::all(col(Features::rows_pos_coefs_max) >= 0)); + REQUIRE(xt::all(col(Features::rows_pos_coefs_mean) >= col(Features::rows_pos_coefs_min))); + REQUIRE(xt::all(col(Features::rows_pos_coefs_mean) <= col(Features::rows_pos_coefs_max))); + } + + SECTION("Stats for constraint negative coefficients") { + REQUIRE(xt::all(col(Features::rows_neg_coefs_count) >= 0)); + REQUIRE(xt::all(col(Features::rows_neg_coefs_mean) <= 0)); + REQUIRE(xt::all(col(Features::rows_neg_coefs_stddev) <= 0)); + REQUIRE(xt::all(col(Features::rows_neg_coefs_min) <= 0)); + REQUIRE(xt::all(col(Features::rows_neg_coefs_max) <= 0)); + REQUIRE(xt::all(col(Features::rows_neg_coefs_mean) >= col(Features::rows_neg_coefs_min))); + REQUIRE(xt::all(col(Features::rows_neg_coefs_mean) <= col(Features::rows_neg_coefs_max))); + } + + SECTION("Slack and ceil distance") { + REQUIRE(xt::all(in_interval(col(Features::slack), 0, 1))); + REQUIRE(xt::all(in_interval(col(Features::ceil_dist), 0, 1))); + } + + SECTION("Pseudocosts") { + REQUIRE(xt::all(col(Features::pseudocost_ratio) >= 0)); + auto const pseudo_sum = col(Features::pseudocost_down) + col(Features::pseudocost_up); + REQUIRE(xt::all(xt::equal(pseudo_sum, col(Features::pseudocost_sum)))); + } + + SECTION("Infeasibility statistics") { + REQUIRE(xt::all(col(Features::n_cutoff_up) >= 0)); + REQUIRE(xt::all(col(Features::n_cutoff_down) >= 0)); + REQUIRE(xt::all(in_interval(col(Features::n_cutoff_up_ratio), 0, 1))); + REQUIRE(xt::all(in_interval(col(Features::n_cutoff_down_ratio), 0, 1))); + } + + SECTION("Dynamic stats for constraint degree") { + REQUIRE(xt::all(col(Features::rows_dynamic_deg_mean) >= 0)); + REQUIRE(xt::all(col(Features::rows_dynamic_deg_stddev) >= 0)); + REQUIRE(xt::all(col(Features::rows_dynamic_deg_min) >= 0)); + REQUIRE(xt::all(col(Features::rows_dynamic_deg_max) >= 0)); + REQUIRE(xt::all(col(Features::rows_dynamic_deg_mean) >= col(Features::rows_dynamic_deg_min))); + REQUIRE(xt::all(col(Features::rows_dynamic_deg_mean) <= col(Features::rows_dynamic_deg_max))); + REQUIRE(xt::all(in_interval(col(Features::rows_dynamic_deg_mean_ratio), 0, 1))); + REQUIRE(xt::all(in_interval(col(Features::rows_dynamic_deg_min_ratio), 0, 1))); + REQUIRE(xt::all(in_interval(col(Features::rows_dynamic_deg_max_ratio), 0, 1))); + } + + SECTION("Min/max for ratios of constraint coeffs. to RHS") { + REQUIRE(xt::all(in_interval(col(Features::coef_pos_rhs_ratio_min), -1, 1))); + REQUIRE(xt::all(in_interval(col(Features::coef_pos_rhs_ratio_max), -1, 1))); + REQUIRE(xt::all(col(Features::coef_pos_rhs_ratio_min) <= col(Features::coef_pos_rhs_ratio_max))); + REQUIRE(xt::all(in_interval(col(Features::coef_neg_rhs_ratio_min), -1, 1))); + REQUIRE(xt::all(in_interval(col(Features::coef_neg_rhs_ratio_max), -1, 1))); + REQUIRE(xt::all(col(Features::coef_neg_rhs_ratio_min) <= col(Features::coef_neg_rhs_ratio_max))); + } + + SECTION("Stats. for active constraint coefficients") { + REQUIRE(xt::all(col(Features::active_coef_weight1_count) >= 0)); + REQUIRE(xt::all(col(Features::active_coef_weight1_mean) >= col(Features::active_coef_weight1_min))); + REQUIRE(xt::all(col(Features::active_coef_weight1_mean) <= col(Features::active_coef_weight1_max))); + REQUIRE(xt::all(col(Features::active_coef_weight2_count) >= 0)); + REQUIRE(xt::all(col(Features::active_coef_weight2_mean) >= col(Features::active_coef_weight2_min))); + REQUIRE(xt::all(col(Features::active_coef_weight2_mean) <= col(Features::active_coef_weight2_max))); + REQUIRE(xt::all(col(Features::active_coef_weight3_count) >= 0)); + REQUIRE(xt::all(col(Features::active_coef_weight3_mean) >= col(Features::active_coef_weight3_min))); + REQUIRE(xt::all(col(Features::active_coef_weight3_mean) <= col(Features::active_coef_weight3_max))); + REQUIRE(xt::all(col(Features::active_coef_weight4_count) >= 0)); + REQUIRE(xt::all(col(Features::active_coef_weight4_mean) >= col(Features::active_coef_weight4_min))); + REQUIRE(xt::all(col(Features::active_coef_weight4_mean) <= col(Features::active_coef_weight4_max))); + } + } +} diff --git a/ecole/libecole/tests/src/observation/test-milp-bipartite.cpp b/ecole/libecole/tests/src/observation/test-milp-bipartite.cpp new file mode 100644 index 0000000..14ae1b6 --- /dev/null +++ b/ecole/libecole/tests/src/observation/test-milp-bipartite.cpp @@ -0,0 +1,56 @@ +#include + +#include +#include +#include + +#include "ecole/observation/milp-bipartite.hpp" + +#include "conftest.hpp" +#include "observation/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("MilpBipartite unit tests", "[unit][obs]") { + auto const normalize = GENERATE(true, false); + observation::unit_tests(observation::MilpBipartite{normalize}); +} + +TEST_CASE("MilpBipartite return correct observation", "[obs]") { + auto normalize = GENERATE(true, false); + auto obs_func = observation::MilpBipartite{normalize}; + auto model = get_model(); + obs_func.before_reset(model); + auto const optional_obs = obs_func.extract(model, false); + + SECTION("Observation is not empty on non terminal state") { REQUIRE(optional_obs.has_value()); } + + SECTION("Observation features are not empty") { + auto const& obs = optional_obs.value(); + REQUIRE(obs.variable_features.size() > 0); + REQUIRE(obs.constraint_features.size() > 0); + REQUIRE(obs.edge_features.nnz() > 0); + } + + SECTION("Observation features have matching shape") { + auto const& obs = optional_obs.value(); + REQUIRE(obs.constraint_features.shape()[0] == obs.edge_features.shape[0]); + REQUIRE(obs.variable_features.shape()[0] == obs.edge_features.shape[1]); + REQUIRE(obs.edge_features.indices.shape()[0] == 2); + REQUIRE(obs.edge_features.indices.shape()[1] == obs.edge_features.nnz()); + } + + SECTION("Variable features are not all nan") { + auto const& var_feat = optional_obs.value().variable_features; + for (std::size_t i = 0; i < var_feat.shape()[1]; ++i) { + REQUIRE_FALSE(xt::all(xt::isnan(xt::col(var_feat, static_cast(i))))); + } + } + + SECTION("Constraint features are not all nan") { + auto const& cons_feat = optional_obs.value().constraint_features; + for (std::size_t i = 0; i < cons_feat.shape()[1]; ++i) { + REQUIRE_FALSE(xt::all(xt::isnan(xt::row(cons_feat, static_cast(i))))); + } + } +} diff --git a/ecole/libecole/tests/src/observation/test-node-bipartite.cpp b/ecole/libecole/tests/src/observation/test-node-bipartite.cpp new file mode 100644 index 0000000..352fb32 --- /dev/null +++ b/ecole/libecole/tests/src/observation/test-node-bipartite.cpp @@ -0,0 +1,55 @@ +#include + +#include +#include +#include + +#include "ecole/observation/node-bipartite.hpp" + +#include "conftest.hpp" +#include "observation/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("NodeBipartite unit tests", "[unit][obs]") { + observation::unit_tests(observation::NodeBipartite{}); +} + +TEST_CASE("NodeBipartite return correct observation", "[obs]") { + auto cache = GENERATE(true, false); + auto obs_func = observation::NodeBipartite{cache}; + auto model = get_model(); + if (cache) { + model.disable_cuts(); + } + obs_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const optional_obs = obs_func.extract(model, false); + + SECTION("Observation is not empty on non terminal state") { REQUIRE(optional_obs.has_value()); } + + SECTION("Observation features are not empty") { + auto const& obs = optional_obs.value(); + REQUIRE(obs.variable_features.size() > 0); + REQUIRE(obs.row_features.size() > 0); + REQUIRE(obs.edge_features.nnz() > 0); + } + + SECTION("Observation features have matching shape") { + auto const& obs = optional_obs.value(); + REQUIRE(obs.row_features.shape()[0] == obs.edge_features.shape[0]); + REQUIRE(obs.variable_features.shape()[0] == obs.edge_features.shape[1]); + REQUIRE(obs.edge_features.indices.shape()[0] == 2); + REQUIRE(obs.edge_features.indices.shape()[1] == obs.edge_features.nnz()); + } + + SECTION("Variable features are not all nan") { + auto const& obs = optional_obs.value(); + REQUIRE_FALSE(xt::all(xt::isnan(obs.variable_features))); + } + + SECTION("Row features are not all nan") { + auto const& obs = optional_obs.value(); + REQUIRE_FALSE(xt::all(xt::isnan(obs.row_features))); + } +} diff --git a/ecole/libecole/tests/src/observation/test-pseudocosts.cpp b/ecole/libecole/tests/src/observation/test-pseudocosts.cpp new file mode 100644 index 0000000..a0c614d --- /dev/null +++ b/ecole/libecole/tests/src/observation/test-pseudocosts.cpp @@ -0,0 +1,35 @@ +#include + +#include +#include + +#include "ecole/observation/pseudocosts.hpp" + +#include "conftest.hpp" +#include "observation/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("Pseudocosts unit tests", "[unit][obs]") { + observation::unit_tests(observation::Pseudocosts{}); +} + +TEST_CASE("Pseudocosts return pseudo costs array", "[obs]") { + auto obs_func = observation::Pseudocosts{}; + auto model = get_model(); + obs_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const obs = obs_func.extract(model, false); + + REQUIRE(obs.has_value()); + auto const& costs = obs.value(); + REQUIRE(costs.size() == model.variables().size()); + + // All branching candidates have a positive pseudocost + for (auto* const var : model.lp_branch_cands()) { + auto const var_index = static_cast(SCIPvarGetProbindex(var)); + auto const pseudocost = costs[var_index]; + REQUIRE(!std::isnan(pseudocost)); + REQUIRE(pseudocost > 0); + } +} diff --git a/ecole/libecole/tests/src/observation/test-strong-branching-scores.cpp b/ecole/libecole/tests/src/observation/test-strong-branching-scores.cpp new file mode 100644 index 0000000..7b0e60a --- /dev/null +++ b/ecole/libecole/tests/src/observation/test-strong-branching-scores.cpp @@ -0,0 +1,31 @@ +#include +#include +#include + +#include "ecole/observation/strong-branching-scores.hpp" + +#include "conftest.hpp" +#include "observation/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("StrongBranchingScores unit tests", "[unit][obs]") { + bool pseudo_candidates = GENERATE(true, false); + observation::unit_tests(observation::StrongBranchingScores{pseudo_candidates}); +} + +TEST_CASE("StrongBranchingScores return correct branchig scores", "[obs]") { + bool pseudo_candidates = GENERATE(true, false); + auto obs_func = observation::StrongBranchingScores{pseudo_candidates}; + auto model = get_model(); + obs_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto const obs = obs_func.extract(model, true); + + REQUIRE(obs.has_value()); + auto const& scores = obs.value(); + REQUIRE(scores.size() == model.lp_columns().size()); + auto const not_nan_scores = xt::filter(scores, !xt::isnan(scores)); + REQUIRE(not_nan_scores.size() > 0); + REQUIRE(xt::all(not_nan_scores >= 0)); +} diff --git a/ecole/libecole/tests/src/observation/unit-tests.hpp b/ecole/libecole/tests/src/observation/unit-tests.hpp new file mode 100644 index 0000000..60eea5a --- /dev/null +++ b/ecole/libecole/tests/src/observation/unit-tests.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "data/unit-tests.hpp" + +namespace ecole::observation { + +template void unit_tests(ObsFunc&& obs_func) { + data::unit_tests(std::forward(obs_func)); +} + +} // namespace ecole::observation diff --git a/ecole/libecole/tests/src/reward/test-bound-integral.cpp b/ecole/libecole/tests/src/reward/test-bound-integral.cpp new file mode 100644 index 0000000..f0aa639 --- /dev/null +++ b/ecole/libecole/tests/src/reward/test-bound-integral.cpp @@ -0,0 +1,50 @@ +#include + +#include "ecole/reward/bound-integral.hpp" + +#include "conftest.hpp" +#include "reward/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("DualIntegral unit tests", "[unit][reward]") { + reward::unit_tests(reward::DualIntegral{}); +} + +TEST_CASE("DualIntegral returns the difference in dual integral between two states", "[reward]") { + auto reward_func = reward::DualIntegral{}; + auto model = get_model(); // a non-trivial instance is loaded + + SECTION("DualIntegral is non-negative before presolving") { + reward_func.before_reset(model); + REQUIRE(reward_func.extract(model) >= 0); + } +} + +TEST_CASE("PrimalIntegral unit tests", "[unit][reward]") { + reward::unit_tests(reward::PrimalIntegral{}); +} + +TEST_CASE("PrimalIntegral returns the difference in dual integral between two states", "[reward]") { + auto reward_func = reward::PrimalIntegral{}; + auto model = get_model(); // a non-trivial instance is loaded + + SECTION("PrimalIntegral is non-negative before presolving") { + reward_func.before_reset(model); + REQUIRE(reward_func.extract(model) >= 0); + } +} + +TEST_CASE("PrimalDualIntegral unit tests", "[unit][reward]") { + reward::unit_tests(reward::PrimalDualIntegral{}); +} + +TEST_CASE("DualInPrimalDualIntegraltegral returns the difference in dual integral between two states", "[reward]") { + auto reward_func = reward::PrimalDualIntegral{}; + auto model = get_model(); // a non-trivial instance is loaded + + SECTION("PrimalDualIntegral is non-negative before presolving") { + reward_func.before_reset(model); + REQUIRE(reward_func.extract(model) >= 0); + } +} diff --git a/ecole/libecole/tests/src/reward/test-is-done.cpp b/ecole/libecole/tests/src/reward/test-is-done.cpp new file mode 100644 index 0000000..eac4406 --- /dev/null +++ b/ecole/libecole/tests/src/reward/test-is-done.cpp @@ -0,0 +1,26 @@ +#include + +#include "ecole/reward/is-done.hpp" + +#include "conftest.hpp" +#include "reward/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("IsDone unit tests", "[unit][reward]") { + reward::unit_tests(reward::IsDone{}); +} + +TEST_CASE("IsDone always return one when done", "[reward]") { + auto done = GENERATE(true, false); + auto reward_func = reward::IsDone{}; + auto model = get_model(); + + reward_func.before_reset(model); + + advance_to_stage(model, SCIP_STAGE_SOLVING); + + REQUIRE(reward_func.extract(model, done) == (done ? 1. : 0.)); + + SECTION("On successive calls") { REQUIRE(reward_func.extract(model, done) == (done ? 1. : 0.)); } +} diff --git a/ecole/libecole/tests/src/reward/test-lp-iterations.cpp b/ecole/libecole/tests/src/reward/test-lp-iterations.cpp new file mode 100644 index 0000000..6f9e70d --- /dev/null +++ b/ecole/libecole/tests/src/reward/test-lp-iterations.cpp @@ -0,0 +1,58 @@ +#include + +#include "ecole/reward/lp-iterations.hpp" + +#include "conftest.hpp" +#include "reward/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("LpIterations unit tests", "[unit][reward]") { + reward::unit_tests(reward::LpIterations{}); +} + +TEST_CASE("LpIterations returns the difference in LP iterations between two states", "[reward]") { + auto reward_func = reward::LpIterations{}; + auto model = get_model(); // a non-trivial instance is loaded + + SECTION("LP iterations is zero before presolving") { + reward_func.before_reset(model); + REQUIRE(reward_func.extract(model) == 0); + } + + SECTION("LP iterations is stricly positive after root node processing") { + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + REQUIRE(reward_func.extract(model) > 0); + } + + SECTION("LP iterations is zero if the model state has not changed") { + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + reward_func.extract(model); + REQUIRE(reward_func.extract(model) == 0); + } + + SECTION("Reset LP iteration counter") { + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto reward = reward_func.extract(model); + model = get_model(); + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + REQUIRE(reward_func.extract(model) == reward); + } + + SECTION("No LP iterations if SCIP is not solving LPs") { + model = get_model(); + model.set_params({ + {"presolving/maxrounds", 0}, + {"lp/iterlim", 0}, + {"lp/rootiterlim", 0}, + {"limits/totalnodes", 1}, + }); + advance_to_stage(model, SCIP_STAGE_SOLVING); + reward_func.before_reset(model); + REQUIRE(reward_func.extract(model) == 0); + } +} diff --git a/ecole/libecole/tests/src/reward/test-n-nodes.cpp b/ecole/libecole/tests/src/reward/test-n-nodes.cpp new file mode 100644 index 0000000..6e577be --- /dev/null +++ b/ecole/libecole/tests/src/reward/test-n-nodes.cpp @@ -0,0 +1,50 @@ +#include +#include +#include + +#include + +#include "ecole/reward/n-nodes.hpp" + +#include "conftest.hpp" +#include "reward/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("NNodes unit tests", "[unit][reward]") { + reward::unit_tests(reward::NNodes{}); +} + +TEST_CASE("NNodes returns the difference in the total number of processed nodes between two states", "[reward]") { + + auto reward_func = reward::NNodes{}; + auto model = get_model(); // a non-trivial instance is loaded + + SECTION("NNodes is zero before presolving") { + reward_func.before_reset(model); + REQUIRE(reward_func.extract(model) == 0); + } + + SECTION("NNodes is one after root node processing") { + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + REQUIRE(reward_func.extract(model) == 1); + } + + SECTION("NNodes is zero if the model state has not changed") { + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + REQUIRE(reward_func.extract(model) >= 0); + REQUIRE(reward_func.extract(model) == 0); + } + + SECTION("Reset NNodes counter") { + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + auto reward = reward_func.extract(model); + model = get_model(); + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + REQUIRE(reward_func.extract(model) == reward); + } +} diff --git a/ecole/libecole/tests/src/reward/test-solving-time.cpp b/ecole/libecole/tests/src/reward/test-solving-time.cpp new file mode 100644 index 0000000..6c191bc --- /dev/null +++ b/ecole/libecole/tests/src/reward/test-solving-time.cpp @@ -0,0 +1,30 @@ +#include + +#include "ecole/reward/solving-time.hpp" + +#include "conftest.hpp" +#include "reward/unit-tests.hpp" + +using namespace ecole; + +TEST_CASE("SolvingTime unit tests", "[unit][reward]") { + bool const wall = GENERATE(true, false); + reward::unit_tests(reward::SolvingTime{wall}); +} + +TEST_CASE("Solving time rewards are positive initially", "[reward]") { + bool const wall = GENERATE(true, false); + auto reward_func = reward::SolvingTime{wall}; + auto model = get_model(); // a non-trivial instance is loaded + + SECTION("Solving time is nonnegative before presolving") { + reward_func.before_reset(model); + REQUIRE(reward_func.extract(model) >= 0); + } + + SECTION("Solving time is stricly positive after root node processing") { + reward_func.before_reset(model); + advance_to_stage(model, SCIP_STAGE_SOLVING); + REQUIRE(reward_func.extract(model) > 0); + } +} diff --git a/ecole/libecole/tests/src/reward/unit-tests.hpp b/ecole/libecole/tests/src/reward/unit-tests.hpp new file mode 100644 index 0000000..71bf8ef --- /dev/null +++ b/ecole/libecole/tests/src/reward/unit-tests.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include + +#include "ecole/traits.hpp" + +#include "conftest.hpp" +#include "data/unit-tests.hpp" + +namespace ecole::reward { + +template void unit_tests(RewardFunc&& reward_func) { + SECTION("Interface is valid") { STATIC_REQUIRE(trait::is_reward_function_v); }; + + SECTION("Function has default constructor") { STATIC_REQUIRE(std::is_default_constructible_v); } + + data::unit_tests(std::forward(reward_func)); +} + +} // namespace ecole::reward diff --git a/ecole/libecole/tests/src/scip/test-model.cpp b/ecole/libecole/tests/src/scip/test-model.cpp new file mode 100644 index 0000000..db1580e --- /dev/null +++ b/ecole/libecole/tests/src/scip/test-model.cpp @@ -0,0 +1,230 @@ +#include +#include +#include +#include +#include + +#include +#include + +#include "ecole/random.hpp" +#include "ecole/scip/callback.hpp" +#include "ecole/scip/exception.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/utils.hpp" + +#include "conftest.hpp" + +using namespace ecole; + +TEST_CASE("Creation of model", "[scip]") { + scip::Model model{}; + SECTION("Move construct") { auto model_moved = std::move(model); } +} + +TEST_CASE("Equality comparison", "[scip]") { + auto model = scip::Model{}; + REQUIRE(model == model); + REQUIRE(model != model.copy_orig()); +} + +TEST_CASE("Create model from file", "[scip]") { + auto model = scip::Model::from_file(problem_file); +} + +TEST_CASE("Raise if file does not exist", "[scip]") { + REQUIRE_THROWS_AS(scip::Model::from_file("/does_not_exist.mps"), scip::ScipError); +} + +TEST_CASE("Model transform", "[scip][slow]") { + auto model = get_model(); + model.transform_prob(); +} + +TEST_CASE("Model presolving", "[scip][slow]") { + auto model = get_model(); + model.presolve(); +} + +TEST_CASE("Model solving", "[scip][slow]") { + SECTION("Synchronously") { + auto model = get_model(); + model.solve(); + } + + SECTION("Asynchronously") { + auto load_solve = [] { + get_model().solve(); + return true; + }; + auto fut1 = std::async(std::launch::async, load_solve); + auto fut2 = std::async(std::launch::async, load_solve); + REQUIRE((fut1.get() && fut2.get())); + } +} + +TEST_CASE("Explicit parameter management", "[scip]") { + using Catch::Contains; + using scip::ParamType; + auto model = scip::Model{}; + auto constexpr int_param = "conflict/minmaxvars"; + + SECTION("Get parameters") { + auto const value = model.get_param(int_param); + REQUIRE(value >= 0); + } + + SECTION("Set parameters") { + model.set_param(int_param, 3); + REQUIRE(model.get_param(int_param) == 3); + } + + SECTION("Throw on wrong parameters type") { + REQUIRE_THROWS_AS(model.get_param(int_param), scip::ScipError); + REQUIRE_THROWS_WITH( + model.get_param(int_param), Contains(int_param) && Contains("int") && Contains("Real")); + + constexpr auto some_real_val = 3.0; + REQUIRE_THROWS_AS(model.set_param(int_param, some_real_val), scip::ScipError); + REQUIRE_THROWS_WITH( + model.set_param(int_param, some_real_val), + Contains(int_param) && Contains("int") && Contains("Real")); + } + + SECTION("Throw on wrong parameter value") { + REQUIRE_THROWS_AS(model.set_param(int_param, -3), scip::ScipError); + REQUIRE_THROWS_WITH(model.set_param(int_param, -3), Contains(int_param) && Contains("-3")); + } + + SECTION("Throw on unknown parameters") { + auto constexpr not_a_param = "not a parameter"; + REQUIRE_THROWS_AS(model.get_param(not_a_param), scip::ScipError); + REQUIRE_THROWS_WITH(model.get_param(not_a_param), Contains(not_a_param)); + REQUIRE_THROWS_AS(model.set_param(not_a_param, 3), scip::ScipError); + REQUIRE_THROWS_WITH(model.set_param(not_a_param, 3), Contains(not_a_param)); + } +} + +TEST_CASE("Automatic parameter management", "[scip]") { + auto model = scip::Model{}; + auto constexpr int_param = "conflict/minmaxvars"; + + SECTION("Get parameters with automatic casting") { + auto const value = model.get_param(int_param); + REQUIRE(value >= 0); + } + + SECTION("Set parameters with automatic casting") { + model.set_param(int_param, 1.); + REQUIRE(model.get_param(int_param) == 1); + } + + SECTION("Const char* parameters can be converted to chars") { + model.set_param("branching/scorefunc", "s"); + REQUIRE(model.get_param("branching/scorefunc") == 's'); + } + + SECTION("String parameters can be converted to chars") { + model.set_param("branching/scorefunc", std::string{"s"}); + REQUIRE(model.get_param("branching/scorefunc") == 's'); + } + + SECTION("Throw on numerical rounding") { + constexpr auto double_not_int = 3.1; + REQUIRE_THROWS_AS(model.set_param(int_param, double_not_int), std::runtime_error); + } + + SECTION("Throw on overflow") { + auto const value = static_cast(std::numeric_limits::max()) * 2.; + REQUIRE_THROWS_AS(model.set_param(int_param, value), std::runtime_error); + } +} + +TEST_CASE("Variant parameter management", "[scip]") { + auto model = scip::Model{}; + auto constexpr int_param = "conflict/minmaxvars"; + + SECTION("Get parameters as variants") { + auto val = model.get_param(int_param); + REQUIRE(std::holds_alternative(val)); + REQUIRE(std::get(val) == model.get_param(int_param)); + } + + SECTION("Set parameters as variants") { + scip::Param new_val = model.get_param(int_param) + 1; + model.set_param(int_param, new_val); + REQUIRE(model.get_param(int_param) == std::get(new_val)); + } +} + +TEST_CASE("Map parameter management", "[scip]") { + auto model = scip::Model{}; + auto constexpr int_param = "conflict/minmaxvars"; + + SECTION("Extract map of parameters") { + auto vals = model.get_params(); + REQUIRE(!vals.empty()); + REQUIRE(vals[int_param] == scip::Param{model.get_param(int_param)}); + } + + SECTION("Set map of parameters") { + auto vals = model.get_params(); + vals[int_param] = std::get(vals[int_param]) + 1; + model.set_params(vals); + REQUIRE(vals[int_param] == scip::Param{model.get_param(int_param)}); + } +} + +TEST_CASE("Iterative branching", "[scip][slow]") { + auto model = get_model(); + auto fcall = model.solve_iter(scip::callback::BranchruleConstructor{}); + + SECTION("Destructed before done") {} + + SECTION("Branch outside of callback") { + while (fcall.has_value()) { + auto const cands = model.lp_branch_cands(); + REQUIRE_FALSE(cands.empty()); + scip::call(SCIPbranchVar, model.get_scip_ptr(), cands[0], nullptr, nullptr, nullptr); + fcall = model.solve_iter_continue(SCIP_BRANCHED); + } + REQUIRE(model.is_solved()); + } + + SECTION("Branch on SCIP default") { + while (fcall.has_value()) { + fcall = model.solve_iter_continue(SCIP_DIDNOTRUN); + } + REQUIRE(model.is_solved()); + } +} + +TEST_CASE("Iterative solving", "[scip][slow]") { + auto model = get_model(); + auto const constructors = std::array{ + scip::callback::BranchruleConstructor{}, + scip::callback::HeuristicConstructor{}, + }; + auto maybe_fcall = model.solve_iter(constructors); + + SECTION("Destructed before done") {} + + SECTION("Using SCIP default") { + auto used_branchrule = false; + auto used_heuristic = false; + while (maybe_fcall.has_value()) { + std::visit( + [&](auto fcall) { + if constexpr (std::is_same_v) { + used_branchrule = true; + } else if constexpr (std::is_same_v) { + used_heuristic = true; + } + }, + maybe_fcall.value()); + maybe_fcall = model.solve_iter_continue(SCIP_DIDNOTRUN); + } + REQUIRE(used_branchrule); + REQUIRE(used_heuristic); + } +} diff --git a/ecole/libecole/tests/src/scip/test-scimpl.cpp b/ecole/libecole/tests/src/scip/test-scimpl.cpp new file mode 100644 index 0000000..03d7b0c --- /dev/null +++ b/ecole/libecole/tests/src/scip/test-scimpl.cpp @@ -0,0 +1,16 @@ +#include +#include + +#include "ecole/scip/scimpl.hpp" + +using namespace ecole; + +TEST_CASE("Allocation of ressources", "[scip]") { + scip::Scimpl scimpl{}; + REQUIRE(SCIPgetStage(scimpl.get_scip_ptr()) == SCIP_STAGE_INIT); +} + +TEST_CASE("Dealocation of ressources", "[scip]") { + { scip::Scimpl scimpl{}; } + BMScheckEmptyMemory(); +} diff --git a/ecole/libecole/tests/src/test-random.cpp b/ecole/libecole/tests/src/test-random.cpp new file mode 100644 index 0000000..776fb22 --- /dev/null +++ b/ecole/libecole/tests/src/test-random.cpp @@ -0,0 +1,34 @@ +#include + +#include "ecole/random.hpp" + +using namespace ecole; + +TEST_CASE("Same seed give same random generators", "[random]") { + ecole::seed(0); + auto rng_1 = ecole::spawn_random_generator(); + ecole::seed(0); + auto rng_2 = ecole::spawn_random_generator(); + REQUIRE(rng_1 == rng_2); +} + +TEST_CASE("Different seed give different random generators", "[random]") { + ecole::seed(0); + auto rng_1 = ecole::spawn_random_generator(); + ecole::seed(1); + auto rng_2 = ecole::spawn_random_generator(); + REQUIRE(rng_1 != rng_2); +} + +TEST_CASE("Successive random generators are different", "[random]") { + auto rng_1 = ecole::spawn_random_generator(); + auto rng_2 = ecole::spawn_random_generator(); + REQUIRE(rng_1 != rng_2); +} + +TEST_CASE("Random generator serialization", "[random]") { + auto const rng = RandomGenerator{42}; // NOLINT This is deterministic for the test + auto const data = serialize(rng); + auto const rng_copy = deserialize(data); + REQUIRE(rng == rng_copy); +} diff --git a/ecole/libecole/tests/src/test-traits.cpp b/ecole/libecole/tests/src/test-traits.cpp new file mode 100644 index 0000000..2e348b6 --- /dev/null +++ b/ecole/libecole/tests/src/test-traits.cpp @@ -0,0 +1,170 @@ +#include + +#include + +#include "ecole/environment/configuring.hpp" +#include "ecole/information/abstract.hpp" +#include "ecole/reward/abstract.hpp" +#include "ecole/traits.hpp" + +using namespace ecole; + +#define STATIC_REQUIRE_SAME(A, B) STATIC_REQUIRE(std::is_same_v) + +TEST_CASE("Test Data functions traits", "[trait]") { + SECTION("Succeed with ref to Model") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&, bool) -> double; + }; + STATIC_REQUIRE(trait::is_data_function_v); + } + + SECTION("Succeed with const ref to Model") { + struct Func { + auto before_reset(scip::Model const&) -> void; + auto extract(scip::Model const&, bool) -> double; + }; + STATIC_REQUIRE(trait::is_data_function_v); + } + + SECTION("Fail when missing before_reset") { + struct Func { + auto extract(scip::Model&, bool) -> double; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } + + SECTION("Fail when before_reset takes Model by value") { + struct Func { + auto before_reset(scip::Model) -> void; + auto extract(scip::Model&, bool) -> double; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } + + SECTION("Fail when before_reset returns non void") { + struct Func { + auto before_reset(scip::Model&) -> double; + auto extract(scip::Model&, bool) -> double; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } + + SECTION("Fail when missing extract") { + struct Func { + auto before_reset(scip::Model&) -> void; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } + + SECTION("Fail when extract takes Model by value") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model, bool) -> double; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } + + SECTION("Fail when missing parameter in extract") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&) -> double; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } + + SECTION("Fail when extra parameter in extract") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&, bool, int) -> double; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } + + SECTION("Fail when extract returns void") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&, bool) -> void; + }; + STATIC_REQUIRE_FALSE(trait::is_data_function_v); + } +} + +TEST_CASE("Test Reward functions traits", "[trait]") { + SECTION("Succeed with double data") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&, bool) -> reward::Reward; + }; + STATIC_REQUIRE(trait::is_reward_function_v); + } + + SECTION("Fail with non Reward data") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&, bool) -> int; + }; + STATIC_REQUIRE_FALSE(trait::is_reward_function_v); + } +} + +TEST_CASE("Test Information functions traits", "[trait]") { + SECTION("Succeed with information map") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&, bool) -> information::InformationMap; + }; + STATIC_REQUIRE(trait::is_information_function_v); + } + + SECTION("Fail with non infomation map") { + struct Func { + auto before_reset(scip::Model&) -> void; + auto extract(scip::Model&, bool) -> int; + }; + STATIC_REQUIRE_FALSE(trait::is_information_function_v); + } +} + +TEST_CASE("Detect if environment", "[trait]") { + SECTION("Positive tests") { STATIC_REQUIRE(trait::is_environment_v>); } + + SECTION("Negative tests") { + STATIC_REQUIRE_FALSE(trait::is_environment_v); + STATIC_REQUIRE_FALSE(trait::is_environment_v); + } +} + +TEST_CASE("Detect if dynamics", "[trait]") { + SECTION("Positive tests") { STATIC_REQUIRE(trait::is_dynamics_v); } + + SECTION("Negative tests") { + STATIC_REQUIRE_FALSE(trait::is_dynamics_v>); + STATIC_REQUIRE_FALSE(trait::is_dynamics_v); + } +} + +TEST_CASE("Detect data type", "[trait]") { + STATIC_REQUIRE_SAME(trait::data_of_t, ecole::NoneType); +} + +TEST_CASE("Detect observation type", "[trait]") { + STATIC_REQUIRE_SAME(trait::observation_of_t, ecole::NoneType); + STATIC_REQUIRE_SAME(trait::observation_of_t>, std::optional); +} + +TEST_CASE("Detect information type", "[trait]") { + STATIC_REQUIRE_SAME(trait::information_of_t, ecole::NoneType); + STATIC_REQUIRE_SAME(trait::information_of_t>, ecole::NoneType); +} + +TEST_CASE("Detect action type", "[trait]") { + STATIC_REQUIRE_SAME(trait::action_of_t>, dynamics::ParamDict); + STATIC_REQUIRE_SAME(trait::action_of_t, dynamics::ParamDict); +} + +TEST_CASE("Detect action set type", "[trait]") { + STATIC_REQUIRE_SAME(trait::action_set_of_t>, ecole::NoneType); + STATIC_REQUIRE_SAME(trait::action_set_of_t, ecole::NoneType); +} diff --git a/ecole/libecole/tests/src/test-utility/tmp-folder.cpp b/ecole/libecole/tests/src/test-utility/tmp-folder.cpp new file mode 100644 index 0000000..1cfe778 --- /dev/null +++ b/ecole/libecole/tests/src/test-utility/tmp-folder.cpp @@ -0,0 +1,38 @@ +#include +#include +#include + +#include "test-utility/tmp-folder.hpp" + +namespace ecole { + +namespace fs = std::filesystem; + +namespace { + +/** Return a random alphanumeric string with `n` charaters. */ +auto random_alphanumeric(std::size_t n) -> std::string { + auto constexpr chars = std::string_view{"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"}; + auto choice = std::uniform_int_distribution{0, chars.length() - 1}; + auto rng = std::minstd_rand{std::random_device{}()}; + auto out = std::string(n, '0'); + std::generate(begin(out), end(out), [&]() { return chars[choice(rng)]; }); + return out; +} + +} // namespace + +TmpFolderRAII::TmpFolderRAII() { + tmp_dir = fs::temp_directory_path() / ("ecole-test-" + random_alphanumeric(rand_size)); + fs::create_directories(tmp_dir); +} + +TmpFolderRAII::~TmpFolderRAII() { + fs::remove_all(tmp_dir); +} + +auto TmpFolderRAII::make_subpath(std::string_view suffix) const -> std::filesystem::path { + return tmp_dir / random_alphanumeric(rand_size).append(suffix); +} + +} // namespace ecole diff --git a/ecole/libecole/tests/src/test-utility/tmp-folder.hpp b/ecole/libecole/tests/src/test-utility/tmp-folder.hpp new file mode 100644 index 0000000..b7c6597 --- /dev/null +++ b/ecole/libecole/tests/src/test-utility/tmp-folder.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +namespace ecole { + +/** A ressource management to create and clean temporary files, */ +class TmpFolderRAII { +public: + /** Size of the random string used to create unique names. */ + inline static auto constexpr rand_size = 10; + + /** Create an empty temporary directory. */ + TmpFolderRAII(); + /** Delete the temporary directory and all its content. */ + ~TmpFolderRAII(); + + TmpFolderRAII(TmpFolderRAII const&) = delete; + auto operator=(TmpFolderRAII const&) -> TmpFolderRAII& = delete; + + /* Get the name of a new unique sub path inside the tmeporary directory. **/ + [[nodiscard]] auto make_subpath(std::string_view suffix = "") const -> std::filesystem::path; + + [[nodiscard]] auto dir() const -> std::filesystem::path const& { return tmp_dir; } + +private: + std::filesystem::path tmp_dir; +}; + +} // namespace ecole diff --git a/ecole/libecole/tests/src/utility/test-chrono.cpp b/ecole/libecole/tests/src/utility/test-chrono.cpp new file mode 100644 index 0000000..23efa23 --- /dev/null +++ b/ecole/libecole/tests/src/utility/test-chrono.cpp @@ -0,0 +1,11 @@ +#include + +#include "ecole/utility/chrono.hpp" + +using namespace ecole; + +TEST_CASE("cpu_clock is monotonic", "[utility]") { + auto const before = utility::cpu_clock::now(); + auto const after = utility::cpu_clock::now(); + REQUIRE(before <= after); +} diff --git a/ecole/libecole/tests/src/utility/test-coroutine.cpp b/ecole/libecole/tests/src/utility/test-coroutine.cpp new file mode 100644 index 0000000..ea9af82 --- /dev/null +++ b/ecole/libecole/tests/src/utility/test-coroutine.cpp @@ -0,0 +1,88 @@ +#include +#include +#include + +#include "ecole/none.hpp" +#include "ecole/scip/scimpl.hpp" +#include "ecole/utility/coroutine.hpp" + +using namespace ecole; + +TEST_CASE("Coroutine manage ressources", "[utility]") { + using Coroutine = utility::Coroutine; + using Executor = Coroutine::Executor; + + SECTION("Coroutine teminate immediatly") { + auto co = Coroutine{[](Executor const& /*executor*/) {}}; + + SECTION("Being waited on") { + auto ret = co.wait(); + REQUIRE_FALSE(ret.has_value()); + } + } + + SECTION("Coroutine is killed in execution") { + auto co = Coroutine{[](Executor& executor) { + auto message = executor.yield(None); + do { + if (Executor::is_stop(message)) { + break; + } + message = executor.yield(None); + } while (true); + }}; + + SECTION("Being waited on") { + auto ret = co.wait(); + REQUIRE(ret.has_value()); + } + } +} + +TEST_CASE("Coroutine can return values", "[utility]") { + using Coroutine = utility::Coroutine; + using Executor = Coroutine::Executor; + auto const max = GENERATE(0, 1, 5); + + auto co = Coroutine{[max](Executor& executor) { + for (int i = 0; i < max; ++i) { + auto message = executor.yield(i); + if (Executor::is_stop(message)) { + break; + } + } + }}; + + for (int i = 0; i < max; ++i) { + auto ret = co.wait(); + REQUIRE(ret.has_value()); + REQUIRE(ret.value() == i); + co.resume(None); + } + REQUIRE_FALSE(co.wait().has_value()); +} + +TEST_CASE("Coroutine can send messages", "[utility]") { + using Coroutine = utility::Coroutine; + using Executor = Coroutine::Executor; + auto const max = GENERATE(0, 1, 5); + + auto co = Coroutine{[max](Executor& executor) { + int last_message = 0; + while (true) { + auto message = executor.yield(last_message); + if (Executor::is_stop(message)) { + break; + } + last_message = std::get(message); + } + }}; + + auto ret = co.wait(); + REQUIRE(ret.has_value()); + constexpr auto message = 10; + co.resume(message); + ret = co.wait(); + REQUIRE(ret.has_value()); + REQUIRE(ret.value() == message); +} diff --git a/ecole/libecole/tests/src/utility/test-graph.cpp b/ecole/libecole/tests/src/utility/test-graph.cpp new file mode 100644 index 0000000..3d9b379 --- /dev/null +++ b/ecole/libecole/tests/src/utility/test-graph.cpp @@ -0,0 +1,136 @@ +#include +#include +#include +#include + +#include + +#include "utility/graph.hpp" + +using namespace ecole; +using Graph = utility::Graph; +using Edge = Graph::Edge; + +template +auto contains(Container const& container, typename Container::value_type const& val) -> bool { + return std::find(container.begin(), container.end(), val) != container.end(); +} + +TEST_CASE("Edge comparison", "[instance][unit]") { + REQUIRE(Edge{0, 1} == Edge{1, 0}); + REQUIRE(Edge{0, 1} != Edge{0, 0}); +} + +TEST_CASE("Unit test graph class used in IndependentSet", "[instance][unit]") { + std::size_t constexpr n_nodes = 4; + auto constexpr edges = std::array{Edge{0, 1}, Edge{2, 0}}; + auto graph = Graph{n_nodes}; + std::for_each(edges.begin(), edges.end(), [&graph](auto edge) { graph.add_edge(edge); }); + + SECTION("Graph builders") { + auto rng = RandomGenerator{}; // NOLINT(cert-msc32-c, cert-msc51-cpp) We want reproducible in tests + + SECTION("Erdos Renyi") { + auto constexpr edge_prob = 0.9; + graph = Graph::erdos_renyi(n_nodes, edge_prob, rng); + } + + SECTION("Barabasi Albert") { + auto constexpr affinity = 1; + graph = Graph::barabasi_albert(n_nodes, affinity, rng); + } + + REQUIRE(graph.n_nodes() == n_nodes); + } + + SECTION("Get the number of nodes") { REQUIRE(graph.n_nodes() == n_nodes); } + + SECTION("Get the number of edges") { REQUIRE(graph.n_edges() == edges.size()); } + + SECTION("Get degrees") { + REQUIRE(graph.degree(0) == 2); + REQUIRE(graph.degree(1) == 1); + REQUIRE(graph.degree(2) == 1); + REQUIRE(graph.degree(3) == 0); + } + + SECTION("Get neighbors") { + for (auto [n1, n2] : edges) { + REQUIRE(contains(graph.neighbors(n1), n2)); + REQUIRE(contains(graph.neighbors(n2), n1)); + } + } + + SECTION("Check if nodes are connected") { + for (auto [n1, n2] : edges) { + REQUIRE(graph.are_connected(n1, n2)); + } + } + + SECTION("Edge visitor visit edges excatly once") { + auto edge_count = std::size_t{0}; + graph.edges_visit([&edge_count](auto /* edge */) { edge_count++; }); + REQUIRE(edge_count == edges.size()); + } + + SECTION("Edge visitor visit correct edges") { + graph.edges_visit([&edges](auto edge) { REQUIRE(contains(edges, edge)); }); + } + + SECTION("Greedy clique partition is a partition") { + auto node_seen = std::vector(n_nodes, false); + auto const cliques = graph.greedy_clique_partition(); + for (auto const& clique : cliques) { + for (auto const node : clique) { + node_seen[node] = true; + } + } + REQUIRE(std::all_of(node_seen.begin(), node_seen.end(), [](auto x) { return x; })); + } + + SECTION("Greedy clique partitions are cliques") { + auto const cliques = graph.greedy_clique_partition(); + for (auto const& clique : cliques) { + auto iter1 = clique.begin(); + while (iter1 != clique.end()) { + auto iter2 = iter1 + 1; + while (iter2 != clique.end()) { + REQUIRE(graph.are_connected(*iter1, *(iter2++))); + } + ++iter1; + } + } + } +} + +TEST_CASE("Erdos Renyi builder", "[instance]") { + // These tests are actually not random because the random generator is always the same, but it could be changed that + // the results should hold with very high probability + auto rng = RandomGenerator{}; // NOLINT(cert-msc32-c, cert-msc51-cpp) We want reproducible in tests + auto constexpr n_nodes = 100; + auto constexpr edge_prob = 0.5; + auto graph = Graph::erdos_renyi(n_nodes, edge_prob, rng); + + // Number of edges follows a binomial(C(n_nodes,2), edge_prob). + // With the Chernov Bounds, we compute that this is true with proba ~ 1-1e-40 + auto constexpr likely_edge_bound = 2000; + REQUIRE(graph.n_edges() >= likely_edge_bound); + REQUIRE(graph.n_edges() <= n_nodes * (n_nodes - 1) - likely_edge_bound); + + // Node degree follows a binomial(n_nodes-1, edge_prob). + // With the Chernov Bounds, we compute that this is true with proba ~ 1-1e-16 + auto constexpr likely_degree_bound = 10; + for (auto node = Graph::Node{0}; node < graph.n_nodes(); ++node) { + REQUIRE(graph.degree(node) >= likely_degree_bound); + REQUIRE(graph.degree(node) <= n_nodes - 1 - likely_degree_bound); + } +} + +TEST_CASE("Barabasi Albert builder", "[instance]") { + auto rng = RandomGenerator{}; // NOLINT(cert-msc32-c, cert-msc51-cpp) We want reproducible in tests + auto constexpr n_nodes = 100; + auto constexpr affinity = 11; + auto graph = Graph::barabasi_albert(n_nodes, affinity, rng); + // Deterministic, according to building algorithm + REQUIRE(graph.n_edges() == (n_nodes - affinity - 1) * affinity + affinity); +} diff --git a/ecole/libecole/tests/src/utility/test-random.cpp b/ecole/libecole/tests/src/utility/test-random.cpp new file mode 100644 index 0000000..e8cf87d --- /dev/null +++ b/ecole/libecole/tests/src/utility/test-random.cpp @@ -0,0 +1,46 @@ +#include +#include + +#include + +#include "ecole/random.hpp" +#include "ecole/utility/random.hpp" + +using namespace ecole; + +template auto all_different(std::vector const& vec) -> bool { + auto s = std::set{vec.begin(), vec.end()}; + return s.size() == vec.size(); +} + +TEST_CASE("Choice return indices within items", "[utility]") { // NOLINT + auto rng = RandomGenerator{}; // NOLINT(cert-msc32-c, cert-msc51-cpp) We want reproducible in tests + auto weights = std::vector{1., 2., 1., 3.}; // NOLINT(readability-magic-numbers) + + std::size_t const n_samples = GENERATE(0UL, 1UL, 2UL, 3UL, 4UL); + auto indices = utility::arg_choice(n_samples, weights, rng); + REQUIRE(all_different(indices)); + REQUIRE(indices.size() == n_samples); + for (auto i : indices) { + REQUIRE(i <= weights.size()); + } +} + +TEST_CASE("Throw on invalid input", "[utility]") { + auto rng = RandomGenerator{}; // NOLINT(cert-msc32-c, cert-msc51-cpp) We want reproducible in tests + auto weights = std::vector{1., 2., 1., 3.}; // NOLINT(readability-magic-numbers) + std::size_t const n_samples = weights.size() + 1; + REQUIRE_THROWS_AS(utility::arg_choice(n_samples, weights, rng), std::invalid_argument); +} + +TEST_CASE("Null weighted items are never selected", "[utility]") { // NOLINT + auto rng = RandomGenerator{}; // NOLINT(cert-msc32-c, cert-msc51-cpp) We want reproducible in tests + auto weights = std::vector{0., 2., 1., 3.}; // NOLINT(readability-magic-numbers) + + std::size_t const n_samples = GENERATE(0UL, 1UL, 2UL, 3UL); + std::size_t constexpr n_trials = 100; + for (std::size_t trial = 0; trial < n_trials; ++trial) { + auto indices = utility::arg_choice(n_samples, weights, rng); + REQUIRE(std::find(indices.begin(), indices.end(), 0) == indices.end()); + } +} diff --git a/ecole/libecole/tests/src/utility/test-sparse-matrix.cpp b/ecole/libecole/tests/src/utility/test-sparse-matrix.cpp new file mode 100644 index 0000000..dc88ee9 --- /dev/null +++ b/ecole/libecole/tests/src/utility/test-sparse-matrix.cpp @@ -0,0 +1,27 @@ +#include + +#include "ecole/utility/sparse-matrix.hpp" + +using namespace ecole; + +TEST_CASE("Sparse matrix unit unit tests", "[unit][utility]") { + auto const matrix = utility::coo_matrix{ + {2., 4., 7.}, // NOLINT(readability-magic-numbers) + {{0, 1, 1}, {1, 1, 2}}, // NOLINT(readability-magic-numbers) + {2, 2}, // NOLINT(readability-magic-numbers) + }; + + SECTION("Equality comparison") { + auto const matrix_copy = matrix; // NOLINT(performance-unnecessary-copy-initialization) + REQUIRE(matrix_copy == matrix); + } + + SECTION("To and from tuple") { + auto t = matrix.to_tuple(); + REQUIRE((std::get(t) == matrix.indices)); + REQUIRE((std::get(t) == matrix.values)); + REQUIRE((std::get(t) == matrix.shape)); + auto const matrix_copy = utility::coo_matrix::from_tuple(std::move(t)); + REQUIRE(matrix_copy == matrix); + } +} diff --git a/ecole/libecole/tests/src/utility/test-vector.cpp b/ecole/libecole/tests/src/utility/test-vector.cpp new file mode 100644 index 0000000..bef0399 --- /dev/null +++ b/ecole/libecole/tests/src/utility/test-vector.cpp @@ -0,0 +1,17 @@ +#include + +#include + +#include "ecole/utility/vector.hpp" + +using namespace ecole; + +TEMPLATE_TEST_CASE("Arange return indices from 0 to n", "[utility]", int, double) { // NOLINT + auto constexpr test_size = 10; + auto vec = utility::arange(test_size); + + STATIC_REQUIRE(std::is_same_v); + REQUIRE(vec.size() == test_size); + REQUIRE(vec[0] == TestType{0}); + REQUIRE(vec[test_size - 1] == TestType{test_size - 1}); +} diff --git a/ecole/pyproject.toml b/ecole/pyproject.toml new file mode 100644 index 0000000..35549ac --- /dev/null +++ b/ecole/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel", + "pybind11>=2.7", + "cmake>=3.15", + "numpy>=1.4", + "ninja", + "scikit-build" +] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 100 diff --git a/ecole/python/.gitignore b/ecole/python/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/ecole/python/ecole/CMakeLists.txt b/ecole/python/ecole/CMakeLists.txt new file mode 100644 index 0000000..b062640 --- /dev/null +++ b/ecole/python/ecole/CMakeLists.txt @@ -0,0 +1,97 @@ +find_package(Python COMPONENTS Interpreter Development NumPy REQUIRED) +find_package(pybind11 REQUIRED) +find_package(xtensor REQUIRED) +find_package(xtensor-python REQUIRED) + +pybind11_add_module( + ecole-py-ext + src/ecole/core/core.cpp + src/ecole/core/version.cpp + src/ecole/core/scip.cpp + src/ecole/core/instance.cpp + src/ecole/core/data.cpp + src/ecole/core/observation.cpp + src/ecole/core/reward.cpp + src/ecole/core/information.cpp + src/ecole/core/dynamics.cpp +) + +target_include_directories( + ecole-py-ext + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/ecole/core + SYSTEM PRIVATE + # Include the headers directly instead of using the CMake target due to it wrongly + # linking against libpython + "${Python_NumPy_INCLUDE_DIRS}" +) + +target_link_libraries( + ecole-py-ext + PRIVATE + Ecole::ecole-lib + Ecole::ecole-py-ext-helper + xtensor-python +) + +ecole_target_add_compile_warnings(ecole-py-ext) + +set_target_properties( + ecole-py-ext PROPERTIES + OUTPUT_NAME core +) +# If no output directory specified, preserve the ecole layout in the build tree +if(NOT DEFINED CMAKE_LIBRARY_OUTPUT_DIRECTORY) + set_target_properties( + ecole-py-ext PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/ecole" + ) +endif() + + +set( + python_files + __init__.py + py.typed + typing.py + version.py + doctor.py + scip.py + instance.py + data.py + observation.py + reward.py + information.py + dynamics.py + environment.py +) +set(PYTHON_SOURCE_FILES ${python_files}) +list(TRANSFORM PYTHON_SOURCE_FILES PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/src/ecole/") + +add_custom_target( + ecole-py-files + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${PYTHON_SOURCE_FILES} "${CMAKE_CURRENT_BINARY_DIR}/ecole" + COMMENT "Copying Python files" +) +add_dependencies(ecole-py-ext ecole-py-files) + +# Scikit build relies on an installation of the Python module to find the library. +# Set through the setup.py script. +if(SKBUILD) + # Rpath for the Ecole Python extension module + set_target_properties( + ecole-py-ext + PROPERTIES + INSTALL_RPATH "${ECOLE_PY_EXT_INSTALL_RPATH}" + ) + + # Relative (to install prefix) path where to install the Python extension module + if(NOT ECOLE_PY_EXT_INSTALL_LIBDIR) + set(ECOLE_PY_EXT_INSTALL_LIBDIR ".") + endif() + install( + TARGETS ecole-py-ext + DESTINATION "${ECOLE_PY_EXT_INSTALL_LIBDIR}" + COMPONENT Ecole_Python_Extension + ) +endif() diff --git a/ecole/python/ecole/README.md b/ecole/python/ecole/README.md new file mode 100644 index 0000000..84537c9 --- /dev/null +++ b/ecole/python/ecole/README.md @@ -0,0 +1,4 @@ +# The Python bindings of the C++ library + +None of the features of Ecole are implemented here. +These directories contain only the logic to create proper bindings. diff --git a/ecole/python/ecole/src/ecole/__init__.py b/ecole/python/ecole/src/ecole/__init__.py new file mode 100644 index 0000000..cd668bf --- /dev/null +++ b/ecole/python/ecole/src/ecole/__init__.py @@ -0,0 +1,15 @@ +import sys + +from ecole.core import RandomGenerator, seed, spawn_random_generator, MarkovError, Default + +import ecole.version +import ecole.data +import ecole.observation +import ecole.reward +import ecole.information +import ecole.scip +import ecole.instance +import ecole.dynamics +import ecole.environment + +__version__ = "{v.major}.{v.minor}.{v.patch}".format(v=ecole.version.get_ecole_lib_version()) diff --git a/ecole/python/ecole/src/ecole/core/caster.hpp b/ecole/python/ecole/src/ecole/core/caster.hpp new file mode 100644 index 0000000..c328706 --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/caster.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include + +#include +#include + +#include "ecole/none.hpp" +#include "ecole/scip/type.hpp" + +/** + * Cutomize PyBind casting for some types. + */ + +namespace pybind11::detail { + +/** + * Custom caster for ecole::NoneType. + * + * Cast to `None` in Python and does not cast to C++. + */ +template <> struct type_caster : void_caster {}; + +/** + * Custom caster for scip::Param. + * + * The default caster for variant greedily cast to the first compile-time compatible + * type found in the variant. + * However it is not necessarily the best one. For instance, given that + * scip::Param contains both `char` and `std::string`, the default caster cast all + * Python `str` as char, and complains (dynamically) when the `str` is longer than one + * character. + * Here, we cast the python value to the largest possible container, knowing that + * scip::Model::set_param will be able to downcast based on the SCIP parameter + * type. + * + * Implement a custom Python to C++ caster for scip::Param + */ +template <> struct type_caster : variant_caster { +public: + /** + * Description and value variable. + * + * This macro establishes the name description in function signatures and declares a + * local variable `value` of type scip::Param. + */ + PYBIND11_TYPE_CASTER(ecole::scip::Param, _("Union[bool, int, float, str]")); // NOLINT + + /** + * Conversion from Python to C++. + * + * Convert a PyObject into a scip::Param instance or return false upon failure. + * The second argument indicates whether implicit conversions should be applied. + * Uses a variant with only the largest container, relying on + * scip::Model::set_param to properly downcast when needed. + * + * @param src The PyObject to convert from. + */ + bool load(handle src, bool /*implicit_conversion*/) { + using namespace ecole; + using ParamHelper = std::variant; + + try { + value = std::visit( + [](auto&& val) -> ecole::scip::Param { return std::forward(val); }, src.cast()); + return true; + } catch (...) { + return false; + } + } + + /** + * Conversion from C++ to Python. + * + * Using the default variant caster from PyBind. + */ + using variant_caster::cast; +}; + +} // namespace pybind11::detail diff --git a/ecole/python/ecole/src/ecole/core/core.cpp b/ecole/python/ecole/src/ecole/core/core.cpp new file mode 100644 index 0000000..6050c06 --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/core.cpp @@ -0,0 +1,95 @@ +#define FORCE_IMPORT_ARRAY + +#include +#include +#include + +#include +#include +#include + +#include "ecole/default.hpp" +#include "ecole/exception.hpp" +#include "ecole/random.hpp" + +#include "core.hpp" + +using namespace ecole; +namespace py = pybind11; + +PYBIND11_MODULE(core, m) { + m.doc() = R"str( + Root module for binding Ecole library. + + All the bindings of Ecole are submodule of this module to enable some adjustment in + the user interface. + )str"; + + xt::import_numpy(); + + py::class_(m, "RandomGenerator") // + .def_property_readonly_static( + "min_seed", [](py::object const& /* cls */) { return std::numeric_limits::min(); }) + .def_property_readonly_static( + "max_seed", [](py::object const& /* cls */) { return std::numeric_limits::max(); }) + .def( + py::init(), + py::arg("value") = RandomGenerator::default_seed, + "Construct the pseudo-random number generator.") + .def( + "seed", + [](RandomGenerator& self, RandomGenerator::result_type value) { self.seed(value); }, + py::arg("value") = RandomGenerator::default_seed, + "Reinitialize the internal state of the random-number generator using new seed " + "value.") + .def("discard", &RandomGenerator::discard, py::arg("n"), R"( + Advance the internal state by n times. + + Equivalent to calling operator() n times and discarding the result. + )") + .def("__call__", &RandomGenerator::operator(), R"( + Generate a pseudo-random value. + + The state of the generator is advanced by one position. + )") + .def(py::self == py::self) // NOLINT(misc-redundant-expression) pybind specific syntax + .def(py::self != py::self) // NOLINT(misc-redundant-expression) pybind specific syntax + .def("__copy__", [](const RandomGenerator& self) { return std::make_unique(self); }) + .def( + "__deepcopy__", + [](const RandomGenerator& self, py::dict const& /* memo */) { return std::make_unique(self); }, + py::arg("memo")) + .def(py::pickle( + [](RandomGenerator const& self) { return serialize(self); }, + [](std::string const& data) { return std::make_unique(deserialize(data)); })); + + m.def("seed", &ecole::seed, py::arg("val"), "Seed the global source of randomness in Ecole."); + m.def("spawn_random_generator", &ecole::spawn_random_generator, R"( + Create new random generator deriving from global source of randomness. + + The global source of randomness is advance so two random engien created successively have different states. + )"); + + py::class_(m, "DefaultType") + .def(py::self == py::self) // NOLINT(misc-redundant-expression) pybind specific syntax + .def(py::self != py::self) // NOLINT(misc-redundant-expression) pybind specific syntax + .def( + "__eq__", + [](DefaultType /*self*/, std::string_view s) { return (s == std::string_view{"Default"}); }, + py::is_operator()) // NOLINT(misc-redundant-expression) pybind specific syntax + .def("__repr__", [](ecole::DefaultType /*self*/) { return "Default"; }); + + m.attr("Default") = ecole::Default; + + py::register_exception(m, "MarkovError"); + py::register_exception(m, "IteratorExhausted", PyExc_StopIteration); + + version::bind_submodule(m.def_submodule("version")); + scip::bind_submodule(m.def_submodule("scip")); + instance::bind_submodule(m.def_submodule("instance")); + data::bind_submodule(m.def_submodule("data")); + observation::bind_submodule(m.def_submodule("observation")); + reward::bind_submodule(m.def_submodule("reward")); + information::bind_submodule(m.def_submodule("information")); + dynamics::bind_submodule(m.def_submodule("dynamics")); +} diff --git a/ecole/python/ecole/src/ecole/core/core.hpp b/ecole/python/ecole/src/ecole/core/core.hpp new file mode 100644 index 0000000..bbdd85b --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/core.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include "caster.hpp" + +namespace ecole { + +namespace version { +void bind_submodule(pybind11::module_ m); +} + +namespace scip { +void bind_submodule(pybind11::module_ m); +} + +namespace data { +void bind_submodule(pybind11::module_ const& m); +} + +namespace instance { +void bind_submodule(pybind11::module const& m); +} + +namespace observation { +void bind_submodule(pybind11::module_ const& m); +} + +namespace reward { +void bind_submodule(pybind11::module_ const& m); +} + +namespace information { +void bind_submodule(pybind11::module_ const& m); +} + +namespace dynamics { +void bind_submodule(pybind11::module_ const& m); +} + +} // namespace ecole diff --git a/ecole/python/ecole/src/ecole/core/data.cpp b/ecole/python/ecole/src/ecole/core/data.cpp new file mode 100644 index 0000000..496beaa --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/data.cpp @@ -0,0 +1,114 @@ +#include +#include + +#include "ecole/data/abstract.hpp" +#include "ecole/data/constant.hpp" +#include "ecole/data/map.hpp" +#include "ecole/data/none.hpp" +#include "ecole/data/timed.hpp" +#include "ecole/data/vector.hpp" +#include "ecole/scip/model.hpp" + +#include "core.hpp" + +namespace ecole::data { + +namespace py = pybind11; + +/** + * A C++ class to wrap any Python object as an data extraction function. + * + * This is used to bind templated types such as MapFunction and VectorFunction. + */ +class PyDataFunction { +public: + PyDataFunction() noexcept = default; + explicit PyDataFunction(py::object data_func) noexcept : data_function(std::move(data_func)) {} + + auto before_reset(scip::Model& model) -> void { data_function.attr("before_reset")(&model); } + + auto extract(scip::Model& model, bool done) -> py::object { return data_function.attr("extract")(&model, done); } + +private: + py::object data_function; +}; + +void bind_submodule(py::module_ const& m) { + m.doc() = "Data extraction functions manipulation."; + + using PyConstantFunction = ConstantFunction; + py::class_(m, "ConstantFunction", "Always return the given value.") + .def(py::init()) + .def("before_reset", &PyConstantFunction::before_reset, py::arg("model"), "Do nothing.") + .def("extract", &PyConstantFunction::extract, py::arg("model"), py::arg("done"), "Return the constant."); + + py::class_(m, "NoneFunction", "Always retrun None.") + .def(py::init<>()) + .def("before_reset", &NoneFunction::before_reset, py::arg("model"), "Do nothing.") + .def("extract", &NoneFunction::extract, py::arg("model"), py::arg("done"), "Return None."); + + using PyVectorFunction = VectorFunction; + py::class_(m, "VectorFunction", "Pack data extraction functions together and return data as list.") + .def(py::init([](py::args const& objects) { + auto functions = std::vector{objects.size()}; + std::transform(objects.begin(), objects.end(), functions.begin(), [](py::handle obj) { + return PyDataFunction{py::reinterpret_borrow(obj)}; + }); + return std::make_unique(std::move(functions)); + })) + .def( + "before_reset", + &PyVectorFunction::before_reset, + py::arg("model"), + "Call before_reset on all data extraction functions.") + .def( + "extract", + &PyVectorFunction::extract, + py::arg("model"), + py::arg("done"), + "Return data from all functions as a tuple."); + + using PyMapFunction = MapFunction; + py::class_(m, "MapFunction", "Pack data extraction functions together and return data as a dict.") + .def(py::init([](py::kwargs const& objects) { + auto functions = std::map{}; + for (auto [key, func] : objects) { + functions.emplace(key.cast(), py::reinterpret_borrow(func)); + } + return std::make_unique(std::move(functions)); + })) + .def( + "before_reset", + &PyMapFunction::before_reset, + py::arg("model"), + "Call before_reset on all data extraction functions.") + .def( + "extract", + &PyMapFunction::extract, + py::arg("model"), + py::arg("done"), + "Return data from all functions as a dict."); + + using PyTimedFunction = TimedFunction; + py::class_(m, "TimedFunction", "Time in seconds of any function.") + .def( + py::init([](py::object func, bool wall) { + return std::make_unique(PyDataFunction{std::move(func)}, wall); + }), + py::arg("func"), + py::arg("wall") = false) + .def(py::init(), py::arg("wall") = false) + .def( + "before_reset", + &PyTimedFunction::before_reset, + py::arg("model"), + "Call before_reset on the data extraction functions.") + .def( + "extract", + &PyTimedFunction::extract, + py::arg("model"), + py::arg("done"), + "Time the data extract function in seconds."); +} + +} // namespace ecole::data diff --git a/ecole/python/ecole/src/ecole/core/dynamics.cpp b/ecole/python/ecole/src/ecole/core/dynamics.cpp new file mode 100644 index 0000000..458a780 --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/dynamics.cpp @@ -0,0 +1,307 @@ +#include + +#include +#include +#include + +#include "ecole/dynamics/branching.hpp" +#include "ecole/dynamics/configuring.hpp" +#include "ecole/dynamics/primal-search.hpp" +#include "ecole/scip/model.hpp" + +#include "core.hpp" + +namespace ecole::dynamics { + +namespace py = pybind11; +template using Numpy = py::array_t; + +template struct dynamics_class : public pybind11::class_ { + using pybind11::class_::class_; + + /** Bind reset_dynamics */ + template auto def_reset_dynamics(Args&&... args) -> auto& { + this->def( + "reset_dynamics", + &Class::reset_dynamics, + py::arg("model"), + py::call_guard(), + std::forward(args)...); + return *this; + } + + /** Bind step_dynamics */ + template auto def_step_dynamics(Args&&... args) -> auto& { + this->def( + "step_dynamics", + &Class::step_dynamics, + py::arg("model"), + py::arg("action"), + py::call_guard(), + std::forward(args)...); + return *this; + } + + /** Bind set_dynamics_random_state */ + template auto def_set_dynamics_random_state(Args&&... args) -> auto& { + this->def( + "set_dynamics_random_state", + &Class::set_dynamics_random_state, + py::arg("model"), + py::arg("rng"), + py::call_guard(), + std::forward(args)...); + return *this; + } +}; + +void bind_submodule(pybind11::module_ const& m) { + m.doc() = "Ecole collection of environment dynamics."; + + { + dynamics_class{m, "BranchingDynamics", R"( + Single variable branching Dynamics. + + Based on a SCIP `branching callback `_ + with maximal priority and no depth limit. + The dynamics give the control back to the user every time the callback would be called. + The user receives as an action set the list of branching candidates, and is expected to select + one of them as the action. + )"} + .def_reset_dynamics(R"( + Start solving up to first branching node. + + Start solving with SCIP defaults (``SCIPsolve``) and give back control to the user on the + first branching decision. + Users can inherit from this dynamics to change the defaults settings such as presolving + and cutting planes. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + + Returns + ------- + done: + Whether the instance is solved. + This can happen without branching, for instance if the instance is solved during presolving. + action_set: + List of indices of branching candidate variables. + Available candidates depend on parameters in :py:meth:`__init__`. + Variable indices (values in the ``action_set``) are their position in the original problem + (``SCIPvarGetProbindex``). + Variable ordering in the ``action_set`` is arbitrary. + )") + .def_step_dynamics(R"( + Branch and resume solving until next branching. + + Branching is done on a single variable using ``SCIPbranchVar``. + The control is given back to the user on the next branching decision or when done. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + action: + The index the LP column of the variable to branch on. One element of the action set. + If an explicit ``ecole.Default`` is passed, then default SCIP branching is used, that is, the next + branching rule is used fetch by SCIP according to their priorities. + + Returns + ------- + done: + Whether the instance is solved. + action_set: + List of indices of branching candidate variables. + Available candidates depend on parameters in :py:meth:`__init__`. + Variable indices (values in the ``action_set``) are their position in the original problem + (``SCIPvarGetProbindex``). + Variables ordering in the ``action_set`` is arbitrary. + )") + .def_set_dynamics_random_state(R"( + Set seeds on the :py:class:`~ecole.scip.Model`. + + Set seed parameters, including permutation, LP, and shift. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + rng: + The source of randomness. Passed by the environment. + )") + .def(py::init(), py::arg("pseudo_candidates") = false, R"( + Create new dynamics. + + Parameters + ---------- + pseudo_candidates: + Whether the action set contains pseudo branching variable candidates (``SCIPgetPseudoBranchCands``) + or LP branching variable candidates (``SCIPgetPseudoBranchCands``). + )"); + } + + { + dynamics_class{m, "ConfiguringDynamics", R"( + Setting solving parameters Dynamics. + + These dynamics are meant to be used as a (contextual) bandit to find good parameters for SCIP. + )"} + .def_reset_dynamics(R"( + Does nothing. + + Users can inherit from this dynamics to change when in the solving process parameters will be set + (for instance after presolving). + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + + Returns + ------- + done: + Whether the instance is solved. Always false. + action_set: + Unused. + )") + .def_step_dynamics(R"( + Set parameters and solve the instance. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + action: + A mapping of parameter names and values. + + Returns + ------- + done: + Whether the instance is solved. Always true. + action_set: + Unused. + )") + .def_set_dynamics_random_state(R"( + Set seeds on the :py:class:`~ecole.scip.Model`. + + Set seed parameters, including permutation, LP, and shift. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + rng: + The source of randomness. Passed by the environment. + )") + .def(py::init<>()); + } + + { + using idx_t = typename PrimalSearchDynamics::Action::first_type::value_type; + using val_t = typename PrimalSearchDynamics::Action::second_type::value_type; + dynamics_class{m, "PrimalSearchDynamics", R"( + Search for primal solutions Dynamics. + + Based on a SCIP `primal heuristic `_ + callback with maximal priority, which executes + after the processing of a node is finished (``SCIP_HEURTIMING_AFTERNODE``). + The dynamics give the control back to the user a few times (trials) each time + the callback is called. The agent receives as an action set the list of all non-fixed + discrete variables at the current node (pseudo branching candidates), and is + expected to give back as an action a partial primal solution, i.e., a value + assignment for a subset of these variables. + + )"} + .def_set_dynamics_random_state(R"( + Set seeds on the :py:class:`~ecole.scip.Model`. + + Set seed parameters, including permutation, LP, and shift. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + rng: + The source of randomness. Passed by the environment. + )") + .def_reset_dynamics(R"( + Start solving up to first primal heuristic call. + + Start solving with SCIP defaults (``SCIPsolve``) and give back control to the user on the + first heuristic call. + Users can inherit from this dynamics to change the defaults settings such as presolving + and cutting planes. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + + Returns + ------- + done: + Whether the instance is solved. + This can happen before the heuristic gets called, for instance if the instance is solved during presolving. + action_set: + List of non-fixed discrete variables (``SCIPgetPseudoBranchCands``). + )") + .def( + "step_dynamics", + [](PrimalSearchDynamics& self, scip::Model& model, std::pair, Numpy> const& action) { + auto const indices = nonstd::span{action.first.data(), static_cast(action.first.size())}; + auto const values = nonstd::span{action.second.data(), static_cast(action.second.size())}; + auto const release = py::gil_scoped_release{}; + return self.step_dynamics(model, {indices, values}); + }, + py::arg("model"), + py::arg("action"), + R"( + Try to obtain a feasible primal solution from the given (partial) primal solution. + + If the number of search trials per node is exceeded, then continue solving until + the next time the heuristic gets called. + + To obtain a complete feasible solution, variables are fixed to their partial assignment + values, and the rest of the variable assigments is deduced by solving an LP in probing + mode. If the provided partial assigment is empty, then nothing is done. + + Parameters + ---------- + model: + The state of the Markov Decision Process. Passed by the environment. + action: + A subset of the variables given in the action set, and their assigned values. + + Returns + ------- + done: + Whether the instance is solved. + action_set: + List of non-fixed discrete variables (``SCIPgetPseudoBranchCands``). + )") + .def( + py::init(), + py::arg("trials_per_node") = 1, + py::arg("depth_freq") = 1, + py::arg("depth_start") = 0, + py::arg("depth_stop") = -1, + R"( + Initialize new PrimalSearchDynamics. + + Parameters + ---------- + trials_per_node: + Number of primal searches performed at each node (or -1 for an infinite number of trials). + depth_freq: + Depth frequency of when the primal search is called (``HEUR_FREQ`` in SCIP). + depth_start: + Tree depth at which the primal search starts being called (``HEUR_FREQOFS`` in SCIP). + depth_stop: + Tree depth after which the primal search stops being called (``HEUR_MAXDEPTH`` in SCIP). + )"); + } +} + +} // namespace ecole::dynamics diff --git a/ecole/python/ecole/src/ecole/core/information.cpp b/ecole/python/ecole/src/ecole/core/information.cpp new file mode 100644 index 0000000..a3d6e7a --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/information.cpp @@ -0,0 +1,25 @@ +#include +#include + +#include "ecole/information/nothing.hpp" +#include "ecole/scip/model.hpp" + +#include "core.hpp" + +namespace ecole::information { + +namespace py = pybind11; + +/** + * Information module bindings definitions. + */ +void bind_submodule(py::module_ const& m) { + m.doc() = "Inforation classes for Ecole."; + + py::class_(m, "Nothing") + .def(py::init<>()) + .def("before_reset", &Nothing::before_reset, py::arg("model"), "Do nothing.") + .def("extract", &Nothing::extract, py::arg("model"), py::arg("done"), "Return an empty dictionnary."); +} + +} // namespace ecole::information diff --git a/ecole/python/ecole/src/ecole/core/instance.cpp b/ecole/python/ecole/src/ecole/core/instance.cpp new file mode 100644 index 0000000..12f239e --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/instance.cpp @@ -0,0 +1,455 @@ +#include +#include + +#include + +#include "ecole/instance/capacitated-facility-location.hpp" +#include "ecole/instance/combinatorial-auction.hpp" +#include "ecole/instance/files.hpp" +#include "ecole/instance/independent-set.hpp" +#include "ecole/instance/set-cover.hpp" +#include "ecole/utility/function-traits.hpp" + +#include "core.hpp" + +namespace py = pybind11; + +namespace ecole::instance { + +/** + * Hold a class member variable function pointer and its name together. + */ +template struct Member { + char const* name; + Ptr value; + + constexpr Member(char const* name_, Ptr value_) : name(name_), value(value_) {} +}; + +/** + * Bind the static method `generate_instance` by unpacking the Parameter struct into individual function parameters. + */ +template +void def_generate_instance(PyClass& py_class, MemberTuple&& members_tuple, char const* docstring = ""); + +/** + * Bind the constructor by unpacking the Parameter struct into individual function parameters. + */ +template +void def_init(PyClass& py_class, MemberTuple&& members_tuple, char const* docstring = ""); + +/** + * Bind all the parameters attributes by forwarding the call to get_paramters. + */ +template void def_attributes(PyClass& py_class, MemberTuple&& members_tuple); + +/** + * Bind infinite Python iteration using the `next` method. + */ +template void def_iterator(PyClass& py_class); + +/** + * Bind a string constructor for Enums. + */ +template void def_init_str(PyEnum& py_enum); + +void bind_submodule(py::module const& m) { + m.doc() = "Random instance generators for Ecole."; + + // The sampling parameters used in constructor and attributes. + auto constexpr file_params = std::tuple{ + Member{"directory", &FileGenerator::Parameters::directory}, + Member{"recursive", &FileGenerator::Parameters::recursive}, + Member{"sampling_mode", &FileGenerator::Parameters::sampling_mode}, + }; + // Bind FileGenerator and remove intermediate Parameter class + auto file_gen = py::class_{m, "FileGenerator"}; + // Bind FileGenerator::Parameter::SamplingMode enum + auto sampling_mode = py::enum_{file_gen, "SamplingMode", R"( + A generator to iterate over files in a directory and load them into :py:class:. + )"}; + sampling_mode // + .value("replace", FileGenerator::Parameters::SamplingMode::replace) + .value("remove", FileGenerator::Parameters::SamplingMode::remove) + .value("remove_and_repeat", FileGenerator::Parameters::SamplingMode::remove_and_repeat); + // Add contructor from str to FileGenerator::Parameter::SamplingMode and make it implicit + def_init_str(sampling_mode); + // Bind FileGenerator methods and remove intermediate Parameter class + def_init(file_gen, file_params, R"( + Create a generator to iterate over local problem files. + + Parameters + -------- + directory: + The path of the directory in which to look for files. + recursive: + Wether sub-directories are searched as well. + sampling_mode: + Method to iterate over files + - "replace": Replace every file in the sampling pool right after it is sampled; + - "remove": Remove every file from the sampling pool right after it is sampled and finish + iteration when all files are sampled once; + - "remove_and_repeat": Remove every file from the sampling pool right after it is sampled + but repeat the procedure (with different order) after all files have been sampled. + )"); + def_attributes(file_gen, file_params); + def_iterator(file_gen); + file_gen.def("seed", &FileGenerator::seed, py::arg(" seed")); + + // The Set Cover parameters used in constructor, generate_instance, and attributes + auto constexpr set_cover_params = std::tuple{ + Member{"n_rows", &SetCoverGenerator::Parameters::n_rows}, + Member{"n_cols", &SetCoverGenerator::Parameters::n_cols}, + Member{"density", &SetCoverGenerator::Parameters::density}, + Member{"max_coef", &SetCoverGenerator::Parameters::max_coef}, + }; + // Bind SetCoverGenerator and remove intermediate Parameter class + auto set_cover_gen = py::class_{m, "SetCoverGenerator"}; + def_generate_instance(set_cover_gen, set_cover_params, R"( + Generate a set cover MILP problem instance. + + Algorithm described in [Balas1980]_. + + Parameters + ---------- + n_rows: + The number of rows. + n_cols: + The number of columns. + density: + The density of the constraint matrix. + The value must be in the range ]0,1]. + max_coef: + Maximum objective coefficient. + The value must be greater than one. + rng: + The random number generator used to peform all sampling. + + References + ---------- + .. [Balas1980] + Egon Balas and Andrew Ho. + "Set covering algorithms using cutting planes, heuristics, and subgradient optimization: A computational study". + *Mathematical Programming*, 12, pp. 37-60. 1980. + )"); + def_init(set_cover_gen, set_cover_params); + def_attributes(set_cover_gen, set_cover_params); + def_iterator(set_cover_gen); + set_cover_gen.def("seed", &SetCoverGenerator::seed, py::arg("seed")); + + // The Independent Set parameters used in constructor, generate_instance, and attributes + auto constexpr independent_set_params = std::tuple{ + Member{"n_nodes", &IndependentSetGenerator::Parameters::n_nodes}, + Member{"graph_type", &IndependentSetGenerator::Parameters::graph_type}, + Member{"edge_probability", &IndependentSetGenerator::Parameters::edge_probability}, + Member{"affinity", &IndependentSetGenerator::Parameters::affinity}, + }; + // Create class for IndependenSetGenerator + auto independent_set_gen = py::class_{m, "IndependentSetGenerator"}; + // Bind IndependenSetGenerator::Parameter::GraphType enum + auto graph_type = py::enum_{independent_set_gen, "GraphType"}; + graph_type // + .value("barabasi_albert", IndependentSetGenerator::Parameters::GraphType::barabasi_albert) + .value("erdos_renyi", IndependentSetGenerator::Parameters::GraphType::erdos_renyi) + .export_values(); // TODO remove + // Add contructor from str to IndependenSetGenerator::Parameter::GraphType and make it implicit + def_init_str(graph_type); + // Bind IndependentSetGenerator methods and remove intermediate Parameter class + def_generate_instance(independent_set_gen, independent_set_params, R"( + Generate an independent set MILP problem instance. + + Given an undireted graph, the problem is to find a maximum subset of nodes such that no pair of nodes are + connected. There are one variable per node in the underlying graph. Instead of adding one constraint per edge, a + greedy algorithm is run to replace these inequalities when clique is found. The maximization problem is + unwheighted, that is all objective coefficients are equal to one. + + The problem are generated using the procedure from [Bergman2016]_, and the graphs are sampled following + [Erdos1959]_ and [Barabasi1999]_. + + Parameters + ---------- + n_nodes: + The number of nodes in the graph, and therefore of variable. + graph_type: + The method used in which to generate graphs. + One of ``"barabasi_albert"`` or ``"erdos_renyi"``. + edge_probability: + The probability of generating each edge. + This parameter must be in the range [0, 1]. + This parameter will only be used if ``graph_type == "erdos_renyi"``. + affinity: + The number of nodes each new node will be attached to, in the sampling scheme. + This parameter must be an integer >= 1. + This parameter will only be used if ``graph_type == "barabasi_albert"``. + rng: + The random number generator used to peform all sampling. + + References + ---------- + .. [Bergman2016] + David Bergman, Andre A. Cire, Willem-Jan Van Hoeve, and John Hooker. + "Decision diagrams for optimization", Section 4.6.4. + *Springer International Publishing*, 2016. + .. [Erdos1959] + Paul Erdos and Alfréd Renyi. + "On Random Graph" + *Publicationes Mathematicae*, pp. 290-297, 1959. + .. [Barabasi1999] + Albert-László Barabási and Réka Albert. + "Emergence of scaling in random networks" + *Science* vol. 286, num. 5439, pp. 509-512, 1999. + )"); + def_init(independent_set_gen, independent_set_params); + def_attributes(independent_set_gen, independent_set_params); + def_iterator(independent_set_gen); + independent_set_gen.def("seed", &IndependentSetGenerator::seed, py::arg("seed")); + + // The Combinatorial Auction parameters used in constructor, generate_instance, and attributes + auto constexpr combinatorial_auction_params = std::tuple{ + Member{"n_items", &CombinatorialAuctionGenerator::Parameters::n_items}, + Member{"n_bids", &CombinatorialAuctionGenerator::Parameters::n_bids}, + Member{"min_value", &CombinatorialAuctionGenerator::Parameters::min_value}, + Member{"max_value", &CombinatorialAuctionGenerator::Parameters::max_value}, + Member{"value_deviation", &CombinatorialAuctionGenerator::Parameters::value_deviation}, + Member{"add_item_prob", &CombinatorialAuctionGenerator::Parameters::add_item_prob}, + Member{"max_n_sub_bids", &CombinatorialAuctionGenerator::Parameters::max_n_sub_bids}, + Member{"additivity", &CombinatorialAuctionGenerator::Parameters::additivity}, + Member{"budget_factor", &CombinatorialAuctionGenerator::Parameters::budget_factor}, + Member{"resale_factor", &CombinatorialAuctionGenerator::Parameters::resale_factor}, + Member{"integers", &CombinatorialAuctionGenerator::Parameters::integers}, + Member{"warnings", &CombinatorialAuctionGenerator::Parameters::warnings}, + }; + // Bind CombinatorialAuctionGenerator and remove intermediate Parameter class + auto combinatorial_auction_gen = py::class_{m, "CombinatorialAuctionGenerator"}; + def_generate_instance(combinatorial_auction_gen, combinatorial_auction_params, R"( + Generate a combinatorial auction MILP problem instance. + + This method generates an instance of a combinatorial auction problem based on the + specified parameters and returns it as an ecole model. + + Algorithm described in [LeytonBrown2000]_. + + Parameters + ---------- + n_items: + The number of items. + n_bids: + The number of bids. + min_value: + The minimum resale value for an item. + max_value: + The maximum resale value for an item. + value_deviation: + The deviation allowed for each bidder's private value of an item, relative from max_value. + add_item_prob: + The probability of adding a new item to an existing bundle. + This parameters must be in the range [0,1]. + max_n_sub_bids: + The maximum number of substitutable bids per bidder (+1 gives the maximum number of bids per bidder). + additivity: + Additivity parameter for bundle prices. Note that additivity < 0 gives sub-additive bids, while + additivity > 0 gives super-additive bids. + budget_factor: + The budget factor for each bidder, relative to their initial bid's price. + resale_factor: + The resale factor for each bidder, relative to their initial bid's resale value. + integers: + Determines if the bid prices should be integral. + warnings: + Determines if warnings should be printed when invalid bundles are skipped in instance generation. + rng: + The random number generator used to peform all sampling. + + References + ---------- + .. [LeytonBrown2000] + Kevin Leyton-Brown, Mark Pearson, and Yoav Shoham. + "Towards a universal test suite for combinatorial auction algorithms". + *Proceedings of ACM Conference on Electronic Commerce* (EC01) pp. 66-76. + Section 4.3., the 'arbitrary' scheme. 2000. + )"); + def_init(combinatorial_auction_gen, combinatorial_auction_params); + def_attributes(combinatorial_auction_gen, combinatorial_auction_params); + def_iterator(combinatorial_auction_gen); + combinatorial_auction_gen.def("seed", &CombinatorialAuctionGenerator::seed, py::arg("seed")); + + // The Capacitated Facility Location parameters used in constructor, generate_instance, and attributes + auto constexpr capacitated_facility_location_params = std::tuple{ + Member{"n_customers", &CapacitatedFacilityLocationGenerator::Parameters::n_customers}, + Member{"n_facilities", &CapacitatedFacilityLocationGenerator::Parameters::n_facilities}, + Member{"continuous_assignment", &CapacitatedFacilityLocationGenerator::Parameters::continuous_assignment}, + Member{"ratio", &CapacitatedFacilityLocationGenerator::Parameters::ratio}, + Member{"demand_interval", &CapacitatedFacilityLocationGenerator::Parameters::demand_interval}, + Member{"capacity_interval", &CapacitatedFacilityLocationGenerator::Parameters::capacity_interval}, + Member{"fixed_cost_cste_interval", &CapacitatedFacilityLocationGenerator::Parameters::fixed_cost_cste_interval}, + Member{"fixed_cost_scale_interval", &CapacitatedFacilityLocationGenerator::Parameters::fixed_cost_scale_interval}, + }; + // Bind CapacitatedFacilityLocationGenerator and remove intermediate Parameter class + auto capacitated_facility_location_gen = + py::class_{m, "CapacitatedFacilityLocationGenerator"}; + def_generate_instance(capacitated_facility_location_gen, capacitated_facility_location_params, R"( + Generate a capacitated facility location MILP problem instance. + + The capacitated facility location assigns a number of customers to be served from a number of facilities. + Not all facilities need to be opened. + In fact, the problem is to minimized the sum of the fixed costs for each facilities and the sum of transportation + costs for serving a given customer from a given facility. + In a variant of the problem, the customers can be served from multiple facilities and the associated variables + become [0,1] continuous. + + The sampling algorithm is described in [Cornuejols1991]_, but uniform sampling as been replaced by *integer* + uniform sampling. + + Parameters + ---------- + n_customers: + The number of customers. + n_facilities: + The number of facilities. + continuous_assignment: + Whether variable for assigning a customer to a facility are binary or [0,1] continuous. + ratio: + After all sampling is performed, the capacities are scaled by `ratio * sum(demands) / sum(capacities)`. + demand_interval: + The customer demands are sampled independently as uniform integers in this interval [lower, upper[. + capacity_interval: + The facility capacities are sampled independently as uniform integers in this interval [lower, upper[. + fixed_cost_cste_interval: + The fixed costs are the sum of two terms. + The first terms in the fixed costs for opening facilities are sampled independently as uniform integers + in this interval [lower, upper[. + fixed_cost_scale_interval: + The fixed costs are the sum of two terms. + The second terms in the fixed costs for opening facilities are sampled independently as uniform integers + in this interval [lower, upper[ multiplied by the square root of their capacity prior to scaling. + This second term reflects the economies of scale. + rng: + The random number generator used to peform all sampling. + + References + ---------- + .. [Cornuejols1991] + Cornuejols G, Sridharan R, Thizy J-M. + "A Comparison of Heuristics and Relaxations for the Capacitated Plant Location Problem". + *European Journal of Operations Research* 50, pp. 280-297. 1991. + )"); + def_init(capacitated_facility_location_gen, capacitated_facility_location_params); + def_attributes(capacitated_facility_location_gen, capacitated_facility_location_params); + def_iterator(capacitated_facility_location_gen); + capacitated_facility_location_gen.def("seed", &CapacitatedFacilityLocationGenerator::seed, py::arg(" seed")); +} + +/****************************************** + * Binding code for instance generators * + ******************************************/ + +/** + * Implementation of def_generate_instance to unpack tuple. + */ +template +void def_generate_instance_impl(PyClass& py_class, char const* docstring, Members&&... members) { + // The C++ class being wrapped + using Generator = typename PyClass::type; + using Parameters = typename Generator::Parameters; + // Instantiate the C++ parameters at compile time to get default parameters. + // FIXME could be constexpr but GCC 7.3 (on conda) is complaining + static auto const default_params = Parameters{}; + // Bind a the static method that takes as input all parameters + py_class.def_static( + "generate_instance", + // Get the type of each parameter and add it to the Python function parameters + [](utility::return_t... params, RandomGenerator& rng) { + // Call the C++ static function with a Parameter struct + return Generator::generate_instance(Parameters{params...}, rng); + }, + // Set name for all function parameters. + // Fetch default value on the default parameters + (py::arg(members.name) = std::invoke(members.value, default_params))..., + py::arg("rng"), + py::call_guard(), + docstring); +} + +template +void def_generate_instance(PyClass& py_class, MemberTuple&& members_tuple, char const* docstring) { + // Forward call to impl in order to unpack the tuple + std::apply( + [&py_class, docstring](auto&&... members) { + def_generate_instance_impl(py_class, docstring, std::forward(members)...); + }, + std::forward(members_tuple)); +} + +/** + * Implementation of def_init to unpack tuple. + */ +template +void def_init_impl(PyClass& py_class, char const* docstring, Members&&... members) { + // The C++ class being wrapped + using Generator = typename PyClass::type; + using Parameters = typename Generator::Parameters; + // Instantiate the C++ parameters at compile time to get default parameters. + // FIXME could be constexpr but GCC 7.3 (on conda) is complaining + static auto const default_params = Parameters{}; + // Bind a constructor that takes as input all parameters + py_class.def( + // Get the type of each parameter and add it to the Python constructor + py::init([](utility::return_t... params, RandomGenerator const* rng) { + // Dispatch to the C++ constructors with a Parameter struct + if (rng == nullptr) { + return std::make_unique(Parameters{params...}); + } + return std::make_unique(Parameters{params...}, *rng); + }), + // Set name for all constructor parameters. + // Fetch default value on the default parameters + (py::arg(members.name) = std::invoke(members.value, default_params))..., + // None as nullptr are allowed + py::arg("rng").none(true) = py::none(), + docstring); +} + +template +void def_init(PyClass& py_class, MemberTuple&& members_tuple, char const* docstring) { + // Forward call to impl in order to unpack the tuple + std::apply( + [&](auto&&... members) { def_init_impl(py_class, docstring, std::forward(members)...); }, + std::forward(members_tuple)); +} + +template void def_attributes_impl(PyClass& py_class, Member&& member) { + // The C++ class being wrapped + using Generator = typename PyClass::type; + // Bind attribute access for a given member variable + // Fetch the value on the Parameter object of the C++ class + py_class.def_property_readonly( + member.name, [value = member.value](Generator& self) { return std::invoke(value, self.get_parameters()); }); +} + +template void def_attributes(PyClass& py_class, MemberTuple&& members_tuple) { + // Forward call to impl in order to unpack the tuple + std::apply( + [&py_class](auto&&... members) { + // Bind attribute access for each member variable (comma operator fold expression). + ((def_attributes_impl(py_class, std::forward(members))), ...); + }, + std::forward(members_tuple)); +} + +template void def_iterator(PyClass& py_class) { + // The C++ class being wrapped + using Generator = typename PyClass::type; + py_class.def("__iter__", [](Generator& self) -> Generator& { return self; }); + py_class.def("__next__", &Generator::next, py::call_guard()); +} + +template void def_init_str(PyEnum& py_enum) { + // The C++ being wrapped + using Enum = typename PyEnum::type; + // Add contructor from str to the enum and make it implicit + py_enum.def(py::init( + [members = py_enum.attr("__members__")](py::str const& name) { return members[name].template cast(); })); + py::implicitly_convertible(); +} + +} // namespace ecole::instance diff --git a/ecole/python/ecole/src/ecole/core/observation.cpp b/ecole/python/ecole/src/ecole/core/observation.cpp new file mode 100644 index 0000000..ca2a532 --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/observation.cpp @@ -0,0 +1,537 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include "ecole/observation/hutter-2011.hpp" +#include "ecole/observation/khalil-2016.hpp" +#include "ecole/observation/milp-bipartite.hpp" +#include "ecole/observation/node-bipartite.hpp" +#include "ecole/observation/node-bipartite-candidate.hpp" +#include "ecole/observation/nothing.hpp" +#include "ecole/observation/pseudocosts.hpp" +#include "ecole/observation/strong-branching-scores.hpp" +#include "ecole/python/auto-class.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/utility/sparse-matrix.hpp" + +#include "core.hpp" + +namespace ecole::observation { + +namespace py = pybind11; + +/** + * Helper function to bind the `before_reset` method of observation functions. + */ +template auto def_before_reset(PyClass pyclass, Args&&... args) { + return pyclass.def( + "before_reset", + &PyClass::type::before_reset, + py::arg("model"), + py::call_guard(), + std::forward(args)...); +} + +/** + * Helper function to bind the `extract` method of observation functions. + */ +template auto def_extract(PyClass pyclass, Args&&... args) { + return pyclass.def( + "extract", + &PyClass::type::extract, + py::arg("model"), + py::arg("done"), + py::call_guard(), + std::forward(args)...); +} + +/** + * Observation module bindings definitions. + */ +void bind_submodule(py::module_ const& m) { + m.doc() = "Observation classes for Ecole."; + + xt::import_numpy(); + + m.attr("Nothing") = py::type::of(); + + using coo_matrix = decltype(NodeBipartiteObs::edge_features); + ecole::python::auto_class(m, "coo_matrix", R"( + Sparse matrix in the coordinate format. + + Similar to Scipy's ``scipy.sparse.coo_matrix`` or PyTorch ``torch.sparse``. + )") + .def_auto_copy() + .def_auto_pickle("values", "indices", "shape") + .def_readwrite_xtensor("values", &coo_matrix::values, "A vector of non zero values in the matrix") + .def_readwrite_xtensor("indices", &coo_matrix::indices, R"( + A matrix holding the indices of non zero coefficient in the sparse matrix. + + There are as many columns as there are non zero coefficients, and each row is a + dimension in the sparse matrix. + )") + .def_readwrite("shape", &coo_matrix::shape, "The dimension of the sparse matrix, as if it was dense.") + .def_property_readonly("nnz", &coo_matrix::nnz); + + // Node bipartite observation + auto node_bipartite_obs = + ecole::python::auto_class(m, "NodeBipartiteObs", R"( + Bipartite graph observation for branch-and-bound nodes. + + The optimization problem is represented as an heterogenous bipartite graph. + On one side, a node is associated with one variable, on the other side a node is + associated with one LP row. + There exist an edge between a variable and a constraint if the variable exists in the + constraint with a non-zero coefficient. + + Each variable and constraint node is associated with a vector of features. + Each edge is associated with the coefficient of the variable in the constraint. + )") + .def_auto_copy() + .def_auto_pickle("variable_features", "row_features", "edge_features") + .def_readwrite_xtensor("variable_features", &NodeBipartiteObs::variable_features, R"rst( + A matrix where each row represents a variable, and each column a feature of the variable. + + Variables are ordered according to their position in the original problem (``SCIPvarGetProbindex``), + hence they can be indexed by the :py:class:`~ecole.environment.Branching` environment ``action_set``. + )rst") + .def_readwrite_xtensor( + "row_features", + &NodeBipartiteObs::row_features, + "A matrix where each row is represents a constraint, and each column a feature of the constraints.") + .def_readwrite( + "edge_features", + &NodeBipartiteObs::edge_features, + "The constraint matrix of the optimization problem, with rows for contraints and " + "columns for variables."); + + py::enum_(node_bipartite_obs, "VariableFeatures") + .value("objective", NodeBipartiteObs::VariableFeatures::objective) + .value("is_type_binary", NodeBipartiteObs::VariableFeatures::is_type_binary) + .value("is_type_integer", NodeBipartiteObs::VariableFeatures::is_type_integer) + .value("is_type_implicit_integer", NodeBipartiteObs::VariableFeatures::is_type_implicit_integer) + .value("is_type_continuous", NodeBipartiteObs::VariableFeatures::is_type_continuous) + .value("has_lower_bound", NodeBipartiteObs::VariableFeatures::has_lower_bound) + .value("has_upper_bound", NodeBipartiteObs::VariableFeatures::has_upper_bound) + .value("normed_reduced_cost", NodeBipartiteObs::VariableFeatures::normed_reduced_cost) + .value("solution_value", NodeBipartiteObs::VariableFeatures::solution_value) + .value("solution_frac", NodeBipartiteObs::VariableFeatures::solution_frac) + .value("is_solution_at_lower_bound", NodeBipartiteObs::VariableFeatures::is_solution_at_lower_bound) + .value("is_solution_at_upper_bound", NodeBipartiteObs::VariableFeatures::is_solution_at_upper_bound) + .value("scaled_age", NodeBipartiteObs::VariableFeatures::scaled_age) + .value("incumbent_value", NodeBipartiteObs::VariableFeatures::incumbent_value) + .value("average_incumbent_value", NodeBipartiteObs::VariableFeatures::average_incumbent_value) + .value("is_basis_lower", NodeBipartiteObs::VariableFeatures::is_basis_lower) + .value("is_basis_basic", NodeBipartiteObs::VariableFeatures::is_basis_basic) + .value("is_basis_upper", NodeBipartiteObs::VariableFeatures::is_basis_upper) + .value("is_basis_zero", NodeBipartiteObs::VariableFeatures ::is_basis_zero); + + py::enum_(node_bipartite_obs, "RowFeatures") + .value("bias", NodeBipartiteObs::RowFeatures::bias) + .value("objective_cosine_similarity", NodeBipartiteObs::RowFeatures::objective_cosine_similarity) + .value("is_tight", NodeBipartiteObs::RowFeatures::is_tight) + .value("dual_solution_value", NodeBipartiteObs::RowFeatures::dual_solution_value) + .value("scaled_age", NodeBipartiteObs::RowFeatures::scaled_age); + + auto node_bipartite = py::class_(m, "NodeBipartite", R"( + Bipartite graph observation function on branch-and bound node. + + This observation function extract structured :py:class:`NodeBipartiteObs`. + )"); + node_bipartite.def(py::init(), py::arg("cache") = false, R"( + Constructor for NodeBipartite. + + Parameters + ---------- + cache : + Whether or not to cache static features within an episode. + Currently, this is only safe if cutting planes are disabled. + )"); + def_before_reset(node_bipartite, "Cache some feature not expected to change during an episode."); + def_extract(node_bipartite, "Extract a new :py:class:`NodeBipartiteObs`."); + + // MILP bipartite observation + auto milp_bipartite_obs = + ecole::python::auto_class(m, "MilpBipartiteObs", R"( + Bipartite graph observation that represents the most recent MILP during presolving. + + The optimization problem is represented as an heterogenous bipartite graph. + On one side, a node is associated with one variable, on the other side a node is + associated with one constraint. + There exist an edge between a variable and a constraint if the variable exists in the + constraint with a non-zero coefficient. + + Each variable and constraint node is associated with a vector of features. + Each edge is associated with the coefficient of the variable in the constraint. + )") + .def_auto_copy() + .def_auto_pickle("variable_features", "constraint_features", "edge_features") + .def_readwrite_xtensor("variable_features", &MilpBipartiteObs::variable_features, R"rst( + A matrix where each row represents a variable, and each column a feature of the variable. + + Variables are ordered according to their position in the original problem (``SCIPvarGetProbindex``), + hence they can be indexed by the :py:class:`~ecole.environment.Branching` environment ``action_set``. + )rst") + .def_readwrite_xtensor( + "constraint_features", + &MilpBipartiteObs::constraint_features, + "A matrix where each row is represents a constraint, and each column a feature of the constraints.") + .def_readwrite( + "edge_features", + &MilpBipartiteObs::edge_features, + "The constraint matrix of the optimization problem, with rows for contraints and columns for variables."); + + py::enum_(milp_bipartite_obs, "VariableFeatures") + .value("objective", MilpBipartiteObs::VariableFeatures::objective) + .value("is_type_binary", MilpBipartiteObs::VariableFeatures::is_type_binary) + .value("is_type_integer", MilpBipartiteObs::VariableFeatures::is_type_integer) + .value("is_type_implicit_integer", MilpBipartiteObs::VariableFeatures::is_type_implicit_integer) + .value("is_type_continuous", MilpBipartiteObs::VariableFeatures::is_type_continuous) + .value("has_lower_bound", MilpBipartiteObs::VariableFeatures::has_lower_bound) + .value("has_upper_bound", MilpBipartiteObs::VariableFeatures::has_upper_bound) + .value("lower_bound", MilpBipartiteObs::VariableFeatures::lower_bound) + .value("upper_bound", MilpBipartiteObs::VariableFeatures::upper_bound); + + py::enum_(milp_bipartite_obs, "ConstraintFeatures") + .value("bias", MilpBipartiteObs::ConstraintFeatures::bias); + + auto milp_bipartite = py::class_(m, "MilpBipartite", R"( + Bipartite graph observation function for the sub-MILP at the latest branch-and-bound node. + + This observation function extract structured :py:class:`MilpBipartiteObs`. + )"); + milp_bipartite.def(py::init(), py::arg("normalize") = false, R"( + Constructor for MilpBipartite. + + Parameters + ---------- + normalize : + Should the features be normalized? + This is recommended for some application such as deep learning models. + )"); + def_before_reset(milp_bipartite, R"(Do nothing.)"); + def_extract(milp_bipartite, "Extract a new :py:class:`MilpBipartiteObs`."); + + // Selin Added + // Node bipartite candidate observation + auto node_bipartite_cand_obs = + ecole::python::auto_class(m, "NodeBipartiteCandObs", R"( + Bipartite graph observation for branch-and-bound nodes. + + The optimization problem is represented as an heterogenous bipartite graph. + On one side, a node is associated with one variable, on the other side a node is + associated with one constraint. + There exist an edge between a variable and a constraint if the variable exists in the + constraint with a non-zero coefficient. + + Each variable and constraint node is associated with a vector of features. + Each edge is associated with the coefficient of the variable in the constraint. + )") + .def_auto_copy() + .def_auto_pickle(std::array{"column_features", "row_features", "edge_features"}) + .def_readwrite_xtensor( + "column_features", + &NodeBipartiteCandObs::column_features, + "A matrix where each row is represents a variable, and each column a feature of the variables."); + + py::enum_(node_bipartite_obs, "ColumnFeatures") + .value("objective", NodeBipartiteCandObs::ColumnFeatures::objective) + .value("is_type_binary", NodeBipartiteCandObs::ColumnFeatures::is_type_binary) + .value("is_type_integer", NodeBipartiteCandObs::ColumnFeatures::is_type_integer) + .value("is_type_implicit_integer", NodeBipartiteCandObs::ColumnFeatures::is_type_implicit_integer) + .value("is_type_continuous", NodeBipartiteCandObs::ColumnFeatures::is_type_continuous) + .value("has_lower_bound", NodeBipartiteCandObs::ColumnFeatures::has_lower_bound) + .value("has_upper_bound", NodeBipartiteCandObs::ColumnFeatures::has_upper_bound) + .value("normed_reduced_cost", NodeBipartiteCandObs::ColumnFeatures::normed_reduced_cost) + .value("solution_value", NodeBipartiteCandObs::ColumnFeatures::solution_value) + .value("solution_frac", NodeBipartiteCandObs::ColumnFeatures::solution_frac) + .value("is_solution_at_lower_bound", NodeBipartiteCandObs::ColumnFeatures::is_solution_at_lower_bound) + .value("is_solution_at_upper_bound", NodeBipartiteCandObs::ColumnFeatures::is_solution_at_upper_bound) + .value("scaled_age", NodeBipartiteCandObs::ColumnFeatures::scaled_age) + .value("incumbent_value", NodeBipartiteCandObs::ColumnFeatures::incumbent_value) + .value("average_incumbent_value", NodeBipartiteCandObs::ColumnFeatures::average_incumbent_value) + .value("is_basis_lower", NodeBipartiteCandObs::ColumnFeatures::is_basis_lower) + .value("is_basis_basic", NodeBipartiteCandObs::ColumnFeatures::is_basis_basic) + .value("is_basis_upper", NodeBipartiteCandObs::ColumnFeatures::is_basis_upper) + .value("is_basis_zero", NodeBipartiteCandObs::ColumnFeatures::is_basis_zero) + .value("solution_infeasibility", NodeBipartiteCandObs::ColumnFeatures::solution_infeasibility) + .value("edge_mean", NodeBipartiteCandObs::ColumnFeatures::edge_mean) + .value("edge_min", NodeBipartiteCandObs::ColumnFeatures::edge_min) + .value("edge_max", NodeBipartiteCandObs::ColumnFeatures::edge_max) + .value("bias_mean", NodeBipartiteCandObs::ColumnFeatures::bias_mean) + .value("bias_min", NodeBipartiteCandObs::ColumnFeatures::bias_min) + .value("bias_max", NodeBipartiteCandObs::ColumnFeatures::bias_max) + .value("obj_cos_sim_mean", NodeBipartiteCandObs::ColumnFeatures::obj_cos_sim_mean) + .value("obj_cos_sim_min", NodeBipartiteCandObs::ColumnFeatures::obj_cos_sim_min) + .value("obj_cos_sim_max", NodeBipartiteCandObs::ColumnFeatures::obj_cos_sim_max) + .value("is_tight_mean", NodeBipartiteCandObs::ColumnFeatures::is_tight_mean) + .value("is_tight_min", NodeBipartiteCandObs::ColumnFeatures::is_tight_min) + .value("is_tight_max", NodeBipartiteCandObs::ColumnFeatures::is_tight_max) + .value("dual_solution_mean", NodeBipartiteCandObs::ColumnFeatures::dual_solution_mean) + .value("dual_solution_min", NodeBipartiteCandObs::ColumnFeatures::dual_solution_min) + .value("dual_solution_max", NodeBipartiteCandObs::ColumnFeatures::dual_solution_max) + .value("scaled_age_mean", NodeBipartiteCandObs::ColumnFeatures::scaled_age_mean) + .value("scaled_age_min", NodeBipartiteCandObs::ColumnFeatures::scaled_age_min) + .value("scaled_age_max", NodeBipartiteCandObs::ColumnFeatures::scaled_age_max); + + auto node_bipartite_cand = py::class_(m, "NodeBipartiteCand", R"( + Bipartite graph observation function on branch-and bound node. + + This observation function extract structured :py:class:`NodeBipartiteCandObs`. + )"); + node_bipartite_cand.def(py::init(), py::arg("cache") = false, R"( + Constructor for NodeBipartiteCand. + + Parameters + ---------- + cache : + Whether or not to cache static features within an episode. + Currently, this is only safe if cutting planes are disabled. + )"); + def_before_reset(node_bipartite_cand, "Cache some feature not expected to change during an episode."); + def_extract(node_bipartite_cand, "Extract a new :py:class:`NodeBipartiteObs`."); + + + + + + + + + // Strong branching observation + auto strong_branching_scores = py::class_(m, "StrongBranchingScores", R"( + Strong branching score observation function on branch-and bound node. + + This observation obtains scores for all LP or pseudo candidate variables at a + branch-and-bound node. + The strong branching score measures the quality of each variable for branching (higher is better). + This observation can be used as an expert for imitation learning algorithms. + + This observation function extracts an array containing the strong branching score for + each variable in the problem. + Variables are ordered according to their position in the original problem (``SCIPvarGetProbindex``), + hence they can be indexed by the :py:class:`~ecole.environment.Branching` environment ``action_set``. + Variables for which a strong branching score is not applicable are filled with ``NaN``. + )"); + strong_branching_scores.def(py::init(), py::arg("pseudo_candidates") = false, R"( + Constructor for StrongBranchingScores. + + Parameters + ---------- + pseudo_candidates : + The parameter determines if strong branching scores are computed for + pseudo candidate variables (when true) or LP candidate variables (when false). + )"); + def_before_reset(strong_branching_scores, R"(Do nothing.)"); + def_extract(strong_branching_scores, "Extract an array containing strong branching scores."); + + // Pseudocosts observation + auto pseudocosts = py::class_(m, "Pseudocosts", R"( + Pseudocosts observation function on branch-and-bound nodes. + + This observation obtains pseudocosts for all LP fractional candidate variables at a + branch-and-bound node. + The pseudocost is a cheap approximation to the strong branching + score and measures the quality of branching for each variable. + This observation can be used as a practical branching strategy by always branching on the + variable with the highest pseudocost, although in practice is it not as efficient as SCIP's + default strategy, reliability pseudocost branching (also known as hybrid branching). + + This observation function extracts an array containing the pseudocost for each variable in the problem. + Variables are ordered according to their position in the original problem (``SCIPvarGetProbindex``), + hence they can be indexed by the :py:class:`~ecole.environment.Branching` environment ``action_set``. + Variables for which a pseudocost is not applicable are filled with ``NaN``. + )"); + pseudocosts.def(py::init<>()); + def_before_reset(pseudocosts, R"(Do nothing.)"); + def_extract(pseudocosts, "Extract an array containing pseudocosts."); + + // Khalil observation + auto khalil2016_obs = ecole::python::auto_class(m, "Khalil2016Obs", R"( + Branching candidates features from Khalil et al. (2016). + + The observation is a matrix where rows represent all variables and columns represent features related + to these variables. + See [Khalil2016]_ for a complete reference on this observation function. + + .. [Khalil2016] + Khalil, Elias Boutros, Pierre Le Bodic, Le Song, George Nemhauser, and Bistra Dilkina. + "`Learning to branch in mixed integer programming. + `_" + *Thirtieth AAAI Conference on Artificial Intelligence*. 2016. + )"); + khalil2016_obs.def_auto_copy() + .def_auto_pickle("features") + .def_readwrite_xtensor("features", &Khalil2016Obs::features, R"rst( + A matrix where each row represents a variable, and each column a feature of the variable. + + Variables are ordered according to their position in the original problem (``SCIPvarGetProbindex``), + hence they can be indexed by the :py:class:`~ecole.environment.Branching` environment ``action_set``. + Variables for which the features are not applicable are filled with ``NaN``. + + The first :py:attr:`Khalil2016Obs.n_static_features` features columns are static (they do not + change through the solving process), and the remaining :py:attr:`Khalil2016Obs.n_dynamic_features` + are dynamic. + )rst") + .def_readonly_static("n_static_features", &Khalil2016Obs::n_static_features) + .def_readonly_static("n_dynamic_features", &Khalil2016Obs::n_dynamic_features); + + py::enum_(khalil2016_obs, "Features") + .value("obj_coef", Khalil2016Obs::Features::obj_coef) + .value("obj_coef_pos_part", Khalil2016Obs::Features::obj_coef_pos_part) + .value("obj_coef_neg_part", Khalil2016Obs::Features::obj_coef_neg_part) + .value("n_rows", Khalil2016Obs::Features::n_rows) + .value("rows_deg_mean", Khalil2016Obs::Features::rows_deg_mean) + .value("rows_deg_stddev", Khalil2016Obs::Features::rows_deg_stddev) + .value("rows_deg_min", Khalil2016Obs::Features::rows_deg_min) + .value("rows_deg_max", Khalil2016Obs::Features::rows_deg_max) + .value("rows_pos_coefs_count", Khalil2016Obs::Features::rows_pos_coefs_count) + .value("rows_pos_coefs_mean", Khalil2016Obs::Features::rows_pos_coefs_mean) + .value("rows_pos_coefs_stddev", Khalil2016Obs::Features::rows_pos_coefs_stddev) + .value("rows_pos_coefs_min", Khalil2016Obs::Features::rows_pos_coefs_min) + .value("rows_pos_coefs_max", Khalil2016Obs::Features::rows_pos_coefs_max) + .value("rows_neg_coefs_count", Khalil2016Obs::Features::rows_neg_coefs_count) + .value("rows_neg_coefs_mean", Khalil2016Obs::Features::rows_neg_coefs_mean) + .value("rows_neg_coefs_stddev", Khalil2016Obs::Features::rows_neg_coefs_stddev) + .value("rows_neg_coefs_min", Khalil2016Obs::Features::rows_neg_coefs_min) + .value("rows_neg_coefs_max", Khalil2016Obs::Features::rows_neg_coefs_max) + .value("slack", Khalil2016Obs::Features::slack) + .value("ceil_dist", Khalil2016Obs::Features::ceil_dist) + .value("pseudocost_up", Khalil2016Obs::Features::pseudocost_up) + .value("pseudocost_down", Khalil2016Obs::Features::pseudocost_down) + .value("pseudocost_ratio", Khalil2016Obs::Features::pseudocost_ratio) + .value("pseudocost_sum", Khalil2016Obs::Features::pseudocost_sum) + .value("pseudocost_product", Khalil2016Obs::Features::pseudocost_product) + .value("n_cutoff_up", Khalil2016Obs::Features::n_cutoff_up) + .value("n_cutoff_down", Khalil2016Obs::Features::n_cutoff_down) + .value("n_cutoff_up_ratio", Khalil2016Obs::Features::n_cutoff_up_ratio) + .value("n_cutoff_down_ratio", Khalil2016Obs::Features::n_cutoff_down_ratio) + .value("rows_dynamic_deg_mean", Khalil2016Obs::Features::rows_dynamic_deg_mean) + .value("rows_dynamic_deg_stddev", Khalil2016Obs::Features::rows_dynamic_deg_stddev) + .value("rows_dynamic_deg_min", Khalil2016Obs::Features::rows_dynamic_deg_min) + .value("rows_dynamic_deg_max", Khalil2016Obs::Features::rows_dynamic_deg_max) + .value("rows_dynamic_deg_mean_ratio", Khalil2016Obs::Features::rows_dynamic_deg_mean_ratio) + .value("rows_dynamic_deg_min_ratio", Khalil2016Obs::Features::rows_dynamic_deg_min_ratio) + .value("rows_dynamic_deg_max_ratio", Khalil2016Obs::Features::rows_dynamic_deg_max_ratio) + .value("coef_pos_rhs_ratio_min", Khalil2016Obs::Features::coef_pos_rhs_ratio_min) + .value("coef_pos_rhs_ratio_max", Khalil2016Obs::Features::coef_pos_rhs_ratio_max) + .value("coef_neg_rhs_ratio_min", Khalil2016Obs::Features::coef_neg_rhs_ratio_min) + .value("coef_neg_rhs_ratio_max", Khalil2016Obs::Features::coef_neg_rhs_ratio_max) + .value("pos_coef_pos_coef_ratio_min", Khalil2016Obs::Features::pos_coef_pos_coef_ratio_min) + .value("pos_coef_pos_coef_ratio_max", Khalil2016Obs::Features::pos_coef_pos_coef_ratio_max) + .value("pos_coef_neg_coef_ratio_min", Khalil2016Obs::Features::pos_coef_neg_coef_ratio_min) + .value("pos_coef_neg_coef_ratio_max", Khalil2016Obs::Features::pos_coef_neg_coef_ratio_max) + .value("neg_coef_pos_coef_ratio_min", Khalil2016Obs::Features::neg_coef_pos_coef_ratio_min) + .value("neg_coef_pos_coef_ratio_max", Khalil2016Obs::Features::neg_coef_pos_coef_ratio_max) + .value("neg_coef_neg_coef_ratio_min", Khalil2016Obs::Features::neg_coef_neg_coef_ratio_min) + .value("neg_coef_neg_coef_ratio_max", Khalil2016Obs::Features::neg_coef_neg_coef_ratio_max) + .value("active_coef_weight1_count", Khalil2016Obs::Features::active_coef_weight1_count) + .value("active_coef_weight1_sum", Khalil2016Obs::Features::active_coef_weight1_sum) + .value("active_coef_weight1_mean", Khalil2016Obs::Features::active_coef_weight1_mean) + .value("active_coef_weight1_stddev", Khalil2016Obs::Features::active_coef_weight1_stddev) + .value("active_coef_weight1_min", Khalil2016Obs::Features::active_coef_weight1_min) + .value("active_coef_weight1_max", Khalil2016Obs::Features::active_coef_weight1_max) + .value("active_coef_weight2_count", Khalil2016Obs::Features::active_coef_weight2_count) + .value("active_coef_weight2_sum", Khalil2016Obs::Features::active_coef_weight2_sum) + .value("active_coef_weight2_mean", Khalil2016Obs::Features::active_coef_weight2_mean) + .value("active_coef_weight2_stddev", Khalil2016Obs::Features::active_coef_weight2_stddev) + .value("active_coef_weight2_min", Khalil2016Obs::Features::active_coef_weight2_min) + .value("active_coef_weight2_max", Khalil2016Obs::Features::active_coef_weight2_max) + .value("active_coef_weight3_count", Khalil2016Obs::Features::active_coef_weight3_count) + .value("active_coef_weight3_sum", Khalil2016Obs::Features::active_coef_weight3_sum) + .value("active_coef_weight3_mean", Khalil2016Obs::Features::active_coef_weight3_mean) + .value("active_coef_weight3_stddev", Khalil2016Obs::Features::active_coef_weight3_stddev) + .value("active_coef_weight3_min", Khalil2016Obs::Features::active_coef_weight3_min) + .value("active_coef_weight3_max", Khalil2016Obs::Features::active_coef_weight3_max) + .value("active_coef_weight4_count", Khalil2016Obs::Features::active_coef_weight4_count) + .value("active_coef_weight4_sum", Khalil2016Obs::Features::active_coef_weight4_sum) + .value("active_coef_weight4_mean", Khalil2016Obs::Features::active_coef_weight4_mean) + .value("active_coef_weight4_stddev", Khalil2016Obs::Features::active_coef_weight4_stddev) + .value("active_coef_weight4_min", Khalil2016Obs::Features::active_coef_weight4_min) + .value("active_coef_weight4_max", Khalil2016Obs::Features::active_coef_weight4_max); + + auto khalil2016 = py::class_(m, "Khalil2016", R"( + Branching candidates features from Khalil et al. (2016). + + This observation function extract structured :py:class:`Khalil2016Obs`. + )"); + khalil2016.def(py::init(), py::arg("pseudo_candidates") = false, R"( + Create new observation. + + Parameters + ---------- + pseudo_candidates: + Whether the pseudo branching variable candidates (``SCIPgetPseudoBranchCands``) + or LP branching variable candidates (``SCIPgetPseudoBranchCands``) are observed. + )"); + def_before_reset(khalil2016, R"(Reset static features cache.)"); + def_extract(khalil2016, "Extract the observation matrix."); + + // Hutter2011 observation + auto hutter_obs = ecole::python::auto_class(m, "Hutter2011Obs", R"( + Instance features from Hutter et al. (2011). + + The observation is a vector of features that globally characterize the instance. + See [Hutter2011]_ for a complete reference on this observation function. + + .. [Hutter2011] + Hutter, Frank, Hoos, Holger H., and Leyton-Brown, Kevin. + "`Sequential model-based optimization for general algorithm configuration. + `_" + *International Conference on Learning and Intelligent Optimization*. 2011. + )"); + hutter_obs.def_auto_copy() + .def_auto_pickle("features") + .def_readwrite_xtensor("features", &Hutter2011Obs::features, "A vector of instance features."); + + py::enum_(hutter_obs, "Features") + .value("nb_variables", Hutter2011Obs::Features::nb_variables) + .value("nb_constraints", Hutter2011Obs::Features::nb_constraints) + .value("nb_nonzero_coefs", Hutter2011Obs::Features::nb_nonzero_coefs) + .value("variable_node_degree_mean", Hutter2011Obs::Features::variable_node_degree_mean) + .value("variable_node_degree_max", Hutter2011Obs::Features::variable_node_degree_max) + .value("variable_node_degree_min", Hutter2011Obs::Features::variable_node_degree_min) + .value("variable_node_degree_std", Hutter2011Obs::Features::variable_node_degree_std) + .value("constraint_node_degree_mean", Hutter2011Obs::Features::constraint_node_degree_mean) + .value("constraint_node_degree_max", Hutter2011Obs::Features::constraint_node_degree_max) + .value("constraint_node_degree_min", Hutter2011Obs::Features::constraint_node_degree_min) + .value("constraint_node_degree_std", Hutter2011Obs::Features::constraint_node_degree_std) + .value("node_degree_mean", Hutter2011Obs::Features::node_degree_mean) + .value("node_degree_max", Hutter2011Obs::Features::node_degree_max) + .value("node_degree_min", Hutter2011Obs::Features::node_degree_min) + .value("node_degree_std", Hutter2011Obs::Features::node_degree_std) + .value("node_degree_25q", Hutter2011Obs::Features::node_degree_25q) + .value("node_degree_75q", Hutter2011Obs::Features::node_degree_75q) + .value("edge_density", Hutter2011Obs::Features::edge_density) + .value("lp_slack_mean", Hutter2011Obs::Features::lp_slack_mean) + .value("lp_slack_max", Hutter2011Obs::Features::lp_slack_max) + .value("lp_slack_l2", Hutter2011Obs::Features::lp_slack_l2) + .value("lp_objective_value", Hutter2011Obs::Features::lp_objective_value) + .value("objective_coef_m_std", Hutter2011Obs::Features::objective_coef_m_std) + .value("objective_coef_n_std", Hutter2011Obs::Features::objective_coef_n_std) + .value("objective_coef_sqrtn_std", Hutter2011Obs::Features::objective_coef_sqrtn_std) + .value("constraint_coef_mean", Hutter2011Obs::Features::constraint_coef_mean) + .value("constraint_coef_std", Hutter2011Obs::Features::constraint_coef_std) + .value("constraint_var_coef_mean", Hutter2011Obs::Features::constraint_var_coef_mean) + .value("constraint_var_coef_std", Hutter2011Obs::Features::constraint_var_coef_std) + .value("discrete_vars_support_size_mean", Hutter2011Obs::Features::discrete_vars_support_size_mean) + .value("discrete_vars_support_size_std", Hutter2011Obs::Features::discrete_vars_support_size_std) + .value("ratio_unbounded_discrete_vars", Hutter2011Obs::Features::ratio_unbounded_discrete_vars) + .value("ratio_continuous_vars", Hutter2011Obs::Features::ratio_continuous_vars); + + auto hutter = py::class_(m, "Hutter2011", R"( + Instance features from Hutter et al. (2011). + + This observation function extracts a structured :py:class:`Hutter2011Obs`. + )"); + hutter.def(py::init<>()); + def_before_reset(hutter, R"(Do nothing.)"); + def_extract(hutter, "Extract the observation matrix."); +} + +} // namespace ecole::observation diff --git a/ecole/python/ecole/src/ecole/core/reward.cpp b/ecole/python/ecole/src/ecole/core/reward.cpp new file mode 100644 index 0000000..984c7b3 --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/reward.cpp @@ -0,0 +1,416 @@ +#include +#include + +#include +#include +#include + +#include "ecole/reward/bound-integral.hpp" +#include "ecole/reward/constant.hpp" +#include "ecole/reward/is-done.hpp" +#include "ecole/reward/lp-iterations.hpp" +#include "ecole/reward/n-nodes.hpp" +#include "ecole/reward/solving-time.hpp" +#include "ecole/scip/model.hpp" + +#include "core.hpp" + +namespace py = pybind11; + +namespace ecole::reward { + +/** + * Proxy class for doing arithmetic on reward functions. + * + * This could be a pure Python class, but it needs to be defined in this module to + * be accessible to reward functions operators. + */ +class Arithmetic { +public: + Arithmetic(py::object operation, py::list const& functions, py::str repr); + void before_reset(py::object const& model); + Reward extract(py::object const& model, bool done); + [[nodiscard]] py::str toString() const; + +private: + py::object operation; + py::list functions; + py::str repr; +}; + +class Cumulative { +public: + Cumulative(py::object function, py::object reduce_func, Reward init_cumul_, py::str repr); + void before_reset(py::object const& model); + Reward extract(py::object const& model, bool done); + [[nodiscard]] py::str toString() const; + +private: + py::object reduce_func; + py::object function; + Reward init_cumul; + Reward cumul; + py::str repr; +}; + +/** + * Helper function to bind common methods. + */ +template void def_before_reset(PyClass /*pyclass*/, Args&&... /*args*/); +template void def_extract(PyClass /*pyclass*/, Args&&... /*args*/); +template void def_operators(PyClass /*pyclass*/); + +/** + * Reward module bindings definitions. + */ +void bind_submodule(py::module_ const& m) { + m.doc() = "Reward classes for Ecole."; + + auto constant = py::class_(m, "Constant", R"( + Constant Reward. + + Always returns the value passed in constructor. + )"); + constant.def(py::init(), py::arg("constant") = 0.); + def_operators(constant); + def_before_reset(constant, "Do nothing."); + def_extract(constant, "Return the constant value."); + + auto arithmetic = py::class_(m, "Arithmetic", R"( + Proxy class for doing arithmetic on reward functions. + + An object of this class is returned by reward function operators to forward calls + to the reward function parameters of the operator. + )"); + arithmetic // + .def(py::init()) + .def("__repr__", &Arithmetic::toString); + def_operators(arithmetic); + def_before_reset(arithmetic, R"( + Reset the reward functions of the operator. + + Calls ``before_reset`` on all reward functions parameters that were used to create this + object. + )"); + def_extract(arithmetic, R"( + Obtain the reward of result of the operator. + + Calls ``extract`` on all reward function parameters that were used to create + this object and compute the operation on the results. + )"); + + auto cumulative = py::class_(m, "Cumulative", R"( + Proxy class for doing cumulating reward throughout an episode. + + An object of this class is returned by reward functions cumulative operations to forward call + to the reward function and apply a reduce function. + )"); + cumulative // + .def(py::init()) + .def("__repr__", &Cumulative::toString); + def_operators(cumulative); + def_before_reset(cumulative, "Reset the wrapped reward function and reset current cumulation."); + def_extract(cumulative, "Obtain the cumulative reward of result of wrapped function."); + + auto isdone = py::class_(m, "IsDone", "Single reward on terminal states."); + isdone.def(py::init<>()); + def_operators(isdone); + def_before_reset(isdone, "Do nothing."); + def_extract(isdone, "Return 1 if the episode is on a terminal state, 0 otherwise."); + + auto lpiterations = py::class_(m, "LpIterations", R"( + LP iterations difference. + + The reward is defined as the number of iterations spent in solving the Linear Programs + associated with the problem since the previous state. + )"); + lpiterations.def(py::init<>()); + def_operators(lpiterations); + def_before_reset(lpiterations, "Reset the internal LP iterations count."); + def_extract(lpiterations, R"( + Update the internal LP iteration count and return the difference. + + The difference in LP iterations is computed in between calls. + )"); + + auto nnodes = py::class_(m, "NNodes", R"( + Number of nodes difference. + + The reward is defined as the total number of nodes processed since the previous state. + )"); + nnodes.def(py::init<>()); + def_operators(nnodes); + def_before_reset(nnodes, "Reset the internal node count."); + def_extract(nnodes, R"( + Update the internal node count and return the difference. + + The difference in number of nodes is computed in between calls. + )"); + + auto solvingtime = py::class_(m, "SolvingTime", R"( + Solving time difference. + + The reward is defined as the number of seconds spent solving the instance since the previous state. + The solving time is specific to the operating system: it includes time spent in + :py:meth:`~ecole.environment.Environment.reset` and time spent waiting on the agent. + )"); + solvingtime.def(py::init(), py::arg("wall") = false, R"( + Create a SolvingTime reward function. + + Parameters + ---------- + wall : + If true, the wall time will be used. If False (default), the process time will be used. + + )"); + def_operators(solvingtime); + def_before_reset(solvingtime, "Reset the internal clock counter."); + def_extract(solvingtime, R"( + Update the internal clock counter and return the difference. + + The difference in solving time is computed in between calls. + )"); + + auto dualintegral = py::class_(m, "DualIntegral", R"( + Dual integral difference. + + The reward is defined as the dual integral since the previous state, where the integral is + computed with respect to the solving time. The solving time is specific to the operating system: + it includes time spent in :py:meth:`~ecole.environment.Environment.reset` and time spent waiting on the agent. + )"); + dualintegral.def( + py::init(), + py::arg("wall") = false, + py::arg("bound_function") = DualIntegral::BoundFunction{}, + + R"( + Create a DualIntegral reward function. + + Parameters + ---------- + wall : + If true, the wall time will be used. If False (default), the process time will be used. + bound_function : + A function which takes an ecole model and returns a tuple of an initial dual bound and the offset + to compute the dual bound with respect to. Values should be ordered as (offset, initial_dual_bound). + The default function returns (0, 1e20) if the problem is a maximization and (0, -1e20) otherwise. + + )"); + def_operators(dualintegral); + def_before_reset(dualintegral, "Reset the internal clock counter and the event handler."); + def_extract(dualintegral, R"( + Computes the current dual integral and returns the difference. + + The difference is computed based on the dual integral between sequential calls. + )"); + + auto primalintegral = py::class_(m, "PrimalIntegral", R"( + Primal integral difference. + + The reward is defined as the primal integral since the previous state, where the integral is + computed with respect to the solving time. The solving time is specific to the operating system: + it includes time spent in :py:meth:`~ecole.environment.Environment.reset` and time spent waiting on the agent. + )"); + primalintegral.def( + py::init(), + py::arg("wall") = false, + py::arg("bound_function") = PrimalIntegral::BoundFunction{}, + R"( + Create a PrimalIntegral reward function. + + Parameters + ---------- + wall : + If true, the wall time will be used. If False (default), the process time will be used. + bound_function : + A function which takes an ecole model and returns a tuple of an initial primal bound and the offset + to compute the primal bound with respect to. Values should be ordered as (offset, initial_primal_bound). + The default function returns (0, -1e20) if the problem is a maximization and (0, 1e20) otherwise. + )"); + def_operators(primalintegral); + def_before_reset(primalintegral, "Reset the internal clock counter and the event handler."); + def_extract(primalintegral, R"( + Computes the current primal integral and returns the difference. + + The difference is computed based on the dual integral between sequential calls. + )"); + + auto primaldualintegral = py::class_(m, "PrimalDualIntegral", R"( + Primal-dual integral difference. + + The reward is defined as the primal-dual integral since the previous state, where the integral is + computed with respect to the solving time. The solving time is specific to the operating system: + it includes time spent in :py:meth:`~ecole.environment.Environment.reset` and time spent waiting on the agent. + )"); + primaldualintegral.def( + py::init(), + py::arg("wall") = false, + py::arg("bound_function") = PrimalDualIntegral::BoundFunction{}, + R"( + Create a PrimalDualIntegral reward function. + + Parameters + ---------- + wall : + If true, the wall time will be used. If False (default), the process time will be used. + bound_function : + A function which takes an ecole model and returns a tuple of an initial primal bound and dual bound. + Values should be ordered as (initial_primal_bound, initial_dual_bound). The default function returns + (-1e20, 1e20) if the problem is a maximization and (1e20, -1e20) otherwise. + )"); + def_operators(primaldualintegral); + def_before_reset(primaldualintegral, "Reset the internal clock counter and the event handler."); + def_extract(primaldualintegral, R"( + Computes the current primal-dual integral and returns the difference. + + The difference is computed based on the primal-dual integral between sequential calls. + )"); +} + +/****************************** + * Definition of Arithmetic * + ******************************/ + +Arithmetic::Arithmetic(py::object operation_, py::list const& functions_, py::str repr_) : + operation(std::move(operation_)), repr(std::move(repr_)) { + auto const Numbers = py::module_::import("numbers").attr("Number"); + for (auto func : functions_) { + if (py::isinstance(func, Numbers)) { + functions.append(py::cast(Constant{func.cast()})); + } else { + functions.append(func); + } + } +} + +void Arithmetic::before_reset(py::object const& model) { + for (auto obs_func : functions) { + obs_func.attr("before_reset")(model); + } +} + +Reward Arithmetic::extract(py::object const& model, bool done) { + py::list rewards{}; + for (auto obs_func : functions) { + rewards.append(obs_func.attr("extract")(model, done)); + } + return operation(*rewards).cast(); +} + +py::str Arithmetic::toString() const { + return repr.format(*functions); +} + +/****************************** + * Definition of Cumulative * + ******************************/ + +Cumulative::Cumulative(py::object function_, py::object reduce_func_, Reward init_cumul_, py::str repr_) : + reduce_func(std::move(reduce_func_)), + function(std::move(function_)), + init_cumul(init_cumul_), + cumul(init_cumul_), + repr(std::move(repr_)) {} + +void Cumulative::before_reset(py::object const& model) { + cumul = init_cumul; + function.attr("before_reset")(model); +} + +Reward Cumulative::extract(py::object const& model, bool done) { + auto reward = function.attr("extract")(model, done); + cumul = reduce_func(py::cast(cumul), reward).cast(); + return cumul; +} + +py::str Cumulative::toString() const { + return repr.format(function); +} + +/************************************ + * Definition of helper functions * + ************************************/ + +template void def_before_reset(PyClass pyclass, Args&&... args) { + pyclass.def("before_reset", &PyClass::type::before_reset, py::arg("model"), std::forward(args)...); +} + +template void def_extract(PyClass pyclass, Args&&... args) { + pyclass.def( + "extract", &PyClass::type::extract, py::arg("model"), py::arg("done") = false, std::forward(args)...); +} + +template void def_operators(PyClass pyclass) { + // Import Python standrad modules + auto const builtins = py::module_::import("builtins"); + auto const math = py::module_::import("math"); + + // Return a function that wraps rewards functions inside an ArithmeticReward. + // The Arithmetic reward function is a reward function class that will call the wrapped + // reward functions and merge there rewards with the relevant operation (sum, prod, ...) + auto const arith_meth = [](auto operation, auto repr) { + return [operation, repr](py::args const& args) { return Arithmetic{operation, args, repr}; }; + }; + + pyclass + // Binary operators + .def("__add__", arith_meth(py::eval("lambda x, y: x + y"), "({} + {})")) + .def("__sub__", arith_meth(py::eval("lambda x, y: x - y"), "({} - {})")) + .def("__mul__", arith_meth(py::eval("lambda x, y: x * y"), "({} * {})")) + .def("__matmul__", arith_meth(py::eval("lambda x, y: x @ y"), "({} @ {})")) + .def("__truediv__", arith_meth(py::eval("lambda x, y: x / y"), "({} / {})")) + .def("__floordiv__", arith_meth(py::eval("lambda x, y: x // y"), "({} // {})")) + .def("__mod__", arith_meth(py::eval("lambda x, y: x % y"), "({} % {})")) + .def("__divmod__", arith_meth(builtins.attr("divmod"), "divmod({}, {})")) + .def("__pow__", arith_meth(builtins.attr("pow"), "({} ** {})")) + .def("__lshift__", arith_meth(py::eval("lambda x, y: x << y"), "({} << {})")) + .def("__rshift__", arith_meth(py::eval("lambda x, y: x >> y"), "({} >> {})")) + .def("__and__", arith_meth(py::eval("lambda x, y: x & y"), "({} & {})")) + .def("__xor__", arith_meth(py::eval("lambda x, y: x ^ y"), "({} ^ {})")) + .def("__or__", arith_meth(py::eval("lambda x, y: x | y"), "({} | {})")) + // Reversed binary operators + .def("__radd__", arith_meth(py::eval("lambda x, y: y + x"), "({1} + {0})")) + .def("__rsub__", arith_meth(py::eval("lambda x, y: y - x"), "({1} - {0})")) + .def("__rmul__", arith_meth(py::eval("lambda x, y: y * x"), "({1} * {0})")) + .def("__rmatmul__", arith_meth(py::eval("lambda x, y: y @ x"), "({1} @ {0})")) + .def("__rtruediv__", arith_meth(py::eval("lambda x, y: y / x"), "({1} / {0})")) + .def("__rfloordiv__", arith_meth(py::eval("lambda x, y: y // x"), "({1} // {0})")) + .def("__rmod__", arith_meth(py::eval("lambda x, y: y % x"), "({1} % {0})")) + .def("__rdivmod__", arith_meth(py::eval("lambda x, y: divmod(y, x)"), "divmod({1}, {0})")) + .def("__rpow__", arith_meth(py::eval("lambda x, y: y ** x"), "({1} ** {0})")) + .def("__rlshift__", arith_meth(py::eval("lambda x, y: y << x"), "({1} << {0})")) + .def("__rrshift__", arith_meth(py::eval("lambda x, y: y >> x"), "({1} >> {0})")) + .def("__rand__", arith_meth(py::eval("lambda x, y: y & x"), "({1} & {0})")) + .def("__rxor__", arith_meth(py::eval("lambda x, y: y ^ x"), "({1} ^ {0})")) + .def("__ror__", arith_meth(py::eval("lambda x, y: y | x"), "({1} | {0})")) + // Unary operator + .def("__neg__", arith_meth(py::eval("lambda x: -x"), "(-{})")) + .def("__pos__", arith_meth(py::eval("lambda x: +x"), "(+{})")) + .def("__abs__", arith_meth(builtins.attr("abs"), "(abs({}))")) + .def("__invert__", arith_meth(py::eval("lambda x: ~x"), "(~{})")) + .def("__int__", arith_meth(builtins.attr("int"), "int({})")) + .def("__float__", arith_meth(builtins.attr("float"), "float({})")) + .def("__complex__", arith_meth(builtins.attr("complex"), "complex({})")) + .def("__round__", arith_meth(builtins.attr("round"), "round({})")) + .def("__trunc__", arith_meth(math.attr("trunc"), "math.trunc({})")) + .def("__floor__", arith_meth(math.attr("floor"), "math.floor({})")) + .def("__ceil__", arith_meth(math.attr("ceil"), "math.ceil({})")); + // Custom Math methods + // clang-format off + for (const auto *const name : { + "exp", "log", "log2", "log10", "sqrt", "sin", "cos", "tan", "asin", "acos", "atan", + "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", "isfinite", "isinf", "isnan" + }) { + pyclass.def(name, arith_meth(math.attr(name), std::string{"{}."} + name + "()")); + } + // clang-format on + pyclass.def("apply", [](py::object const& self, py::object func) { + return Arithmetic{std::move(func), py::make_tuple(self), "lambda({})"}; + }); + // Cumulative methods + pyclass.def("cumsum", [](py::object self) { + return Cumulative{std::move(self), py::eval("lambda x, y: x + y"), 0., "{}.cumsum()"}; + }); +} + +} // namespace ecole::reward diff --git a/ecole/python/ecole/src/ecole/core/scip.cpp b/ecole/python/ecole/src/ecole/core/scip.cpp new file mode 100644 index 0000000..675cd19 --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/scip.cpp @@ -0,0 +1,201 @@ +#include +#include +#include + +#include +#include +#include + +#include "ecole/python/auto-class.hpp" +#include "ecole/scip/callback.hpp" +#include "ecole/scip/model.hpp" +#include "ecole/scip/scimpl.hpp" + +#include "core.hpp" + +namespace ecole::scip { + +namespace py = pybind11; + +namespace callback { + +void bind_submodule(py::module_ m) { + m.doc() = "Callback utilities for iterative solving."; + + py::enum_{m, "Result"} + .value("DidNotRun", SCIP_DIDNOTRUN) + .value("Delayed", SCIP_DELAYED) + .value("DidNotFind", SCIP_DIDNOTFIND) + .value("Feasible", SCIP_FEASIBLE) + .value("Infeasible", SCIP_INFEASIBLE) + .value("Unbounded", SCIP_UNBOUNDED) + .value("CutOff", SCIP_CUTOFF) + .value("Separated", SCIP_SEPARATED) + .value("NewRound", SCIP_NEWROUND) + .value("ReducedDOM", SCIP_REDUCEDDOM) + .value("ConsAdded", SCIP_CONSADDED) + .value("ConsChanged", SCIP_CONSCHANGED) + .value("Branched", SCIP_BRANCHED) + .value("SolveLP", SCIP_SOLVELP) + .value("FoundSol", SCIP_FOUNDSOL) + .value("Suspended", SCIP_SUSPENDED) + .value("Success", SCIP_SUCCESS) + .value("DelayNode", SCIP_DELAYNODE); + + py::enum_{m, "Type"} + .value("Branchrule", Type::Branchrule) // + .value("Heuristic", Type::Heuristic); + + m.def("name", name, "Return the name used by the reverse callback."); + + m.attr("priority_max") = priority_max; + m.attr("max_depth_none") = max_depth_none; + m.attr("max_bound_distance_none") = max_bound_distance_none; + m.attr("frequency_always") = frequency_always; + m.attr("frequency_offset_none") = frequency_offset_none; + + python::auto_data_class(m, "BranchruleConstructor") + .def_auto_members( + python::Member{"priority", &BranchruleConstructor::priority}, + python::Member{"max_depth", &BranchruleConstructor::max_depth}, + python::Member{"max_bound_distance", &BranchruleConstructor::max_bound_distance}); + + python::auto_data_class(m, "HeuristicConstructor") + .def_auto_members( + python::Member{"priority", &HeuristicConstructor::priority}, + python::Member{"frequency", &HeuristicConstructor::frequency}, + python::Member{"frequency_offset", &HeuristicConstructor::frequency_offset}, + python::Member{"max_depth", &HeuristicConstructor::max_depth}, + python::Member{"timing_mask", &HeuristicConstructor::timing_mask}); + + auto branchrule_call = python::auto_data_class(m, "BranchruleCall"); + py::enum_(branchrule_call, "Where") + .value("LP", BranchruleCall::Where::LP) + .value("External", BranchruleCall::Where::External) + .value("Pseudo", BranchruleCall::Where::Pseudo); + branchrule_call.def_auto_members( + python::Member{"allow_add_constraints", &BranchruleCall::allow_add_constraints}, + python::Member{"where", &BranchruleCall::where}); + + python::auto_data_class(m, "HeuristicCall") + .def_auto_members( + python::Member{"heuristic_timing", &HeuristicCall::heuristic_timing}, + python::Member{"node_infeasible", &HeuristicCall::node_infeasible}); +} + +} // namespace callback + +void bind_submodule(py::module_ m) { + m.doc() = "Scip wrappers for ecole."; + + py::register_exception(m, "ScipError"); + + py::enum_{m, "Stage"} + .value("Init", SCIP_STAGE_INIT) + .value("Problem", SCIP_STAGE_PROBLEM) + .value("Transforming", SCIP_STAGE_TRANSFORMING) + .value("Transformed", SCIP_STAGE_TRANSFORMED) + .value("InitPresolve", SCIP_STAGE_INITPRESOLVE) + .value("Presolving", SCIP_STAGE_PRESOLVING) + .value("ExitPresolve", SCIP_STAGE_EXITPRESOLVE) + .value("Presolved", SCIP_STAGE_PRESOLVED) + .value("InitSolve", SCIP_STAGE_INITSOLVE) + .value("Solving", SCIP_STAGE_SOLVING) + .value("Solved", SCIP_STAGE_SOLVED) + .value("ExitSolve", SCIP_STAGE_EXITSOLVE) + .value("FreeTrans", SCIP_STAGE_FREETRANS) + .value("Free", SCIP_STAGE_FREE); + + // SCIP_HEURTIMING is simply a collection of Macros! We create a scope for holding the values. + struct HeurTiming {}; + py::class_{m, "HeurTiming"} + .def_property_readonly_static("DuringLpLoop", [](py::handle /*cls*/) { return SCIP_HEURTIMING_DURINGLPLOOP; }) + .def_property_readonly_static("AfterLpLoop", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERLPLOOP; }) + .def_property_readonly_static("AfterLpNode", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERLPNODE; }) + .def_property_readonly_static("AfterPseudoNode", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERPSEUDONODE; }) + .def_property_readonly_static("AfterLpPlunge", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERLPPLUNGE; }) + .def_property_readonly_static( + "AfterPseudoPlunge", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERPSEUDOPLUNGE; }) + .def_property_readonly_static( + "DuringPricingLoop", [](py::handle /*cls*/) { return SCIP_HEURTIMING_DURINGPRICINGLOOP; }) + .def_property_readonly_static("BeforePresol", [](py::handle /*cls*/) { return SCIP_HEURTIMING_BEFOREPRESOL; }) + .def_property_readonly_static( + "DuringPresolLoop", [](py::handle /*cls*/) { return SCIP_HEURTIMING_DURINGPRESOLLOOP; }) + .def_property_readonly_static("AfterPropLoop", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERPROPLOOP; }) + .def_property_readonly_static("AfterNode", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERNODE; }) + .def_property_readonly_static("AfterPlunge", [](py::handle /*cls*/) { return SCIP_HEURTIMING_AFTERPLUNGE; }); + + callback::bind_submodule(m.def_submodule("callback")); + + py::class_(m, "Model") // + .def_static("from_file", &Model::from_file, py::arg("filepath"), py::call_guard()) + .def_static("prob_basic", &Model::prob_basic, py::arg("name") = "Model") + .def_static( + "from_pyscipopt", + [](py::object const& pyscipopt_model) { + if (pyscipopt_model.attr("_freescip").cast()) { + py::capsule cap = pyscipopt_model.attr("to_ptr")(py::arg("give_ownership") = true); + std::unique_ptr uptr = nullptr; + uptr.reset(reinterpret_cast(py::cast(cap))); + return Model{std::make_unique(std::move(uptr))}; + } + throw scip::ScipError{"Cannot create an Ecole Model from a non-owning PyScipOpt pointer."}; + }, + // Keep the scip::Model (owner of the pointer) at least until the PyScipOpt model + // is alive, as PyScipOpt is now sharing a non-owning pointer. + py::keep_alive<1, 0>(), + py::arg("model")) + + .def(py::self == py::self) // NOLINT(misc-redundant-expression) pybind specific syntax + .def(py::self != py::self) // NOLINT(misc-redundant-expression) pybind specific syntax + + .def("copy_orig", &Model::copy_orig, py::call_guard()) + .def( + "as_pyscipopt", + [](scip::Model& model) { + auto const Model_class = py::module_::import("pyscipopt.scip").attr("Model"); + auto const cap = py::capsule{reinterpret_cast(model.get_scip_ptr()), "scip"}; + return Model_class.attr("from_ptr")(cap, py::arg("take_ownership") = false); + }, + // Keep the scip::Model (owner of the pointer) at least until the PyScipOpt model + // is alive, as PyScipOpt is a view on the ecole Model. + py::keep_alive<0, 1>()) + + .def("set_messagehdlr_quiet", &Model::set_messagehdlr_quiet, py::arg("quiet")) + + .def_property("name", &Model::name, &Model::set_name) + .def_property_readonly("stage", &Model::stage) + + .def("get_param", &Model::get_param, py::arg("name")) + .def("set_param", &Model::set_param, py::arg("name"), py::arg("value")) + .def("get_params", &Model::get_params) + .def("set_params", &Model::set_params, py::arg("name_values")) + .def("disable_cuts", &Model::disable_cuts) + .def("disable_presolve", &Model::disable_presolve) + .def("write_problem", &Model::write_problem, py::arg("filepath"), py::call_guard()) + + .def("transform_prob", &Model::transform_prob, py::call_guard()) + .def("presolve", &Model::presolve, py::call_guard()) + .def("solve", &Model::solve, py::call_guard()) + + .def_property_readonly("is_solved", &Model::is_solved) + .def_property_readonly("primal_bound", &Model::primal_bound) + .def_property_readonly("dual_bound", &Model::dual_bound) + + .def( + "solve_iter", + [](Model& self, py::args const& py_args) { + // Create C++ vector needed to call the function + auto args = std::vector{}; + args.reserve(py::len(py_args)); + // Copy the function arguments into the vector + std::transform(py_args.begin(), py_args.end(), std::back_inserter(args), [](py::handle py_arg) { + return py_arg.cast(); + }); + // Call the function + return self.solve_iter(args); + }) + .def("solve_iter_continue", &Model::solve_iter_continue); +} + +} // namespace ecole::scip diff --git a/ecole/python/ecole/src/ecole/core/version.cpp b/ecole/python/ecole/src/ecole/core/version.cpp new file mode 100644 index 0000000..60829d5 --- /dev/null +++ b/ecole/python/ecole/src/ecole/core/version.cpp @@ -0,0 +1,30 @@ +#include +#include + +#include "ecole/version.hpp" + +namespace ecole::version { + +namespace py = pybind11; + +void bind_submodule(py::module_ m) { + m.doc() = "Ecole version utilities."; + + py::class_(m, "VersionInfo") // + .def_readwrite("major", &VersionInfo::major) + .def_readwrite("minor", &VersionInfo::minor) + .def_readwrite("patch", &VersionInfo::patch) + .def_readwrite("revision", &VersionInfo::revision) + .def_readwrite("build_type", &VersionInfo::build_type) + .def_readwrite("build_os", &VersionInfo::build_os) + .def_readwrite("build_time", &VersionInfo::build_time) + .def_readwrite("build_compiler", &VersionInfo::build_compiler); + + m.def("get_ecole_lib_version", &get_ecole_lib_version); + m.def("get_ecole_lib_path", &get_ecole_lib_path); + m.def("get_scip_buildtime_version", &get_scip_buildtime_version); + m.def("get_scip_lib_version", &get_scip_lib_version); + m.def("get_scip_lib_path", &get_scip_lib_path); +} + +} // namespace ecole::version diff --git a/ecole/python/ecole/src/ecole/data.py b/ecole/python/ecole/src/ecole/data.py new file mode 100644 index 0000000..21e069e --- /dev/null +++ b/ecole/python/ecole/src/ecole/data.py @@ -0,0 +1,39 @@ +import numbers + +import ecole +from ecole.core.data import * + + +def parse(something, default): + """Recursively parse data function aggregates into their corresponding functions. + + For instance, vector of function are transformed into functions of vector, and similarily for + maps, tuple, constants, etc. + + Parameters + ---------- + something: + Object to parse. + default: + Objet to return for when something is identified as asking for the environment specific default. + + Return + ------ + data_func: + A data extraction function to be used as an information, observation, or sometimes reward function. + + """ + if something == ecole.Default: + if default is None: + raise ValueError("""Cannot parse "default" without a default value.""") + return parse(default, None) + elif something is None: + return NoneFunction() + elif isinstance(something, numbers.Number): + return ConstantFunction(something) + elif isinstance(something, (tuple, list)): + return VectorFunction(*(parse(s, default) for s in something)) + elif isinstance(something, dict): + return MapFunction(**{name: parse(s, default) for name, s in something.items()}) + else: + return something diff --git a/ecole/python/ecole/src/ecole/doctor.py b/ecole/python/ecole/src/ecole/doctor.py new file mode 100644 index 0000000..f14981e --- /dev/null +++ b/ecole/python/ecole/src/ecole/doctor.py @@ -0,0 +1,28 @@ +import ecole.version + + +if __name__ == "__main__": + ecole_lib_version_info = ecole.version.get_ecole_lib_version() + scip_lib_version_info = ecole.version.get_scip_lib_version() + scip_buildtime_version_info = ecole.version.get_scip_buildtime_version() + print( + ( + "Ecole version info:\n" + " Version : {v.major}.{v.minor}.{v.patch}\n" + " Git Revision : {v.revision}\n" + " Build Type : {v.build_type}\n" + " Build OS : {v.build_os}\n" + " Build Time : {v.build_time}\n" + " Build Compiler: {v.build_compiler}\n" + " Path: : {path}" + ).format(v=ecole_lib_version_info, path=ecole.version.get_ecole_lib_path()) + ) + print( + ( + "Built against SCIP:\n" + " Version : {v.major}.{v.minor}.{v.patch}\n" + "Running SCIP:\n" + " Version : {v.major}.{v.minor}.{v.patch}\n" + " Path: : {path}" + ).format(v=scip_lib_version_info, path=ecole.version.get_scip_lib_path()) + ) diff --git a/ecole/python/ecole/src/ecole/dynamics.py b/ecole/python/ecole/src/ecole/dynamics.py new file mode 100644 index 0000000..221b7d0 --- /dev/null +++ b/ecole/python/ecole/src/ecole/dynamics.py @@ -0,0 +1 @@ +from ecole.core.dynamics import * diff --git a/ecole/python/ecole/src/ecole/environment.py b/ecole/python/ecole/src/ecole/environment.py new file mode 100644 index 0000000..1d94f00 --- /dev/null +++ b/ecole/python/ecole/src/ecole/environment.py @@ -0,0 +1,222 @@ +"""Ecole collection of environments.""" + +import ecole + + +class Environment: + """Ecole Partially Observable Markov Decision Process (POMDP). + + Similar to OpenAI Gym, environments represent the task that an agent is supposed to solve. + For maximum customizability, different components are composed/orchestrated in this class. + """ + + __Dynamics__ = None + __DefaultObservationFunction__ = ecole.observation.Nothing + __DefaultRewardFunction__ = ecole.reward.IsDone + __DefaultInformationFunction__ = ecole.information.Nothing + + def __init__( + self, + observation_function=ecole.Default, + reward_function=ecole.Default, + information_function=ecole.Default, + scip_params=None, + **dynamics_kwargs + ) -> None: + """Create a new environment object. + + Parameters + ---------- + observation_function: + An object of type :py:class:`~ecole.observation.ObservationFunction` used to customize the + observation returned by :meth:`reset` and :meth:`step`. + reward_function: + An object of type :py:class:`~ecole.reward.RewardFunction` used to customize the reward + returned by :meth:`reset` and :meth:`step`. + information_function: + An object of type :py:class:`~ecole.information.InformationFunction` used to customize the + additional information returned by :meth:`reset` and :meth:`step`. + scip_params: + Parameters set on the underlying :py:class:`~ecole.scip.Model` at the start of every episode. + **dynamics_kwargs: + Other arguments are passed to the constructor of the :py:class:`~ecole.typing.Dynamics`. + + """ + + self.reward_function = ecole.data.parse(reward_function, self.__DefaultRewardFunction__()) + self.observation_function = ecole.data.parse( + observation_function, self.__DefaultObservationFunction__() + ) + self.information_function = ecole.data.parse( + information_function, self.__DefaultInformationFunction__() + ) + self.scip_params = scip_params if scip_params is not None else {} + self.model = None + self.dynamics = self.__Dynamics__(**dynamics_kwargs) + self.can_transition = False + self.rng = ecole.spawn_random_generator() + + def reset(self, instance, *dynamics_args, **dynamics_kwargs): + """Start a new episode. + + This method brings the environment to a new initial state, *i.e.* starts a new + episode. + The method can be called at any point in time. + + Parameters + ---------- + instance: + The combinatorial optimization problem to tackle during the newly started + episode. + Either a file path to an instance that can be read by SCIP, or a `Model` whose problem + definition data will be copied. + dynamics_args: + Extra arguments are forwarded as is to the underlying :py:class:`~ecole.typing.Dynamics`. + dynamics_kwargs: + Extra arguments are forwarded as is to the underlying :py:class:`~ecole.typing.Dynamics`. + + Returns + ------- + observation: + The observation extracted from the initial state. + Typically used to take the next action. + action_set: + An optional subset that defines which actions are accepted in the next transition. + For some environment, the action set may change at every transition. + reward_offset: + An offset on the total cumulated reward, a.k.a. the initial reward. + This reward does not impact learning (as no action has yet been taken) but can nonetheless + be used for evaluation purposes. For example, in the total cumulated reward of an episode + one may want to account for computations that happened during :py:meth:`reset` + (*e.g.* computation time, number of LP iteration in presolving...). + done: + A boolean flag indicating whether the current state is terminal. + If this flag is true, then the current episode is finished, and :meth:`step` + cannot be called any more. + info: + A collection of environment specific information about the transition. + This is not necessary for the control problem, but is useful to gain + insights about the environment. + + """ + self.can_transition = True + try: + if isinstance(instance, ecole.core.scip.Model): + self.model = instance.copy_orig() + else: + self.model = ecole.core.scip.Model.from_file(instance) + self.model.set_params(self.scip_params) + + self.dynamics.set_dynamics_random_state(self.model, self.rng) + + # Reset data extraction functions + self.reward_function.before_reset(self.model) + self.observation_function.before_reset(self.model) + self.information_function.before_reset(self.model) + + # Place the environment in its initial state + done, action_set = self.dynamics.reset_dynamics( + self.model, *dynamics_args, **dynamics_kwargs + ) + self.can_transition = not done + + # Extract additional information to be returned by reset + reward_offset = self.reward_function.extract(self.model, done) + if not done: + observation = self.observation_function.extract(self.model, done) + else: + observation = None + information = self.information_function.extract(self.model, done) + + return observation, action_set, reward_offset, done, information + except Exception as e: + self.can_transition = False + raise e + + def step(self, action, *dynamics_args, **dynamics_kwargs): + """Transition from one state to another. + + This method takes a user action to transition from the current state to the + next. + The method **cannot** be called if the environment has not been reset since its + instantiation or since a terminal state has been reached. + + Parameters + ---------- + action: + The action to take in as part of the Markov Decision Process. + If an action set has been given in the latest call (inluding calls to + :meth:`reset`), then the action **must** comply with the action set. + dynamics_args: + Extra arguments are forwarded as is to the underlying :py:class:`~ecole.typing.Dynamics`. + dynamics_kwargs: + Extra arguments are forwarded as is to the underlying :py:class:`~ecole.typing.Dynamics`. + + Returns + ------- + observation: + The observation extracted from the initial state. + Typically used to take the next action. + action_set: + An optional subset that defines which actions are accepted in the next transition. + For some environment, the action set may change at every transition. + reward: + A real number to use for reinforcement learning. + done: + A boolean flag indicating whether the current state is terminal. + If this flag is true, then the current episode is finished, and :meth:`step` + cannot be called any more. + info: + A collection of environment specific information about the transition. + This is not necessary for the control problem, but is useful to gain + insights about the environment. + + """ + if not self.can_transition: + raise ecole.MarkovError("Environment need to be reset.") + + try: + # Transition the environment to the next state + done, action_set = self.dynamics.step_dynamics( + self.model, action, *dynamics_args, **dynamics_kwargs + ) + self.can_transition = not done + + # Extract additional information to be returned by step + reward = self.reward_function.extract(self.model, done) + if not done: + observation = self.observation_function.extract(self.model, done) + else: + observation = None + information = self.information_function.extract(self.model, done) + + return observation, action_set, reward, done, information + except Exception as e: + self.can_transition = False + raise e + + def seed(self, value: int) -> None: + """Set the random seed of the environment. + + The random seed is used to seed the environment :py:class:`~ecole.RandomGenerator`. + At every call to :py:meth:`reset`, the random generator is used to create new seeds + for the solver. + Setting the seed once will ensure determinism for the next trajectories. + By default, the random generator is initialized by the + `random `_ module. + """ + self.rng.seed(value) + + +class Branching(Environment): + __Dynamics__ = ecole.dynamics.BranchingDynamics + __DefaultObservationFunction__ = ecole.observation.NodeBipartite + + +class Configuring(Environment): + __Dynamics__ = ecole.dynamics.ConfiguringDynamics + + +class PrimalSearch(Environment): + __Dynamics__ = ecole.dynamics.PrimalSearchDynamics + __DefaultObservationFunction__ = ecole.observation.NodeBipartite diff --git a/ecole/python/ecole/src/ecole/information.py b/ecole/python/ecole/src/ecole/information.py new file mode 100644 index 0000000..3493b27 --- /dev/null +++ b/ecole/python/ecole/src/ecole/information.py @@ -0,0 +1 @@ +from ecole.core.information import * diff --git a/ecole/python/ecole/src/ecole/instance.py b/ecole/python/ecole/src/ecole/instance.py new file mode 100644 index 0000000..7f24728 --- /dev/null +++ b/ecole/python/ecole/src/ecole/instance.py @@ -0,0 +1 @@ +from ecole.core.instance import * diff --git a/ecole/python/ecole/src/ecole/observation.py b/ecole/python/ecole/src/ecole/observation.py new file mode 100644 index 0000000..8eee3b3 --- /dev/null +++ b/ecole/python/ecole/src/ecole/observation.py @@ -0,0 +1 @@ +from ecole.core.observation import * diff --git a/ecole/python/ecole/src/ecole/py.typed b/ecole/python/ecole/src/ecole/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ecole/python/ecole/src/ecole/reward.py b/ecole/python/ecole/src/ecole/reward.py new file mode 100644 index 0000000..d8dc176 --- /dev/null +++ b/ecole/python/ecole/src/ecole/reward.py @@ -0,0 +1 @@ +from ecole.core.reward import * diff --git a/ecole/python/ecole/src/ecole/scip.py b/ecole/python/ecole/src/ecole/scip.py new file mode 100644 index 0000000..91bf528 --- /dev/null +++ b/ecole/python/ecole/src/ecole/scip.py @@ -0,0 +1 @@ +from ecole.core.scip import * diff --git a/ecole/python/ecole/src/ecole/typing.py b/ecole/python/ecole/src/ecole/typing.py new file mode 100644 index 0000000..0afe636 --- /dev/null +++ b/ecole/python/ecole/src/ecole/typing.py @@ -0,0 +1,301 @@ +"""Typing information for Ecole. + +Ecole flexibility relies on +`structural subtyping `_ +and therefore requires to explicit the structures at hand. +""" + +from typing import TypeVar, Tuple, Dict, Iterator, Any, overload, Protocol + +import ecole + + +Action = TypeVar("Action") +ActionSet = TypeVar("ActionSet") + + +class Dynamics(Protocol[Action, ActionSet]): + """Dynamics are raw environments. + + The class is a bare :py:class:`ecole.environment.Environment` without rewards, + observations, and other utlilities. + It defines the state transitions of a Markov Decision Process, that is the series of steps and + possible actions of the environment. + """ + + def set_dynamics_random_state( + self, model: ecole.scip.Model, rng: ecole.RandomGenerator + ) -> None: + """Set the random state of the episode. + + This method is called by :py:meth:`~ecole.environment.Environment.reset` to + set all the random elements of the dynamics for the upcoming episode. + The random generator is kept between episodes in order to sample different episodes. + + Parameters + ---------- + model: + The SCIP model that will be used through the episode. + rng: + The random generator used by the environment from which random numbers can be extracted. + + """ + ... + + def reset_dynamics(self, model: ecole.scip.Model) -> Tuple[bool, ActionSet]: + """Start a new episode. + + This method brings the environment to a new initial state, *i.e.* starts a new + episode. + The method can be called at any point in time. + + Parameters + ---------- + model: + The SCIP model that will be used through the episode. + + Returns + ------- + done: + A boolean flag indicating wether the current state is terminal. + If this is true, the episode is finished, and :meth:`step_dynamics` cannot be called. + action_set: + An optional subset of accepted action in the next transition. + For some environment, this may change at every transition. + + """ + ... + + def step_dynamics(self, model: ecole.scip.Model, action: Action) -> Tuple[bool, ActionSet]: + """Transition from one state to another. + + This method takes the user action to transition from the current state to the + next. + The method **cannot** be called if the dynamics has not been reset since its + instantiation or is in a terminal state. + + Parameters + ---------- + action: + The action to take in as part of the Markov Decision Process. + If an action set has been given in the latest call (inluding calls to + :meth:`reset_dynamics`), then the action **must** be in that set. + + Returns + ------- + done: + A boolean flag indicating wether the current state is terminal. + If this is true, the episode is finished, and this method cannot be called + until :meth:`reset_dynamics` has been called. + action_set: + An optional subset of accepted action in the next transition. + For some environment, this may change at every transition. + + """ + ... + + +Data = TypeVar("Data") + + +class DataFunction(Protocol[Data]): + """The parent class of all function extracting data from the environment. + + Data functions are a generic alias for :py:class:`~ecole.typing.ObservationFunction`, + :py:class:`~ecole.typing.RewardFunction`, and :py:class:`~ecole.typing.InformationFunction` + with different data types, such as float for rewards. + + Having a similar interface between them makes it easier to combine them in various ways, such + as creating :py:class:`~ecole.typing.ObservationFunction` or :py:class:`~ecole.typing.InformationFunction` + from a dictionnary of :py:class:`~ecole.typing.RewardFunction`. + + This class is meant to represent a function of the whole state trajectory/history. + However, because it is not feasible to keep all the previous states in memory, this equivalent + implementation as a class let the object store information from one transition to another. + + See Also + -------- + RewardFunction + + """ + + def before_reset(self, model: ecole.scip.Model) -> None: + """Reset internal data at the start of episodes. + + The method is called on new episodes :py:meth:`~ecole.environment.Environment.reset` right before + the MDP is actually reset, that is right before the environment calls + :py:meth:`~ecole.typing.Dynamics.reset_dynamics`. + + It is usually used to reset the internal data. + + Parameters + ---------- + model: + The :py:class:`~ecole.scip.Model`, model defining the current state of the solver. + + """ + ... + + def extract(self, model: ecole.scip.Model, done: bool) -> Data: + """Extract the data on the given state. + + Extract the data after transitionning on the new state given by ``model``. + The function is reponsible for keeping track of relevant information from previous states. + This can safely be done in this method as it will only be called *once per state* *i.e.*, + this method is not a getter and can have side effects. + + Parameters + ---------- + model: + The :py:class:`~ecole.scip.Model`, model defining the current state of the solver. + done: + A flag indicating wether the state is terminal (as decided by the environment). + + Returns + ------- + : + The return is passed to the user by the environment. + + """ + ... + + +def _set_docstring(doc): + """Decorator to dynamically set docstring.""" + + def decorator(func): + func.__doc__ = doc + return func + + return decorator + + +Observation = TypeVar("Observation") + + +class ObservationFunction(DataFunction[Observation], Protocol[Observation]): + """Class repsonsible for extracting observations. + + Observation functions are objects given to the :py:class:`~ecole.environment.Environment` to + extract the observations used to take the next action. + + This class presents the interface expected to define a valid observation function. + It is not necessary to inherit from this class, as observation functions are defined by + `structural subtyping `_. + It is exists to support Python type hints. + + See Also + -------- + DataFunction : + Observation function are equivalent to the generic data function, that is a function to + extact an arbitrary type of data. + + """ + + @_set_docstring(DataFunction.before_reset.__doc__) + def before_reset(self, model: ecole.scip.Model) -> None: + ... + + @_set_docstring(DataFunction.extract.__doc__.replace("data", "observation")) + def extract(self, model: ecole.scip.Model, done: bool) -> Observation: + ... + + +class RewardFunction(DataFunction[float], Protocol): + """Class responsible for extracting rewards. + + Reward functions are objects given to the :py:class:`~ecole.environment.Environment` + to extract the reward used for learning. + + This class presents the interface expected to define a valid reward function. + It is not necessary to inherit from this class, as reward functions are defined by + `structural subtyping `_. + It is exists to support Python type hints. + + Note + ---- + Rewards, or rather reward offset, are also extracted on :py:meth:`~ecole.environment.Environment.reset`. + This has no use for learning (since not action has been taken), but is useful when using the cumulative + reward sum as a metric. + + See Also + -------- + DataFunction : + Reward function are a specific type of generic data function where the data extracted are reward + of type ``float``. + + """ + + @_set_docstring(DataFunction.before_reset.__doc__) + def before_reset(self, model: ecole.scip.Model) -> None: + ... + + @_set_docstring(DataFunction.extract.__doc__.replace("data", "reward")) + def extract(self, model: ecole.scip.Model, done: bool) -> float: + ... + + +Information = TypeVar("Information") + + +class InformationFunction(DataFunction[Dict[str, Information]], Protocol[Information]): + """Class repsonsible for extracting the the information dictionnary. + + Information functions are objects given to the :py:class:`~ecole.environment.Environment` to + extract the addtional information about the environment. + + A common pattern is use additional :py:class:`ecole.typing.RewardFunction` and + :py:class:`ecole.typing.ObservationFunction` to easily create information functions. + + This class presents the interface expected to define a valid information function. + It is not necessary to inherit from this class, as information functions are defined by + `structural subtyping `_. + It is exists to support Python type hints. + + See Also + -------- + DataFunction : + Information function are a specific type of generic data function where the data extracted + are dictionnary of string to any type. + + """ + + @_set_docstring(DataFunction.before_reset.__doc__) + def before_reset(self, model: ecole.scip.Model) -> None: + ... + + @_set_docstring(DataFunction.extract.__doc__.replace("data", "information")) + def extract(self, model: ecole.scip.Model, done: bool) -> Dict[str, Information]: + ... + + +class InstanceGenerator(Protocol): + """A class to generate generate and iteratate over random problem instance. + + The class combines a :py:class:`~ecole.RandomGenerator` with the static function :py:meth:`generate_instance` + to provide iterating capabilities. + """ + + @staticmethod + def generate_instance( + *args: Any, rng: ecole.RandomGenerator, **kwargs: Any + ) -> ecole.scip.Model: + """Generate a problem instance using the random generator for any source of randomness.""" + ... + + @overload + def __init__(self, *args: Any, rng: ecole.RandomGenerator, **kwargs: Any) -> None: + """Create an iterator with the given parameters and a copy of the random state.""" + ... + + def __next__(self) -> ecole.scip.Model: + """Generate a problem instance using the random generator of the class.""" + ... + + def __iter__(self) -> Iterator[ecole.scip.Model]: + """Return itself as an iterator.""" + ... + + def seed(self, int) -> None: + """Seed the random generator of the class.""" + ... diff --git a/ecole/python/ecole/src/ecole/version.py b/ecole/python/ecole/src/ecole/version.py new file mode 100644 index 0000000..e705689 --- /dev/null +++ b/ecole/python/ecole/src/ecole/version.py @@ -0,0 +1 @@ +from ecole.core.version import * diff --git a/ecole/python/ecole/tests/conftest.py b/ecole/python/ecole/tests/conftest.py new file mode 100644 index 0000000..38b8937 --- /dev/null +++ b/ecole/python/ecole/tests/conftest.py @@ -0,0 +1,72 @@ +"""Pytest configuration file.""" + +import pathlib + +import pytest + +import ecole + + +TEST_SOURCE_DIR = pathlib.Path(__file__).parent.resolve() +DATA_DIR = TEST_SOURCE_DIR / "../../../libecole/tests/data" + + +def pytest_addoption(parser): + """Add no-slow command line argument to pytest.""" + parser.addoption("--no-slow", action="store_true", default=False, help="do not run slow tests") + + +def pytest_configure(config): + """Add slow marker to pytest.""" + config.addinivalue_line("markers", "slow: mark test as slow to run") + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to not run slow tests if not specified.""" + if config.getoption("--no-slow"): + skip_slow = pytest.mark.skip(reason="--no-slow option provided") + for item in items: + if "slow" in item.keywords: + item.add_marker(skip_slow) + + +@pytest.fixture(scope="session") +def problem_file(): + """Return a MILP problem file.""" + return DATA_DIR / "enlight8.mps" + + +@pytest.fixture +def model(problem_file): + """Return a Model object with a valid problem.""" + model_obj = ecole.scip.Model.from_file(problem_file) + model_obj.disable_cuts() + model_obj.disable_presolve() + return model_obj + + +@pytest.fixture +def model_copy(model): + """Return a Model object with a valid problem.""" + return model.copy_orig() + + +@pytest.helpers.register +def advance_to_stage(the_model: ecole.scip.Model, stage: ecole.scip.Stage) -> ecole.scip.Model: + """Utility to advance a model to the root node.""" + if stage == ecole.scip.Stage.Problem: + return the_model + if stage == ecole.scip.Stage.Transformed: + the_model.transform_prob() + return the_model + if stage == ecole.scip.Stage.Presolved: + the_model.presolve() + return the_model + if stage == ecole.scip.Stage.Solving: + dyn = ecole.dynamics.BranchingDynamics() + dyn.reset_dynamics(the_model) + return the_model + if stage == ecole.scip.Stage.Solved: + the_model.solve() + return the_model + raise NotImplementedError(f"Not implemented for stage {stage}") diff --git a/ecole/python/ecole/tests/test_data.py b/ecole/python/ecole/tests/test_data.py new file mode 100644 index 0000000..182e90e --- /dev/null +++ b/ecole/python/ecole/tests/test_data.py @@ -0,0 +1,140 @@ +import itertools +import unittest.mock as mock + +import pytest + +import ecole + + +@pytest.mark.parametrize("done", (True, False)) +@pytest.mark.parametrize("cste", (True, 1, "hello")) +def test_ConstantFunction(model, done, cste): + """Always return the same constant.""" + cste_func = ecole.data.ConstantFunction(cste) + cste_func.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + data = cste_func.extract(model, done) + assert data == cste + + +@pytest.mark.parametrize("done", (True, False)) +def test_VectorFunction(model, done): + """Dispach calls and pack the result in a list.""" + data_func1, data_func2 = mock.MagicMock(), mock.MagicMock() + tuple_data_func = ecole.data.VectorFunction(data_func1, data_func2) + + tuple_data_func.before_reset(model) + data_func1.before_reset.assert_called_once_with(model) + data_func2.before_reset.assert_called_once_with(model) + + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + data_func1.extract.return_value = "something" + data_func2.extract.return_value = "else" + data = tuple_data_func.extract(model, done) + assert data == ["something", "else"] + + +@pytest.mark.parametrize("done", (True, False)) +def test_MapFunction(model, done): + """Dispach calls and pack the result in a dict.""" + data_func1, data_func2 = mock.MagicMock(), mock.MagicMock() + dict_data_func = ecole.data.MapFunction(name1=data_func1, name2=data_func2) + + dict_data_func.before_reset(model) + data_func1.before_reset.assert_called_once_with(model) + data_func2.before_reset.assert_called_once_with(model) + + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + data_func1.extract.return_value = "something" + data_func2.extract.return_value = "else" + data = dict_data_func.extract(model, done) + assert data == {"name1": "something", "name2": "else"} + + +@pytest.mark.parametrize("done", (True, False)) +@pytest.mark.parametrize("wall", (True, False)) +def test_TimedFunction(model, done, wall): + """Time a given data function.""" + data_func = mock.MagicMock() + time_data_func = ecole.data.TimedFunction(data_func, wall=wall) + + time_data_func.before_reset(model) + data_func.before_reset.assert_called_once_with(model) + + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + time = time_data_func.extract(model, done) + assert time > 0 + + +def test_parse_None(): + """None is parsed as NoneFunction.""" + assert isinstance(ecole.data.parse(None, mock.MagicMock()), ecole.data.NoneFunction) + + +def test_parse_default(): + """Default return default.""" + default_func = mock.MagicMock() + assert ecole.data.parse(ecole.Default, default_func) == default_func + + +def test_parse_default_str(): + """Default return default.""" + default_func = mock.MagicMock() + assert ecole.data.parse("Default", default_func) == default_func + + +def test_parse_self_reference(): + """Default can not be used in the default function.""" + with pytest.raises(ValueError): + ecole.data.parse(ecole.Default, ecole.Default) + + +def test_parse_number(): + """Number return ConstantFunction.""" + assert isinstance(ecole.data.parse(1, mock.MagicMock()), ecole.data.ConstantFunction) + + +def test_parse_tuple(): + """Tuple is parsed as VectorFunction.""" + aggregate = (mock.MagicMock(), mock.MagicMock()) + assert isinstance(ecole.data.parse(aggregate, mock.MagicMock()), ecole.data.VectorFunction) + + +def test_parse_dict(): + """Dict is parsed as MapFunction.""" + aggregate = {"name1": mock.MagicMock(), "name2": mock.MagicMock()} + assert isinstance(ecole.data.parse(aggregate, mock.MagicMock()), ecole.data.MapFunction) + + +def test_parse_recursive(model): + """Parsing is recursive.""" + aggregate = { + "name1": mock.MagicMock(), + "name2": (mock.MagicMock(), None, 1), + "name3": "Default", + } + default_func = mock.MagicMock() + default_func.extract.return_value == mock.MagicMock() + func = ecole.data.parse(aggregate, default_func) + # Using the extract method to inspect the recusive parsing since Vector, Map, Constant functions are private. + data = func.extract(model, False) + assert isinstance(data, dict) + assert isinstance(data["name2"], list) + assert data["name2"][1] is None + assert data["name2"][2] == 1 + assert data["name3"] == default_func.extract.return_value + + +def test_parse_recursive_default(model): + """Default function is parsed as well.""" + aggregate = { + "name1": mock.MagicMock(), + "name2": (mock.MagicMock(), None, 1), + } + func = ecole.data.parse("Default", aggregate) + # Using the extract method to inspect the recusive parsing since Vector, Map, Constant functions are private. + data = func.extract(model, False) + assert isinstance(data, dict) + assert isinstance(data["name2"], list) + assert data["name2"][1] is None + assert data["name2"][2] == 1 diff --git a/ecole/python/ecole/tests/test_dynamics.py b/ecole/python/ecole/tests/test_dynamics.py new file mode 100644 index 0000000..af9624b --- /dev/null +++ b/ecole/python/ecole/tests/test_dynamics.py @@ -0,0 +1,129 @@ +"""Test Ecole dynamics in Python. + +Most dynamics classes are written in Ecole C++ library. +This is where the logic should be tested. +The tests provided here run the same assertions on all dynamics. +They mostly test that the code Bindings work as expected. +""" + +import pytest +import numpy as np + +import ecole + + +class DynamicsUnitTests: + def test_default_init(self): + """Construct with default arguments.""" + type(self.dynamics)() + + def test_reset(self, model): + """Successive calls to reset.""" + self.dynamics.reset_dynamics(model.copy_orig()) + self.dynamics.reset_dynamics(model) + + def test_action_set(self, model): + """The action set is of valid type.""" + _, action_set = self.dynamics.reset_dynamics(model) + self.assert_action_set(action_set) + + def test_step(self, model): + """Get an action_set and take an action.""" + done, action_set = self.dynamics.reset_dynamics(model) + assert not done # We need instances where solving is not trivial + self.dynamics.step_dynamics(model, self.policy(action_set)) + + @pytest.mark.slow + def test_full_trajectory(self, model): + """Run a complete trajectory.""" + done, action_set = self.dynamics.reset_dynamics(model) + assert not done # We need instances where solving is not trivial + while not done: + done, action_set = self.dynamics.step_dynamics(model, self.policy(action_set)) + + def test_exception(self, model): + """Bad action raise exceptions.""" + with pytest.raises((ecole.scip.ScipError, ValueError)): + _, action_set = self.dynamics.reset_dynamics(model) + self.dynamics.step_dynamics(model, self.bad_policy(action_set)) + + def test_set_random_state(self, model): + """Random generator is consumed.""" + rng = ecole.RandomGenerator(33) + self.dynamics.set_dynamics_random_state(model, rng) + assert rng != ecole.RandomGenerator(33) + + +class TestBranching(DynamicsUnitTests): + @staticmethod + def assert_action_set(action_set): + assert isinstance(action_set, np.ndarray) + assert action_set.ndim == 1 + assert action_set.size > 0 + assert action_set.dtype == np.uint64 + + @staticmethod + def policy(action_set): + return action_set[0] + + @staticmethod + def bad_policy(action_set): + return 1 << 31 + + def setup_method(self, method): + self.dynamics = ecole.dynamics.BranchingDynamics(False) + + +class TestBranchingDefault(TestBranching): + @staticmethod + def policy(action_set): + return ecole.Default + + +class TestBranching_Pseudocandidate(TestBranching): + def setup_method(self, method): + self.dynamics = ecole.dynamics.BranchingDynamics(True) + + +class TestConfiguring(DynamicsUnitTests): + @staticmethod + def assert_action_set(action_set): + assert action_set is None + + @staticmethod + def policy(action_set): + return { + "branching/scorefunc": "s", + "branching/scorefac": 0.1, + "branching/divingpscost": False, + "conflict/lpiterations": 0, + "heuristics/undercover/fixingalts": "ln", + } + + @staticmethod + def bad_policy(action_set): + return {"not/a/parameter": 44} + + def setup_method(self, method): + self.dynamics = ecole.dynamics.ConfiguringDynamics() + + +class TestPrimalSearch(DynamicsUnitTests): + @staticmethod + def assert_action_set(action_set): + assert isinstance(action_set, np.ndarray) + assert action_set.ndim == 1 + assert action_set.size > 0 + assert action_set.dtype == np.uint64 + + @staticmethod + def policy(action_set): + # Mixed numpy array and list + return (action_set, [0.0] * len(action_set)) + + @staticmethod + def bad_policy(action_set): + return ([1 << 31], [0.0]) + + def setup_method(self, method): + self.dynamics = ecole.dynamics.PrimalSearchDynamics() diff --git a/ecole/python/ecole/tests/test_environment.py b/ecole/python/ecole/tests/test_environment.py new file mode 100644 index 0000000..06bc070 --- /dev/null +++ b/ecole/python/ecole/tests/test_environment.py @@ -0,0 +1,57 @@ +"""Unit tests for Ecole Environment.""" + +import unittest.mock as mock +import pytest + +import ecole + + +class MockDynamics(mock.MagicMock): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.reset_dynamics = mock.MagicMock(return_value=(False, "some_action_set")) + self.step_dynamics = mock.MagicMock(return_value=(True, "other_action_set")) + + +class MockEnvironment(ecole.environment.Environment): + __Dynamics__ = MockDynamics + + +def test_reset(model): + """Reset with a model.""" + env = MockEnvironment() + _, _, _, _, _ = env.reset(model) + assert model is not env.model # Model is copied + assert not env.model.is_solved + + env.dynamics.reset_dynamics.assert_called_with(env.model) + env.dynamics.set_dynamics_random_state.assert_called() + + +def test_step(model): + """Step with some action.""" + env = MockEnvironment() + env.reset(model) + _, _, _, _, _ = env.step("some action") + env.dynamics.step_dynamics.assert_called_with(env.model, "some action") + + +def test_step_error(model): + """Step without reset.""" + env = MockEnvironment() + with pytest.raises(ecole.MarkovError): + env.step("some action") + + +def test_seed(): + """Random generator is consumed.""" + env = MockEnvironment() + env.seed(33) + assert env.rng == ecole.RandomGenerator(33) + + +def test_scip_params(model): + """Reset sets parameters on the model.""" + env = MockEnvironment(scip_params={"concurrent/paramsetprefix": "testname"}) + env.reset(model) + assert env.model.get_param("concurrent/paramsetprefix") == "testname" diff --git a/ecole/python/ecole/tests/test_information.py b/ecole/python/ecole/tests/test_information.py new file mode 100644 index 0000000..0e4fe14 --- /dev/null +++ b/ecole/python/ecole/tests/test_information.py @@ -0,0 +1,55 @@ +"""Test Ecole information functions in Python. + +Most information functions are written in Ecole C++ library. +This is where the logic should be tested. +Here, + - Some tests automatically run the same assertions on all functions; + - Other tests that information returned form information functions are bound to the correct types. +""" + +import numpy as np +import pytest + +import ecole + + +def pytest_generate_tests(metafunc): + """Parametrize the `information_function` fixture. + + Add information functions here to have them automatically run all the tests that take + `information_function` as input. + """ + if "information_function" in metafunc.fixturenames: + all_information_functions = (ecole.information.Nothing(),) + metafunc.parametrize("information_function", all_information_functions) + + +def test_default_init(information_function): + """Construct with default arguments.""" + type(information_function)() + + +def test_before_reset(information_function, model): + """Successive calls to before_reset.""" + information_function.before_reset(model) + information_function.before_reset(model) + + +def test_extract(information_function, model): + """Obtain information.""" + information_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + information_function.extract(model, False) + + +def make_info(info_func, model): + info_func.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + return info_func.extract(model, False) + + +def test_Nothing_information(model): + """Observation of Nothing is None.""" + info = make_info(ecole.information.Nothing(), model) + assert isinstance(info, dict) + assert len(info) == 0 diff --git a/ecole/python/ecole/tests/test_instance.py b/ecole/python/ecole/tests/test_instance.py new file mode 100644 index 0000000..f6ce346 --- /dev/null +++ b/ecole/python/ecole/tests/test_instance.py @@ -0,0 +1,117 @@ +"""Test Ecole instance generators in Python. + +Most instance generartors are written in Ecole C++ library. +This is where the logic should be tested. +The tests provided here run the same assertions on all generators. +They mostly test that the code Bindings work as expected. +""" + +import itertools + +import pytest + +import ecole + + +@pytest.fixture(scope="module") +def tmp_dataset(tmp_path_factory, problem_file): + """Create a local dataset of problem files.""" + model = ecole.scip.Model.from_file(problem_file) + path = tmp_path_factory.mktemp("instances") + for name in "abc": + model.write_problem(path / f"model-{name}.lp") + return path + + +@pytest.fixture( + params=( + ecole.instance.FileGenerator, + ecole.instance.SetCoverGenerator, + ecole.instance.CombinatorialAuctionGenerator, + ecole.instance.IndependentSetGenerator, + ecole.instance.CapacitatedFacilityLocationGenerator, + ) +) +def instance_generator(request, tmp_dataset): + """Fixture to run tests with various instance generators.""" + args = { + ecole.instance.FileGenerator: {"directory": str(tmp_dataset)}, + ecole.instance.SetCoverGenerator: {"n_rows": 100, "n_cols": 200}, + ecole.instance.CombinatorialAuctionGenerator: {"n_items": 50, "n_bids": 150}, + ecole.instance.IndependentSetGenerator: {"n_nodes": 100}, + ecole.instance.CapacitatedFacilityLocationGenerator: { + "n_customers": 60, + "n_facilities": 50, + }, + } + return request.param(**args[request.param]) + + +def test_default_init(instance_generator): + """Construct with default arguments.""" + if isinstance(instance_generator, ecole.instance.FileGenerator): + pytest.skip("No dataset in default directory") + type(instance_generator)() + + +def test_rng_init(instance_generator): + """Construct a random generator.""" + if isinstance(instance_generator, ecole.instance.FileGenerator): + pytest.skip("No dataset in default directory") + type(instance_generator)(rng=ecole.RandomGenerator()) + + +def test_generate_instance(instance_generator): + """Use stateless instance generating function.""" + if isinstance(instance_generator, ecole.instance.FileGenerator): + pytest.skip("No generate_instance for file loaders") + InstanceGenerator = type(instance_generator) + model = InstanceGenerator.generate_instance(rng=ecole.RandomGenerator()) + assert isinstance(model, ecole.scip.Model) + + +def test_infinite_iteration(instance_generator): + """For loop, even if infinite, can iterate over the iterator.""" + for model in instance_generator: + assert isinstance(model, ecole.scip.Model) + break + + +def test_repeated_slice_iteration(instance_generator): + """Generate a finite number of instances in multiple epochs.""" + for epoch in range(2): + for model in itertools.islice(instance_generator, 2): + assert isinstance(model, ecole.scip.Model) + + +def test_FileGenerator_parameters(tmp_dataset): + """Parameters are bound in the constructor and as attributes.""" + generator = ecole.instance.FileGenerator(directory=str(tmp_dataset), sampling_mode="remove") + assert generator.sampling_mode.name == "remove" + + +def test_SetCoverGenerator_parameters(): + """Parameters are bound in the constructor and as attributes.""" + generator = ecole.instance.SetCoverGenerator(n_cols=10) + assert generator.n_cols == 10 + + +def test_IndependentSetGenerator_parameters(): + """Parameters are bound in the constructor and as attributes.""" + generator = ecole.instance.IndependentSetGenerator(graph_type="erdos_renyi") + assert generator.graph_type.name == "erdos_renyi" + + +def test_CombinatorialAuctionGenerator_parameters(): + """Parameters are bound in the constructor and as attributes.""" + generator = ecole.instance.CombinatorialAuctionGenerator(additivity=-1) + assert generator.additivity == -1 + + +def test_CapacitatedFacilityLocationGenerator_parameters(): + """Parameters are bound in the constructor and as attributes.""" + generator = ecole.instance.CapacitatedFacilityLocationGenerator( + ratio=-1, demand_interval=(1, 5) + ) + assert generator.ratio == -1 + assert generator.demand_interval == (1, 5) diff --git a/ecole/python/ecole/tests/test_observation.py b/ecole/python/ecole/tests/test_observation.py new file mode 100644 index 0000000..45d26f6 --- /dev/null +++ b/ecole/python/ecole/tests/test_observation.py @@ -0,0 +1,145 @@ +"""Test Ecole observation functions in Python. + +Most observation functions are written in Ecole C++ library. +This is where the logic should be tested. +Here, + - Some tests automatically run the same assertions on all functions; + - Other tests that observation returned form observation functions are bound to the correct types. +""" + +import copy +import pickle + +import numpy as np +import pytest + +import ecole + + +# TODO adapt for MilpBiparite that must not be in stage solving +def pytest_generate_tests(metafunc): + """Parametrize the `observation_function` fixture. + + Add observation functions here to have them automatically run all the tests that take + `observation_function` as input. + """ + if "observation_function" in metafunc.fixturenames: + all_observation_functions = ( + ecole.observation.Nothing(), + ecole.observation.NodeBipartite(), + ecole.observation.MilpBipartite(), + ecole.observation.StrongBranchingScores(True), + ecole.observation.StrongBranchingScores(False), + ecole.observation.Pseudocosts(), + ecole.observation.Khalil2016(), + ecole.observation.Hutter2011(), + ) + metafunc.parametrize("observation_function", all_observation_functions) + + +def test_default_init(observation_function): + """Construct with default arguments.""" + type(observation_function)() + + +def test_before_reset(observation_function, model): + """Successive calls to before_reset.""" + observation_function.before_reset(model) + observation_function.before_reset(model) + + +def test_extract(observation_function, model): + """Obtain observation.""" + observation_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + observation_function.extract(model, False) + + +def make_obs(obs_func, model, stage=ecole.scip.Stage.Solving): + """Utility function to extract observation on root node.""" + obs_func.before_reset(model) + pytest.helpers.advance_to_stage(model, stage) + return obs_func.extract(model, False) + + +def test_observation_deepcopy(observation_function, model): + """Deepcopy observation.""" + obs = make_obs(observation_function, model) + copy.deepcopy(obs) + + +def test_observation_pickle(observation_function, model): + """Pickle and unpickle observation.""" + obs = make_obs(observation_function, model) + blob = pickle.dumps(obs) + obs_copy = pickle.loads(blob) + + +def assert_array(arr, ndim=1, non_empty=True, dtype=np.double): + assert isinstance(arr, np.ndarray) + assert arr.ndim == ndim + assert (not non_empty) or (arr.size > 0) + assert arr.dtype == dtype + + +def test_Nothing_observation(model): + """Observation of Nothing is None.""" + assert make_obs(ecole.observation.Nothing(), model) is None + + +def test_NodeBipartite_observation(model): + """Observation of NodeBipartite is a type with array attributes.""" + obs = make_obs(ecole.observation.NodeBipartite(), model) + assert isinstance(obs, ecole.observation.NodeBipartiteObs) + assert_array(obs.variable_features, ndim=2) + assert_array(obs.row_features, ndim=2) + assert_array(obs.edge_features.values) + assert_array(obs.edge_features.indices, ndim=2, dtype=np.uint64) + + # Check that there are enums describing feeatures + assert len(obs.VariableFeatures.__members__) == obs.variable_features.shape[1] + assert len(obs.RowFeatures.__members__) == obs.row_features.shape[1] + + +def test_MilpBipartite_observation(model): + """Observation of MilpBipartite is a type with array attributes.""" + obs = make_obs(ecole.observation.MilpBipartite(), model, stage=ecole.scip.Stage.Problem) + assert isinstance(obs, ecole.observation.MilpBipartiteObs) + assert_array(obs.variable_features, ndim=2) + assert_array(obs.constraint_features, ndim=2) + assert_array(obs.edge_features.values) + assert_array(obs.edge_features.indices, ndim=2, dtype=np.uint64) + + # Check that there are enums describing feeatures + assert len(obs.VariableFeatures.__members__) == obs.variable_features.shape[1] + assert len(obs.ConstraintFeatures.__members__) == obs.constraint_features.shape[1] + + +def test_StrongBranchingScores_observation(model): + """Observation of StrongBranchingScores is a numpy array.""" + obs = make_obs(ecole.observation.StrongBranchingScores(), model) + assert_array(obs) + + +def test_Pseudocosts_observation(model): + """Observation of Pseudocosts is a numpy array.""" + obs = make_obs(ecole.observation.Pseudocosts(), model) + assert_array(obs) + + +def test_Khalil2016_observation(model): + """Observation of Khalil2016 is a numpy matrix.""" + obs = make_obs(ecole.observation.Khalil2016(), model) + assert_array(obs.features, ndim=2) + + # Check that there are enums describing feeatures + assert len(obs.Features.__members__) == obs.features.shape[1] + + +def test_Hutter2011_observation(model): + """Observation of Hutter2011 is a numpy vector.""" + obs = make_obs(ecole.observation.Hutter2011(), model, stage=ecole.scip.Stage.Problem) + assert_array(obs.features, ndim=1) + + # Check that there are enums describing feeatures + assert len(obs.Features.__members__) == obs.features.shape[0] diff --git a/ecole/python/ecole/tests/test_random.py b/ecole/python/ecole/tests/test_random.py new file mode 100644 index 0000000..00231dc --- /dev/null +++ b/ecole/python/ecole/tests/test_random.py @@ -0,0 +1,60 @@ +import copy +import pickle + +import ecole + + +def test_RandomGenerator(): + """Test bindings of the RandomGenerator.""" + assert ecole.RandomGenerator.min_seed < ecole.RandomGenerator.max_seed + rng = ecole.RandomGenerator(42) + rand_val_1 = rng() + assert isinstance(rand_val_1, int) + + rng.seed(42) + rand_val_2 = rng() + assert rand_val_1 == rand_val_2 + + +def test_RandomGenerator_copy(): + """Copy create a new RandomGenerator with same state.""" + rng = ecole.RandomGenerator(42) + + rng_copy = copy.copy(rng) + assert rng_copy == rng + assert rng_copy is not rng + + rng_deepcopy = copy.deepcopy(rng) + assert rng_deepcopy == rng + assert rng_deepcopy is not rng + + +def test_RandomGenerator_pickle(): + """Pickle preserve the state of the RandomGenerator.""" + rng = ecole.RandomGenerator(42) + assert rng == pickle.loads(pickle.dumps(rng)) + + +def test_same_seed(): + """Same seed give same random generators.""" + ecole.seed(0) + rng_1 = ecole.spawn_random_generator() + ecole.seed(0) + rng_2 = ecole.spawn_random_generator() + assert rng_1 == rng_2 + + +def test_differen_seed(): + """Different seeds give different random generators.""" + ecole.seed(0) + rng_1 = ecole.spawn_random_generator() + ecole.seed(2) + rng_2 = ecole.spawn_random_generator() + assert rng_1 != rng_2 + + +def test_spawn_generator(): + """Successive random generators are different""" + rng_1 = ecole.spawn_random_generator() + rng_2 = ecole.spawn_random_generator() + assert rng_1 != rng_2 diff --git a/ecole/python/ecole/tests/test_reward.py b/ecole/python/ecole/tests/test_reward.py new file mode 100644 index 0000000..d2079fd --- /dev/null +++ b/ecole/python/ecole/tests/test_reward.py @@ -0,0 +1,143 @@ +"""Test Ecole reward functions in Python. + +Most reward functions are written in Ecole C++ library. +This is where the logic should be tested. +The tests provided here run the same assertions on all reward functions. +They mostly test that the code Bindings work as expected. +""" + +import math + +import pytest + +import ecole + + +def pytest_generate_tests(metafunc): + """Parametrize the `reward_function` fixture. + + Add reward functions here to have them automatically run all the tests that take + `reward_function` as input. + """ + if "reward_function" in metafunc.fixturenames: + all_reward_functions = ( + ecole.reward.Constant(), + ecole.reward.IsDone(), + ecole.reward.NNodes(), + ecole.reward.LpIterations(), + ecole.reward.SolvingTime(), + ecole.reward.PrimalIntegral(bound_function=lambda x: (0.0, 0.0)), + ecole.reward.DualIntegral(bound_function=lambda x: (0.0, 0.0)), + ecole.reward.PrimalDualIntegral(bound_function=lambda x: (0.0, 0.0)), + ) + metafunc.parametrize("reward_function", all_reward_functions) + + +def test_default_init(reward_function): + """Construct with default arguments.""" + type(reward_function)() + + +def test_before_reset(reward_function, model, model_copy): + """Successive calls to before_reset.""" + reward_function.before_reset(model) + reward_function.before_reset(model_copy) + + +@pytest.mark.parametrize("done", [True, False]) +def test_extract(reward_function, done, model): + """Rewards are floats.""" + reward_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + reward = reward_function.extract(model, done) + assert isinstance(reward, float) + + +@pytest.mark.parametrize("done", [True, False]) +def test_reproducability(reward_function, done, model, model_copy): + """Same trajectories yield same rewards.""" + reward_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + reward1 = reward_function.extract(model, done) + + reward_function.before_reset(model_copy) + pytest.helpers.advance_to_stage(model_copy, ecole.scip.Stage.Solving) + reward2 = reward_function.extract(model_copy, done=done) + + assert reward1 == pytest.approx(reward2, rel=1.0) + + +@pytest.mark.parametrize( + "func_formula,reward_formula", + [ + [lambda r: -r] * 2, + [lambda r: r - 3] * 2, + [lambda r: 3 - r] * 2, + [lambda r: abs(-r) + 2] * 2, + [lambda rf: rf.exp(), lambda r: math.exp(r)], + [lambda rf: rf.apply(lambda r: r + 2), lambda r: r + 2], + ], +) +def test_operators(reward_function, model, model_copy, func_formula, reward_formula): + """Operators produce operations on rewards.""" + reward_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + reward = reward_function.extract(model) + # WARNING reward_function and formula_reward_function share underlying reference with current + # Python implementation but the test works due to reward function reproducability. + formula_reward_function = func_formula(reward_function) + formula_reward_function.before_reset(model_copy) + pytest.helpers.advance_to_stage(model_copy, ecole.scip.Stage.Solving) + formula_reward = formula_reward_function.extract(model_copy) + assert formula_reward == pytest.approx(reward_formula(reward), rel=1.0) + + +def test_cumsum(reward_function, model, model_copy): + """Operators produce operations on rewards.""" + reward_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + reward1 = reward_function.extract(model) + reward2 = reward_function.extract(model) + # WARNING reward_function and cum_reward_function share underlying reference with current + # Python implementation but the test works due to reward function reproducability. + cum_reward_function = reward_function.cumsum() + cum_reward_function.before_reset(model_copy) + pytest.helpers.advance_to_stage(model_copy, ecole.scip.Stage.Solving) + cum_reward1 = cum_reward_function.extract(model_copy) + cum_reward2 = cum_reward_function.extract(model_copy) + + assert cum_reward1 == pytest.approx(reward1, rel=1.0) + assert cum_reward2 == pytest.approx(reward1 + reward2, rel=1.0) + + +def test_primal_integral_lambda(model): + """Tests passing a lambda function into primal integral class.""" + reward_function = ecole.reward.PrimalIntegral(bound_function=lambda x: (0, 1e3)) + + reward_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + reward = reward_function.extract(model) + + assert reward >= 0 + + +def test_dual_integral_lambda(model): + """Tests passing a lambda function into dual integral class.""" + reward_function = ecole.reward.DualIntegral(bound_function=lambda x: (0, -1e3)) + + reward_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + reward = reward_function.extract(model) + + assert reward >= 0 + + +def test_primal_dual_integral_lambda(model): + """Tests passing a lambda function into primal-dual integral class.""" + reward_function = ecole.reward.PrimalDualIntegral(bound_function=lambda x: (1e3, -1e3)) + + reward_function.before_reset(model) + pytest.helpers.advance_to_stage(model, ecole.scip.Stage.Solving) + reward = reward_function.extract(model) + + assert reward >= 0 diff --git a/ecole/python/ecole/tests/test_scip.py b/ecole/python/ecole/tests/test_scip.py new file mode 100644 index 0000000..608b3e9 --- /dev/null +++ b/ecole/python/ecole/tests/test_scip.py @@ -0,0 +1,175 @@ +import importlib.util + +import pytest + +import ecole.scip + + +requires_pyscipopt = pytest.mark.skipif( + importlib.util.find_spec("pyscipopt") is None, + reason="PyScipOpt is not installed.", +) + + +def test_equality(model): + assert model == model + assert model != 33 + + +def test_copy_orig(model): + model_copy = model.copy_orig() + assert model is not model_copy + assert model != model_copy + + +@requires_pyscipopt +def test_from_pyscipopt_shared(): + """Ecole share same pointer.""" + import pyscipopt.scip + + param, value = "concurrent/paramsetprefix", "ecole_dummy" + pyscipopt_model = pyscipopt.scip.Model() + pyscipopt_model.setParam(param, value) + ecole_model = ecole.scip.Model.from_pyscipopt(pyscipopt_model) + assert ecole_model.get_param(param) == value + + +@requires_pyscipopt +def test_from_pyscipopt_ownership(): + """PyScipOpt model remains valid if Ecole model goes out of scope.""" + import pyscipopt.scip + + pyscipopt_model = pyscipopt.scip.Model() + # ecole_model becomes pointer owner + ecole_model = ecole.scip.Model.from_pyscipopt(pyscipopt_model) + assert not pyscipopt_model._freescip + del ecole_model + pyscipopt_model.getParams() + + +@requires_pyscipopt +def test_from_pyscipopt_no_ownership(model): + """Fail to convert if PyScipOpt does not have ownership.""" + pyscipopt_model = model.as_pyscipopt() + with pytest.raises(ecole.scip.ScipError): + ecole.scip.Model.from_pyscipopt(pyscipopt_model) + + +@requires_pyscipopt +def test_as_pyscipopt_shared(model): + """PyScipOpt share same pointer.""" + param, value = "concurrent/paramsetprefix", "ecole_dummy" + model.set_param(param, value) + pyscipopt_model = model.as_pyscipopt() + assert pyscipopt_model.getParam(param) == value + + +@requires_pyscipopt +def test_as_pyscipopt_ownership(model): + """PyScipOpt model remains valid if Ecole model goes out of scope.""" + # Making a copy to be sure no reference is held elsewhere + ecole_model = model.copy_orig() + # ecole_model remains pointer owner + pyscipopt_model = ecole_model.as_pyscipopt() + assert not pyscipopt_model._freescip + del ecole_model + # Try to access some value + pyscipopt_model.getParams() + + +def test_name(model): + """Set and get problem name.""" + model.name = "foo" + assert model.name == "foo" + + +def test_stage(model): + assert model.stage == ecole.scip.Stage.Problem + + +def test_exception(model): + with pytest.raises(ecole.scip.ScipError): + model.get_param("not_a_param") + + +names_types = ( + ("branching/preferbinary", bool), # Bool param + ("conflict/maxlploops", int), # Int param + ("limits/nodes", int), # Long int param + ("limits/time", float), # Real param + ("branching/pscost/strategy", str), # Char param + ("concurrent/paramsetprefix", str), # String param +) + + +@pytest.mark.parametrize("name,param_type", names_types) +def test_get_param(model, name, param_type): + assert isinstance(model.get_param(name), param_type) + + +@pytest.mark.parametrize("name,param_type", names_types) +def test_set_param(model, name, param_type): + if param_type is str: + value = "v" # A value accepted for the Char parameter + else: + value = param_type(1) # Cast one to the required type + model.set_param(name, value) + assert model.get_param(name) == value + + +def test_get_params(model): + params = model.get_params() + assert len(params) > 0 + for name, param_type in names_types: + assert isinstance(params[name], param_type) + + +def test_set_params(model): + # Some values to test + params = {name: "v" if param_type is str else param_type(1) for name, param_type in names_types} + model.set_params(params) + + for name, _ in names_types: + assert model.get_param(name) == params[name] + + +@pytest.mark.slow +def test_transform_prob(model): + model.transform_prob() + + +@pytest.mark.slow +def test_presolve(model): + model.presolve() + + +@pytest.mark.slow +def test_presolve(model): + model.solve() + + +def test_is_solved(model): + assert not model.is_solved + + +def test_bounds(model): + assert model.dual_bound < model.primal_bound + + +@pytest.mark.slow +def test_solve_iter(model): + used_branchrule = False + used_heuristic = False + + fcall = model.solve_iter( + ecole.scip.callback.BranchruleConstructor(), ecole.scip.callback.HeuristicConstructor() + ) + while fcall is not None: + fcall = model.solve_iter_continue(ecole.scip.callback.Result.DidNotRun) + if isinstance(fcall, ecole.scip.callback.BranchruleCall): + used_branchrule = True + elif isinstance(fcall, ecole.scip.callback.HeuristicCall): + used_heuristic = True + + assert used_branchrule + assert used_heuristic diff --git a/ecole/python/ecole/tests/test_version.py b/ecole/python/ecole/tests/test_version.py new file mode 100644 index 0000000..a34a849 --- /dev/null +++ b/ecole/python/ecole/tests/test_version.py @@ -0,0 +1,42 @@ +import pytest + +import ecole.core + + +def test_version(): + """Extract version of library and git revision.""" + version = ecole.version.get_ecole_lib_version() + assert isinstance(version.major, int) + assert isinstance(version.minor, int) + assert isinstance(version.patch, int) + assert isinstance(version.revision, str) + assert isinstance(version.build_type, str) + assert isinstance(version.build_os, str) + assert isinstance(version.build_time, str) + assert isinstance(version.build_compiler, str) + + +def test_scip_version(): + """Extract version of SCIP library used to compile Ecole.""" + version = ecole.version.get_scip_buildtime_version() + assert isinstance(version.major, int) + assert isinstance(version.minor, int) + assert isinstance(version.patch, int) + + +def test_match_importlib(): + """Package version match inner version.""" + try: + import importlib.metadata + + try: + version = importlib.metadata.version(__name__) + assert version == ecole.__version__ + + # Without packaging Ecole, we cannot use importlib to get the version + except importlib.metadata.PackageNotFoundError: + pytest.skip() + + # In Python <= 3.8 we cannot use importlib to get the version + except ModuleNotFoundError: + pytest.skip() diff --git a/ecole/python/extension-helper/CMakeLists.txt b/ecole/python/extension-helper/CMakeLists.txt new file mode 100644 index 0000000..1814b68 --- /dev/null +++ b/ecole/python/extension-helper/CMakeLists.txt @@ -0,0 +1,74 @@ +# File that download the dependencies of libecole +include(dependencies/public.cmake) + +find_package(pybind11 REQUIRED) +find_package(xtensor REQUIRED) +find_package(xtensor-python REQUIRED) + +add_library(ecole-py-ext-helper INTERFACE) +add_library(Ecole::ecole-py-ext-helper ALIAS ecole-py-ext-helper) + +target_include_directories( + ecole-py-ext-helper + INTERFACE + $ + $ +) + +target_link_libraries( + ecole-py-ext-helper + INTERFACE + pybind11::headers + xtensor-python +) + +# Installation library and symlink +include(GNUInstallDirs) +install( + TARGETS ecole-py-ext-helper + EXPORT "EcoleExtensionHelperTargets" +) + +# Install CMake targets definition +install( + EXPORT "EcoleExtensionHelperTargets" + FILE "EcoleExtensionHelperTargets.cmake" + NAMESPACE Ecole:: + COMPONENT Ecole_Python_Development + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/EcoleExtensionHelper" +) + +# Install headers +install( + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/include/ecole" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" + COMPONENT Ecole_Python_Development + FILES_MATCHING PATTERN "*.hpp" +) + +# Generate and install config and version files +include(CMakePackageConfigHelpers) +configure_package_config_file( + "EcoleExtensionHelperConfig.cmake.in" + "EcoleExtensionHelperConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/EcoleExtensionHelper" +) +write_basic_package_version_file( + "EcoleExtensionHelperConfigVersion.cmake" + VERSION "${Ecole_VERSION}" + COMPATIBILITY SameMinorVersion +) +install( + FILES + "${CMAKE_CURRENT_BINARY_DIR}/EcoleExtensionHelperConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/EcoleExtensionHelperConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/EcoleExtensionHelper" + COMPONENT Ecole_Python_Development +) + +# Install the files to download dependencies (not mandatory but useful for users) +install( + FILES "${CMAKE_CURRENT_SOURCE_DIR}/dependencies/public.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/EcoleExtensionHelper" + COMPONENT Ecole_Python_Development +) diff --git a/ecole/python/extension-helper/EcoleExtensionHelperConfig.cmake.in b/ecole/python/extension-helper/EcoleExtensionHelperConfig.cmake.in new file mode 100644 index 0000000..db8b75d --- /dev/null +++ b/ecole/python/extension-helper/EcoleExtensionHelperConfig.cmake.in @@ -0,0 +1,17 @@ +@PACKAGE_INIT@ + +option(ECOLE_DOWNLOAD_DEPENDENCIES "Download the static and header libraries used in Ecole public interface" ON) +if(ECOLE_DOWNLOAD_DEPENDENCIES) + include("${CMAKE_CURRENT_LIST_DIR}/../Ecole/DependenciesResolver.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/public.cmake") +endif() + +include(CMakeFindDependencyMacro) + +find_dependency(pybind11 @pybind11_VERSION@ REQUIRED) +find_dependency(xtensor @xtensor_VERSION@ REQUIRED) +find_dependency(xtensor-python @xtensor-python_VERSION@ REQUIRED) + +if(NOT TARGET Ecole::ecole-py-ext-helper) + include("${CMAKE_CURRENT_LIST_DIR}/EcoleExtensionHelperTargets.cmake") +endif() diff --git a/ecole/python/extension-helper/dependencies/public.cmake b/ecole/python/extension-helper/dependencies/public.cmake new file mode 100644 index 0000000..7f9e61d --- /dev/null +++ b/ecole/python/extension-helper/dependencies/public.cmake @@ -0,0 +1,21 @@ +find_package(Python COMPONENTS Interpreter Development NumPy REQUIRED) + +find_or_download_package( + NAME pybind11 + URL https://github.com/pybind/pybind11/archive/v2.9.1.tar.gz + URL_HASH SHA256=c6160321dc98e6e1184cc791fbeadd2907bb4a0ce0e447f2ea4ff8ab56550913 + CONFIGURE_ARGS + -D PYBIND11_TEST=OFF + -D "Python_EXECUTABLE=${Python_EXECUTABLE}" + -D "PYTHON_EXECUTABLE=${Python_EXECUTABLE}" +) + +find_or_download_package( + NAME xtensor-python + URL https://github.com/xtensor-stack/xtensor-python/archive/0.25.1.tar.gz + URL_HASH SHA256=1e70db455a4dcba226c450bf9261a05a0c2fad513b84be35a3d139067356e6a1 + CONFIGURE_ARGS + -D BUILD_TESTS=OFF + -D "Python_EXECUTABLE=${Python_EXECUTABLE}" + -D "PYTHON_EXECUTABLE=${Python_EXECUTABLE}" +) diff --git a/ecole/python/extension-helper/include/ecole/python/auto-class.hpp b/ecole/python/extension-helper/include/ecole/python/auto-class.hpp new file mode 100644 index 0000000..6ee3a13 --- /dev/null +++ b/ecole/python/extension-helper/include/ecole/python/auto-class.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +namespace ecole::python { + +template struct auto_class : public pybind11::class_ { + using pybind11::class_::class_; + + /** An Alternative pybind11::class_::def_readwrite for xtensor members. */ + template + auto def_readwrite_xtensor(Str&& name, MemberPtr&& member_ptr, Args&&... args) -> auto& { + using Member = std::remove_reference_t>; + using value_type = typename Member::value_type; + auto constexpr rank = xt::get_rank::value; + this->def_property( + std::forward(name), + [member_ptr](Class& self) -> decltype(auto) { return std::invoke(member_ptr, self); }, + [member_ptr](Class& self, xt::pytensor const& val) { std::invoke(member_ptr, self) = val; }, + std::forward(args)...); + return *this; + } + + /** Copy and deep copy from copy constructor. */ + auto def_auto_copy() -> auto& { + this->def("__copy__", [](Class const& self) { return std::make_unique(self); }); + this->def( + "__deepcopy__", + [](Class const& self, pybind11::dict const& /*memo*/) { return std::make_unique(self); }, + pybind11::arg("memo")); + return *this; + } + + /** Pickle capbilities using Python attributes. + * + * The given attributes name must be sufficient to define the object. + * They must be bound to Python with read-write capabilities. + */ + template auto def_auto_pickle(Str... names) -> auto& { + this->def(pybind11::pickle( + [names = std::array{names...}](pybind11::handle self) { + auto dict = pybind11::dict{}; + for (auto const& name : names) { + dict[name] = self.attr(name); + } + return dict; + }, + [names = std::array{names...}](pybind11::dict const& dict) { + // Constructor may not be bound so we create the object from C++ and cast it + auto obj = std::make_unique(); + auto py_obj = pybind11::cast(obj.get()); + for (auto const& name : names) { + py_obj.attr(name) = dict[name]; + } + return obj; + })); + return *this; + } +}; + +/** Hold a class member variable function pointer and its name together. */ +template struct Member { + char const* name; + FuncPtr value; + + constexpr Member(char const* the_name, FuncPtr the_value) : name{the_name}, value{the_value} {} +}; + +/** Utility to bind a data class, that is C-struct, named-tuple... like class. */ +template struct auto_data_class : public auto_class { + using auto_class::auto_class; + using typename pybind11::class_::type; + + template auto def_auto_members(Member... members) -> auto& { + def_auto_init(members...); + def_auto_attributes(members...); + this->def_auto_copy(); + this->def_auto_pickle(members.name...); + return *this; + } + + template auto def_auto_init(Member... members) -> auto& { + // Instantiate the C++ type at compile time to get default parameters. + auto constexpr default_params = type{}; + // Bind a constructor that takes as input all parameters + this->def( + // Get the type of each parameter and add it to the Python constructor + pybind11::init>...>(), + // Set name for all constructor parameters and fetch default value on the default parameters + (pybind11::arg(members.name) = std::invoke(members.value, default_params))...); + return *this; + } + + /** Bind attribute access for all class attributes. */ + template auto def_auto_attributes(Member... members) -> auto& { + ((this->def_readwrite(members.name, members.value)), ...); + return *this; + } +}; + +} // namespace ecole::python diff --git a/ecole/setup.py b/ecole/setup.py new file mode 100644 index 0000000..70f9012 --- /dev/null +++ b/ecole/setup.py @@ -0,0 +1,100 @@ +import pathlib +import re +import sys +import os +import shlex +import platform + +from typing import List + +import skbuild + +__dir__ = pathlib.Path(__file__).resolve().parent + + +def get_file(file: pathlib.Path) -> str: + """Extract all lines from a file.""" + with open(file, "r") as f: + return f.read() + + +def get_version(version_file: pathlib.Path) -> str: + """Extract version from the Ecole VERSION file according to PEP440.""" + lines = get_file(version_file) + major = re.search(r"VERSION_MAJOR\s+(\d+)", lines).group(1) + minor = re.search(r"VERSION_MINOR\s+(\d+)", lines).group(1) + patch = re.search(r"VERSION_PATCH\s+(\d+)", lines).group(1) + pre = re.search(r"VERSION_PRE\s+([\.\w]*)", lines).group(1) + post = re.search(r"VERSION_POST\s+([\.\w]*)", lines).group(1) + dev = re.search(r"VERSION_DEV\s+([\.\w]*)", lines).group(1) + return f"{major}.{minor}.{patch}{pre}{post}{dev}" + + +def get_env_cmake_args() -> List[str]: + """Return the list of extra CMake arguments from the environment. + + When called through conda-build (environment variable `CONDA_BUILD` is set), the `CMAKE_INSTALL_<>` + are filtered out as they + """ + cmake_args = shlex.split(os.environ.get("CMAKE_ARGS", "")) + if "CONDA_BUILD" in os.environ: + install_re = re.compile(r"-D\s*CMAKE_INSTALL.*") + cmake_args = [a for a in cmake_args if not install_re.search(a)] + return cmake_args + + +def get_cmake_install_args() -> List[str]: + """Return default installation settings.""" + if "CONDA_BUILD" in os.environ: + return get_cmake_out_package_install_args() + else: + return get_cmake_in_package_install_args() + + +def get_cmake_in_package_install_args() -> List[str]: + """Return default installation settings for installing libecole in the package.""" + system = platform.system() + if system == "Linux": + origin = r"${ORIGIN}" + elif system == "Darwin": + origin = "@loader_path" + else: + raise NotImplementedError(f"OS {system} is not supported") + return [ + "-DBUILD_SHARED_LIBS=ON", + "-DCMAKE_INSTALL_LIBDIR=lib", + "-DCMAKE_INSTALL_BINDIR=bin", + "-DCMAKE_INSTALL_INCLUDEDIR=include", + "-DECOLE_PY_EXT_INSTALL_LIBDIR='.'", + "-DECOLE_PY_EXT_INSTALL_RPATH={origin}/lib".format(origin=origin), + ] + + +def get_cmake_out_package_install_args() -> List[str]: + """Return default installation settings for an extrenal libecole installation.""" + return ["-DECOLE_BUILD_LIB=OFF", "-DCMAKE_INSTALL_RPATH_USE_LINK_PATH=ON"] + + +skbuild.setup( + name="ecole", + author="Antoine Prouvost et al.", + version=get_version(__dir__ / "VERSION"), + url="https://www.ecole.ai", + description="Extensible Combinatorial Optimization Learning Environments", + long_description=get_file(__dir__ / "README.rst"), + long_description_content_type="text/x-rst", + license="BSD-3-Clause", + packages=["ecole"], + package_dir={"": "python/ecole/src"}, + package_data={"ecole": ["py.typed"]}, + cmake_languages=["CXX"], + cmake_install_dir="python/ecole/src/ecole", # Must match package_dir layout + cmake_minimum_required_version="3.14", + # FIXME No way to pass cmake argument to scikit-build through pip (for now) + # https://github.com/scikit-build/scikit-build/issues/479 + # So we read them from an environment variable + cmake_args=get_cmake_install_args() + get_env_cmake_args(), + zip_safe=False, + python_requires=">=3.6", + install_requires=["numpy>=1.4"], +)