Implement MaxCutPerturber

This commit is contained in:
2025-12-08 13:21:04 -06:00
parent 9192bb02eb
commit 15cdb7e679
3 changed files with 48 additions and 153 deletions

View File

@@ -1666,17 +1666,7 @@
"cell_type": "markdown",
"id": "j49upfw2o8k",
"metadata": {},
"source": [
"### Random instance generator\n",
"\n",
"The class [MaxCutGenerator][MaxCutGenerator] can be used to generate random instances of this problem. The generator operates in two modes:\n",
"\n",
"When `fix_graph=False`, a new random Erdős-Rényi graph $G_{n,p}$ is generated for each instance, where $n$ (number of vertices) and $p$ (edge probability) are sampled from the provided probability distributions. Each edge is assigned a random weight drawn from the set $\\{-1, +1\\}$ with equal probability.\n",
"\n",
"When `fix_graph=True`, a single random graph is generated during initialization and reused across all instances. To create variations, the generator randomly flips the sign of each edge weight with probability `w_jitter`, allowing for instances with the same graph structure but different edge weight patterns.\n",
"\n",
"[MaxCutGenerator]: ../../api/problems/#miplearn.problems.maxcut.MaxCutGenerator"
]
"source": "### Random instance generator\n\nThe class [MaxCutGenerator][MaxCutGenerator] can be used to generate random instances of this problem. For each instance, the generator creates a new random Erdős-Rényi graph $G_{n,p}$, where $n$ (number of vertices) and $p$ (edge probability) are sampled from user-provided probability distributions. Each edge is assigned a random weight drawn from the set $\\{-1, +1\\}$ with equal probability.\n\nTo create multiple instances with the same graph structure but different edge weight patterns, you can use [MaxCutPerturber][MaxCutPerturber]. This class takes an existing MaxCutData instance and generates new instances by randomly flipping the sign of each edge weight with a given probability while keeping the graph structure fixed.\n\n[MaxCutGenerator]: ../../api/problems/#miplearn.problems.maxcut.MaxCutGenerator\n[MaxCutPerturber]: ../../api/problems/#miplearn.problems.maxcut.MaxCutPerturber"
},
{
"cell_type": "markdown",
@@ -1688,85 +1678,11 @@
},
{
"cell_type": "code",
"execution_count": 10,
"execution_count": null,
"id": "uge28hmv3a",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"graph edges: [(0, 2), (0, 3), (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), (7, 9), (8, 9)]\n",
"weights[0]: [ 1 1 1 -1 -1 -1 -1 -1 -1 -1 1 -1 -1 1 1 -1 -1]\n",
"weights[1]: [-1 1 -1 -1 -1 1 -1 1 -1 1 -1 1 -1 -1 1 -1 1]\n",
"\n",
"Gurobi Optimizer version 13.0.0 build v13.0.0rc1 (linux64 - \"Ubuntu 22.04.5 LTS\")\n",
"\n",
"CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n",
"Thread count: 16 physical cores, 16 logical processors, using up to 16 threads\n",
"\n",
"Optimize a model with 0 rows, 10 columns and 0 nonzeros (Min)\n",
"Model fingerprint: 0x005f9eac\n",
"Model has 5 linear objective coefficients\n",
"Model has 17 quadratic objective terms\n",
"Variable types: 0 continuous, 10 integer (10 binary)\n",
"Coefficient statistics:\n",
" Matrix range [0e+00, 0e+00]\n",
" Objective range [1e+00, 5e+00]\n",
" QObjective range [2e+00, 2e+00]\n",
" Bounds range [1e+00, 1e+00]\n",
" RHS range [0e+00, 0e+00]\n",
"Found heuristic solution: objective 0.0000000\n",
"Found heuristic solution: objective -3.0000000\n",
"Presolve removed 0 rows and 10 columns\n",
"Presolve time: 0.00s\n",
"Presolve: All rows and columns removed\n",
"\n",
"Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)\n",
"Thread count was 1 (of 16 available processors)\n",
"\n",
"Solution count 2: -3 0 \n",
"No other solutions better than -3\n",
"\n",
"Optimal solution found (tolerance 1.00e-04)\n",
"Best objective -3.000000000000e+00, best bound -3.000000000000e+00, gap 0.0000%\n",
"\n",
"User-callback calls 100, time in user-callback 0.00 sec\n"
]
}
],
"source": [
"import random\n",
"import numpy as np\n",
"from scipy.stats import uniform, randint\n",
"from miplearn.problems.maxcut import (\n",
" MaxCutGenerator,\n",
" build_maxcut_model_gurobipy,\n",
")\n",
"\n",
"# Set random seed to make example reproducible\n",
"random.seed(42)\n",
"np.random.seed(42)\n",
"\n",
"# Generate random instances with a fixed 10-node graph,\n",
"# 30% edge probability, and random weight jittering\n",
"data = MaxCutGenerator(\n",
" n=randint(low=10, high=11),\n",
" p=uniform(loc=0.3, scale=0.0),\n",
" w_jitter=0.2,\n",
" fix_graph=True,\n",
").generate(10)\n",
"\n",
"# Print the graph and weights for two instances\n",
"print(\"graph edges:\", list(data[0].graph.edges()))\n",
"print(\"weights[0]:\", data[0].weights)\n",
"print(\"weights[1]:\", data[1].weights)\n",
"print()\n",
"\n",
"# Build and optimize the first instance\n",
"model = build_maxcut_model_gurobipy(data[0])\n",
"model.optimize()"
]
"outputs": [],
"source": "import random\nimport numpy as np\nfrom scipy.stats import uniform, randint\nfrom miplearn.problems.maxcut import (\n MaxCutGenerator,\n MaxCutPerturber,\n build_maxcut_model_gurobipy,\n)\n\n# Set random seed to make example reproducible\nrandom.seed(42)\nnp.random.seed(42)\n\n# Generate a reference instance with a 10-node graph and 30% edge probability\ngenerator = MaxCutGenerator(\n n=randint(low=10, high=11),\n p=uniform(loc=0.3, scale=0.0),\n)\nreference_instance = generator.generate(1)[0]\n\n# Generate perturbed instances using the reference\nperturber = MaxCutPerturber(w_jitter=0.2)\ndata = perturber.perturb(reference_instance, 10)\n\n# Print the graph and weights for two instances\nprint(\"graph edges:\", list(data[0].graph.edges()))\nprint(\"weights[0]:\", data[0].weights)\nprint(\"weights[1]:\", data[1].weights)\nprint()\n\n# Build and optimize the first instance\nmodel = build_maxcut_model_gurobipy(data[0])\nmodel.optimize()"
}
],
"metadata": {
@@ -1790,4 +1706,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@@ -25,24 +25,18 @@ class MaxCutData:
class MaxCutGenerator:
"""
Random instance generator for the Maximum Cut Problem.
"""Random instance generator for the Maximum Cut Problem.
The generator operates in two modes. When `fix_graph=True`, a single random Erdős-Rényi graph $G_{n,
p}$ is generated during initialization, with parameters $n$ and $p$ drawn from their respective probability
distributions, and each edge is assigned a random weight drawn from the set {-1, 1}, with equal probability. To
generate each instance variation, the generator randomly flips the sign of each edge weight with probability
`w_jitter`. The graph remains the same across all variations.
When `fix_graph=False`, a new random graph is generated for each instance, with random {-1,1} edge weights.
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.
For each instance, the generator assigns random edge weights drawn from the set {-1, 1}
with equal probability.
"""
def __init__(
self,
n: rv_frozen,
p: rv_frozen,
w_jitter: float = 0.0,
fix_graph: bool = False,
):
"""
Initialize the problem generator.
@@ -53,35 +47,16 @@ class MaxCutGenerator:
Probability distribution for the number of nodes.
p: rv_continuous
Probability distribution for the graph density.
w_jitter: float
Probability that each edge weight flips from -1 to 1. Only applicable if fix_graph is True.
fix_graph: bool
Controls graph generation for instances. If false, a new random graph is
generated for each instance. If true, the same graph is reused across instances.
"""
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.n = n
self.p = p
self.w_jitter = w_jitter
self.fix_graph = fix_graph
self.graph = None
self.weights = None
if fix_graph:
self.graph = self._generate_graph()
self.weights = self._generate_weights(self.graph)
def generate(self, n_samples: int) -> List[MaxCutData]:
def _sample() -> MaxCutData:
if self.graph is not None:
graph = self.graph
weights = self.weights
jitter = self._generate_jitter(graph)
weights = weights * jitter
else:
graph = self._generate_graph()
weights = self._generate_weights(graph)
assert weights is not None
graph = self._generate_graph()
weights = self._generate_weights(graph)
return MaxCutData(graph, weights)
return [_sample() for _ in range(n_samples)]
@@ -94,6 +69,41 @@ class MaxCutGenerator:
m = graph.number_of_edges()
return np.random.randint(2, size=(m,)) * 2 - 1
class MaxCutPerturber:
"""Perturbation generator for existing Maximum Cut instances.
Takes an existing MaxCutData instance and generates new instances by randomly
flipping the sign of each edge weight with a given probability while keeping
the graph structure fixed.
"""
def __init__(
self,
w_jitter: float = 0.05,
):
"""Initialize the perturbation generator.
Parameters
----------
w_jitter: float
Probability that each edge weight flips sign (from -1 to 1 or vice versa).
"""
assert 0.0 <= w_jitter <= 1.0, "w_jitter should be between 0.0 and 1.0"
self.w_jitter = w_jitter
def perturb(
self,
instance: MaxCutData,
n_samples: int,
) -> List[MaxCutData]:
def _sample() -> MaxCutData:
jitter = self._generate_jitter(instance.graph)
weights = instance.weights * jitter
return MaxCutData(instance.graph, weights)
return [_sample() for _ in range(n_samples)]
def _generate_jitter(self, graph: Graph) -> np.ndarray:
m = graph.number_of_edges()
return (np.random.rand(m) >= self.w_jitter).astype(int) * 2 - 1

View File

@@ -22,12 +22,11 @@ def _set_seed() -> None:
np.random.seed(42)
def test_maxcut_generator_not_fixed() -> None:
def test_maxcut_generator() -> None:
_set_seed()
gen = MaxCutGenerator(
n=randint(low=5, high=6),
p=uniform(loc=0.5, scale=0.0),
fix_graph=False,
)
data = gen.generate(3)
assert len(data) == 3
@@ -41,35 +40,6 @@ def test_maxcut_generator_not_fixed() -> None:
(3, 4),
]
assert data[0].weights.tolist() == [-1, 1, -1, -1, -1, 1]
assert list(data[1].graph.nodes()) == [0, 1, 2, 3, 4]
assert list(data[1].graph.edges()) == [(0, 1), (0, 3), (0, 4), (1, 4), (3, 4)]
assert data[1].weights.tolist() == [-1, -1, -1, 1, -1]
def test_maxcut_generator_fixed() -> None:
random.seed(42)
np.random.seed(42)
gen = MaxCutGenerator(
n=randint(low=5, high=6),
p=uniform(loc=0.5, scale=0.0),
fix_graph=True,
w_jitter=0.25,
)
data = gen.generate(3)
assert len(data) == 3
for i in range(3):
assert list(data[i].graph.nodes()) == [0, 1, 2, 3, 4]
assert list(data[i].graph.edges()) == [
(0, 2),
(0, 3),
(0, 4),
(2, 3),
(2, 4),
(3, 4),
]
assert data[0].weights.tolist() == [-1, -1, 1, 1, -1, 1]
assert data[1].weights.tolist() == [-1, -1, -1, -1, 1, -1]
assert data[2].weights.tolist() == [1, 1, -1, -1, -1, 1]
def test_maxcut_model() -> None:
@@ -77,7 +47,6 @@ def test_maxcut_model() -> None:
data = MaxCutGenerator(
n=randint(low=10, high=11),
p=uniform(loc=0.5, scale=0.0),
fix_graph=True,
).generate(1)[0]
for model in [
build_maxcut_model_gurobipy(data),