From 485625e07f1912053b67f5c5bb52ac2e0b990030 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Mon, 8 Dec 2025 15:37:16 -0600 Subject: [PATCH] Implement MinWeightVertexCoverPerturber --- docs/guide/problems.ipynb | 39 +++++++++++++-------- miplearn/problems/vertexcover.py | 60 +++++++++++++++++++++++++++++++- miplearn/solvers/pyomo.py | 2 +- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/docs/guide/problems.ipynb b/docs/guide/problems.ipynb index a52a7ab..ad1f424 100644 --- a/docs/guide/problems.ipynb +++ b/docs/guide/problems.ipynb @@ -1596,7 +1596,10 @@ "\n", "The class [MinWeightVertexCoverGenerator][MinWeightVertexCoverGenerator] can be used to generate random instances of this problem. The class accepts the same parameters and behaves in the same way as [MaxWeightStableSetGenerator][MaxWeightStableSetGenerator]. See the [stable set section](#Stable-Set) for more details on the generation process.\n", "\n", + "To create multiple instances with the same graph structure but different vertex weights, you can use [MinWeightVertexCoverPerturber][MinWeightVertexCoverPerturber]. This class takes an existing MinWeightVertexCoverData instance and generates new instances by applying random scaling factors to the vertex weights while keeping the graph fixed.\n", + "\n", "[MinWeightVertexCoverGenerator]: ../../api/problems/#module-miplearn.problems.vertexcover\n", + "[MinWeightVertexCoverPerturber]: ../../api/problems/#module-miplearn.problems.vertexcover\n", "[MaxWeightStableSetGenerator]: ../../api/problems/#miplearn.problems.stab.MaxWeightStableSetGenerator\n", "\n", "### Example" @@ -1604,7 +1607,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 13, "id": "5fff7afe-5b7a-4889-a502-66751ec979bf", "metadata": { "ExecuteTime": { @@ -1618,8 +1621,8 @@ "output_type": "stream", "text": [ "graph [(0, 2), (0, 4), (0, 8), (1, 2), (1, 3), (1, 5), (1, 6), (1, 9), (2, 5), (2, 9), (3, 6), (3, 7), (6, 9), (7, 8), (8, 9)]\n", - "weights[0] [37.45 95.07 73.2 59.87 15.6 15.6 5.81 86.62 60.11 70.81]\n", - "weights[1] [ 2.06 96.99 83.24 21.23 18.18 18.34 30.42 52.48 43.19 29.12]\n", + "weights[0] [33.78 94.78 71.97 55.15 14.32 14.33 5.41 82.5 56.7 65.79]\n", + "weights[1] [36. 86.89 68.02 56.08 14.75 15.26 5.35 82.41 57.66 64.06]\n", "\n", "Gurobi Optimizer version 13.0.0 build v13.0.0rc1 (linux64 - \"Ubuntu 22.04.5 LTS\")\n", "\n", @@ -1627,34 +1630,34 @@ "Thread count: 16 physical cores, 16 logical processors, using up to 16 threads\n", "\n", "Optimize a model with 15 rows, 10 columns and 30 nonzeros (Min)\n", - "Model fingerprint: 0x2d2d1390\n", + "Model fingerprint: 0xf99bd426\n", "Model has 10 linear objective coefficients\n", "Variable types: 0 continuous, 10 integer (10 binary)\n", "Coefficient statistics:\n", " Matrix range [1e+00, 1e+00]\n", - " Objective range [6e+00, 1e+02]\n", + " Objective range [5e+00, 9e+01]\n", " Bounds range [1e+00, 1e+00]\n", " RHS range [1e+00, 1e+00]\n", - "Found heuristic solution: objective 301.0000000\n", + "Found heuristic solution: objective 283.6700000\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: cutoff, 8 iterations, 0.00 seconds (0.00 work units)\n", + "Root relaxation: cutoff, 6 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 cutoff 0 301.00000 301.00000 0.00% - 0s\n", + " 0 0 cutoff 0 283.67000 283.67000 0.00% - 0s\n", "\n", - "Explored 1 nodes (8 simplex iterations) in 0.00 seconds (0.00 work units)\n", + "Explored 1 nodes (6 simplex iterations) in 0.00 seconds (0.00 work units)\n", "Thread count was 16 (of 16 available processors)\n", "\n", - "Solution count 1: 301 \n", + "Solution count 1: 283.67 \n", "\n", "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 3.010000000000e+02, best bound 3.010000000000e+02, gap 0.0000%\n", + "Best objective 2.836700000000e+02, best bound 2.836700000000e+02, gap 0.0000%\n", "\n", "User-callback calls 335, time in user-callback 0.00 sec\n" ] @@ -1666,6 +1669,7 @@ "from scipy.stats import uniform, randint\n", "from miplearn.problems.vertexcover import (\n", " MinWeightVertexCoverGenerator,\n", + " MinWeightVertexCoverPerturber,\n", " build_vertexcover_model_gurobipy,\n", ")\n", "\n", @@ -1673,13 +1677,20 @@ "random.seed(42)\n", "np.random.seed(42)\n", "\n", - "# Generate random instances with a 10-node graph,\n", + "# Generate a reference instance with a 10-node graph,\n", "# 25% density and random weights in the [0, 100] interval.\n", - "data = MinWeightVertexCoverGenerator(\n", + "generator = MinWeightVertexCoverGenerator(\n", " w=uniform(loc=0.0, scale=100.0),\n", " n=randint(low=10, high=11),\n", " p=uniform(loc=0.25, scale=0.0),\n", - ").generate(10)\n", + ")\n", + "reference_instance = generator.generate(1)[0]\n", + "\n", + "# Generate perturbed instances using the reference\n", + "perturber = MinWeightVertexCoverPerturber(\n", + " w_jitter=uniform(loc=0.9, scale=0.1),\n", + ")\n", + "data = perturber.perturb(reference_instance, 10)\n", "\n", "# Print the graph and weights for two instances\n", "print(\"graph\", data[0].graph.edges)\n", diff --git a/miplearn/problems/vertexcover.py b/miplearn/problems/vertexcover.py index 2815d8c..cdc7d79 100644 --- a/miplearn/problems/vertexcover.py +++ b/miplearn/problems/vertexcover.py @@ -12,7 +12,11 @@ from networkx import Graph from scipy.stats import uniform, randint from scipy.stats.distributions import rv_frozen -from .stab import MaxWeightStableSetGenerator +from .stab import ( + MaxWeightStableSetGenerator, + MaxWeightStableSetPerturber, + MaxWeightStableSetData, +) from miplearn.solvers.gurobi import GurobiModel from ..io import read_pkl_gz @@ -24,12 +28,34 @@ class MinWeightVertexCoverData: class MinWeightVertexCoverGenerator: + """Random instance generator for the Minimum-Weight Vertex Cover Problem. + + Generates instances by creating a new random Erdős-Rényi graph $G_{n,p}$ for each + instance, where $n$ and $p$ are sampled from user-provided probability distributions + `n` and `p`. For each instance, the generator independently samples each $w_v$ from + the user-provided probability distribution `w`. + """ + def __init__( self, w: rv_frozen = uniform(loc=10.0, scale=1.0), n: rv_frozen = randint(low=250, high=251), p: rv_frozen = uniform(loc=0.05, scale=0.0), ): + """Initialize the problem generator. + + Parameters + ---------- + w: rv_continuous + Probability distribution for vertex weights. + n: rv_discrete + Probability distribution for parameter $n$ in Erdős-Rényi model. + p: rv_continuous + Probability distribution for parameter $p$ in Erdős-Rényi model. + """ + assert isinstance(w, rv_frozen), "w should be a SciPy probability distribution" + assert isinstance(n, rv_frozen), "n should be a SciPy probability distribution" + assert isinstance(p, rv_frozen), "p should be a SciPy probability distribution" self._generator = MaxWeightStableSetGenerator(w, n, p) def generate(self, n_samples: int) -> List[MinWeightVertexCoverData]: @@ -39,6 +65,38 @@ class MinWeightVertexCoverGenerator: ] +class MinWeightVertexCoverPerturber: + """Perturbation generator for existing Minimum-Weight Vertex Cover instances. + + Takes an existing MinWeightVertexCoverData instance and generates new instances + by applying randomization factors to the existing weights while keeping the graph fixed. + """ + + def __init__( + self, + w_jitter: rv_frozen = uniform(loc=0.9, scale=0.2), + ): + """Initialize the perturbation generator. + + Parameters + ---------- + w_jitter: rv_continuous + Probability distribution for randomization factors applied to vertex weights. + """ + self._perturber = MaxWeightStableSetPerturber(w_jitter) + + def perturb( + self, + instance: MinWeightVertexCoverData, + n_samples: int, + ) -> List[MinWeightVertexCoverData]: + stab_instance = MaxWeightStableSetData(instance.graph, instance.weights) + perturbed_instances = self._perturber.perturb(stab_instance, n_samples) + return [ + MinWeightVertexCoverData(s.graph, s.weights) for s in perturbed_instances + ] + + def build_vertexcover_model_gurobipy( data: Union[str, MinWeightVertexCoverData] ) -> GurobiModel: diff --git a/miplearn/solvers/pyomo.py b/miplearn/solvers/pyomo.py index 1960641..cbfab4e 100644 --- a/miplearn/solvers/pyomo.py +++ b/miplearn/solvers/pyomo.py @@ -262,7 +262,7 @@ class PyomoModel(AbstractModel): if len(obj_quad) > 0: nvars = len(names) matrix = np.zeros((nvars, nvars)) - for ((left_varname, right_varname), coeff) in obj_quad.items(): + for (left_varname, right_varname), coeff in obj_quad.items(): assert left_varname in varname_to_idx assert right_varname in varname_to_idx left_idx = varname_to_idx[left_varname]