qqa 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qqa/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """Quasi-Quantum Annealing (QQA) for combinatorial and spin-glass optimization.
2
+
3
+ Reference:
4
+ Y. Ichikawa, Y. Arai. "Continuous Tensor Relaxation for Finding Diverse
5
+ Solutions in Combinatorial Optimization." ICLR 2025.
6
+
7
+ Typical usage::
8
+
9
+ import networkx as nx
10
+ import qqa
11
+
12
+ qqa.fix_seed(0)
13
+ g = nx.random_regular_graph(d=3, n=50, seed=0)
14
+ problem = qqa.MaximumIndependentSet(g, penalty=2)
15
+ result = qqa.anneal(problem, sol_size=100, num_epochs=1500)
16
+ print(result.best_obj, result.runtime)
17
+
18
+ Spin-glass example::
19
+
20
+ problem = qqa.SherringtonKirkpatrick(N=100, seed=0)
21
+ result = qqa.anneal(problem, sol_size=200, num_epochs=2000)
22
+ print("E_0 per spin:", result.best_obj / 100)
23
+ """
24
+
25
+ from qqa.annealing import AnnealResult, anneal
26
+ from qqa.problems import (
27
+ QAP,
28
+ TSP,
29
+ BalancedGraphPartition,
30
+ BinaryPerceptron,
31
+ Coloring,
32
+ COProblem,
33
+ EdwardsAnderson,
34
+ GraphBisection,
35
+ HopfieldMemory,
36
+ Ising1D,
37
+ Knapsack,
38
+ MaxClique,
39
+ MaxCliqueInstance,
40
+ MaxCut,
41
+ MaxCutInstance,
42
+ MaximumIndependentSet,
43
+ MaximumIndependentSetInstance,
44
+ MaxSAT3,
45
+ NQueens,
46
+ NumberPartitioning,
47
+ QUBOProblem,
48
+ SherringtonKirkpatrick,
49
+ SpinProblem,
50
+ UserProblem,
51
+ VertexCover,
52
+ load_problem_from_file,
53
+ user_problem_from_source,
54
+ )
55
+ from qqa.relaxation import (
56
+ BinaryInstanceRelaxation,
57
+ BinaryRelaxation,
58
+ CategoricalRelaxation,
59
+ SpinRelaxation,
60
+ )
61
+ from qqa.schedule import LinearBGSchedule
62
+ from qqa.utils import fix_seed, generate_graph
63
+
64
+ __version__ = "0.3.0"
65
+
66
+ __all__ = [
67
+ "QAP",
68
+ "TSP",
69
+ "AnnealResult",
70
+ "BalancedGraphPartition",
71
+ "BinaryInstanceRelaxation",
72
+ "BinaryPerceptron",
73
+ "BinaryRelaxation",
74
+ "CategoricalRelaxation",
75
+ "COProblem",
76
+ "Coloring",
77
+ "EdwardsAnderson",
78
+ "GraphBisection",
79
+ "HopfieldMemory",
80
+ "Ising1D",
81
+ "Knapsack",
82
+ "LinearBGSchedule",
83
+ "MaxClique",
84
+ "MaxCliqueInstance",
85
+ "MaxCut",
86
+ "MaxCutInstance",
87
+ "MaxSAT3",
88
+ "MaximumIndependentSet",
89
+ "MaximumIndependentSetInstance",
90
+ "NQueens",
91
+ "NumberPartitioning",
92
+ "QUBOProblem",
93
+ "SherringtonKirkpatrick",
94
+ "SpinProblem",
95
+ "SpinRelaxation",
96
+ "UserProblem",
97
+ "VertexCover",
98
+ "__version__",
99
+ "anneal",
100
+ "fix_seed",
101
+ "generate_graph",
102
+ "load_problem_from_file",
103
+ "user_problem_from_source",
104
+ ]
qqa/annealing.py ADDED
@@ -0,0 +1,279 @@
1
+ """Unified Quasi-Quantum Annealing loop.
2
+
3
+ This module replaces the four legacy ``batch_annealing_*`` functions from the
4
+ original repository with a single :func:`anneal` routine that delegates
5
+ problem-specific behaviour to :mod:`qqa.relaxation` and :mod:`qqa.callbacks`.
6
+
7
+ Single-instance binary problems, batched-instance problems, and categorical
8
+ problems all share this same loop.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass, field
15
+ from time import time
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+ import torch
20
+
21
+ from qqa.callbacks import Callback, CallbackState, HistoryRecorder
22
+ from qqa.schedule import LinearBGSchedule
23
+
24
+
25
+ @dataclass
26
+ class AnnealResult:
27
+ """Result returned by :func:`anneal`.
28
+
29
+ Attributes
30
+ ----------
31
+ best_sol:
32
+ Tensor of the best discrete solution(s) found during annealing. Shape
33
+ depends on the problem: ``(sol_size, N)`` for single-instance, or
34
+ ``(num_instance, max_node)`` for batched-instance problems.
35
+ best_obj:
36
+ Best objective value observed. ``float`` for single-instance problems,
37
+ ``numpy.ndarray`` of shape ``(num_instance,)`` for batched-instance.
38
+ runtime:
39
+ Wall-clock time of the annealing loop in seconds.
40
+ history:
41
+ Dict of per-epoch metrics (``loss_mean``, ``penalty_mean``,
42
+ ``diversity``, ``bg``). Empty if ``record_history=False``.
43
+ callbacks:
44
+ List of callback instances that were active. Useful for retrieving
45
+ e.g. ``TrajectoryTracker.values``.
46
+ """
47
+
48
+ best_sol: torch.Tensor
49
+ best_obj: Any
50
+ runtime: float
51
+ history: dict = field(default_factory=dict)
52
+ callbacks: list[Callback] = field(default_factory=list)
53
+ score: dict = field(default_factory=dict)
54
+ """Human-readable problem-specific score produced by
55
+ :py:meth:`COProblem.score_summary` (``label``, ``value``, ``unit``,
56
+ ``feasible``, ``extra``). Empty for batched-instance problems."""
57
+
58
+
59
+ def _is_instance_problem(problem) -> bool:
60
+ return hasattr(problem, "num_instance")
61
+
62
+
63
+ def anneal(
64
+ problem,
65
+ *,
66
+ sol_size: int = 100,
67
+ learning_rate: float = 1.0,
68
+ temp: float = 0.0,
69
+ schedule: LinearBGSchedule | None = None,
70
+ min_bg: float | None = None,
71
+ max_bg: float | None = None,
72
+ curve_rate: int = 2,
73
+ div_param: float = 0.0,
74
+ num_epochs: int = 10_000,
75
+ check_interval: int = 1000,
76
+ device: str | torch.device = "cpu",
77
+ callbacks: Sequence[Callback] = (),
78
+ record_history: bool = True,
79
+ verbose: bool = True,
80
+ ) -> AnnealResult:
81
+ """Run Quasi-Quantum Annealing on ``problem``.
82
+
83
+ Parameters
84
+ ----------
85
+ problem:
86
+ Any :class:`~qqa.problems.COProblem` subclass. Must expose
87
+ ``loss_fn(x)`` and a ``relaxation`` attribute.
88
+ sol_size:
89
+ Number of parallel candidate solutions (batch size).
90
+ learning_rate:
91
+ AdamW learning rate for the relaxed variable.
92
+ temp:
93
+ Langevin noise temperature. If ``0`` no noise is added.
94
+ schedule:
95
+ Callable ``(epoch, num_epochs) -> bg``. If ``None`` a
96
+ :class:`LinearBGSchedule` is built from ``min_bg``/``max_bg``.
97
+ min_bg, max_bg:
98
+ Convenience override for the default linear schedule.
99
+ curve_rate:
100
+ Exponent of the QQA penalty (must be even for the convex regime).
101
+ div_param:
102
+ Weight of the diversity term. Set to 0 to disable.
103
+ num_epochs:
104
+ Number of gradient steps.
105
+ check_interval:
106
+ How often to print progress logs.
107
+ device:
108
+ torch device.
109
+ callbacks:
110
+ Additional callbacks. A :class:`HistoryRecorder` is prepended when
111
+ ``record_history=True``.
112
+ record_history:
113
+ If True, loss/penalty/diversity/bg are recorded per epoch.
114
+ verbose:
115
+ If True, print periodic progress.
116
+ """
117
+ if schedule is None:
118
+ schedule = LinearBGSchedule(
119
+ -2.0 if min_bg is None else min_bg,
120
+ 0.1 if max_bg is None else max_bg,
121
+ )
122
+
123
+ relax = problem.relaxation
124
+
125
+ cb_list: list[Callback] = []
126
+ recorder: HistoryRecorder | None = None
127
+ if record_history:
128
+ recorder = HistoryRecorder()
129
+ cb_list.append(recorder)
130
+ cb_list.extend(callbacks)
131
+
132
+ runtime_start = time()
133
+ x = relax.init(sol_size, problem, device)
134
+ optimizer = torch.optim.AdamW([x], lr=learning_rate)
135
+
136
+ hp = {"div_param": float(div_param)}
137
+ is_batch = _is_instance_problem(problem)
138
+
139
+ with torch.no_grad():
140
+ x_disc = relax.project(x)
141
+ loss_disc = problem.loss_fn(x_disc)
142
+ if is_batch:
143
+ min_vals, min_idx = torch.min(loss_disc, dim=0)
144
+ best_obj = min_vals.detach().cpu().numpy().astype(np.float64)
145
+ best_sol = x_disc[min_idx, torch.arange(x_disc.size(1))].detach().clone()
146
+ else:
147
+ min_val, min_idx = torch.min(loss_disc, dim=0)
148
+ best_obj = float(min_val.item())
149
+ # Store the single winning replica (not the whole batch) so that
150
+ # downstream code — ``problem.score_summary``, CLI, notebooks —
151
+ # sees a clean ``(N, ...)`` tensor rather than ``(B, N, ...)``.
152
+ best_sol = x_disc[int(min_idx.item())].detach().clone()
153
+
154
+ for cb in cb_list:
155
+ cb.on_train_begin(
156
+ CallbackState(
157
+ epoch=-1,
158
+ num_epochs=num_epochs,
159
+ bg=float(schedule(0, num_epochs)),
160
+ x=x,
161
+ losses=torch.zeros(1),
162
+ penalties=torch.zeros(1),
163
+ diversity=torch.zeros(()),
164
+ best_obj=best_obj,
165
+ hyperparams=hp,
166
+ problem=problem,
167
+ relaxation=relax,
168
+ )
169
+ )
170
+
171
+ for epoch in range(num_epochs):
172
+ optimizer.zero_grad()
173
+ bg = float(schedule(epoch, num_epochs))
174
+
175
+ x_fwd = relax.forward(x)
176
+ losses = problem.loss_fn(x_fwd) # (B,) or (B, I)
177
+ penalties = relax.penalty(x, curve_rate) # matching shape
178
+ diversity = relax.diversity(x) if sol_size > 1 else torch.tensor(0.0, device=x.device)
179
+ div_term = -diversity * sol_size
180
+
181
+ # Unified weighted objective: uses sums so that (B, I) problems
182
+ # contribute each instance equally.
183
+ dp = hp["div_param"]
184
+ total = (losses.sum() + (penalties * bg).sum()) * (1 - dp) + div_term * dp
185
+ total.backward()
186
+ optimizer.step()
187
+
188
+ relax.perturb_(x, learning_rate, temp)
189
+
190
+ with torch.no_grad():
191
+ x_disc = relax.project(x)
192
+ loss_disc = problem.loss_fn(x_disc)
193
+ if is_batch:
194
+ min_vals, min_idx = torch.min(loss_disc, dim=0)
195
+ vals_np = min_vals.detach().cpu().numpy().astype(np.float64)
196
+ improved = vals_np < best_obj
197
+ if improved.any():
198
+ sel = x_disc[min_idx, torch.arange(x_disc.size(1))]
199
+ best_sol = torch.where(
200
+ torch.tensor(improved, device=sel.device).unsqueeze(-1),
201
+ sel,
202
+ best_sol,
203
+ )
204
+ best_obj = np.minimum(best_obj, vals_np)
205
+ else:
206
+ min_val, min_idx = torch.min(loss_disc, dim=0)
207
+ if min_val.item() < best_obj:
208
+ best_obj = float(min_val.item())
209
+ best_sol = x_disc[int(min_idx.item())].detach().clone()
210
+
211
+ state = CallbackState(
212
+ epoch=epoch,
213
+ num_epochs=num_epochs,
214
+ bg=bg,
215
+ x=x,
216
+ losses=losses.detach(),
217
+ penalties=penalties.detach(),
218
+ diversity=diversity.detach() if torch.is_tensor(diversity) else diversity,
219
+ best_obj=best_obj,
220
+ hyperparams=hp,
221
+ problem=problem,
222
+ relaxation=relax,
223
+ )
224
+ for cb in cb_list:
225
+ cb.on_epoch_end(state)
226
+
227
+ if verbose and (epoch % check_interval == 0 or epoch == num_epochs - 1):
228
+ _print_progress(epoch, best_obj, losses, penalties, diversity, bg, hp["div_param"])
229
+
230
+ runtime = time() - runtime_start
231
+ if verbose:
232
+ print("\n" + "=" * 30 + " [FINAL] " + "=" * 30)
233
+ print(f" BEST LOSS : {best_obj}")
234
+ print(f" RUN TIME : {runtime:.2f} s")
235
+ print("=" * 69)
236
+
237
+ for cb in cb_list:
238
+ cb.on_train_end(state)
239
+
240
+ history = recorder.history if recorder is not None else {}
241
+
242
+ # Human-readable score. Only meaningful for single-instance problems,
243
+ # where ``best_sol`` is a single solution tensor.
244
+ score: dict = {}
245
+ if not is_batch:
246
+ try:
247
+ score = problem.score_summary(best_sol)
248
+ except Exception as exc: # noqa: BLE001 - surface but never abort
249
+ score = {
250
+ "label": "loss",
251
+ "value": float(best_obj),
252
+ "unit": "",
253
+ "feasible": True,
254
+ "extra": {"error": str(exc)},
255
+ }
256
+
257
+ return AnnealResult(
258
+ best_sol=best_sol,
259
+ best_obj=best_obj,
260
+ runtime=runtime,
261
+ history=history,
262
+ callbacks=cb_list,
263
+ score=score,
264
+ )
265
+
266
+
267
+ def _print_progress(epoch, best_obj, losses, penalties, diversity, bg, div_param):
268
+ mean_loss = float(losses.detach().mean().item())
269
+ mean_pen = float(penalties.detach().mean().item())
270
+ div_val = float(diversity.item()) if torch.is_tensor(diversity) else float(diversity)
271
+ print("\n" + "=" * 30 + " [LOG] " + "=" * 32)
272
+ print(f"[ EPOCH {epoch} ]")
273
+ print(f" Best Loss So Far : {best_obj}")
274
+ print(f" Mean(Loss) : {mean_loss:.4f}")
275
+ print(f" Mean(Penalty) : {mean_pen:.4f}")
276
+ print(f" BG : {bg:.4f}")
277
+ print(f" DIV Value : {div_val:.4f}")
278
+ print(f" div_param : {div_param:.4f}")
279
+ print("=" * 69)
qqa/callbacks.py ADDED
@@ -0,0 +1,171 @@
1
+ """Callbacks for the QQA annealing loop.
2
+
3
+ Callbacks receive a ``CallbackState`` snapshot at the end of every epoch and
4
+ can record metrics, adjust hyper-parameters, or track auxiliary objectives.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ import torch
13
+
14
+
15
+ @dataclass
16
+ class CallbackState:
17
+ """Mutable context passed to callbacks at each epoch.
18
+
19
+ The annealing loop writes fields here. Callbacks may read any field and
20
+ may write to ``extras`` or mutate ``hyperparams`` (e.g. ``div_param``).
21
+ """
22
+
23
+ epoch: int
24
+ num_epochs: int
25
+ bg: float
26
+ x: torch.Tensor
27
+ losses: torch.Tensor
28
+ penalties: torch.Tensor
29
+ diversity: torch.Tensor
30
+ best_obj: Any
31
+ hyperparams: dict
32
+ problem: Any
33
+ relaxation: Any
34
+ extras: dict = field(default_factory=dict)
35
+
36
+
37
+ class Callback:
38
+ """Base class. Override ``on_epoch_end`` (and optionally other hooks)."""
39
+
40
+ def on_train_begin(self, state: CallbackState) -> None: # pragma: no cover
41
+ pass
42
+
43
+ def on_epoch_end(self, state: CallbackState) -> None: # pragma: no cover
44
+ pass
45
+
46
+ def on_train_end(self, state: CallbackState) -> None: # pragma: no cover
47
+ pass
48
+
49
+
50
+ class HistoryRecorder(Callback):
51
+ """Record loss / penalty / diversity statistics per epoch."""
52
+
53
+ def __init__(self) -> None:
54
+ self.history: dict[str, list] = {
55
+ "loss_mean": [],
56
+ "loss_std": [],
57
+ "loss_min": [],
58
+ "penalty_mean": [],
59
+ "penalty_std": [],
60
+ "diversity": [],
61
+ "bg": [],
62
+ "best_obj": [],
63
+ }
64
+
65
+ def on_epoch_end(self, state: CallbackState) -> None:
66
+ losses = state.losses.detach()
67
+ penalties = state.penalties.detach()
68
+ self.history["loss_mean"].append(float(losses.mean().item()))
69
+ self.history["loss_std"].append(float(losses.std().item()) if losses.numel() > 1 else 0.0)
70
+ self.history["loss_min"].append(float(losses.min().item()))
71
+ self.history["penalty_mean"].append(float(penalties.mean().item()))
72
+ self.history["penalty_std"].append(
73
+ float(penalties.std().item()) if penalties.numel() > 1 else 0.0
74
+ )
75
+ div = state.diversity
76
+ self.history["diversity"].append(float(div.item()) if torch.is_tensor(div) else float(div))
77
+ self.history["bg"].append(state.bg)
78
+ bo = state.best_obj
79
+ if hasattr(bo, "tolist"):
80
+ self.history["best_obj"].append(bo.tolist())
81
+ else:
82
+ self.history["best_obj"].append(float(bo))
83
+
84
+
85
+ class AutoDivTuner(Callback):
86
+ """Adaptively tune ``div_param`` to target a desired diversity ratio.
87
+
88
+ At each epoch: ``ratio = diversity / (sol_size * N)``. The controller
89
+ nudges ``div_param`` by ``lr * (ratio - target)`` and clips to ``[0, 1]``.
90
+ """
91
+
92
+ def __init__(self, target: float = 0.3, lr: float = 1e-3) -> None:
93
+ self.target = target
94
+ self.lr = lr
95
+
96
+ def on_epoch_end(self, state: CallbackState) -> None:
97
+ sol_size = state.x.shape[0]
98
+ if sol_size <= 1:
99
+ return
100
+ num_vars = state.relaxation.num_variables(state.problem)
101
+ div_val = (
102
+ float(state.diversity.item())
103
+ if torch.is_tensor(state.diversity)
104
+ else float(state.diversity)
105
+ )
106
+ ratio = div_val / (sol_size * num_vars)
107
+ diff = ratio - self.target
108
+ dp = state.hyperparams.get("div_param", 0.0)
109
+ dp = max(0.0, min(1.0, dp + self.lr * diff))
110
+ state.hyperparams["div_param"] = dp
111
+
112
+
113
+ class PopulationTracker(Callback):
114
+ """Snapshot the parallel population for post-hoc parallel-search visualisation.
115
+
116
+ Records, every ``stride`` epochs:
117
+
118
+ * ``loss`` — the ``(sol_size,)`` per-replica loss.
119
+ * ``x`` — optionally, the continuous variables (heavier but lets you
120
+ reconstruct PCA trajectories or per-variable heatmaps).
121
+
122
+ Attributes:
123
+ epochs: list of recorded epochs.
124
+ loss: list of ``(sol_size,)`` numpy arrays.
125
+ x: list of ``(sol_size, ...)`` numpy arrays when
126
+ ``record_x=True``; otherwise empty.
127
+ """
128
+
129
+ def __init__(self, stride: int = 10, record_x: bool = True, max_replicas: int | None = None):
130
+ self.stride = max(1, int(stride))
131
+ self.record_x = bool(record_x)
132
+ self.max_replicas = max_replicas
133
+ self.epochs: list[int] = []
134
+ self.loss: list[Any] = []
135
+ self.x: list[Any] = []
136
+
137
+ def on_epoch_end(self, state: CallbackState) -> None:
138
+ if state.epoch % self.stride != 0 and state.epoch != state.num_epochs - 1:
139
+ return
140
+ self.epochs.append(int(state.epoch))
141
+ losses = state.losses.detach().cpu().numpy()
142
+ if self.max_replicas is not None:
143
+ losses = losses[: self.max_replicas]
144
+ self.loss.append(losses)
145
+ if self.record_x:
146
+ x = state.x.detach().cpu().numpy()
147
+ if self.max_replicas is not None:
148
+ x = x[: self.max_replicas]
149
+ self.x.append(x)
150
+
151
+
152
+ class TrajectoryTracker(Callback):
153
+ """Track a secondary problem's objective per epoch.
154
+
155
+ Useful for e.g. monitoring the "true" MIS size while optimising a
156
+ penalised QUBO formulation.
157
+ """
158
+
159
+ def __init__(self, aux_problem, mode: str = "mean") -> None:
160
+ if mode not in ("mean", "min"):
161
+ raise ValueError("mode must be 'mean' or 'min'")
162
+ self.aux_problem = aux_problem
163
+ self.mode = mode
164
+ self.values: list[float] = []
165
+
166
+ def on_epoch_end(self, state: CallbackState) -> None:
167
+ with torch.no_grad():
168
+ x_disc = state.relaxation.project(state.x)
169
+ loss_aux = self.aux_problem.loss_fn(x_disc)
170
+ val = -loss_aux.mean().item() if self.mode == "mean" else -loss_aux.min().item()
171
+ self.values.append(float(val))