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 +104 -0
- qqa/annealing.py +279 -0
- qqa/callbacks.py +171 -0
- qqa/cli.py +375 -0
- qqa/datasets.py +248 -0
- qqa/legacy.py +200 -0
- qqa/problems/__init__.py +80 -0
- qqa/problems/base.py +63 -0
- qqa/problems/categorical.py +115 -0
- qqa/problems/extras.py +616 -0
- qqa/problems/qubo.py +273 -0
- qqa/problems/spin.py +448 -0
- qqa/problems/user.py +180 -0
- qqa/relaxation.py +197 -0
- qqa/schedule.py +27 -0
- qqa/utils.py +91 -0
- qqa/visualization.py +710 -0
- qqa-0.3.0.dist-info/METADATA +532 -0
- qqa-0.3.0.dist-info/RECORD +22 -0
- qqa-0.3.0.dist-info/WHEEL +4 -0
- qqa-0.3.0.dist-info/entry_points.txt +2 -0
- qqa-0.3.0.dist-info/licenses/LICENCE.txt +12 -0
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))
|