pymdls 0.1.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.
- mdls/__init__.py +37 -0
- mdls/core.py +472 -0
- pymdls-0.1.0.dist-info/METADATA +154 -0
- pymdls-0.1.0.dist-info/RECORD +6 -0
- pymdls-0.1.0.dist-info/WHEEL +4 -0
- pymdls-0.1.0.dist-info/licenses/LICENSE +21 -0
mdls/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""MDLS: multi-directional local search with user-supplied moves.
|
|
2
|
+
|
|
3
|
+
The public interface is small:
|
|
4
|
+
|
|
5
|
+
from mdls import MDLS, move
|
|
6
|
+
|
|
7
|
+
@move(improves=0, attempts=4)
|
|
8
|
+
def my_move(x, instance, rng): ...
|
|
9
|
+
|
|
10
|
+
search = MDLS(evaluate, moves=[my_move()], instance=instance)
|
|
11
|
+
result = search.run(init, max_eval=10_000)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .core import (
|
|
15
|
+
EPS_REL,
|
|
16
|
+
Archive,
|
|
17
|
+
MDLS,
|
|
18
|
+
MoveStats,
|
|
19
|
+
Result,
|
|
20
|
+
all_feasible,
|
|
21
|
+
dominates,
|
|
22
|
+
move,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"EPS_REL",
|
|
29
|
+
"Archive",
|
|
30
|
+
"MDLS",
|
|
31
|
+
"MoveStats",
|
|
32
|
+
"Result",
|
|
33
|
+
"all_feasible",
|
|
34
|
+
"dominates",
|
|
35
|
+
"move",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
mdls/core.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""Multi-Directional Local Search (Tricoire 2012) with user-supplied moves.
|
|
2
|
+
|
|
3
|
+
A problem defines two things:
|
|
4
|
+
|
|
5
|
+
evaluate(x, instance) -> (f, g) or f # every objective is minimised
|
|
6
|
+
move(x, instance, rng) -> x or None # one improving attempt, or None
|
|
7
|
+
|
|
8
|
+
Moves are tagged with the objective they improve and grouped by it; each
|
|
9
|
+
iteration runs one sampled move per objective. A move may retry its attempt up
|
|
10
|
+
to `attempts` times to find an improvement. The class owns the archive,
|
|
11
|
+
selection, budgets and statistics.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy as _copy
|
|
17
|
+
import functools
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from numba import njit
|
|
24
|
+
|
|
25
|
+
EPS_REL = 1e-9 # relative tolerance: smaller differences count as equal
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def move(improves, attempts=1):
|
|
31
|
+
"""Tag a move with the objective it improves; call the result to bind parameters.
|
|
32
|
+
|
|
33
|
+
The decorated function is one attempt: it returns a candidate that strictly
|
|
34
|
+
improves objective `improves`, or None. The decorator retries the attempt up
|
|
35
|
+
to `attempts` times and returns the first improving candidate. An attempt
|
|
36
|
+
that returns None must leave its argument unchanged.
|
|
37
|
+
|
|
38
|
+
@move(improves=0, attempts=4)
|
|
39
|
+
def drop_add(x, instance, rng, n_drop=3): ...
|
|
40
|
+
|
|
41
|
+
moves = [drop_add(n_drop=5)]
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(attempts, (int, np.integer)) or isinstance(attempts, bool) or int(attempts) < 1:
|
|
44
|
+
raise ValueError(f"attempts must be a positive integer, got {attempts!r}")
|
|
45
|
+
if not isinstance(improves, (int, np.integer)) or isinstance(improves, bool) or int(improves) < 0:
|
|
46
|
+
raise ValueError(f"improves must be a non-negative integer, got {improves!r}")
|
|
47
|
+
attempts, improves = int(attempts), int(improves)
|
|
48
|
+
|
|
49
|
+
def decorate(fn):
|
|
50
|
+
@functools.wraps(fn)
|
|
51
|
+
def make(**params):
|
|
52
|
+
step = functools.partial(fn, **params)
|
|
53
|
+
|
|
54
|
+
@functools.wraps(fn)
|
|
55
|
+
def move_fn(x, instance, rng):
|
|
56
|
+
for _ in range(attempts):
|
|
57
|
+
y = step(x, instance, rng)
|
|
58
|
+
if y is not None:
|
|
59
|
+
return y
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
move_fn.improves = improves
|
|
63
|
+
move_fn.attempts = attempts
|
|
64
|
+
return move_fn
|
|
65
|
+
|
|
66
|
+
make.improves = improves
|
|
67
|
+
make.attempts = attempts
|
|
68
|
+
make.__move_factory__ = True
|
|
69
|
+
return make
|
|
70
|
+
return decorate
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def dominates(a, b, eps_rel=EPS_REL):
|
|
74
|
+
"""True when a is no worse than b everywhere and better somewhere (minimisation)."""
|
|
75
|
+
a, b = np.asarray(a, float), np.asarray(b, float)
|
|
76
|
+
tol = eps_rel * np.maximum(np.abs(a), np.abs(b))
|
|
77
|
+
return bool(np.all(a <= b + tol) and np.any(a < b - tol))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@njit(cache=True)
|
|
81
|
+
def _archive_insert(front, point, eps_rel, keep):
|
|
82
|
+
"""Dominance scan over a preallocated front.
|
|
83
|
+
|
|
84
|
+
Returns (accepted, k). `keep[:len(front)]` marks the points that survive;
|
|
85
|
+
the new point itself is inserted by the Python wrapper.
|
|
86
|
+
"""
|
|
87
|
+
n = front.shape[0]
|
|
88
|
+
for i in range(n):
|
|
89
|
+
no_worse = True
|
|
90
|
+
for j in range(point.shape[0]):
|
|
91
|
+
tol = eps_rel * max(abs(front[i, j]), abs(point[j]))
|
|
92
|
+
if front[i, j] > point[j] + tol:
|
|
93
|
+
no_worse = False
|
|
94
|
+
break
|
|
95
|
+
if no_worse:
|
|
96
|
+
return False, 0 # dominated by, or duplicate of, a held point
|
|
97
|
+
|
|
98
|
+
k = 0
|
|
99
|
+
for i in range(n):
|
|
100
|
+
point_no_worse, point_better = True, False
|
|
101
|
+
for j in range(point.shape[0]):
|
|
102
|
+
tol = eps_rel * max(abs(front[i, j]), abs(point[j]))
|
|
103
|
+
if point[j] > front[i, j] + tol:
|
|
104
|
+
point_no_worse = False
|
|
105
|
+
break
|
|
106
|
+
if point[j] < front[i, j] - tol:
|
|
107
|
+
point_better = True
|
|
108
|
+
keep[i] = not (point_no_worse and point_better)
|
|
109
|
+
if keep[i]:
|
|
110
|
+
k += 1
|
|
111
|
+
return True, k
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Archive:
|
|
115
|
+
"""Feasible non-dominated solutions, each stored with its objective vector."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, n_obj, capacity=1024, eps_rel=EPS_REL):
|
|
118
|
+
if not isinstance(n_obj, (int, np.integer)) or isinstance(n_obj, bool) or int(n_obj) < 1:
|
|
119
|
+
raise ValueError(f"n_obj must be a positive integer, got {n_obj!r}")
|
|
120
|
+
if not isinstance(capacity, (int, np.integer)) or isinstance(capacity, bool) or int(capacity) < 1:
|
|
121
|
+
raise ValueError(f"capacity must be a positive integer, got {capacity!r}")
|
|
122
|
+
eps_rel = float(eps_rel)
|
|
123
|
+
if not np.isfinite(eps_rel) or eps_rel < 0:
|
|
124
|
+
raise ValueError(f"eps_rel must be finite and non-negative, got {eps_rel!r}")
|
|
125
|
+
self.n_obj, self.eps_rel = int(n_obj), eps_rel
|
|
126
|
+
self._f = np.empty((int(capacity), self.n_obj))
|
|
127
|
+
self._keep = np.empty(int(capacity), dtype=np.bool_)
|
|
128
|
+
self._n = 0
|
|
129
|
+
self.solutions = []
|
|
130
|
+
|
|
131
|
+
def __len__(self):
|
|
132
|
+
return self._n
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def objectives(self):
|
|
136
|
+
"""(n, n_obj) view of the front; read only."""
|
|
137
|
+
return self._f[:self._n]
|
|
138
|
+
|
|
139
|
+
def add(self, objectives, solution):
|
|
140
|
+
"""Offer a feasible solution; evicts what it dominates.
|
|
141
|
+
Returns True if it entered, False if dominated or a duplicate."""
|
|
142
|
+
f = np.asarray(objectives, float).reshape(-1)
|
|
143
|
+
if f.shape[0] != self.n_obj:
|
|
144
|
+
raise ValueError(f"expected {self.n_obj} objectives, got {f.shape[0]}")
|
|
145
|
+
if not np.all(np.isfinite(f)):
|
|
146
|
+
raise ValueError(f"non-finite objectives {f}; reject via constraints instead")
|
|
147
|
+
if self._n == self._f.shape[0]:
|
|
148
|
+
self._grow()
|
|
149
|
+
|
|
150
|
+
accepted, k = _archive_insert(self._f[:self._n], f, self.eps_rel, self._keep[:self._n])
|
|
151
|
+
if not accepted:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
survivors = self._keep[:self._n]
|
|
155
|
+
self._f[:k] = self._f[:self._n][survivors]
|
|
156
|
+
self._f[k] = f
|
|
157
|
+
self.solutions = [s for s, kept in zip(self.solutions, survivors) if kept] + [solution]
|
|
158
|
+
self._n = k + 1
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
def _grow(self):
|
|
162
|
+
capacity = 2 * self._f.shape[0]
|
|
163
|
+
grown = np.empty((capacity, self.n_obj))
|
|
164
|
+
grown[:self._n] = self._f[:self._n]
|
|
165
|
+
self._f = grown
|
|
166
|
+
self._keep = np.empty(capacity, dtype=np.bool_)
|
|
167
|
+
|
|
168
|
+
def knee(self, nadir=None):
|
|
169
|
+
"""Index of the point nearest the ideal after normalising by (nadir - ideal).
|
|
170
|
+
Pass a fixed nadir when comparing runs."""
|
|
171
|
+
f = self.objectives
|
|
172
|
+
ideal = f.min(axis=0)
|
|
173
|
+
nad = f.max(axis=0) if nadir is None else np.asarray(nadir, float)
|
|
174
|
+
return int(np.argmin(np.linalg.norm((f - ideal) / np.maximum(nad - ideal, 1e-10), axis=1)))
|
|
175
|
+
|
|
176
|
+
def extreme(self, k):
|
|
177
|
+
"""Index of the best point on objective k."""
|
|
178
|
+
return int(np.argmin(self.objectives[:, k]))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class MoveStats:
|
|
183
|
+
"""Per-move counters for one run."""
|
|
184
|
+
objective: int = -1
|
|
185
|
+
calls: int = 0
|
|
186
|
+
no_move: int = 0
|
|
187
|
+
not_descent: int = 0
|
|
188
|
+
infeasible: int = 0
|
|
189
|
+
dominated: int = 0
|
|
190
|
+
accepted: int = 0
|
|
191
|
+
seconds: float = 0.0
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def acceptance(self):
|
|
195
|
+
return self.accepted / self.calls if self.calls else 0.0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class Result:
|
|
200
|
+
"""Archive plus run accounting returned by MDLS.run."""
|
|
201
|
+
archive: Archive
|
|
202
|
+
n_eval: int
|
|
203
|
+
n_iter: int
|
|
204
|
+
elapsed: float
|
|
205
|
+
stopped_by: str
|
|
206
|
+
stats: dict = field(default_factory=dict)
|
|
207
|
+
history: list = field(default_factory=list)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def objectives(self):
|
|
211
|
+
return self.archive.objectives
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def solutions(self):
|
|
215
|
+
return self.archive.solutions
|
|
216
|
+
|
|
217
|
+
def summary(self):
|
|
218
|
+
"""Multi-line text report of the run and each move."""
|
|
219
|
+
out = [f"MDLS: {self.n_iter} iterations, {self.n_eval} evaluations, {self.elapsed:.2f} s, "
|
|
220
|
+
f"stopped by {self.stopped_by}; front holds {len(self.archive)}",
|
|
221
|
+
f" {'move':24s} {'obj':>3s} {'calls':>7s} {'no-move':>8s} {'no-desc':>7s} {'infeas':>7s} "
|
|
222
|
+
f"{'dom':>7s} {'accepted':>8s} {'acc %':>6s} {'sec':>6s}"]
|
|
223
|
+
for label, s in self.stats.items():
|
|
224
|
+
out.append(f" {label:24s} {s.objective:3d} {s.calls:7d} {s.no_move:8d} {s.not_descent:7d} "
|
|
225
|
+
f"{s.infeasible:7d} {s.dominated:7d} {s.accepted:8d} {100 * s.acceptance:6.1f} "
|
|
226
|
+
f"{s.seconds:6.2f}")
|
|
227
|
+
return "\n".join(out)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def all_feasible(g):
|
|
231
|
+
"""Default feasibility rule: every constraint value g <= 0 (None means unconstrained)."""
|
|
232
|
+
return g is None or np.size(g) == 0 or bool(np.all(np.asarray(g, float) <= 0.0))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class MDLS:
|
|
236
|
+
"""Archive, selection, loop and budgets; the moves are passed in.
|
|
237
|
+
|
|
238
|
+
evaluate(x, instance) -> (f, g) or f
|
|
239
|
+
move(x, instance, rng) -> solution or None, tagged with @move(improves=k, attempts=n)
|
|
240
|
+
|
|
241
|
+
A move is one improving attempt, retried up to `attempts` times by its
|
|
242
|
+
decorator. Override select / offer / on_iteration to change one step.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, evaluate, moves, instance=None, is_feasible=None,
|
|
246
|
+
selection=None, eps_rel=EPS_REL, verbose=0, copy_solution=None):
|
|
247
|
+
if not callable(evaluate):
|
|
248
|
+
raise TypeError("evaluate must be callable")
|
|
249
|
+
if is_feasible is not None and not callable(is_feasible):
|
|
250
|
+
raise TypeError("is_feasible must be callable or None")
|
|
251
|
+
if selection is not None and not callable(selection):
|
|
252
|
+
raise TypeError("selection must be callable or None")
|
|
253
|
+
if copy_solution is not None and not callable(copy_solution):
|
|
254
|
+
raise TypeError("copy_solution must be callable or None")
|
|
255
|
+
eps_rel = float(eps_rel)
|
|
256
|
+
if not np.isfinite(eps_rel) or eps_rel < 0:
|
|
257
|
+
raise ValueError(f"eps_rel must be finite and non-negative, got {eps_rel!r}")
|
|
258
|
+
if not isinstance(verbose, (int, np.integer)) or isinstance(verbose, bool) or int(verbose) < 0:
|
|
259
|
+
raise ValueError(f"verbose must be a non-negative integer, got {verbose!r}")
|
|
260
|
+
self.evaluate_fn, self.instance = evaluate, instance
|
|
261
|
+
self.pools = self._index_moves(moves)
|
|
262
|
+
if not self.pools:
|
|
263
|
+
raise ValueError("MDLS needs at least one move")
|
|
264
|
+
self._is_feasible = is_feasible if is_feasible is not None else all_feasible
|
|
265
|
+
self._selection = selection
|
|
266
|
+
self._copy_solution_fn = copy_solution
|
|
267
|
+
self.eps_rel, self.verbose = eps_rel, int(verbose)
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _label(mv):
|
|
271
|
+
"""Name a move for the stats table; partials are named after their function."""
|
|
272
|
+
if isinstance(mv, functools.partial):
|
|
273
|
+
mv = mv.func
|
|
274
|
+
return getattr(mv, "__name__", type(mv).__name__)
|
|
275
|
+
|
|
276
|
+
def _index_moves(self, moves):
|
|
277
|
+
"""Group tagged moves by objective: {k: [(label, move), ...]}."""
|
|
278
|
+
pools, seen = {}, {}
|
|
279
|
+
for mv in moves:
|
|
280
|
+
if getattr(mv, "__move_factory__", False):
|
|
281
|
+
name = getattr(mv, "__name__", "move")
|
|
282
|
+
raise ValueError(f"{name} is a move factory; call it first, e.g. {name}(...)")
|
|
283
|
+
tag = getattr(mv, "improves", None)
|
|
284
|
+
if tag is None:
|
|
285
|
+
raise ValueError(f"{self._label(mv)!r} is not tagged; define it with @move(improves=k)")
|
|
286
|
+
if not isinstance(tag, (int, np.integer)) or isinstance(tag, bool):
|
|
287
|
+
raise ValueError(f"{self._label(mv)!r} has a non-integer objective tag {tag!r}")
|
|
288
|
+
mi = getattr(mv, "attempts", 1)
|
|
289
|
+
if not isinstance(mi, (int, np.integer)) or isinstance(mi, bool) or int(mi) < 1:
|
|
290
|
+
raise ValueError(f"{self._label(mv)!r} has invalid attempts {mi!r}")
|
|
291
|
+
base = self._label(mv)
|
|
292
|
+
seen[base] = seen.get(base, 0) + 1
|
|
293
|
+
label = base if seen[base] == 1 else f"{base}#{seen[base]}"
|
|
294
|
+
pools.setdefault(int(tag), []).append((label, mv))
|
|
295
|
+
return dict(sorted(pools.items()))
|
|
296
|
+
|
|
297
|
+
def _check_pools(self, n_obj):
|
|
298
|
+
"""Refuse tags for missing objectives and require one move per objective."""
|
|
299
|
+
bad = [k for k in self.pools if not 0 <= k < n_obj]
|
|
300
|
+
if bad:
|
|
301
|
+
raise ValueError(f"moves tagged for objective(s) {bad}, problem has {n_obj}")
|
|
302
|
+
missing = [k for k in range(n_obj) if k not in self.pools]
|
|
303
|
+
if missing:
|
|
304
|
+
raise ValueError(f"no move tagged for objective(s) {missing}; add @move(improves=k)")
|
|
305
|
+
|
|
306
|
+
def evaluate(self, x):
|
|
307
|
+
"""Call the user's evaluate and normalise to (f, g).
|
|
308
|
+
A tuple whose first item is a vector is read as (f, g)."""
|
|
309
|
+
out = self.evaluate_fn(x, self.instance)
|
|
310
|
+
if isinstance(out, tuple) and len(out) == 2:
|
|
311
|
+
if np.ndim(out[0]) >= 1:
|
|
312
|
+
f, g = out
|
|
313
|
+
elif np.ndim(out[1]) >= 1:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
"evaluate returned a scalar objective with a vector constraint; "
|
|
316
|
+
"wrap f in a list or array, e.g. return [f], g")
|
|
317
|
+
else:
|
|
318
|
+
f, g = out, None
|
|
319
|
+
else:
|
|
320
|
+
f, g = out, None
|
|
321
|
+
return np.asarray(f, float).reshape(-1), g
|
|
322
|
+
|
|
323
|
+
def select(self, archive, rng):
|
|
324
|
+
"""Index of the archive member to search from; uniform by default."""
|
|
325
|
+
if self._selection is not None:
|
|
326
|
+
return int(self._selection(archive, rng))
|
|
327
|
+
return int(rng.integers(len(archive)))
|
|
328
|
+
|
|
329
|
+
def offer(self, f, x, archive):
|
|
330
|
+
"""Offer a feasible candidate to the archive; True if it entered."""
|
|
331
|
+
return archive.add(f, x)
|
|
332
|
+
|
|
333
|
+
def copy(self, solution):
|
|
334
|
+
"""Return a private copy of an archived solution for a move to mutate.
|
|
335
|
+
|
|
336
|
+
Uses `copy_solution` from the constructor when given. Otherwise NumPy
|
|
337
|
+
arrays are copied with `.copy()` and every other object is deep-copied,
|
|
338
|
+
so a move cannot mutate the archive through a nested structure.
|
|
339
|
+
Override this method to change the copy rule for a whole subclass.
|
|
340
|
+
"""
|
|
341
|
+
if self._copy_solution_fn is not None:
|
|
342
|
+
return self._copy_solution_fn(solution)
|
|
343
|
+
if isinstance(solution, np.ndarray):
|
|
344
|
+
return solution.copy()
|
|
345
|
+
return _copy.deepcopy(solution)
|
|
346
|
+
|
|
347
|
+
def _is_descent(self, f, base_f, k):
|
|
348
|
+
"""True when f strictly improves objective k over base_f, within eps."""
|
|
349
|
+
tol = self.eps_rel * max(abs(f[k]), abs(base_f[k]))
|
|
350
|
+
return bool(f[k] < base_f[k] - tol)
|
|
351
|
+
|
|
352
|
+
def on_iteration(self, iteration, n_eval, archive):
|
|
353
|
+
"""Hook after each iteration; logs progress every `verbose` iterations."""
|
|
354
|
+
if self.verbose and (iteration + 1) % self.verbose == 0:
|
|
355
|
+
fr = archive.objectives
|
|
356
|
+
span = " ".join(f"f{k} {fr[:, k].min():.4g}..{fr[:, k].max():.4g}"
|
|
357
|
+
for k in range(fr.shape[1]))
|
|
358
|
+
logger.info(" iter %6d eval %7d archive %5d %s",
|
|
359
|
+
iteration + 1, n_eval, len(archive), span)
|
|
360
|
+
|
|
361
|
+
def run(self, init, max_iter=None, max_eval=None, time_limit=None, seed=0, record_every=0):
|
|
362
|
+
"""Search from init until a budget is spent.
|
|
363
|
+
|
|
364
|
+
init is one solution, a sequence of solutions, or a callable
|
|
365
|
+
init(rng) -> solutions. A flat list or tuple of scalars is treated as
|
|
366
|
+
one vector solution; wrap it in a list to pass several scalar solutions.
|
|
367
|
+
max_eval counts initial solutions too; at least one budget is required.
|
|
368
|
+
"""
|
|
369
|
+
if max_iter is None and max_eval is None and time_limit is None:
|
|
370
|
+
raise ValueError("give at least one of max_iter, max_eval, time_limit")
|
|
371
|
+
for name, value in (("max_iter", max_iter), ("max_eval", max_eval)):
|
|
372
|
+
if value is not None and (not isinstance(value, (int, np.integer))
|
|
373
|
+
or isinstance(value, bool) or int(value) < 1):
|
|
374
|
+
raise ValueError(f"{name} must be a positive integer or None, got {value!r}")
|
|
375
|
+
if time_limit is not None:
|
|
376
|
+
if isinstance(time_limit, bool):
|
|
377
|
+
raise ValueError(f"time_limit must be positive and finite or None, got {time_limit!r}")
|
|
378
|
+
try:
|
|
379
|
+
time_limit = float(time_limit)
|
|
380
|
+
except (TypeError, ValueError):
|
|
381
|
+
raise ValueError(f"time_limit must be positive and finite or None, got {time_limit!r}") from None
|
|
382
|
+
if not np.isfinite(time_limit) or time_limit <= 0:
|
|
383
|
+
raise ValueError(f"time_limit must be positive and finite or None, got {time_limit!r}")
|
|
384
|
+
if not isinstance(record_every, (int, np.integer)) or isinstance(record_every, bool) or record_every < 0:
|
|
385
|
+
raise ValueError(f"record_every must be a non-negative integer, got {record_every!r}")
|
|
386
|
+
|
|
387
|
+
rng, t0 = np.random.default_rng(seed), time.time()
|
|
388
|
+
archive, n_eval = self._start(init, rng, max_eval)
|
|
389
|
+
stats = {label: MoveStats(objective=objective)
|
|
390
|
+
for objective, entries in self.pools.items() for label, _ in entries}
|
|
391
|
+
history, iteration, stopped_by = [], 0, "max_iter"
|
|
392
|
+
|
|
393
|
+
def out_of_evals():
|
|
394
|
+
return max_eval is not None and n_eval >= max_eval
|
|
395
|
+
|
|
396
|
+
while True:
|
|
397
|
+
if max_iter is not None and iteration >= max_iter:
|
|
398
|
+
stopped_by = "max_iter"
|
|
399
|
+
break
|
|
400
|
+
if out_of_evals():
|
|
401
|
+
stopped_by = "max_eval"
|
|
402
|
+
break
|
|
403
|
+
if time_limit is not None and time.time() - t0 > time_limit:
|
|
404
|
+
stopped_by = "time_limit"
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
idx = self.select(archive, rng)
|
|
408
|
+
base = archive.solutions[idx]
|
|
409
|
+
base_f = archive.objectives[idx].copy()
|
|
410
|
+
for entries in self.pools.values(): # one sampled move per objective
|
|
411
|
+
if out_of_evals():
|
|
412
|
+
break
|
|
413
|
+
label, mv = entries[int(rng.integers(len(entries)))]
|
|
414
|
+
st = stats[label]
|
|
415
|
+
st.calls += 1
|
|
416
|
+
started = time.perf_counter()
|
|
417
|
+
candidate = mv(self.copy(base), self.instance, rng)
|
|
418
|
+
st.seconds += time.perf_counter() - started
|
|
419
|
+
if candidate is None:
|
|
420
|
+
st.no_move += 1
|
|
421
|
+
continue
|
|
422
|
+
f, g = self.evaluate(candidate)
|
|
423
|
+
n_eval += 1
|
|
424
|
+
if not self._is_feasible(g):
|
|
425
|
+
st.infeasible += 1
|
|
426
|
+
elif not self._is_descent(f, base_f, mv.improves):
|
|
427
|
+
st.not_descent += 1
|
|
428
|
+
elif self.offer(f, candidate, archive):
|
|
429
|
+
st.accepted += 1
|
|
430
|
+
else:
|
|
431
|
+
st.dominated += 1
|
|
432
|
+
|
|
433
|
+
iteration += 1
|
|
434
|
+
if record_every and iteration % record_every == 0:
|
|
435
|
+
history.append((iteration, n_eval, len(archive)))
|
|
436
|
+
self.on_iteration(iteration, n_eval, archive)
|
|
437
|
+
|
|
438
|
+
return Result(archive, n_eval, iteration, time.time() - t0, stopped_by, stats, history)
|
|
439
|
+
|
|
440
|
+
def _start(self, init, rng, max_eval=None):
|
|
441
|
+
"""Evaluate the initial solutions, seed the archive and check the move tags."""
|
|
442
|
+
init = init(rng) if callable(init) else init
|
|
443
|
+
if isinstance(init, (list, tuple)):
|
|
444
|
+
if not init:
|
|
445
|
+
solutions = []
|
|
446
|
+
elif all(np.isscalar(item) for item in init):
|
|
447
|
+
solutions = [init] # a single vector solution such as [1.0, 2.0]
|
|
448
|
+
else:
|
|
449
|
+
solutions = list(init)
|
|
450
|
+
elif hasattr(init, "__iter__") and not isinstance(init, (str, bytes, np.ndarray, dict)):
|
|
451
|
+
solutions = list(init)
|
|
452
|
+
else:
|
|
453
|
+
solutions = [init]
|
|
454
|
+
if not solutions:
|
|
455
|
+
raise ValueError("init produced no solutions")
|
|
456
|
+
|
|
457
|
+
archive, n_eval = None, 0
|
|
458
|
+
for solution in solutions:
|
|
459
|
+
if max_eval is not None and n_eval >= max_eval:
|
|
460
|
+
break
|
|
461
|
+
f, g = self.evaluate(solution)
|
|
462
|
+
n_eval += 1
|
|
463
|
+
if archive is None:
|
|
464
|
+
archive = Archive(f.shape[0], eps_rel=self.eps_rel)
|
|
465
|
+
self._check_pools(archive.n_obj)
|
|
466
|
+
if self._is_feasible(g):
|
|
467
|
+
self.offer(f, solution, archive)
|
|
468
|
+
if archive is None or len(archive) == 0:
|
|
469
|
+
raise RuntimeError(
|
|
470
|
+
"no initial solution is feasible; nothing to search from "
|
|
471
|
+
f"(evaluated {n_eval} initial solution(s))")
|
|
472
|
+
return archive, n_eval
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pymdls
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-directional local search with user-supplied moves
|
|
5
|
+
Project-URL: Homepage, https://github.com/nsushant/ORBITAL/tree/MDLS
|
|
6
|
+
Project-URL: Repository, https://github.com/nsushant/ORBITAL/tree/MDLS
|
|
7
|
+
Project-URL: Documentation, https://github.com/nsushant/ORBITAL/blob/MDLS/user_guide.md
|
|
8
|
+
Project-URL: Issues, https://github.com/nsushant/ORBITAL/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/nsushant/ORBITAL/blob/MDLS/CHANGELOG.md
|
|
10
|
+
Author: Sushanta Nigudkar
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: local search,mdls,metaheuristics,multi-objective optimisation,multi-objective optimization,pareto
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: numba>=0.57
|
|
26
|
+
Requires-Dist: numpy>=1.23
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
31
|
+
Provides-Extra: examples
|
|
32
|
+
Requires-Dist: matplotlib>=3.6; extra == 'examples'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# mdls
|
|
36
|
+
|
|
37
|
+
`mdls` is a small, decorator-based implementation of **multi-directional local
|
|
38
|
+
search** (MDLS) for multi-objective optimisation. You supply an `evaluate`
|
|
39
|
+
function and one move per objective; the library owns the non-dominated
|
|
40
|
+
archive, selection, budgets and statistics.
|
|
41
|
+
|
|
42
|
+
The method follows Tricoire, Graf and Hartl (2012). This package is a
|
|
43
|
+
generalised, reusable version of the MDLS engine used in
|
|
44
|
+
[ORBITAL](https://github.com/nsushant/ORBITAL/tree/MDLS).
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install pymdls
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The distribution is named `pymdls`; the import package is `mdls`, so the code
|
|
53
|
+
below always uses `from mdls import ...`.
|
|
54
|
+
|
|
55
|
+
Python 3.10 or newer is required. NumPy and Numba are installed automatically.
|
|
56
|
+
|
|
57
|
+
For the example's plotting command, install the extra:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install "pymdls[examples]"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import numpy as np
|
|
67
|
+
|
|
68
|
+
from mdls import MDLS, move
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def evaluate(x, inst):
|
|
72
|
+
"""MDLS minimises; g <= 0 means feasible."""
|
|
73
|
+
return -(inst["obj"] @ x), [inst["weight"] @ x - inst["cap"]]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _toggle(x, inst, rng, k, n_toggle):
|
|
77
|
+
"""One attempt: flip items, keep the copy if objective k improves."""
|
|
78
|
+
y = x.copy()
|
|
79
|
+
flip = rng.choice(x.size, size=n_toggle, replace=False)
|
|
80
|
+
y[flip] = ~y[flip]
|
|
81
|
+
if inst["weight"] @ y > inst["cap"]:
|
|
82
|
+
return None
|
|
83
|
+
return y if inst["obj"][k] @ y > inst["obj"][k] @ x else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@move(improves=0, attempts=8)
|
|
87
|
+
def toggle_0(x, inst, rng, n_toggle=2):
|
|
88
|
+
return _toggle(x, inst, rng, 0, n_toggle)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@move(improves=1, attempts=8)
|
|
92
|
+
def toggle_1(x, inst, rng, n_toggle=2):
|
|
93
|
+
return _toggle(x, inst, rng, 1, n_toggle)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
rng = np.random.default_rng(0)
|
|
97
|
+
n = 20
|
|
98
|
+
inst = {"obj": rng.random((2, n)), "weight": rng.random(n), "cap": 0.3 * n}
|
|
99
|
+
moves = [toggle_0(n_toggle=2), toggle_1(n_toggle=2)]
|
|
100
|
+
search = MDLS(evaluate, moves=moves, instance=inst)
|
|
101
|
+
|
|
102
|
+
res = search.run([np.zeros(n, dtype=bool)], max_eval=500, seed=0)
|
|
103
|
+
print(res.summary())
|
|
104
|
+
print(res.objectives)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## What the library provides
|
|
108
|
+
|
|
109
|
+
- **Archive** of feasible, non-dominated solutions with a relative tolerance,
|
|
110
|
+
duplicate rejection and dominated-point eviction.
|
|
111
|
+
- **Move contract**: a move is one improving attempt, optionally retried up to
|
|
112
|
+
`attempts` times by the `@move` decorator.
|
|
113
|
+
- **Balanced search**: every iteration applies one sampled move per objective
|
|
114
|
+
from the same archived base solution.
|
|
115
|
+
- **Budgets**: stop on iterations (`max_iter`), evaluations (`max_eval`,
|
|
116
|
+
including initial solutions) or wall-clock time (`time_limit`).
|
|
117
|
+
- **Statistics**: per-move counts of calls, no-moves, non-descents, infeasible
|
|
118
|
+
results, dominated results, accepted results and time.
|
|
119
|
+
- **Extension points**: `is_feasible`, `selection`, `copy_solution`, and the
|
|
120
|
+
`select`, `offer`, `copy` and `on_iteration` hooks.
|
|
121
|
+
|
|
122
|
+
Moves are ordinary functions and stay readable; Numba is optional but is used
|
|
123
|
+
inside the library's dominance scan and can be used inside your own moves.
|
|
124
|
+
|
|
125
|
+
## Examples
|
|
126
|
+
|
|
127
|
+
The repository includes a bi-objective 0/1 knapsack:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python examples/knapsack.py run # one search, writes front.csv
|
|
131
|
+
python examples/knapsack.py plot # fronts for up to four seeds, writes front.png
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The full tutorial, including the move contract and every option, is in
|
|
135
|
+
[`user_guide.md`](https://github.com/nsushant/ORBITAL/blob/MDLS/user_guide.md).
|
|
136
|
+
|
|
137
|
+
## Testing
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pip install -e ".[dev,examples]"
|
|
141
|
+
pytest
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT. See [LICENSE](LICENSE).
|
|
147
|
+
|
|
148
|
+
## Citation
|
|
149
|
+
|
|
150
|
+
If you use this package in academic work, cite the MDLS paper and optionally
|
|
151
|
+
the software itself (see `CITATION.cff`):
|
|
152
|
+
|
|
153
|
+
> Tricoire, F., Graf, A., & Hartl, R. F. (2012). The bi-objective stochastic
|
|
154
|
+
> covering tour problem. *Computers & Operations Research*, 39(7), 1582–1592.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
mdls/__init__.py,sha256=CsP7KP5yOhKqgbLuhamwdH-1lSAQjjCAoxkOEOpkqx0,631
|
|
2
|
+
mdls/core.py,sha256=6Svk7iG373PfDG7x-aiNYiHEF4KI2zlBd9VCeCKnGRQ,20120
|
|
3
|
+
pymdls-0.1.0.dist-info/METADATA,sha256=d--qZFeCCNkOCLZilRDLZEAIyEKbxKvcPolc_gnGbyI,5193
|
|
4
|
+
pymdls-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
5
|
+
pymdls-0.1.0.dist-info/licenses/LICENSE,sha256=cJpK2lgBVaFro6joWv37LSwEiXMoJgVFdkwQXiK8I8c,1074
|
|
6
|
+
pymdls-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sushanta Nigudkar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|