From 8805a83c1cc5c0448b13821cf94ef67a815bcccf Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 7 Nov 2023 15:36:31 -0600 Subject: [PATCH] Implement MemorizingCutsComponent; STAB: switch to edge formulation --- docs/guide/problems.ipynb | 160 ++++++++++++++++----------- miplearn/collectors/basic.py | 3 +- miplearn/components/cuts/__init__.py | 0 miplearn/components/cuts/mem.py | 105 ++++++++++++++++++ miplearn/components/lazy/mem.py | 81 ++------------ miplearn/problems/stab.py | 64 ++++++----- miplearn/solvers/abstract.py | 6 +- miplearn/solvers/gurobi.py | 65 +++++++++-- miplearn/solvers/pyomo.py | 6 +- tests/components/cuts/__init__.py | 0 tests/components/cuts/test_mem.py | 80 ++++++++++++++ tests/components/lazy/test_mem.py | 6 +- tests/conftest.py | 48 ++++++-- tests/fixtures/gen_stab.py | 23 ++++ tests/fixtures/stab-n50-00000.h5 | Bin 0 -> 42208 bytes tests/fixtures/stab-n50-00000.mps.gz | Bin 0 -> 7005 bytes tests/fixtures/stab-n50-00000.pkl.gz | Bin 0 -> 3002 bytes tests/fixtures/stab-n50-00001.h5 | Bin 0 -> 30212 bytes tests/fixtures/stab-n50-00001.mps.gz | Bin 0 -> 7011 bytes tests/fixtures/stab-n50-00001.pkl.gz | Bin 0 -> 3005 bytes tests/fixtures/stab-n50-00002.h5 | Bin 0 -> 34868 bytes tests/fixtures/stab-n50-00002.mps.gz | Bin 0 -> 6998 bytes tests/fixtures/stab-n50-00002.pkl.gz | Bin 0 -> 3014 bytes tests/problems/test_stab.py | 6 +- tests/test_lazy_pyomo.py | 4 +- 25 files changed, 454 insertions(+), 203 deletions(-) create mode 100644 miplearn/components/cuts/__init__.py create mode 100644 miplearn/components/cuts/mem.py create mode 100644 tests/components/cuts/__init__.py create mode 100644 tests/components/cuts/test_mem.py create mode 100644 tests/fixtures/gen_stab.py create mode 100644 tests/fixtures/stab-n50-00000.h5 create mode 100644 tests/fixtures/stab-n50-00000.mps.gz create mode 100644 tests/fixtures/stab-n50-00000.pkl.gz create mode 100644 tests/fixtures/stab-n50-00001.h5 create mode 100644 tests/fixtures/stab-n50-00001.mps.gz create mode 100644 tests/fixtures/stab-n50-00001.pkl.gz create mode 100644 tests/fixtures/stab-n50-00002.h5 create mode 100644 tests/fixtures/stab-n50-00002.mps.gz create mode 100644 tests/fixtures/stab-n50-00002.pkl.gz diff --git a/docs/guide/problems.ipynb b/docs/guide/problems.ipynb index 948a7ff..4ffbd36 100644 --- a/docs/guide/problems.ipynb +++ b/docs/guide/problems.ipynb @@ -108,7 +108,11 @@ "execution_count": 1, "id": "f14e560c-ef9f-4c48-8467-72d6acce5f9f", "metadata": { - "tags": [] + "tags": [], + "ExecuteTime": { + "end_time": "2023-11-07T16:29:48.409419720Z", + "start_time": "2023-11-07T16:29:47.824353556Z" + } }, "outputs": [ { @@ -126,10 +130,11 @@ "8 [ 8.47 21.9 16.58 15.37 3.76 3.91 1.57 20.57 14.76 18.61] 94.58\n", "9 [ 8.57 22.77 17.06 16.25 4.14 4. 1.56 22.97 14.09 19.09] 100.79\n", "\n", + "Restricted license - for non-production use only - expires 2024-10-28\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 20 rows, 110 columns and 210 nonzeros\n", "Model fingerprint: 0x1ff9913f\n", @@ -154,22 +159,14 @@ "H 0 0 2.0000000 1.27484 36.3% - 0s\n", " 0 0 1.27484 0 4 2.00000 1.27484 36.3% - 0s\n", "\n", - "Explored 1 nodes (38 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 32 (of 32 available processors)\n", + "Explored 1 nodes (38 simplex iterations) in 0.02 seconds (0.00 work units)\n", + "Thread count was 12 (of 12 available processors)\n", "\n", "Solution count 3: 2 4 5 \n", "\n", "Optimal solution found (tolerance 1.00e-04)\n", "Best objective 2.000000000000e+00, best bound 2.000000000000e+00, gap 0.0000%\n" ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/axavier/.conda/envs/miplearn2/lib/python3.9/site-packages/tqdm/auto.py:22: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n" - ] } ], "source": [ @@ -304,7 +301,12 @@ "cell_type": "code", "execution_count": 2, "id": "1ce5f8fb-2769-4fbd-a40c-fd62b897690a", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2023-11-07T16:29:48.485068449Z", + "start_time": "2023-11-07T16:29:48.406139946Z" + } + }, "outputs": [ { "name": "stdout", @@ -323,8 +325,8 @@ "\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 5 rows, 10 columns and 50 nonzeros\n", "Model fingerprint: 0xaf3ac15e\n", @@ -352,7 +354,7 @@ " Cover: 1\n", "\n", "Explored 1 nodes (4 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 32 (of 32 available processors)\n", + "Thread count was 12 (of 12 available processors)\n", "\n", "Solution count 2: -1279 -804 \n", "No other solutions better than -1279\n", @@ -470,7 +472,12 @@ "cell_type": "code", "execution_count": 3, "id": "4e0e4223-b4e0-4962-a157-82a23a86e37d", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2023-11-07T16:29:48.575025403Z", + "start_time": "2023-11-07T16:29:48.453962705Z" + } + }, "outputs": [ { "name": "stdout", @@ -493,8 +500,8 @@ "\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 21 rows, 110 columns and 220 nonzeros\n", "Model fingerprint: 0x8d8d9346\n", @@ -529,7 +536,7 @@ "* 0 0 0 91.2300000 91.23000 0.00% - 0s\n", "\n", "Explored 1 nodes (70 simplex iterations) in 0.02 seconds (0.00 work units)\n", - "Thread count was 32 (of 32 available processors)\n", + "Thread count was 12 (of 12 available processors)\n", "\n", "Solution count 10: 91.23 93.92 93.98 ... 368.79\n", "\n", @@ -643,7 +650,12 @@ "cell_type": "code", "execution_count": 4, "id": "3224845b-9afd-463e-abf4-e0e93d304859", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2023-11-07T16:29:48.804292323Z", + "start_time": "2023-11-07T16:29:48.492933268Z" + } + }, "outputs": [ { "name": "stdout", @@ -660,8 +672,8 @@ "\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 5 rows, 10 columns and 28 nonzeros\n", "Model fingerprint: 0xe5c2d4fa\n", @@ -676,8 +688,8 @@ "Presolve time: 0.00s\n", "Presolve: All rows and columns removed\n", "\n", - "Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)\n", - "Thread count was 1 (of 32 available processors)\n", + "Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 1 (of 12 available processors)\n", "\n", "Solution count 1: 213.49 \n", "\n", @@ -775,8 +787,9 @@ "id": "cc797da7", "metadata": { "collapsed": false, - "jupyter": { - "outputs_hidden": false + "ExecuteTime": { + "end_time": "2023-11-07T16:29:48.806917868Z", + "start_time": "2023-11-07T16:29:48.781619530Z" } }, "outputs": [ @@ -795,8 +808,8 @@ "\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 5 rows, 10 columns and 28 nonzeros\n", "Model fingerprint: 0x4ee91388\n", @@ -811,9 +824,8 @@ "Presolve time: 0.00s\n", "Presolve: All rows and columns removed\n", "\n", - "Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)\n", - "Thread count was 1 (of 32 available processors)\n", - "\n", + "Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 1 (of 12 available processors)\n", "Solution count 2: -1986.37 -1265.56 \n", "No other solutions better than -1986.37\n", "\n", @@ -875,11 +887,10 @@ "$$\n", "\\begin{align*}\n", "\\text{minimize} \\;\\;\\; & -\\sum_{v \\in V} w_v x_v \\\\\n", - "\\text{such that} \\;\\;\\; & \\sum_{v \\in C} x_v \\leq 1 & \\forall C \\in \\mathcal{C} \\\\\n", + "\\text{such that} \\;\\;\\; & x_v + x_u \\leq 1 & \\forall (v,u) \\in E \\\\\n", "& x_v \\in \\{0, 1\\} & \\forall v \\in V\n", "\\end{align*}\n", - "$$\n", - "where $\\mathcal{C}$ is the set of cliques in $G$. We recall that a clique is a subset of vertices in which every pair of vertices is adjacent." + "$$" ] }, { @@ -903,7 +914,12 @@ "cell_type": "code", "execution_count": 6, "id": "0f996e99-0ec9-472b-be8a-30c9b8556931", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2023-11-07T16:29:48.954896857Z", + "start_time": "2023-11-07T16:29:48.825579097Z" + } + }, "outputs": [ { "name": "stdout", @@ -913,13 +929,14 @@ "weights[0] [37.45 95.07 73.2 59.87 15.6 15.6 5.81 86.62 60.11 70.81]\n", "weights[1] [ 2.06 96.99 83.24 21.23 18.18 18.34 30.42 52.48 43.19 29.12]\n", "\n", + "Set parameter PreCrush to value 1\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", - "Optimize a model with 10 rows, 10 columns and 24 nonzeros\n", - "Model fingerprint: 0xf4c21689\n", + "Optimize a model with 15 rows, 10 columns and 30 nonzeros\n", + "Model fingerprint: 0x3240ea4a\n", "Variable types: 0 continuous, 10 integer (10 binary)\n", "Coefficient statistics:\n", " Matrix range [1e+00, 1e+00]\n", @@ -927,26 +944,28 @@ " Bounds range [1e+00, 1e+00]\n", " RHS range [1e+00, 1e+00]\n", "Found heuristic solution: objective -219.1400000\n", - "Presolve removed 2 rows and 2 columns\n", + "Presolve removed 7 rows and 2 columns\n", "Presolve time: 0.00s\n", "Presolved: 8 rows, 8 columns, 19 nonzeros\n", "Variable types: 0 continuous, 8 integer (8 binary)\n", "\n", - "Root relaxation: objective -2.205650e+02, 4 iterations, 0.00 seconds (0.00 work units)\n", + "Root relaxation: objective -2.205650e+02, 5 iterations, 0.00 seconds (0.00 work units)\n", "\n", " Nodes | Current Node | Objective Bounds | Work\n", " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", "\n", " 0 0 infeasible 0 -219.14000 -219.14000 0.00% - 0s\n", "\n", - "Explored 1 nodes (4 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 32 (of 32 available processors)\n", + "Explored 1 nodes (5 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 12 (of 12 available processors)\n", "\n", "Solution count 1: -219.14 \n", "No other solutions better than -219.14\n", "\n", "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective -2.191400000000e+02, best bound -2.191400000000e+02, gap 0.0000%\n" + "Best objective -2.191400000000e+02, best bound -2.191400000000e+02, gap 0.0000%\n", + "\n", + "User-callback calls 300, time in user-callback 0.00 sec\n" ] } ], @@ -956,7 +975,7 @@ "from scipy.stats import uniform, randint\n", "from miplearn.problems.stab import (\n", " MaxWeightStableSetGenerator,\n", - " build_stab_model_gurobipy,\n", + " build_stab_model,\n", ")\n", "\n", "# Set random seed to make example reproducible\n", @@ -979,7 +998,7 @@ "print()\n", "\n", "# Load and optimize the first instance\n", - "model = build_stab_model_gurobipy(data[0])\n", + "model = build_stab_model(data[0])\n", "model.optimize()\n" ] }, @@ -1053,8 +1072,9 @@ "id": "9d0c56c6", "metadata": { "collapsed": false, - "jupyter": { - "outputs_hidden": false + "ExecuteTime": { + "end_time": "2023-11-07T16:29:48.958833448Z", + "start_time": "2023-11-07T16:29:48.898121017Z" } }, "outputs": [ @@ -1085,11 +1105,12 @@ " [ 444. 398. 371. 454. 356. 476. 565. 374. 0. 274.]\n", " [ 668. 446. 317. 648. 469. 752. 394. 286. 274. 0.]]\n", "\n", + "Set parameter PreCrush to value 1\n", "Set parameter LazyConstraints to value 1\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 10 rows, 45 columns and 90 nonzeros\n", "Model fingerprint: 0x719675e5\n", @@ -1114,7 +1135,7 @@ " Lazy constraints: 3\n", "\n", "Explored 1 nodes (17 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 32 (of 32 available processors)\n", + "Thread count was 12 (of 12 available processors)\n", "\n", "Solution count 1: 2921 \n", "\n", @@ -1263,8 +1284,9 @@ "id": "6217da7c", "metadata": { "collapsed": false, - "jupyter": { - "outputs_hidden": false + "ExecuteTime": { + "end_time": "2023-11-07T16:29:49.061613905Z", + "start_time": "2023-11-07T16:29:48.941857719Z" } }, "outputs": [ @@ -1300,8 +1322,8 @@ "\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 578 rows, 360 columns and 2128 nonzeros\n", "Model fingerprint: 0x4dc1c661\n", @@ -1312,7 +1334,7 @@ " Bounds range [1e+00, 1e+00]\n", " RHS range [1e+00, 1e+03]\n", "Presolve removed 244 rows and 131 columns\n", - "Presolve time: 0.02s\n", + "Presolve time: 0.01s\n", "Presolved: 334 rows, 229 columns, 842 nonzeros\n", "Variable types: 116 continuous, 113 integer (113 binary)\n", "Found heuristic solution: objective 440662.46430\n", @@ -1340,7 +1362,7 @@ " Relax-and-lift: 7\n", "\n", "Explored 1 nodes (234 simplex iterations) in 0.04 seconds (0.02 work units)\n", - "Thread count was 32 (of 32 available processors)\n", + "Thread count was 12 (of 12 available processors)\n", "\n", "Solution count 5: 364722 368600 374044 ... 440662\n", "\n", @@ -1450,7 +1472,12 @@ "cell_type": "code", "execution_count": 9, "id": "5fff7afe-5b7a-4889-a502-66751ec979bf", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2023-11-07T16:29:49.075657363Z", + "start_time": "2023-11-07T16:29:49.049561363Z" + } + }, "outputs": [ { "name": "stdout", @@ -1462,8 +1489,8 @@ "\n", "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", "\n", - "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", "\n", "Optimize a model with 15 rows, 10 columns and 30 nonzeros\n", "Model fingerprint: 0x2d2d1390\n", @@ -1487,7 +1514,7 @@ " 0 0 infeasible 0 301.00000 301.00000 0.00% - 0s\n", "\n", "Explored 1 nodes (8 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 32 (of 32 available processors)\n", + "Thread count was 12 (of 12 available processors)\n", "\n", "Solution count 1: 301 \n", "\n", @@ -1531,12 +1558,13 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "9f12e91f", "metadata": { "collapsed": false, - "jupyter": { - "outputs_hidden": false + "ExecuteTime": { + "end_time": "2023-11-07T16:29:49.075852252Z", + "start_time": "2023-11-07T16:29:49.050243601Z" } }, "outputs": [], diff --git a/miplearn/collectors/basic.py b/miplearn/collectors/basic.py index d7e190e..278251e 100644 --- a/miplearn/collectors/basic.py +++ b/miplearn/collectors/basic.py @@ -60,8 +60,7 @@ class BasicCollector: # Add lazy constraints to model if model.lazy_enforce is not None: - model.lazy_enforce(model, model.lazy_constrs_) - h5.put_scalar("mip_lazy", repr(model.lazy_constrs_)) + model.lazy_enforce(model, model.lazy_) # Save MPS file model.write(mps_filename) diff --git a/miplearn/components/cuts/__init__.py b/miplearn/components/cuts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/miplearn/components/cuts/mem.py b/miplearn/components/cuts/mem.py new file mode 100644 index 0000000..38ca663 --- /dev/null +++ b/miplearn/components/cuts/mem.py @@ -0,0 +1,105 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import logging +from typing import List, Dict, Any, Hashable, Union + +import numpy as np +from sklearn.preprocessing import MultiLabelBinarizer + +from miplearn.extractors.abstract import FeaturesExtractor +from miplearn.h5 import H5File +from miplearn.solvers.abstract import AbstractModel + +logger = logging.getLogger(__name__) + + +class _BaseMemorizingConstrComponent: + def __init__(self, clf: Any, extractor: FeaturesExtractor, field: str) -> None: + self.clf = clf + self.extractor = extractor + self.constrs_: List[Hashable] = [] + self.n_features_: int = 0 + self.n_targets_: int = 0 + self.field = field + + def fit( + self, + train_h5: List[str], + ) -> None: + logger.info("Reading training data...") + n_samples = len(train_h5) + x, y, constrs, n_features = [], [], [], None + constr_to_idx: Dict[Hashable, int] = {} + for h5_filename in train_h5: + with H5File(h5_filename, "r") as h5: + # Store constraints + sample_constrs_str = h5.get_scalar(self.field) + assert sample_constrs_str is not None + assert isinstance(sample_constrs_str, str) + sample_constrs = eval(sample_constrs_str) + assert isinstance(sample_constrs, list) + y_sample = [] + for c in sample_constrs: + if c not in constr_to_idx: + constr_to_idx[c] = len(constr_to_idx) + constrs.append(c) + y_sample.append(constr_to_idx[c]) + y.append(y_sample) + + # Extract features + x_sample = self.extractor.get_instance_features(h5) + assert len(x_sample.shape) == 1 + if n_features is None: + n_features = len(x_sample) + else: + assert len(x_sample) == n_features + x.append(x_sample) + logger.info("Constructing matrices...") + assert n_features is not None + self.n_features_ = n_features + self.constrs_ = constrs + self.n_targets_ = len(constr_to_idx) + x_np = np.vstack(x) + assert x_np.shape == (n_samples, n_features) + y_np = MultiLabelBinarizer().fit_transform(y) + assert y_np.shape == (n_samples, self.n_targets_) + logger.info( + f"Dataset has {n_samples:,d} samples, " + f"{n_features:,d} features and {self.n_targets_:,d} targets" + ) + logger.info("Training classifier...") + self.clf.fit(x_np, y_np) + + def predict( + self, + msg: str, + test_h5: str, + ) -> List[Hashable]: + with H5File(test_h5, "r") as h5: + x_sample = self.extractor.get_instance_features(h5) + assert x_sample.shape == (self.n_features_,) + x_sample = x_sample.reshape(1, -1) + logger.info(msg) + y = self.clf.predict(x_sample) + assert y.shape == (1, self.n_targets_) + y = y.reshape(-1) + return [self.constrs_[i] for (i, yi) in enumerate(y) if yi > 0.5] + + +class MemorizingCutsComponent(_BaseMemorizingConstrComponent): + def __init__(self, clf: Any, extractor: FeaturesExtractor) -> None: + super().__init__(clf, extractor, "mip_cuts") + + def before_mip( + self, + test_h5: str, + model: AbstractModel, + stats: Dict[str, Any], + ) -> None: + if model.cuts_enforce is None: + return + assert self.constrs_ is not None + model.cuts_aot_ = self.predict("Predicting cutting planes...", test_h5) + stats["Cuts: AOT"] = len(model.cuts_aot_) diff --git a/miplearn/components/lazy/mem.py b/miplearn/components/lazy/mem.py index a08d2d5..f054de4 100644 --- a/miplearn/components/lazy/mem.py +++ b/miplearn/components/lazy/mem.py @@ -1,74 +1,22 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. + import logging from typing import List, Dict, Any, Hashable -import numpy as np -from sklearn.preprocessing import MultiLabelBinarizer - +from miplearn.components.cuts.mem import ( + _BaseMemorizingConstrComponent, +) from miplearn.extractors.abstract import FeaturesExtractor -from miplearn.h5 import H5File from miplearn.solvers.abstract import AbstractModel logger = logging.getLogger(__name__) -class MemorizingLazyConstrComponent: +class MemorizingLazyComponent(_BaseMemorizingConstrComponent): def __init__(self, clf: Any, extractor: FeaturesExtractor) -> None: - self.clf = clf - self.extractor = extractor - self.constrs_: List[Hashable] = [] - self.n_features_: int = 0 - self.n_targets_: int = 0 - - def fit(self, train_h5: List[str]) -> None: - logger.info("Reading training data...") - n_samples = len(train_h5) - x, y, constrs, n_features = [], [], [], None - constr_to_idx: Dict[Hashable, int] = {} - for h5_filename in train_h5: - with H5File(h5_filename, "r") as h5: - - # Store lazy constraints - sample_constrs_str = h5.get_scalar("mip_lazy") - assert sample_constrs_str is not None - assert isinstance(sample_constrs_str, str) - sample_constrs = eval(sample_constrs_str) - assert isinstance(sample_constrs, list) - y_sample = [] - for c in sample_constrs: - if c not in constr_to_idx: - constr_to_idx[c] = len(constr_to_idx) - constrs.append(c) - y_sample.append(constr_to_idx[c]) - y.append(y_sample) - - # Extract features - x_sample = self.extractor.get_instance_features(h5) - assert len(x_sample.shape) == 1 - if n_features is None: - n_features = len(x_sample) - else: - assert len(x_sample) == n_features - x.append(x_sample) - - logger.info("Constructing matrices...") - assert n_features is not None - self.n_features_ = n_features - self.constrs_ = constrs - self.n_targets_ = len(constr_to_idx) - x_np = np.vstack(x) - assert x_np.shape == (n_samples, n_features) - y_np = MultiLabelBinarizer().fit_transform(y) - assert y_np.shape == (n_samples, self.n_targets_) - logger.info( - f"Dataset has {n_samples:,d} samples, " - f"{n_features:,d} features and {self.n_targets_:,d} targets" - ) - - logger.info("Training classifier...") - self.clf.fit(x_np, y_np) + super().__init__(clf, extractor, "mip_lazy") def before_mip( self, @@ -78,23 +26,8 @@ class MemorizingLazyConstrComponent: ) -> None: if model.lazy_enforce is None: return - assert self.constrs_ is not None - - # Read features - with H5File(test_h5, "r") as h5: - x_sample = self.extractor.get_instance_features(h5) - assert x_sample.shape == (self.n_features_,) - x_sample = x_sample.reshape(1, -1) - - # Predict violated constraints - logger.info("Predicting violated lazy constraints...") - y = self.clf.predict(x_sample) - assert y.shape == (1, self.n_targets_) - y = y.reshape(-1) - - # Enforce constraints - violations = [self.constrs_[i] for (i, yi) in enumerate(y) if yi > 0.5] + violations = self.predict("Predicting violated lazy constraints...", test_h5) logger.info(f"Enforcing {len(violations)} constraints ahead-of-time...") model.lazy_enforce(model, violations) stats["Lazy Constraints: AOT"] = len(violations) diff --git a/miplearn/problems/stab.py b/miplearn/problems/stab.py index c89cc24..fdaa621 100644 --- a/miplearn/problems/stab.py +++ b/miplearn/problems/stab.py @@ -1,14 +1,13 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - +import logging from dataclasses import dataclass -from typing import List, Union +from typing import List, Union, Any, Hashable import gurobipy as gp import networkx as nx import numpy as np -import pyomo.environ as pe from gurobipy import GRB, quicksum from networkx import Graph from scipy.stats import uniform, randint @@ -16,7 +15,8 @@ from scipy.stats.distributions import rv_frozen from miplearn.io import read_pkl_gz from miplearn.solvers.gurobi import GurobiModel -from miplearn.solvers.pyomo import PyomoModel + +logger = logging.getLogger(__name__) @dataclass @@ -82,35 +82,43 @@ class MaxWeightStableSetGenerator: return nx.generators.random_graphs.binomial_graph(self.n.rvs(), self.p.rvs()) -def build_stab_model_gurobipy(data: MaxWeightStableSetData) -> GurobiModel: - data = _read_stab_data(data) +def build_stab_model(data: MaxWeightStableSetData) -> GurobiModel: + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, MaxWeightStableSetData) + model = gp.Model() nodes = list(data.graph.nodes) + + # Variables and objective function x = model.addVars(nodes, vtype=GRB.BINARY, name="x") model.setObjective(quicksum(-data.weights[i] * x[i] for i in nodes)) - for clique in nx.find_cliques(data.graph): - model.addConstr(quicksum(x[i] for i in clique) <= 1) - model.update() - return GurobiModel(model) + # Edge inequalities + for (i1, i2) in data.graph.edges: + model.addConstr(x[i1] + x[i2] <= 1) -def build_stab_model_pyomo( - data: MaxWeightStableSetData, - solver: str = "gurobi_persistent", -) -> PyomoModel: - data = _read_stab_data(data) - model = pe.ConcreteModel() - nodes = pe.Set(initialize=list(data.graph.nodes)) - model.x = pe.Var(nodes, domain=pe.Boolean, name="x") - model.obj = pe.Objective(expr=sum([-data.weights[i] * model.x[i] for i in nodes])) - model.clique_eqs = pe.ConstraintList() - for clique in nx.find_cliques(data.graph): - model.clique_eqs.add(expr=sum(model.x[i] for i in clique) <= 1) - return PyomoModel(model, solver) + def cuts_separate(m: GurobiModel) -> List[Hashable]: + # Retrieve optimal fractional solution + x_val = m.inner.cbGetNodeRel(x) + # Check that we selected at most one vertex for each + # clique in the graph (sum <= 1) + violations: List[Hashable] = [] + for clique in nx.find_cliques(data.graph): + if sum(x_val[i] for i in clique) > 1.0001: + violations.append(tuple(sorted(clique))) + return violations -def _read_stab_data(data: Union[str, MaxWeightStableSetData]) -> MaxWeightStableSetData: - if isinstance(data, str): - data = read_pkl_gz(data) - assert isinstance(data, MaxWeightStableSetData) - return data + def cuts_enforce(m: GurobiModel, violations: List[Any]) -> None: + logger.info(f"Adding {len(violations)} clique cuts...") + for clique in violations: + m.add_constr(quicksum(x[i] for i in clique) <= 1) + + model.update() + + return GurobiModel( + model, + cuts_separate=cuts_separate, + cuts_enforce=cuts_enforce, + ) diff --git a/miplearn/solvers/abstract.py b/miplearn/solvers/abstract.py index 986716e..6f750de 100644 --- a/miplearn/solvers/abstract.py +++ b/miplearn/solvers/abstract.py @@ -23,7 +23,11 @@ class AbstractModel(ABC): def __init__(self) -> None: self.lazy_enforce: Optional[Callable] = None self.lazy_separate: Optional[Callable] = None - self.lazy_constrs_: Optional[List[Any]] = None + self.lazy_: Optional[List[Any]] = None + self.cuts_enforce: Optional[Callable] = None + self.cuts_separate: Optional[Callable] = None + self.cuts_: Optional[List[Any]] = None + self.cuts_aot_: Optional[List[Any]] = None self.where = self.WHERE_DEFAULT @abstractmethod diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 31dd1d9..7a29a9c 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -1,6 +1,7 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. +import logging from typing import Dict, Optional, Callable, Any, List import gurobipy as gp @@ -11,16 +12,40 @@ from scipy.sparse import lil_matrix from miplearn.h5 import H5File from miplearn.solvers.abstract import AbstractModel - -def _gurobi_callback(model: AbstractModel, where: int) -> None: - assert model.lazy_separate is not None - assert model.lazy_enforce is not None - assert model.lazy_constrs_ is not None - if where == GRB.Callback.MIPSOL: - model.where = model.WHERE_LAZY - violations = model.lazy_separate(model) - model.lazy_constrs_.extend(violations) - model.lazy_enforce(model, violations) +logger = logging.getLogger(__name__) + + +def _gurobi_callback(model: AbstractModel, gp_model: gp.Model, where: int) -> None: + # Lazy constraints + if model.lazy_separate is not None: + assert model.lazy_enforce is not None + assert model.lazy_ is not None + if where == GRB.Callback.MIPSOL: + model.where = model.WHERE_LAZY + violations = model.lazy_separate(model) + if len(violations) > 0: + model.lazy_.extend(violations) + model.lazy_enforce(model, violations) + + # User cuts + if model.cuts_separate is not None: + assert model.cuts_enforce is not None + assert model.cuts_ is not None + if where == GRB.Callback.MIPNODE: + status = gp_model.cbGet(GRB.Callback.MIPNODE_STATUS) + if status == GRB.OPTIMAL: + model.where = model.WHERE_CUTS + if model.cuts_aot_ is not None: + violations = model.cuts_aot_ + model.cuts_aot_ = None + logger.info(f"Enforcing {len(violations)} cuts ahead-of-time...") + else: + violations = model.cuts_separate(model) + if len(violations) > 0: + model.cuts_.extend(violations) + model.cuts_enforce(model, violations) + + # Cleanup model.where = model.WHERE_DEFAULT @@ -44,10 +69,14 @@ class GurobiModel(AbstractModel): inner: gp.Model, lazy_separate: Optional[Callable] = None, lazy_enforce: Optional[Callable] = None, + cuts_separate: Optional[Callable] = None, + cuts_enforce: Optional[Callable] = None, ) -> None: super().__init__() self.lazy_separate = lazy_separate self.lazy_enforce = lazy_enforce + self.cuts_separate = cuts_separate + self.cuts_enforce = cuts_enforce self.inner = inner def add_constrs( @@ -125,6 +154,10 @@ class GurobiModel(AbstractModel): except AttributeError: pass self._extract_after_mip_solution_pool(h5) + if self.lazy_ is not None: + h5.put_scalar("mip_lazy", repr(self.lazy_)) + if self.cuts_ is not None: + h5.put_scalar("mip_cuts", repr(self.cuts_)) def fix_variables( self, @@ -149,14 +182,22 @@ class GurobiModel(AbstractModel): stats["Fixed variables"] = n_fixed def optimize(self) -> None: - self.lazy_constrs_ = [] + self.lazy_ = [] + self.cuts_ = [] def callback(_: gp.Model, where: int) -> None: - _gurobi_callback(self, where) + _gurobi_callback(self, self.inner, where) + # Required parameters for lazy constraints if self.lazy_enforce is not None: self.inner.setParam("PreCrush", 1) self.inner.setParam("LazyConstraints", 1) + + # Required parameters for user cuts + if self.cuts_enforce is not None: + self.inner.setParam("PreCrush", 1) + + if self.lazy_enforce is not None or self.cuts_enforce is not None: self.inner.optimize(callback) else: self.inner.optimize() diff --git a/miplearn/solvers/pyomo.py b/miplearn/solvers/pyomo.py index 60d2a11..2d64f40 100644 --- a/miplearn/solvers/pyomo.py +++ b/miplearn/solvers/pyomo.py @@ -36,7 +36,6 @@ class PyomoModel(AbstractModel): self._is_warm_start_available = False self.lazy_separate = lazy_separate self.lazy_enforce = lazy_enforce - self.lazy_constrs_: Optional[List[Any]] = None if not hasattr(self.inner, "dual"): self.inner.dual = Suffix(direction=Suffix.IMPORT) self.inner.rc = Suffix(direction=Suffix.IMPORT) @@ -131,15 +130,14 @@ class PyomoModel(AbstractModel): self.solver.update_var(var) def optimize(self) -> None: - self.lazy_constrs_ = [] - + self.lazy_ = [] if self.lazy_separate is not None: assert ( self.solver_name == "gurobi_persistent" ), "Callbacks are currently only supported on gurobi_persistent" def callback(_: Any, __: Any, where: int) -> None: - _gurobi_callback(self, where) + _gurobi_callback(self, self.solver, where) self.solver.set_gurobi_param("PreCrush", 1) self.solver.set_gurobi_param("LazyConstraints", 1) diff --git a/tests/components/cuts/__init__.py b/tests/components/cuts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/cuts/test_mem.py b/tests/components/cuts/test_mem.py new file mode 100644 index 0000000..e766331 --- /dev/null +++ b/tests/components/cuts/test_mem.py @@ -0,0 +1,80 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2023, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from typing import Any, List, Hashable, Dict +from unittest.mock import Mock + +import gurobipy as gp +import networkx as nx +from gurobipy import GRB, quicksum +from sklearn.dummy import DummyClassifier +from sklearn.neighbors import KNeighborsClassifier + +from miplearn.components.cuts.mem import MemorizingCutsComponent +from miplearn.extractors.abstract import FeaturesExtractor +from miplearn.problems.stab import build_stab_model +from miplearn.solvers.gurobi import GurobiModel +from miplearn.solvers.learning import LearningSolver +import numpy as np + + +# def test_usage() -> None: +# model = _build_cut_model() +# solver = LearningSolver(components=[]) +# solver.optimize(model) +# assert model.cuts_ is not None +# assert len(model.cuts_) > 0 +# assert False + + +def test_mem_component( + stab_h5: List[str], + default_extractor: FeaturesExtractor, +) -> None: + clf = Mock(wraps=DummyClassifier()) + comp = MemorizingCutsComponent(clf=clf, extractor=default_extractor) + comp.fit(stab_h5) + + # Should call fit method with correct arguments + clf.fit.assert_called() + x, y = clf.fit.call_args.args + assert x.shape == (3, 50) + assert y.shape == (3, 388) + y = y.tolist() + assert y[0][:20] == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + assert y[1][:20] == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1] + assert y[2][:20] == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1] + + # Should store violations + assert comp.constrs_ is not None + assert comp.n_features_ == 50 + assert comp.n_targets_ == 388 + assert len(comp.constrs_) == 388 + + # Call before-mip + stats: Dict[str, Any] = {} + model = Mock() + comp.before_mip(stab_h5[0], model, stats) + + # Should call predict with correct args + clf.predict.assert_called() + (x_test,) = clf.predict.call_args.args + assert x_test.shape == (1, 50) + + # Should set cuts_aot_ + assert model.cuts_aot_ is not None + assert len(model.cuts_aot_) == 243 + + +def test_usage_stab( + stab_h5: List[str], + default_extractor: FeaturesExtractor, +) -> None: + data_filenames = [f.replace(".h5", ".pkl.gz") for f in stab_h5] + clf = KNeighborsClassifier(n_neighbors=1) + comp = MemorizingCutsComponent(clf=clf, extractor=default_extractor) + solver = LearningSolver(components=[comp]) + solver.fit(data_filenames) + stats = solver.optimize(data_filenames[0], build_stab_model) + assert stats["Cuts: AOT"] > 0 diff --git a/tests/components/lazy/test_mem.py b/tests/components/lazy/test_mem.py index 54147a4..39ab650 100644 --- a/tests/components/lazy/test_mem.py +++ b/tests/components/lazy/test_mem.py @@ -8,7 +8,7 @@ from unittest.mock import Mock from sklearn.dummy import DummyClassifier from sklearn.neighbors import KNeighborsClassifier -from miplearn.components.lazy.mem import MemorizingLazyConstrComponent +from miplearn.components.lazy.mem import MemorizingLazyComponent from miplearn.extractors.abstract import FeaturesExtractor from miplearn.problems.tsp import build_tsp_model from miplearn.solvers.learning import LearningSolver @@ -19,7 +19,7 @@ def test_mem_component( default_extractor: FeaturesExtractor, ) -> None: clf = Mock(wraps=DummyClassifier()) - comp = MemorizingLazyConstrComponent(clf=clf, extractor=default_extractor) + comp = MemorizingLazyComponent(clf=clf, extractor=default_extractor) comp.fit(tsp_h5) # Should call fit method with correct arguments @@ -56,7 +56,7 @@ def test_usage_tsp( # Should not crash data_filenames = [f.replace(".h5", ".pkl.gz") for f in tsp_h5] clf = KNeighborsClassifier(n_neighbors=1) - comp = MemorizingLazyConstrComponent(clf=clf, extractor=default_extractor) + comp = MemorizingLazyComponent(clf=clf, extractor=default_extractor) solver = LearningSolver(components=[comp]) solver.fit(data_filenames) solver.optimize(data_filenames[0], build_tsp_model) diff --git a/tests/conftest.py b/tests/conftest.py index ac9537e..e91fcff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - +import os +import shutil +import tempfile from glob import glob -from os.path import dirname -from typing import List +from os.path import dirname, basename, isfile +from tempfile import NamedTemporaryFile +from typing import List, Any import pytest @@ -12,14 +15,45 @@ from miplearn.extractors.abstract import FeaturesExtractor from miplearn.extractors.fields import H5FieldsExtractor +def _h5_fixture(pattern: str, request: Any) -> List[str]: + """ + Create a temporary copy of the provided .h5 files, along with the companion + .pkl.gz files, and return the path to the copy. Also register a finalizer, + so that the temporary folder is removed after the tests. + """ + filenames = glob(f"{dirname(__file__)}/fixtures/{pattern}") + print(filenames) + tmpdir = tempfile.mkdtemp() + + def cleanup() -> None: + shutil.rmtree(tmpdir) + + request.addfinalizer(cleanup) + + print(tmpdir) + for f in filenames: + fbase, _ = os.path.splitext(f) + for ext in [".h5", ".pkl.gz"]: + dest = os.path.join(tmpdir, f"{basename(fbase)}{ext}") + print(dest) + shutil.copy(f"{fbase}{ext}", dest) + assert isfile(dest) + return sorted(glob(f"{tmpdir}/*.h5")) + + +@pytest.fixture() +def multiknapsack_h5(request: Any) -> List[str]: + return _h5_fixture("multiknapsack*.h5", request) + + @pytest.fixture() -def multiknapsack_h5() -> List[str]: - return sorted(glob(f"{dirname(__file__)}/fixtures/multiknapsack-n100*.h5")) +def tsp_h5(request: Any) -> List[str]: + return _h5_fixture("tsp*.h5", request) @pytest.fixture() -def tsp_h5() -> List[str]: - return sorted(glob(f"{dirname(__file__)}/fixtures/tsp-n20*.h5")) +def stab_h5(request: Any) -> List[str]: + return _h5_fixture("stab*.h5", request) @pytest.fixture() diff --git a/tests/fixtures/gen_stab.py b/tests/fixtures/gen_stab.py new file mode 100644 index 0000000..7d942d1 --- /dev/null +++ b/tests/fixtures/gen_stab.py @@ -0,0 +1,23 @@ +from os.path import dirname + +import numpy as np +from scipy.stats import uniform, randint + +from miplearn.collectors.basic import BasicCollector +from miplearn.io import write_pkl_gz +from miplearn.problems.stab import ( + MaxWeightStableSetGenerator, + build_stab_model, +) + +np.random.seed(42) +gen = MaxWeightStableSetGenerator( + w=uniform(10.0, scale=1.0), + n=randint(low=50, high=51), + p=uniform(loc=0.5, scale=0.0), + fix_graph=True, +) +data = gen.generate(3) +data_filenames = write_pkl_gz(data, dirname(__file__), prefix="stab-n50-") +collector = BasicCollector() +collector.collect(data_filenames, build_stab_model) diff --git a/tests/fixtures/stab-n50-00000.h5 b/tests/fixtures/stab-n50-00000.h5 new file mode 100644 index 0000000000000000000000000000000000000000..ea3ea6287c8e72c0d8bf7e521cbf661bb734d26f GIT binary patch literal 42208 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/stab-n50-00000.mps.gz b/tests/fixtures/stab-n50-00000.mps.gz new file mode 100644 index 0000000000000000000000000000000000000000..a0e1c89ded75beaa43ef662b9185ed2755b15e98 GIT binary patch literal 7005 zcmd^C`9IX_`#yy!yJiyEhhdtK<&d$AZJg6g)+kxBB+jvpZL(xLh-o4QQ;1|4AxntR zVoOH3}FKDs74zf5x) z-TTO^`Xm0;IjO+XB!6Efv6S1POT+XkZadA*se7XDHJ>~Q*_a`VzTa3`SQwsPXx6e* zxrs@^RAVkyQx4z4pVs390?!t2C z$KZoixEo6eZ?*aEAdC01Iggc&Gs~`kV5Q^AQou_x-yPUUf3ni)VS8xaUKvrsi!IJ{ zW7&cl+S#Jb z)zHay!@RU2B6kZa4)Y}?W0Yv}G%wnI8jAMmp#t{8*mF}s3foQd_KJu*yh_EHE-Z%9 z&Q>~a7|?$yentLEaSQn`#jnVJDQ+RV+5JoSCwAWn?`HQe;UC$3C*0i`JJ)&bj^G`^ z62UCN0zn&l-zQJ{d3h+lK*0+}Hce#ND%5b8JIwDAhG zIX-K*x$q*-PHW9=C|R_2ZI~{%xpFVwZgY01Z=Kw%6|yoho4mQP)V#S#4tpHBxiUi+ z-CUapTZ&&;F&5Q+`9W z@^o`diCx9}Lg5Mbqz(c<@B|PAM4wP37(m{3F6%}J7*Oo90U+g+{02QvDOdSZk$3?a zihUM)f4bg6(daN!t6uq)*dG!Pq}XQyn0960$E0-UHmqU#y#`yxo51<_ z94^rP=BC08Le#1ZauD@3E!AUq$30~|J7uX+vLHV-S1HMfV^?2i9~x^E^m&p z>wz3uaew1KQ9Je9$ruxolS8@QvyVero>Fh}^_M*5!{oUN&-?q%eyC*cndvfYv*?kM z%ogd)ie3D>SM)~-C?;eA!UeXI7A`>aEJ4^vxt*&vvcs4HG*w_j=4nLC3V6{Fn{;W9 zhtFcnWey0aRXou`$Dzx=4s~Tq#oI}IWJmcts+bx&pqBgueVC=6#NmK3U17#i@vcce zK?qVEK?xF&@kk#EAwYm7m$u4q;4}r`DsK`QF2M0Ek^K1$+MEDauf_74S~gU_Aza|7 zzv-WSDzK+#B#LEGwumh=_cyI&TUt2n6eL$a7W3$vdEp&@9Cp;hwpz2M0-ajP*qx_{snC&7TwDiO@(kd4RQ;;<2hmgR_XO1-D zu|@D!dE_7#I-@?KgD4Y*w#y}S{?+%9s_6gUX3QWx=wFW=vg zL(7rTUDB|aAN$%>_TaaVPit|*5Y{k;y@%H(jdGneNPHhU@U(l9;!*DwS_1r|9|hUQ zLV3WmT?bbe>aF8oi+N$6%Zw%(@vZG}Dpxm~zN`(3tU|!dZWO#deoow}qfymrxe)$F zcMnM;#;b+auas`DdZzAB!XWuim2tx{$;Yh-JxS75JAn|qJl={3qNISPN|Q5uLFvSI z(m@`^H;B9@Q>2oLOHe1oOVwUs3^%MwA`iNUixuLZ@s1SxPQ2lu-{RENuMTbN*;Fi;Z7AYeGL1QibGBY zc)+8)_M4kLluSF#P5mfx4Yb{{?Yr`=GXV*lts#35MrUF#4m>Q`Ao#p_M_OC=EO);B zMY~_hNePp5iM{WKKQU_7f)Ic*TVINQ{%+`FgiXaUWIw5>k4lmus*|X}DzJXryuS^& zQuW$6vIh<}f+a6s6u1W`VTIKfSk>M)93Ks+5J3T1&Hs_*q^PO6@GN>|^0<%6L-KX- zRB{r(N{QUUThe3n`J_0K%t=i;YlbDWIwD$l_x1FrSI3tA2Gr z8%ZNI^&%N^@?dOj|6S1Hhio2-y@Ni~6|r^q5a(v$*AmAY=Zlroh&`9xI}?b!i*${9 zIwUwv%8B6KnR6zM2-VgRJ{zRpde;szz^CyeLU4daO{wi#xfzA z+4^B=E_>fotD%cLPNj3+SLxUJO62}|ODcM%we>hON#nyM)PdlE>Xyd1PmQ5Xjn$>? zr&KIKtd&qZIS8XWc|(!`8|!%Pely--S>d4RUOZ8#TlMUP09w0F<`biqvfh~xGel0W@+;K3lbWD-oA7#RldjZ=5;T(JR z1bv}1frHT)a>m6bqCVDs7KQ5D4mHldvPmPJZ5Q(C&*!S~lnuS3cPJrqmN;yHqp~_C zeaRdFFSdLJ<4>gN?d^gw1>g)i#imZfni7~3^|l@dYNcf`G`X&c(n3-^$?Dxb&pb@u z2Vqg((Z63)#LFCca?H>t4;sSlt!5n9qNkJ@a?+xo^xjtZi3_MXC-B>CHqcv&7>-x2 zeWXnxpNy@d3y&Ads1!_v_L=eK*8lz_L2t4FXJ%bCm3grWN4*t~1WcG^FvFSq%dfY{2s^nSMtUy zrnn@)!e^CH1IDDq>?UYoJ(JOFnWZUrE(~2yE@Ddf;`0vKc6(DQck!`wfU@|oPkMzm z4O&b=kb;MkGJ+9|mq8H?-l$~}n!HfjgRdIE}sb zo}?4H2^`|#C-kKPDvYjnzt|w)FJoWeE3>jy%Od*thrDkTzKa^4s&O|BH9ViR@@4Ax z&B}j>{+EP()SrT5;>>TrMSa4`a0cQ|>kZ)ORqCG4gw9tY7BJNR=&TH)mUaF39OGrj@YT0Awby^nOEh?ggCJ85fc$ATK~t|bUQfy@vb;+C$K8@3g?aMeKFxL|*IR$QGX{K-p{WXC~7 zJj7B@Tc@_EL1IyPy(d5L>R#>E4@inSBd1|pQ=nmdhgQ9j-z_y<_1B=m9m#~j@gRqy z{c-o^)IN$#XpyK5>9znbtKR$v^wOQ@k%pT~U?P>j7uu1;8>_o%8@#fv@EG2=eq~H)8 z?$Q(OX6`tmyoi#%0DKv{Ka|}`W0Vk1ui0lY=xud(k-eusib~oLYfrslr8f!g^UdH( zbm&BTI;wxnsA1d(9Es-ACpu?AII3?6&Ys07g3fa~Z{&{QA#Ns=)rv}}xRk7&&M z&>$3Ur27xgG$QU+lnKi)I2p-sarDC$eOO2}xsmNqCHA>1*(_cigd1KhO0$qyEpXaoaOlnr^Y4#VVXRdwFB9wisg(al$UrPph& zZ%|Kug%vZb50i^y>E_RUrH5*+&rsU}Fisj(40w57m`5~pW9|)i-TL&v=5O7Dd6{kl Hzy0=qq*hAf literal 0 HcmV?d00001 diff --git a/tests/fixtures/stab-n50-00000.pkl.gz b/tests/fixtures/stab-n50-00000.pkl.gz new file mode 100644 index 0000000000000000000000000000000000000000..8d2fe1e388251e6283afde53d0369c930b07b51d GIT binary patch literal 3002 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/stab-n50-00001.h5 b/tests/fixtures/stab-n50-00001.h5 new file mode 100644 index 0000000000000000000000000000000000000000..c88468af4690b1e2f77fe6dfc489e59ee5e6b3ef GIT binary patch literal 30212 zcmeHw3tUX;|NoifvWZ+*XzM6cE}2`+loE8 zzHYnLB9hx$qSaa@5w=v4_C)fXrH!-4k6lK&5IWw9w{Nfg@ z+~@561MJS{J>5oJ;!!*v*fZksctX|=x3ANH;xz~F>H@tk_*th;LsN+(YNz2q>-&?nZ`(SqVN zrL9N?P!vtIYCyG`@(*w^=9%f?62;;2ogT4P9X2tlKelgBP#@|`c0lqPQB)ab0558V$<%wJp+?3OFSCx$5!+AL<8&RZwjL!;O}p|R`xYPa+1WEq6R9S3 zdL_*ma(nJfYqK9EB{_%Jr;R=6o>TtqH{SAZ{l_`qd7g8&rm(ZkH(8yQKTw^n8a2D! zEb}n-qxXykO|3m=H)+>>O=g&zws7sY3vsnnPDmrzsxzk+QAT^qx(As+MP3WSv@R(d2aawq-RoYje-Q{>+t& zo0s}s?R5QMqv}z&9w-Z=E_}D-eB-^HE?%j-z16%E1M(-E3>-A}@Q5}wPK8~6h_m`C z=b~HKDC_wWk12hA4*h)A0GZFvKG9CgLK;svx3fs}r`sQyC8x&r-+QU%xgzCJ%FL+Z zTf?c|tKA=M9FC?{83yY}VQaS!(9Y>(5dX`5HnS90iLasn(=D zygcbpK;^w2c^mMgc)Q-ehc#2~rnx!o`*h1^6G}4A_O2|l%v&=-llt(Y+aa$?tt*UI zoGZjDoi}i#^JeoFLw8bip0IA|JYn3@cW3*L2@e;2sUI`!Dx@5^Ko%k{fcrJq}#l_k9KqzOUg8)&2L<> zIk_gjMe6N=u5-Qqw6i=Z{(&0);)nA1kj!1J>#mvFk)lp^O&$~`b3bpybB_*NpwPU&VQ!t4chx{Qlg#t7i`nIpv(^cGxG#5Hcs*G2 zrQ^bFIa`lz9Z5v*>zogBA<)z({CbxAD%NW&!bnr$pn+#7C zH&yVdR8 zS+s<!j1jYejGpPnCZEas{3N9$9w$!o)FN~;PK_yLXy_;)J^#SkOP|t#Qx1~md9t&!3 zVq0q}5e`42#^igqr_z9-8D%be)`{{1F$-!(;F)d|e!tn4YTA9ah}s9#b`%v=PfA?_ zhuTty{r+^PE)iuB<8FIX%YkZ1rI{b|qoP2}nlf)`7C=P+wH4LpS;+vZCGgVJ3#%?e zDIq9pLk%|lBZO)RhRvyTqvS9u9T-|uJ#QODQI)`8Nj-RUJ&Jk=)Mk|Jafk8L6i{YC z<;*pjOeKI=bE;RX(@E5H;%}5hl}xpS!_TNbb#A3lso(=m9sKgArPK~Mw4_99Lf27U ziM2G@nH|&!IBY|iJ`c*I?hyt_@)zf*gT#lq!fEHI`e2QwzQ}!XnTjC(+V2j(Ld_?J z?_54|i?SsQqN0O$C`S-$OWEYlE1-Ny@Xhyr{*dwlhIW*QKeCis1scpLi)(|QQU`%* zMIHV<>M4~CycSfAvPBIw05r6sI-6a33AA_JK15ZR1c7p!vMqa5%d5hyKAzulKL4lt z_z=fmFE=xyP+P zhc@t){@8rlY)@<9z}fx88O;-y{ABI))ofqa+Y6`8Xm2g>@$M&FuyFdKwbqUbZ(o;7 z3#ZKww-$YIx}VeTg^4TfSW6O4`}QaenmWBj2EUJ8Kf&Cf=?fNRh~;*^&RIdz<_2d7 z2W{-anNGrvA=7T5@EVXy~;pr+t};D<6bOCR{t) zqe4A3(c(7WOPVD}QBPmE;-W-5!=r zO>(_02#C)Ttty+o`0#By5p|d{uZ~Jr9N#>x0~bb*KEOp`FGaR9X5IU zx4E<+15=-g)7vFS_%&a4X@23J^bXr1{M+VUN~zGK+pH|| zYjx;S^1~h6^C$dNY7ubumJ-|pm+ z2P192zm{!Tu{*WKBA-r@o@<(-n*Ya&{Pr83CQWh9Zhc;rQZ_8#dhvmCEw`(Z@88Vt zxc@-*XAf1W)vo#NQsd7xUsXE)?&16nJL0q3{#Ke&IWgZR?diEzM@y3*J~*hbo}fsbA9=CgLruyJiV+q=DW^O+kxZCnJieY%7-Pdf0EO^;8!y{(%q zoUyjOjkCSCPp80zv-YgDach0ryQ6U7%&p-z-JMVSIH(pT9lm4ZUf0gMok`G)j23I8 z9qfGU`vuL~y=aYV%Z=V0It9(#9K6QGaidSy7j-JXYc~gm+m$(igSt1Fby0eIt2C!? zJzm`zl~bIDf7@+;@zrM6zOR1N|9orN{O@Hoaow!$oS3*MpzMpIrxzzIQI-uqnqyWa za4%T=eA%rsFUta-7g?FEIr9#m+w$_|t=40EEK4n1Qx*Sp;hjGwt}6Uvx!5fGha5%Y zWr>CPIpX@Mmo1(z{8c9Zc3$aYpIk}7?^`CWEWP+b@p4aGmRFTDs7J2NlnrjGyU&(O zWF@7ObFE5}OD|1YUETYjeYb)&lLGta+7_%k{QSFzbyG{Fd1kBD9|?^9E#=$mRYB2N zKF;@U+IPzvo8@1E2iv`gseSs@wW&iC@!2|lB|O0(c!!i z82jNB_O>fUIYAD^JN6$9UR-?9E#uINO%B-s<@XLpOfL4QOnc_s`L5?AMaZ(nh3mkB zY~Qq?XDcopSv_n~5}>nw*rIuh?}c2fx=?l6&Z=O|L`|w)tCD#YeX5F91@+Ih$+JDb z$1BUfa!!Sh`htbHeqQHgiKS<|bSqdp=~9n-6H+UD2pT!O>|O`$ZK2mnJFUz#eHJvL z*rPncYQ;SZMaX7fb?yjkUTYlbaKEDl6o zd#hj8m1Wdqb+QHH!D*#IlQu&{lQ#PvJb1r)#r<}Ww6-f-m9I|N2;LnE9y0jR#j+=^ zZUJSNLll!U&-AKM^xy2|ywTy8b%$=o-Wllo+4xnfHoUl*bH=}ViL89hU9?;kG-b0#?8e3gs}F_`NG%@dHa_jQHI)Z$ ziBjW^cs;sku_&o@fLoSFtioaQ-7Z&FyF9Tx;#G4m;fPxRq^4Uy&gHUI<#C&RYZN`e z@Zw&>$EU1XbF6y#c(IwASI$7UoTp2xM?X57U%C6)kv6|=I99#?*5#a4J9CWbdvxN)N*<9{Jx zluyp5ycLUgs~1{aj16u!@!@c(wKrAtTyl8ahr{B4UOEh_^;m2jddl*{VX<8gi$;yv zlUkb>J-VLkuIF&AKlJhl@FVjYJApBC9L#;J=TQpg4j7ydapU$whco9K-)OdHykb?# zFa;eDqftaD#>-XoIJru#P)5@Nz60MuK!?RDBE#u%%$Z8Yr|lvY(XsJPVtW%4j{$?| zC}p@j(uMYk*2p960_|xJrAlrW>~BviBweKvN4__WTs>)_gOKm#J-!RAh*oPMM=D1X zN{mV=ldIKA6_8`qis;dFYz(bYB8!G-QJa`>fzhFit8|<~6G1!iMYKv82Xc;lP#hT> z6|JU2qr*Xz??gu{qX7w}+Qc7&qep>exhh7bK!^D8QhAt27A^}l@leV~jZ(-I@@NgM z)`V&lYK=msb}^x8|4@xe5l^c^L6F96s6gJC&j;smHyk(u!p6t}gK=_>iEs!Q4gqrX z1^~jqf(htaIe6~C=c`RPVmM&?1`rTmeFRK=G;$S$2Au_) z&TBlQ=>aN56!eTw^e6{B^^Da!q3K{S3xSZ051(Dd4$eZUvq0*CzFldU_K>1O|G9wp8q#TAOZ3m+`Oc5Qb zO0ci>qp~lKYdS(6t0MJ+h9Me@i0cwjET&BP>7snkF)k)iRVt{B8EfB`8H@}03FbfKM{ z^#*JgGTi@Ww31TMA1byQi^oL#@Q78ZusS1EOVq1kY>mYxMt~jy!{3DN6CV>v4~mxK z4irJzgW@KSfr$g=IjD9(^of?mMunkr*m}#=n%?1X;1wE!ZJKVxA}N4O1!D>tpYAH) z^Bpik0m>m&EP=`f{Y~&OXqQSH#2t{0?us8!%)H4SKZqzfF!<;yff^|$KpUtMNgX8b ztV-m-f0HVSgR`NkUg6<5_X9Y8xjW*-wD`YL(8w((6$RC4pKvR zIZT9av*F|*kwDk~r44O6*buzYhWPC!93>(r(chS0+XYU45yo4Qa&mZY6X+J3&De)h zK1}k?d}oeIhKGK~YTKm_Qqda$5lP;Hhy+BP`BGd{FhkJEFnM(CP7dO~=vdJ|umMK2 zsJ|Kq+F;uM+Q!?G2X%;G@Mz<}Qlf{It{rUf--wmd+rtp>9mTq8C4J2V+wSNf78;uP z2i3~aK>`JlgwPedWmSnB1WtykOgv&WF!7J3V1*(NCk4C9ZgKIio-PnEb3v3!)d`zBoNVRMN~|rJYH{Rfoqj$oLr;^lZ%MfUkd79 zmC$jaYP`md?F55$}!}>}?nIz?3 zBsJ-;q?MWoSk7=u0yP~b*WkKR6A>ECinCWVuqx%a=uu=vVW&|>!csk228&LZMde-j zaEXQ!lm5Cw0^^hc96^U!7OptLkSC1j==!Gi(J-emOY06MCWv}hxEhNL3zdzf@jy+B z1mZ5_8Ut~RHDCZaM?}?xx%h-nZ|;IsOZoTUT@d(i-`nWlzxI8t|K4WDwYkCn9W1hH zJr?Gni#z>yu()39u?Se}HTu7UMNzHC!dFr};=hAMWv$0zX+lo7SFzw8K;!n@gJ^&A zyO8dFX}II}3%$Z!k1L<)dc<0KxOduEd$J(Z(%+GXT3p>* zOS|cfZsAiCQ>VW0SXZ6yH+R};my*PyuxrN`7@bV~wN={(UahPeJnpzN?BxQZ8#UJV zXAHdg!r#N_+_9taC(>hVdyI@c-1J;xk z^CgXV8Kq4edPWbcv8{_%O-*esmUo7w0*!1P8C*7u3yR9Ellz*br%tZQ_^%fqF+J9Z% z5uTsO+Vu%VxA^HoWW^A*rr`~Gy0!I?!yS#k{L13ex#Ct6zW(%4@7%85^LPq(k1svX zwc9qRzQdj)0{@}+@iRp#X60OjH^+c`el%J;R64>7mSPsv=VVqlp=^`W zt$B~e-b#=z4~e<{mC^2wIj1M|7Q5HUnLSGW+ZWl>{3=qubvypx{OJb`CtjWM=+VoO zPt0sm6%!(wR&G80vUk<~aWk*G4~-8Qo4snXx2G~|^_XvblCB;MaQ*3clgZvk9iKFz z=G8Izo&0>oKFNj0pMFYF$JzH&V3i?1W3lbrPg`6>|L*@q;9ms(Mc|`C;6tw4rt2X! zf?Lnl(sf(@;2&~6Bo@wkShVZKp43t-mQ?S%@gcEjt%t?$wLC9`w``YavCz9O^lkS- zK45Qt{h02BO!Pdt@qMs(@)2U;?6ctZp_5)S}gwR ze&KifE=2DhSx3FfIfk1>YVEy{OZEnQ(0L?74~;YqjaoXVZlgyRYV+b9=8;~#nIJ!= zdXaT)z?t{K0^ZI*wvP#mxKJzM`(QEfBgA58!=jy9EZW1p#_+HO_ZkE4WuC8|<1;$% z@DfLOB?-LfvktGG(3lsiS3bUA;iP8j5&wT4R<`P1p~ZroN4VIz{X53a%_H@UdF}K< zJI{q)Yv++5_n(WjSm@Qxw>_Knh>h%HdN#}R*CB`B2aA^r2Vgbv?(u7j>TnP7( zzQ!Z~O+wI4w24_xBqBkCrctFq1GvC7>KY`V2KA$(T5gUAo@t2&UT8T%Ou%t-M2=G% zfC%)XyJ&|Cilc$BNGpg;guu8ja^?`=8n_U+2E1*D*oVDP!l8f;f$KySBEfN^eb7LB zK_1Q*prfMK>3Yl}G))u%H}(+xiQrI7tfB^B5MWm#QHZX<0Wk-r#Do*}#3XW~3a$EA zxKXzlr8sWFiHH%eu@NPJfZ?MV{3Fik(I9dyjX0-G4iv_dE;Q(Kh67L|7O^U|=Xz#< z#`TNVG^bJsYM35m^da4Vh9^SQirK_jAw;lFOmoo)iOWFL5=dMq$~s5%ATnA#90w5r zc`zhEW7u5~BjYYuh9)sO;Dnmc4mKJPP=b{bfd)nrxENvdO)IR;79f&wM*17IA_353 z#xqEOdrU84BPi2nAXc=J2ph+MxrHvgu@QYlB3;>91ds~ zz2-vKs^&Tv6B-K&iG>*DS`daew-9Zl70rN3A_4^L8zBjcQ-ZlI;v*ZOu;W6Hf}oHb z;{YL5j&+JbA6kfGAgpbKctvFVfIK*WO{nD+GPr|e1~=e@W<1yRHV{}*Zp1UmNVpJS zR0w+}BMBAd*%pS1aRZSnRHQ5BIaB}^~`kfN`n|eU}Av= zgq;;KpmRhFl4v6Nftt{B*z1UTD#)CIc1eo8(xB5%0!77KUI8bQfcnlGq@`8_(+~n+ zhChpe0ChnRyw<>Z&v0vXaAt`f5MvS+(dV4fD${S(y8GHN0RJIs4l~l#1Objh29FjO zj;ghWF)=>8(tivURikrU2B0ER+g@!TsbT1g4s?zg`;cfN!-+Mh?~1M`IEy&6bQVzo zb}eAUOsCugODLEgq|O@5UKk;wMq1E1ju-HV0K;|zQv(fJv_KPI(cmjxr<2jPyjccp zklBeTB3w5km4FZNapMX?;}$1Og=VW0dJAZgMHUGI2LTee!Jr+jdKwrkU*#lYiHvlu zLT&(HFX_oMfU$_9X)$&MG0$-VkwgaPuQf1p07_vHdJ;@E;v~3*5E(;SJqR}{hdrYh z{9Dp0A)S<_q#-Lhh8we{nrj;dzk)4RZo(x$y;UYle*v2I} zM~8rPD>eO*#?&j2Ld>~^CgvlALIy4|2?-4}0w>bBg)q>_5(8+WSFQ?l-2<|02nPC) z240bI&B=oIWFSKXWZ`A7uEhQY23;6%f=+?{H_|a=Y$G6|W;o%rlW|WHfHR5iU>0d> z2R3j-G|l;yfC^^4j@b{bWZsAP$a;h+xNySMZtOVX6okcK#kmgzZHItk zIt;J}z-_XGH0)!v#loYTmw;1OS_Orr7b8&#Mws~s5C_}Z5)M+# z>Im#19fuL{YO9r=awKyj@y);j3V)d*7z`68>`8CFWgWsnib~lmc(p)~K1Yu+cCOEX zA0sDi#10_H>LLX}uW)c(W+0DPLey_99wbw+cMw8ODPTnURErabHF%_D(pCU$B!dmr zFfKFKj@aKoBk2T8B&YRbsDMvzyJL=I$v*Q1k%JmUj7x><};841iPMnL^_uwjc zuB8FmhzuQ{uyL>f37`1iGlV16CtS+WPoo*izn}k45isn3BZ}J4@~MnRHH(ZPy{Qf* zSrD#_i}vowb%~j-F*a;OYz!skTACWPpp+?l^COSalZr$sbC)+rK?_Q?XWyd*3CC(A zd8p>Qax$L+9p2yq?|&K*7OGaLN8nqNV%4s0979VTLpZ!kh|Npy9$b5KT{{G+icn)9 z-dwAmG}}so@!{H==t)PyAzlsCOhON-8+<%<#nxtAD zt(N!G{pSXTl97-Q@K&$y;6Db&=GL4D<|uZseulTV;aXd;t?*icSeZN=+)-=Pe7JW4H|Qs}nH|EhF|Hk$ ICWFua11fYGHvj+t literal 0 HcmV?d00001 diff --git a/tests/fixtures/stab-n50-00001.mps.gz b/tests/fixtures/stab-n50-00001.mps.gz new file mode 100644 index 0000000000000000000000000000000000000000..1e8311b7933fd890279132df8c9574ca71bb1526 GIT binary patch literal 7011 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/stab-n50-00002.h5 b/tests/fixtures/stab-n50-00002.h5 new file mode 100644 index 0000000000000000000000000000000000000000..c049b30d10a2fb640720444ff9791f0009aaa176 GIT binary patch literal 34868 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/stab-n50-00002.pkl.gz b/tests/fixtures/stab-n50-00002.pkl.gz new file mode 100644 index 0000000000000000000000000000000000000000..d8e5ae4b96f841c2c552f8ff0193b97ab4981346 GIT binary patch literal 3014 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 Imv|fi0CNS;i2wiq literal 0 HcmV?d00001 diff --git a/tests/problems/test_stab.py b/tests/problems/test_stab.py index 7c95d03..236cab4 100644 --- a/tests/problems/test_stab.py +++ b/tests/problems/test_stab.py @@ -9,8 +9,7 @@ import numpy as np from miplearn.h5 import H5File from miplearn.problems.stab import ( MaxWeightStableSetData, - build_stab_model_pyomo, - build_stab_model_gurobipy, + build_stab_model, ) from miplearn.solvers.abstract import AbstractModel @@ -21,8 +20,7 @@ def test_stab() -> None: weights=np.array([1.0, 1.0, 1.0, 1.0, 1.0]), ) for model in [ - build_stab_model_pyomo(data), - build_stab_model_gurobipy(data), + build_stab_model(data), ]: assert isinstance(model, AbstractModel) with NamedTemporaryFile() as tempfile: diff --git a/tests/test_lazy_pyomo.py b/tests/test_lazy_pyomo.py index a96f32e..27d3de2 100644 --- a/tests/test_lazy_pyomo.py +++ b/tests/test_lazy_pyomo.py @@ -39,6 +39,6 @@ def _build_model() -> PyomoModel: def test_pyomo_callback() -> None: model = _build_model() model.optimize() - assert model.lazy_constrs_ is not None - assert len(model.lazy_constrs_) > 0 + assert model.lazy_ is not None + assert len(model.lazy_) > 0 assert model.inner.x.value == 0.0