{ "cells": [ { "cell_type": "markdown", "id": "b4bd8bd6-3ce9-4932-852f-f98a44120a3e", "metadata": {}, "source": [ "# User cuts and lazy constraints\n", "\n", "User cuts and lazy constraints are two advanced mixed-integer programming techniques that can accelerate solver performance. User cuts are additional constraints, derived from the constraints already in the model, that can tighten the feasible region and eliminate fractional solutions, thus reducing the size of the branch-and-bound tree. Lazy constraints, on the other hand, are constraints that are potentially part of the problem formulation but are omitted from the initial model to reduce its size; these constraints are added to the formulation only once the solver finds a solution that violates them. While both techniques have been successful, significant computational effort may still be required to generate strong user cuts and to identify violated lazy constraints, which can reduce their effectiveness.\n", "\n", "MIPLearn is able to predict which user cuts and which lazy constraints to enforce at the beginning of the optimization process, using machine learning. In this tutorial, we will use the framework to predict subtour elimination constraints for the **traveling salesman problem** using Gurobipy. We assume that MIPLearn has already been correctly installed.\n", "\n", "
\n", "\n", "Solver Compatibility\n", "\n", "User cuts and lazy constraints are also supported in the Python/Pyomo and Julia/JuMP versions of the package. See the source code of build_tsp_model_pyomo and build_tsp_model_jump for more details. Note, however, the following limitations:\n", "\n", "- Python/Pyomo: Only `gurobi_persistent` is currently supported. PRs implementing callbacks for other persistent solvers are welcome.\n", "- Julia/JuMP: Only solvers supporting solver-independent callbacks are supported. As of JuMP 1.19, this includes Gurobi, CPLEX, XPRESS, SCIP and GLPK. Note that HiGHS and Cbc are not supported. As newer versions of JuMP implement further callback support, MIPLearn should become automatically compatible with these solvers.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "72229e1f-cbd8-43f0-82ee-17d6ec9c3b7d", "metadata": {}, "source": [ "## Modeling the traveling salesman problem\n", "\n", "Given a list of cities and the distances between them, the **traveling salesman problem (TSP)** asks for the shortest route starting at the first city, visiting each other city exactly once, then returning to the first city. This problem is a generalization of the Hamiltonian path problem, one of Karp's 21 NP-complete problems, and has many practical applications, including routing delivery trucks and scheduling airline routes.\n", "\n", "To describe an instance of TSP, we need to specify the number of cities $n$, and an $n \\times n$ matrix of distances. The class `TravelingSalesmanData`, in the `miplearn.problems.tsp` package, can hold this data:" ] }, { "cell_type": "markdown", "id": "4598a1bc-55b6-48cc-a050-2262786c203a", "metadata": {}, "source": [ "```python\n", "@dataclass\r\n", "class TravelingSalesmanData:\r\n", " n_cities: int\r\n", " distances: np.ndarray\n", "```" ] }, { "cell_type": "markdown", "id": "3a43cc12-1207-4247-bdb2-69a6a2910738", "metadata": {}, "source": [ "MIPLearn also provides `TravelingSalesmandGenerator`, a random generator for TSP instances, and `build_tsp_model_gurobipy`, a function which converts `TravelingSalesmanData` into an actual gurobipy optimization model, and which uses lazy constraints to enforce subtour elimination.\n", "\n", "The example below is a simplified and annotated version of `build_tsp_model_gurobipy`, illustrating the usage of callbacks with MIPLearn. Compared the the previous tutorial examples, note that, in addition to defining the variables, objective function and constraints of our problem, we also define two callback functions `lazy_separate` and `lazy_enforce`." ] }, { "cell_type": "code", "execution_count": 1, "id": "e4712a85-0327-439c-8889-933e1ff714e7", "metadata": {}, "outputs": [], "source": [ "import gurobipy as gp\n", "from gurobipy import quicksum, GRB, tuplelist\n", "from miplearn.solvers.gurobi import GurobiModel\n", "import networkx as nx\n", "import numpy as np\n", "from miplearn.problems.tsp import (\n", " TravelingSalesmanData,\n", " TravelingSalesmanGenerator,\n", ")\n", "from scipy.stats import uniform, randint\n", "from miplearn.io import write_pkl_gz, read_pkl_gz\n", "from miplearn.collectors.basic import BasicCollector\n", "from miplearn.solvers.learning import LearningSolver\n", "from miplearn.components.lazy.mem import MemorizingLazyComponent\n", "from miplearn.extractors.fields import H5FieldsExtractor\n", "from sklearn.neighbors import KNeighborsClassifier\n", "\n", "# Set up random seed to make example more reproducible\n", "np.random.seed(42)\n", "\n", "# Set up Python logging\n", "import logging\n", "\n", "logging.basicConfig(level=logging.WARNING)\n", "\n", "\n", "def build_tsp_model_gurobipy_simplified(data):\n", " # Read data from file if a filename is provided\n", " if isinstance(data, str):\n", " data = read_pkl_gz(data)\n", "\n", " # Create empty gurobipy model\n", " model = gp.Model()\n", "\n", " # Create set of edges between every pair of cities, for convenience\n", " edges = tuplelist(\n", " (i, j) for i in range(data.n_cities) for j in range(i + 1, data.n_cities)\n", " )\n", "\n", " # Add binary variable x[e] for each edge e\n", " x = model.addVars(edges, vtype=GRB.BINARY, name=\"x\")\n", "\n", " # Add objective function\n", " model.setObjective(quicksum(x[(i, j)] * data.distances[i, j] for (i, j) in edges))\n", "\n", " # Add constraint: must choose two edges adjacent to each city\n", " model.addConstrs(\n", " (\n", " quicksum(x[min(i, j), max(i, j)] for j in range(data.n_cities) if i != j)\n", " == 2\n", " for i in range(data.n_cities)\n", " ),\n", " name=\"eq_degree\",\n", " )\n", "\n", " def lazy_separate(m: GurobiModel):\n", " \"\"\"\n", " Callback function that finds subtours in the current solution.\n", " \"\"\"\n", " # Query current value of the x variables\n", " x_val = m.inner.cbGetSolution(x)\n", "\n", " # Initialize empty set of violations\n", " violations = []\n", "\n", " # Build set of edges we have currently selected\n", " selected_edges = [e for e in edges if x_val[e] > 0.5]\n", "\n", " # Build a graph containing the selected edges, using networkx\n", " graph = nx.Graph()\n", " graph.add_edges_from(selected_edges)\n", "\n", " # For each component of the graph\n", " for component in list(nx.connected_components(graph)):\n", "\n", " # If the component is not the entire graph, we found a\n", " # subtour. Add the edge cut to the list of violations.\n", " if len(component) < data.n_cities:\n", " cut_edges = [\n", " [e[0], e[1]]\n", " for e in edges\n", " if (e[0] in component and e[1] not in component)\n", " or (e[0] not in component and e[1] in component)\n", " ]\n", " violations.append(cut_edges)\n", "\n", " # Return the list of violations\n", " return violations\n", "\n", " def lazy_enforce(m: GurobiModel, violations) -> None:\n", " \"\"\"\n", " Callback function that, given a list of subtours, adds lazy\n", " constraints to remove them from the feasible region.\n", " \"\"\"\n", " print(f\"Enforcing {len(violations)} subtour elimination constraints\")\n", " for violation in violations:\n", " m.add_constr(quicksum(x[e[0], e[1]] for e in violation) >= 2)\n", "\n", " return GurobiModel(\n", " model,\n", " lazy_separate=lazy_separate,\n", " lazy_enforce=lazy_enforce,\n", " )" ] }, { "cell_type": "markdown", "id": "58875042-d6ac-4f93-b3cc-9a5822b11dad", "metadata": {}, "source": [ "The `lazy_separate` function starts by querying the current fractional solution value through `m.inner.cbGetSolution` (recall that `m.inner` is a regular gurobipy model), then finds the set of violated lazy constraints. Unlike a regular lazy constraint solver callback, note that `lazy_separate` does not add the violated constraints to the model; it simply returns a list of objects that uniquely identifies the set of lazy constraints that should be generated. Enforcing the constraints is the responsbility of the second callback function, `lazy_enforce`. This function takes as input the model and the list of violations found by `lazy_separate`, converts them into actual constraints, and adds them to the model through `m.add_constr`.\n", "\n", "During training data generation, MIPLearn calls `lazy_separate` and `lazy_enforce` in sequence, inside a regular solver callback. However, once the machine learning models are trained, MIPLearn calls `lazy_enforce` directly, before the optimization process starts, with a list of **predicted** violations, as we will see in the example below." ] }, { "cell_type": "markdown", "id": "5839728e-406c-4be2-ba81-83f2b873d4b2", "metadata": {}, "source": [ "
\n", "\n", "Constraint Representation\n", "\n", "How should user cuts and lazy constraints be represented is a decision that the user can make; MIPLearn is representation agnostic. The objects returned by `lazy_separate`, however, are serialized as JSON and stored in the HDF5 training data files. Therefore, it is recommended to use only simple objects, such as lists, tuples and dictionaries.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "847ae32e-fad7-406a-8797-0d79065a07fd", "metadata": {}, "source": [ "## Generating training data\n", "\n", "To test the callback defined above, we generate a small set of TSP instances, using the provided random instance generator. As in the previous tutorial, we generate some test instances and some training instances, then solve them using `BasicCollector`. Input problem data is stored in `tsp/train/00000.pkl.gz, ...`, whereas solver training data (including list of required lazy constraints) is stored in `tsp/train/00000.h5, ...`." ] }, { "cell_type": "code", "execution_count": 2, "id": "eb63154a-1fa6-4eac-aa46-6838b9c201f6", "metadata": {}, "outputs": [], "source": [ "# Configure generator to produce instances with 50 cities located\n", "# in the 1000 x 1000 square, and with slightly perturbed distances.\n", "gen = TravelingSalesmanGenerator(\n", " x=uniform(loc=0.0, scale=1000.0),\n", " y=uniform(loc=0.0, scale=1000.0),\n", " n=randint(low=50, high=51),\n", " gamma=uniform(loc=1.0, scale=0.25),\n", " fix_cities=True,\n", " round=True,\n", ")\n", "\n", "# Generate 500 instances and store input data file to .pkl.gz files\n", "data = gen.generate(500)\n", "train_data = write_pkl_gz(data[0:450], \"tsp/train\")\n", "test_data = write_pkl_gz(data[450:500], \"tsp/test\")\n", "\n", "# Solve the training instances in parallel, collecting the required lazy\n", "# constraints, in addition to other information, such as optimal solution.\n", "bc = BasicCollector()\n", "bc.collect(train_data, build_tsp_model_gurobipy_simplified, n_jobs=10)" ] }, { "cell_type": "markdown", "id": "6903c26c-dbe0-4a2e-bced-fdbf93513dde", "metadata": {}, "source": [ "## Training and solving new instances" ] }, { "cell_type": "markdown", "id": "57cd724a-2d27-4698-a1e6-9ab8345ef31f", "metadata": {}, "source": [ "After producing the training dataset, we can train the machine learning models to predict which lazy constraints are necessary. In this tutorial, we use the following ML strategy: given a new instance, find the 50 most similar ones in the training dataset and verify how often each lazy constraint was required. If a lazy constraint was required for the majority of the 50 most-similar instances, enforce it ahead-of-time for the current instance. To measure instance similarity, use the objective function only. This ML strategy can be implemented using `MemorizingLazyComponent` with `H5FieldsExtractor` and `KNeighborsClassifier`, as shown below." ] }, { "cell_type": "code", "execution_count": 3, "id": "43779e3d-4174-4189-bc75-9f564910e212", "metadata": {}, "outputs": [], "source": [ "solver = LearningSolver(\n", " components=[\n", " MemorizingLazyComponent(\n", " extractor=H5FieldsExtractor(instance_fields=[\"static_var_obj_coeffs\"]),\n", " clf=KNeighborsClassifier(n_neighbors=100),\n", " ),\n", " ],\n", ")\n", "solver.fit(train_data)" ] }, { "cell_type": "markdown", "id": "12480712-9d3d-4cbc-a6d7-d6c1e2f950f4", "metadata": {}, "source": [ "Next, we solve one of the test instances using the trained solver. In the run below, we can see that MIPLearn adds many lazy constraints ahead-of-time, before the optimization starts. During the optimization process itself, some additional lazy constraints are required, but very few." ] }, { "cell_type": "code", "execution_count": 4, "id": "23f904ad-f1a8-4b5a-81ae-c0b9e813a4b2", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Set parameter Threads to value 1\n", "Restricted license - for non-production use only - expires 2024-10-28\n", "Gurobi Optimizer version 10.0.3 build v10.0.3rc0 (linux64)\n", "\n", "CPU model: 13th Gen Intel(R) Core(TM) i7-13800H, instruction set [SSE2|AVX|AVX2]\n", "Thread count: 10 physical cores, 20 logical processors, using up to 1 threads\n", "\n", "Optimize a model with 50 rows, 1225 columns and 2450 nonzeros\n", "Model fingerprint: 0x04d7bec1\n", "Coefficient statistics:\n", " Matrix range [1e+00, 1e+00]\n", " Objective range [1e+01, 1e+03]\n", " Bounds range [1e+00, 1e+00]\n", " RHS range [2e+00, 2e+00]\n", "Presolve time: 0.00s\n", "Presolved: 50 rows, 1225 columns, 2450 nonzeros\n", "\n", "Iteration Objective Primal Inf. Dual Inf. Time\n", " 0 4.0600000e+02 9.700000e+01 0.000000e+00 0s\n", " 66 5.5880000e+03 0.000000e+00 0.000000e+00 0s\n", "\n", "Solved in 66 iterations and 0.01 seconds (0.00 work units)\n", "Optimal objective 5.588000000e+03\n", "\n", "User-callback calls 107, time in user-callback 0.00 sec\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "INFO:miplearn.components.cuts.mem:Predicting violated lazy constraints...\n", "INFO:miplearn.components.lazy.mem:Enforcing 19 constraints ahead-of-time...\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Enforcing 19 subtour elimination constraints\n", "Set parameter PreCrush to value 1\n", "Set parameter LazyConstraints to value 1\n", "Gurobi Optimizer version 10.0.3 build v10.0.3rc0 (linux64)\n", "\n", "CPU model: 13th Gen Intel(R) Core(TM) i7-13800H, instruction set [SSE2|AVX|AVX2]\n", "Thread count: 10 physical cores, 20 logical processors, using up to 1 threads\n", "\n", "Optimize a model with 69 rows, 1225 columns and 6091 nonzeros\n", "Model fingerprint: 0x09bd34d6\n", "Variable types: 0 continuous, 1225 integer (1225 binary)\n", "Coefficient statistics:\n", " Matrix range [1e+00, 1e+00]\n", " Objective range [1e+01, 1e+03]\n", " Bounds range [1e+00, 1e+00]\n", " RHS range [2e+00, 2e+00]\n", "Found heuristic solution: objective 29853.000000\n", "Presolve time: 0.00s\n", "Presolved: 69 rows, 1225 columns, 6091 nonzeros\n", "Variable types: 0 continuous, 1225 integer (1225 binary)\n", "\n", "Root relaxation: objective 6.139000e+03, 93 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 6139.00000 0 6 29853.0000 6139.00000 79.4% - 0s\n", "H 0 0 6390.0000000 6139.00000 3.93% - 0s\n", " 0 0 6165.50000 0 10 6390.00000 6165.50000 3.51% - 0s\n", "Enforcing 3 subtour elimination constraints\n", " 0 0 6165.50000 0 6 6390.00000 6165.50000 3.51% - 0s\n", " 0 0 6198.50000 0 16 6390.00000 6198.50000 3.00% - 0s\n", "* 0 0 0 6219.0000000 6219.00000 0.00% - 0s\n", "\n", "Cutting planes:\n", " Gomory: 11\n", " MIR: 1\n", " Zero half: 4\n", " Lazy constraints: 3\n", "\n", "Explored 1 nodes (222 simplex iterations) in 0.03 seconds (0.02 work units)\n", "Thread count was 1 (of 20 available processors)\n", "\n", "Solution count 3: 6219 6390 29853 \n", "\n", "Optimal solution found (tolerance 1.00e-04)\n", "Best objective 6.219000000000e+03, best bound 6.219000000000e+03, gap 0.0000%\n", "\n", "User-callback calls 141, time in user-callback 0.00 sec\n" ] } ], "source": [ "# Increase log verbosity, so that we can see what is MIPLearn doing\n", "logging.getLogger(\"miplearn\").setLevel(logging.INFO)\n", "\n", "# Solve a new test instance\n", "solver.optimize(test_data[0], build_tsp_model_gurobipy_simplified);" ] }, { "cell_type": "markdown", "id": "79cc3e61-ee2b-4f18-82cb-373d55d67de6", "metadata": {}, "source": [ "Finally, we solve the same instance, but using a regular solver, without ML prediction. We can see that a much larger number of lazy constraints are added during the optimization process itself. Additionally, the solver requires a larger number of iterations to find the optimal solution. There is not a significant difference in running time because of the small size of these instances." ] }, { "cell_type": "code", "execution_count": 5, "id": "a015c51c-091a-43b6-b761-9f3577fc083e", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Gurobi Optimizer version 10.0.3 build v10.0.3rc0 (linux64)\n", "\n", "CPU model: 13th Gen Intel(R) Core(TM) i7-13800H, instruction set [SSE2|AVX|AVX2]\n", "Thread count: 10 physical cores, 20 logical processors, using up to 1 threads\n", "\n", "Optimize a model with 50 rows, 1225 columns and 2450 nonzeros\n", "Model fingerprint: 0x04d7bec1\n", "Coefficient statistics:\n", " Matrix range [1e+00, 1e+00]\n", " Objective range [1e+01, 1e+03]\n", " Bounds range [1e+00, 1e+00]\n", " RHS range [2e+00, 2e+00]\n", "Presolve time: 0.00s\n", "Presolved: 50 rows, 1225 columns, 2450 nonzeros\n", "\n", "Iteration Objective Primal Inf. Dual Inf. Time\n", " 0 4.0600000e+02 9.700000e+01 0.000000e+00 0s\n", " 66 5.5880000e+03 0.000000e+00 0.000000e+00 0s\n", "\n", "Solved in 66 iterations and 0.01 seconds (0.00 work units)\n", "Optimal objective 5.588000000e+03\n", "\n", "User-callback calls 107, time in user-callback 0.00 sec\n", "Set parameter PreCrush to value 1\n", "Set parameter LazyConstraints to value 1\n", "Gurobi Optimizer version 10.0.3 build v10.0.3rc0 (linux64)\n", "\n", "CPU model: 13th Gen Intel(R) Core(TM) i7-13800H, instruction set [SSE2|AVX|AVX2]\n", "Thread count: 10 physical cores, 20 logical processors, using up to 1 threads\n", "\n", "Optimize a model with 50 rows, 1225 columns and 2450 nonzeros\n", "Model fingerprint: 0x77a94572\n", "Variable types: 0 continuous, 1225 integer (1225 binary)\n", "Coefficient statistics:\n", " Matrix range [1e+00, 1e+00]\n", " Objective range [1e+01, 1e+03]\n", " Bounds range [1e+00, 1e+00]\n", " RHS range [2e+00, 2e+00]\n", "Found heuristic solution: objective 29695.000000\n", "Presolve time: 0.00s\n", "Presolved: 50 rows, 1225 columns, 2450 nonzeros\n", "Variable types: 0 continuous, 1225 integer (1225 binary)\n", "\n", "Root relaxation: objective 5.588000e+03, 68 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 5588.00000 0 12 29695.0000 5588.00000 81.2% - 0s\n", "Enforcing 9 subtour elimination constraints\n", "Enforcing 11 subtour elimination constraints\n", "H 0 0 27241.000000 5588.00000 79.5% - 0s\n", " 0 0 5898.00000 0 8 27241.0000 5898.00000 78.3% - 0s\n", "Enforcing 4 subtour elimination constraints\n", "Enforcing 3 subtour elimination constraints\n", " 0 0 6066.00000 0 - 27241.0000 6066.00000 77.7% - 0s\n", "Enforcing 2 subtour elimination constraints\n", " 0 0 6128.00000 0 - 27241.0000 6128.00000 77.5% - 0s\n", " 0 0 6139.00000 0 6 27241.0000 6139.00000 77.5% - 0s\n", "H 0 0 6368.0000000 6139.00000 3.60% - 0s\n", " 0 0 6154.75000 0 15 6368.00000 6154.75000 3.35% - 0s\n", "Enforcing 2 subtour elimination constraints\n", " 0 0 6154.75000 0 6 6368.00000 6154.75000 3.35% - 0s\n", " 0 0 6165.75000 0 11 6368.00000 6165.75000 3.18% - 0s\n", "Enforcing 3 subtour elimination constraints\n", " 0 0 6204.00000 0 6 6368.00000 6204.00000 2.58% - 0s\n", "* 0 0 0 6219.0000000 6219.00000 0.00% - 0s\n", "\n", "Cutting planes:\n", " Gomory: 5\n", " MIR: 1\n", " Zero half: 4\n", " Lazy constraints: 4\n", "\n", "Explored 1 nodes (224 simplex iterations) in 0.10 seconds (0.03 work units)\n", "Thread count was 1 (of 20 available processors)\n", "\n", "Solution count 4: 6219 6368 27241 29695 \n", "\n", "Optimal solution found (tolerance 1.00e-04)\n", "Best objective 6.219000000000e+03, best bound 6.219000000000e+03, gap 0.0000%\n", "\n", "User-callback calls 170, time in user-callback 0.01 sec\n" ] } ], "source": [ "solver = LearningSolver(components=[]) # empty set of ML components\n", "solver.optimize(test_data[0], build_tsp_model_gurobipy_simplified);" ] }, { "cell_type": "markdown", "id": "432c99b2-67fe-409b-8224-ccef91de96d1", "metadata": {}, "source": [ "## Learning user cuts\n", "\n", "The example above focused on lazy constraints. To enforce user cuts instead, the procedure is very similar, with the following changes:\n", "\n", "- Instead of `lazy_separate` and `lazy_enforce`, use `cuts_separate` and `cuts_enforce`\n", "- Instead of `m.inner.cbGetSolution`, use `m.inner.cbGetNodeRel`\n", "\n", "For a complete example, see `build_stab_model_gurobipy`, `build_stab_model_pyomo` and `build_stab_model_jump`, which solves the maximum-weight stable set problem using user cut callbacks." ] }, { "cell_type": "code", "execution_count": null, "id": "e6cb694d-8c43-410f-9a13-01bf9e0763b7", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.7" } }, "nbformat": 4, "nbformat_minor": 5 }