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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.