pymdls 0.1.0__tar.gz
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.
- pymdls-0.1.0/.gitignore +31 -0
- pymdls-0.1.0/CHANGELOG.md +32 -0
- pymdls-0.1.0/CITATION.cff +38 -0
- pymdls-0.1.0/LICENSE +21 -0
- pymdls-0.1.0/PKG-INFO +154 -0
- pymdls-0.1.0/README.md +120 -0
- pymdls-0.1.0/examples/knapsack.py +195 -0
- pymdls-0.1.0/examples/params.json +16 -0
- pymdls-0.1.0/pyproject.toml +76 -0
- pymdls-0.1.0/src/mdls/__init__.py +37 -0
- pymdls-0.1.0/src/mdls/core.py +472 -0
- pymdls-0.1.0/tests/test_archive.py +75 -0
- pymdls-0.1.0/tests/test_engine.py +263 -0
- pymdls-0.1.0/tests/test_knapsack.py +63 -0
- pymdls-0.1.0/tests/test_moves.py +156 -0
- pymdls-0.1.0/tests/test_package.py +9 -0
- pymdls-0.1.0/user_guide.md +391 -0
pymdls-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Byte-compiled / cache
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.nbi
|
|
5
|
+
*.nbc
|
|
6
|
+
|
|
7
|
+
# Environments
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
env/
|
|
11
|
+
|
|
12
|
+
# Packaging and builds
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
*.egg
|
|
17
|
+
|
|
18
|
+
# Tests and coverage
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
htmlcov/
|
|
23
|
+
|
|
24
|
+
# Example output
|
|
25
|
+
front.csv
|
|
26
|
+
front.png
|
|
27
|
+
|
|
28
|
+
# Editors and OS
|
|
29
|
+
.DS_Store
|
|
30
|
+
.idea/
|
|
31
|
+
.vscode/
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file. The format
|
|
4
|
+
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
5
|
+
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-11-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `MDLS` engine with an archive of feasible non-dominated solutions, uniform
|
|
14
|
+
selection by default, and iteration, evaluation and wall-clock budgets.
|
|
15
|
+
- `@move(improves=k, attempts=n)` decorator for readable, configurable moves.
|
|
16
|
+
- `Archive` with relative-tolerance dominance, duplicate rejection, dynamic
|
|
17
|
+
growth, `knee()` and `extreme(k)` helpers.
|
|
18
|
+
- `Result` and `MoveStats` with per-move accounting, including dominated
|
|
19
|
+
candidates, and a text `summary()`.
|
|
20
|
+
- Extension points: `is_feasible`, `selection`, `copy_solution`, and the
|
|
21
|
+
`select`, `offer`, `copy` and `on_iteration` hooks.
|
|
22
|
+
- Logging-based progress output (`verbose=n`) instead of printing.
|
|
23
|
+
- Bi-objective knapsack example with `run` and `plot` commands.
|
|
24
|
+
- Test suite, user guide, packaging metadata and GitHub Actions workflows for
|
|
25
|
+
CI and PyPI trusted publishing.
|
|
26
|
+
|
|
27
|
+
The PyPI distribution is named `pymdls` because `mdls` is blocked by PyPI's
|
|
28
|
+
name-similarity rule (it collides with the existing project `mdis`). The
|
|
29
|
+
import package is still `mdls`.
|
|
30
|
+
|
|
31
|
+
[Unreleased]: https://github.com/nsushant/ORBITAL/compare/v0.1.0...MDLS
|
|
32
|
+
[0.1.0]: https://github.com/nsushant/ORBITAL/releases/tag/v0.1.0
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
title: MDLS
|
|
4
|
+
abstract: "A decorator-based implementation of multi-directional local search for multi-objective optimisation, with an archive of feasible non-dominated solutions and user-supplied moves."
|
|
5
|
+
type: software
|
|
6
|
+
authors:
|
|
7
|
+
- family-names: Nigudkar
|
|
8
|
+
given-names: Sushanta
|
|
9
|
+
version: 0.1.0
|
|
10
|
+
date-released: 2026-11-10
|
|
11
|
+
license: MIT
|
|
12
|
+
repository-code: "https://github.com/nsushant/ORBITAL/tree/MDLS"
|
|
13
|
+
url: "https://github.com/nsushant/ORBITAL/tree/MDLS"
|
|
14
|
+
identifiers:
|
|
15
|
+
- type: pypi
|
|
16
|
+
value: pymdls
|
|
17
|
+
keywords:
|
|
18
|
+
- multi-objective optimisation
|
|
19
|
+
- multi-objective optimization
|
|
20
|
+
- local search
|
|
21
|
+
- pareto
|
|
22
|
+
- metaheuristics
|
|
23
|
+
preferred-citation:
|
|
24
|
+
type: article
|
|
25
|
+
authors:
|
|
26
|
+
- family-names: Tricoire
|
|
27
|
+
given-names: Fabien
|
|
28
|
+
- family-names: Graf
|
|
29
|
+
given-names: Alexandra
|
|
30
|
+
- family-names: Hartl
|
|
31
|
+
given-names: Richard F.
|
|
32
|
+
title: "The bi-objective stochastic covering tour problem"
|
|
33
|
+
year: 2012
|
|
34
|
+
journal: "Computers & Operations Research"
|
|
35
|
+
volume: 39
|
|
36
|
+
issue: 7
|
|
37
|
+
start: 1582
|
|
38
|
+
end: 1592
|
pymdls-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pymdls-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
pymdls-0.1.0/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# mdls
|
|
2
|
+
|
|
3
|
+
`mdls` is a small, decorator-based implementation of **multi-directional local
|
|
4
|
+
search** (MDLS) for multi-objective optimisation. You supply an `evaluate`
|
|
5
|
+
function and one move per objective; the library owns the non-dominated
|
|
6
|
+
archive, selection, budgets and statistics.
|
|
7
|
+
|
|
8
|
+
The method follows Tricoire, Graf and Hartl (2012). This package is a
|
|
9
|
+
generalised, reusable version of the MDLS engine used in
|
|
10
|
+
[ORBITAL](https://github.com/nsushant/ORBITAL/tree/MDLS).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install pymdls
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The distribution is named `pymdls`; the import package is `mdls`, so the code
|
|
19
|
+
below always uses `from mdls import ...`.
|
|
20
|
+
|
|
21
|
+
Python 3.10 or newer is required. NumPy and Numba are installed automatically.
|
|
22
|
+
|
|
23
|
+
For the example's plotting command, install the extra:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install "pymdls[examples]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from mdls import MDLS, move
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def evaluate(x, inst):
|
|
38
|
+
"""MDLS minimises; g <= 0 means feasible."""
|
|
39
|
+
return -(inst["obj"] @ x), [inst["weight"] @ x - inst["cap"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _toggle(x, inst, rng, k, n_toggle):
|
|
43
|
+
"""One attempt: flip items, keep the copy if objective k improves."""
|
|
44
|
+
y = x.copy()
|
|
45
|
+
flip = rng.choice(x.size, size=n_toggle, replace=False)
|
|
46
|
+
y[flip] = ~y[flip]
|
|
47
|
+
if inst["weight"] @ y > inst["cap"]:
|
|
48
|
+
return None
|
|
49
|
+
return y if inst["obj"][k] @ y > inst["obj"][k] @ x else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@move(improves=0, attempts=8)
|
|
53
|
+
def toggle_0(x, inst, rng, n_toggle=2):
|
|
54
|
+
return _toggle(x, inst, rng, 0, n_toggle)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@move(improves=1, attempts=8)
|
|
58
|
+
def toggle_1(x, inst, rng, n_toggle=2):
|
|
59
|
+
return _toggle(x, inst, rng, 1, n_toggle)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
rng = np.random.default_rng(0)
|
|
63
|
+
n = 20
|
|
64
|
+
inst = {"obj": rng.random((2, n)), "weight": rng.random(n), "cap": 0.3 * n}
|
|
65
|
+
moves = [toggle_0(n_toggle=2), toggle_1(n_toggle=2)]
|
|
66
|
+
search = MDLS(evaluate, moves=moves, instance=inst)
|
|
67
|
+
|
|
68
|
+
res = search.run([np.zeros(n, dtype=bool)], max_eval=500, seed=0)
|
|
69
|
+
print(res.summary())
|
|
70
|
+
print(res.objectives)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## What the library provides
|
|
74
|
+
|
|
75
|
+
- **Archive** of feasible, non-dominated solutions with a relative tolerance,
|
|
76
|
+
duplicate rejection and dominated-point eviction.
|
|
77
|
+
- **Move contract**: a move is one improving attempt, optionally retried up to
|
|
78
|
+
`attempts` times by the `@move` decorator.
|
|
79
|
+
- **Balanced search**: every iteration applies one sampled move per objective
|
|
80
|
+
from the same archived base solution.
|
|
81
|
+
- **Budgets**: stop on iterations (`max_iter`), evaluations (`max_eval`,
|
|
82
|
+
including initial solutions) or wall-clock time (`time_limit`).
|
|
83
|
+
- **Statistics**: per-move counts of calls, no-moves, non-descents, infeasible
|
|
84
|
+
results, dominated results, accepted results and time.
|
|
85
|
+
- **Extension points**: `is_feasible`, `selection`, `copy_solution`, and the
|
|
86
|
+
`select`, `offer`, `copy` and `on_iteration` hooks.
|
|
87
|
+
|
|
88
|
+
Moves are ordinary functions and stay readable; Numba is optional but is used
|
|
89
|
+
inside the library's dominance scan and can be used inside your own moves.
|
|
90
|
+
|
|
91
|
+
## Examples
|
|
92
|
+
|
|
93
|
+
The repository includes a bi-objective 0/1 knapsack:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python examples/knapsack.py run # one search, writes front.csv
|
|
97
|
+
python examples/knapsack.py plot # fronts for up to four seeds, writes front.png
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The full tutorial, including the move contract and every option, is in
|
|
101
|
+
[`user_guide.md`](https://github.com/nsushant/ORBITAL/blob/MDLS/user_guide.md).
|
|
102
|
+
|
|
103
|
+
## Testing
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install -e ".[dev,examples]"
|
|
107
|
+
pytest
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT. See [LICENSE](LICENSE).
|
|
113
|
+
|
|
114
|
+
## Citation
|
|
115
|
+
|
|
116
|
+
If you use this package in academic work, cite the MDLS paper and optionally
|
|
117
|
+
the software itself (see `CITATION.cff`):
|
|
118
|
+
|
|
119
|
+
> Tricoire, F., Graf, A., & Hartl, R. F. (2012). The bi-objective stochastic
|
|
120
|
+
> covering tour problem. *Computers & Operations Research*, 39(7), 1582–1592.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Bi-objective 0/1 knapsack solved with mdls.MDLS; run `python examples/knapsack.py -h`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numba import njit
|
|
10
|
+
|
|
11
|
+
from mdls import MDLS, move
|
|
12
|
+
|
|
13
|
+
COLOURS = ["#1f77b4", "#d62728", "#2ca02c", "#9467bd"] # one per compared run; grey is reserved
|
|
14
|
+
GREY = "#9e9e9e"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_instance(n_items, cap_frac, seed):
|
|
18
|
+
"""Random weights and two profit rows; capacity is a fraction of total weight."""
|
|
19
|
+
rng = np.random.default_rng(seed)
|
|
20
|
+
w = rng.integers(10, 100, n_items).astype(float)
|
|
21
|
+
p = rng.integers(10, 100, (2, n_items)).astype(float)
|
|
22
|
+
return dict(w=w, p=p, cap=cap_frac * w.sum())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def evaluate(x, inst):
|
|
26
|
+
"""f = negated profits (MDLS minimises), g = [load - capacity] (feasible when <= 0)."""
|
|
27
|
+
return -(inst["p"] @ x), [inst["w"] @ x - inst["cap"]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@njit
|
|
31
|
+
def greedy_fill(x, w, cap, order):
|
|
32
|
+
"""Add the items of x in `order` while they fit; x is modified in place."""
|
|
33
|
+
load = 0.0
|
|
34
|
+
for i in range(x.size):
|
|
35
|
+
if x[i]:
|
|
36
|
+
load += w[i]
|
|
37
|
+
for t in range(order.size):
|
|
38
|
+
i = order[t]
|
|
39
|
+
if not x[i] and load + w[i] <= cap:
|
|
40
|
+
x[i] = True
|
|
41
|
+
load += w[i]
|
|
42
|
+
return x
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _drop_add(x, inst, rng, k, n_drop, noise):
|
|
46
|
+
"""One attempt: drop the weakest held items on profit k, refill greedily.
|
|
47
|
+
|
|
48
|
+
Returns a copy that strictly improves profit k, or None; x is left untouched
|
|
49
|
+
so the decorator can retry from the same base.
|
|
50
|
+
"""
|
|
51
|
+
p, w = inst["p"][k], inst["w"]
|
|
52
|
+
held = np.flatnonzero(x)
|
|
53
|
+
if held.size == 0:
|
|
54
|
+
return None
|
|
55
|
+
y = x.copy()
|
|
56
|
+
ratio = p[held] / w[held]
|
|
57
|
+
noisy = ratio * rng.uniform(1 - noise, 1 + noise, held.size) # avoids stalling
|
|
58
|
+
y[held[np.argsort(noisy)[:n_drop]]] = False
|
|
59
|
+
greedy_fill(y, w, inst["cap"], np.argsort(-p / w))
|
|
60
|
+
return y if p @ y > p @ x else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@move(improves=0, attempts=4)
|
|
64
|
+
def drop_add_0(x, inst, rng, n_drop=3, noise=0.2):
|
|
65
|
+
"""Move for profit 0: drop the weakest held items, refill greedily.
|
|
66
|
+
|
|
67
|
+
The drop is noisy, so the decorator retries up to four times.
|
|
68
|
+
"""
|
|
69
|
+
return _drop_add(x, inst, rng, 0, n_drop, noise)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@move(improves=1, attempts=4)
|
|
73
|
+
def drop_add_1(x, inst, rng, n_drop=3, noise=0.2):
|
|
74
|
+
"""Move for profit 1: drop the weakest held items, refill greedily.
|
|
75
|
+
|
|
76
|
+
The drop is noisy, so the decorator retries up to four times.
|
|
77
|
+
"""
|
|
78
|
+
return _drop_add(x, inst, rng, 1, n_drop, noise)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@njit
|
|
82
|
+
def find_swap(w, p, slack, i_draw, j_draw):
|
|
83
|
+
"""First drawn pair that raises profit 0 and still fits; returns (found, i, j)."""
|
|
84
|
+
for t in range(i_draw.size):
|
|
85
|
+
i, j = i_draw[t], j_draw[t]
|
|
86
|
+
if p[j] > p[i] and w[j] - w[i] <= slack:
|
|
87
|
+
return True, i, j
|
|
88
|
+
return False, 0, 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@move(improves=0, attempts=1)
|
|
92
|
+
def swap(x, inst, rng, tries=50):
|
|
93
|
+
"""Move for profit 0: first-improvement 1-1 swap over randomly drawn pairs.
|
|
94
|
+
|
|
95
|
+
`tries` is the batch size inside the numba kernel, so no decorator retries.
|
|
96
|
+
"""
|
|
97
|
+
w, p = inst["w"], inst["p"][0]
|
|
98
|
+
held, free = np.flatnonzero(x), np.flatnonzero(~x)
|
|
99
|
+
if held.size == 0 or free.size == 0:
|
|
100
|
+
return None
|
|
101
|
+
slack = inst["cap"] - w @ x
|
|
102
|
+
i_draw = held[rng.integers(0, held.size, tries)]
|
|
103
|
+
j_draw = free[rng.integers(0, free.size, tries)]
|
|
104
|
+
found, i, j = find_swap(w, p, slack, i_draw, j_draw)
|
|
105
|
+
if not found:
|
|
106
|
+
return None
|
|
107
|
+
x[i], x[j] = False, True
|
|
108
|
+
return x
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build(cfg):
|
|
112
|
+
"""Instance, MDLS object and init function from a config dict."""
|
|
113
|
+
inst = make_instance(cfg["n_items"], cfg["capacity_fraction"], cfg["instance_seed"])
|
|
114
|
+
moves = [drop_add_0(n_drop=cfg["n_drop"], noise=cfg["noise"]),
|
|
115
|
+
drop_add_1(n_drop=cfg["n_drop"], noise=cfg["noise"])]
|
|
116
|
+
if cfg["use_swap"]:
|
|
117
|
+
moves.append(swap(tries=cfg["swap_tries"]))
|
|
118
|
+
n = len(inst["w"])
|
|
119
|
+
|
|
120
|
+
def init(rng):
|
|
121
|
+
empty = np.zeros(n, dtype=bool)
|
|
122
|
+
starts = [greedy_fill(empty.copy(), inst["w"], inst["cap"],
|
|
123
|
+
np.argsort(-inst["p"][k] / inst["w"])) for k in range(2)]
|
|
124
|
+
return starts + [greedy_fill(empty.copy(), inst["w"], inst["cap"], rng.permutation(n))
|
|
125
|
+
for _ in range(cfg["n_random_starts"])]
|
|
126
|
+
|
|
127
|
+
search = MDLS(evaluate, moves=moves, instance=inst, verbose=cfg["verbose"])
|
|
128
|
+
return inst, search, init
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_run(cfg):
|
|
132
|
+
"""One search; prints the summary and writes the front as CSV."""
|
|
133
|
+
_, search, init = build(cfg)
|
|
134
|
+
res = search.run(init, max_eval=cfg["max_eval"], seed=cfg["seeds"][0])
|
|
135
|
+
print(res.summary())
|
|
136
|
+
f = -res.objectives
|
|
137
|
+
f = f[np.argsort(f[:, 0])]
|
|
138
|
+
np.savetxt(cfg["front_csv"], f, delimiter=",", header="profit_0,profit_1", comments="", fmt="%.0f")
|
|
139
|
+
k = res.archive.knee()
|
|
140
|
+
print(f"knee: {-res.objectives[k, 0]:.0f}, {-res.objectives[k, 1]:.0f}; front written to {cfg['front_csv']}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_plot(cfg):
|
|
144
|
+
"""Fronts and archive growth for up to 4 seeds, one colour each."""
|
|
145
|
+
import matplotlib
|
|
146
|
+
matplotlib.use("Agg")
|
|
147
|
+
import matplotlib.pyplot as plt
|
|
148
|
+
seeds = cfg["seeds"][:4]
|
|
149
|
+
_, search, init = build(cfg)
|
|
150
|
+
fig, (a1, a2) = plt.subplots(1, 2, figsize=(10, 4))
|
|
151
|
+
for c, s in zip(COLOURS, seeds):
|
|
152
|
+
res = search.run(init, max_eval=cfg["max_eval"], seed=s, record_every=cfg["record_every"])
|
|
153
|
+
f = -res.objectives
|
|
154
|
+
f = f[np.argsort(f[:, 0])]
|
|
155
|
+
a1.plot(f[:, 0], f[:, 1], color=GREY, lw=0.8, zorder=1)
|
|
156
|
+
a1.scatter(f[:, 0], f[:, 1], color=c, s=14, zorder=2, label=f"seed {s}")
|
|
157
|
+
if res.history:
|
|
158
|
+
h = np.array(res.history)
|
|
159
|
+
a2.plot(h[:, 1], h[:, 2], color=GREY, lw=0.8, zorder=1)
|
|
160
|
+
a2.scatter(h[:, 1], h[:, 2], color=c, s=10, zorder=2, label=f"seed {s}")
|
|
161
|
+
a1.set_xlabel("Profit 1"); a1.set_ylabel("Profit 2")
|
|
162
|
+
a2.set_xlabel("Evaluations"); a2.set_ylabel("Front size")
|
|
163
|
+
a1.legend(); a2.legend()
|
|
164
|
+
fig.tight_layout()
|
|
165
|
+
fig.savefig(cfg["plot_png"], dpi=150)
|
|
166
|
+
print(f"plot written to {cfg['plot_png']}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv=None):
|
|
170
|
+
"""Parse the command line, load the config, apply overrides, dispatch."""
|
|
171
|
+
ap = argparse.ArgumentParser(description="MDLS on a bi-objective knapsack")
|
|
172
|
+
ap.add_argument("command", choices=["run", "plot"])
|
|
173
|
+
ap.add_argument("--config", default=None,
|
|
174
|
+
help="parameter file (JSON); defaults to params.json next to this script")
|
|
175
|
+
ap.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE",
|
|
176
|
+
help="override config entries, e.g. --set max_eval=2000 seeds=[1,2]")
|
|
177
|
+
args = ap.parse_args(argv)
|
|
178
|
+
config = Path(args.config) if args.config else Path(__file__).with_name("params.json")
|
|
179
|
+
with config.open() as fh:
|
|
180
|
+
cfg = json.load(fh)
|
|
181
|
+
for kv in args.set:
|
|
182
|
+
key, val = kv.split("=", 1)
|
|
183
|
+
if key not in cfg:
|
|
184
|
+
ap.error(f"unknown config key {key!r}")
|
|
185
|
+
try:
|
|
186
|
+
cfg[key] = json.loads(val)
|
|
187
|
+
except json.JSONDecodeError:
|
|
188
|
+
cfg[key] = val # not a JSON literal: keep as string
|
|
189
|
+
logging.basicConfig(level=logging.INFO if cfg["verbose"] else logging.WARNING,
|
|
190
|
+
format="%(message)s")
|
|
191
|
+
{"run": cmd_run, "plot": cmd_plot}[args.command](cfg)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"n_items": 100,
|
|
3
|
+
"capacity_fraction": 0.5,
|
|
4
|
+
"instance_seed": 1,
|
|
5
|
+
"n_random_starts": 5,
|
|
6
|
+
"n_drop": 3,
|
|
7
|
+
"noise": 0.2,
|
|
8
|
+
"use_swap": true,
|
|
9
|
+
"swap_tries": 50,
|
|
10
|
+
"max_eval": 6000,
|
|
11
|
+
"seeds": [0, 1, 2, 3],
|
|
12
|
+
"record_every": 50,
|
|
13
|
+
"verbose": 0,
|
|
14
|
+
"front_csv": "front.csv",
|
|
15
|
+
"plot_png": "front.png"
|
|
16
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pymdls"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Multi-directional local search with user-supplied moves"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Sushanta Nigudkar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"multi-objective optimisation",
|
|
16
|
+
"multi-objective optimization",
|
|
17
|
+
"local search",
|
|
18
|
+
"pareto",
|
|
19
|
+
"metaheuristics",
|
|
20
|
+
"mdls",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Science/Research",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"numpy>=1.23",
|
|
36
|
+
"numba>=0.57",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
examples = ["matplotlib>=3.6"]
|
|
41
|
+
dev = [
|
|
42
|
+
"build>=1.0",
|
|
43
|
+
"pytest>=7.0",
|
|
44
|
+
"twine>=5.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/nsushant/ORBITAL/tree/MDLS"
|
|
49
|
+
Repository = "https://github.com/nsushant/ORBITAL/tree/MDLS"
|
|
50
|
+
Documentation = "https://github.com/nsushant/ORBITAL/blob/MDLS/user_guide.md"
|
|
51
|
+
Issues = "https://github.com/nsushant/ORBITAL/issues"
|
|
52
|
+
Changelog = "https://github.com/nsushant/ORBITAL/blob/MDLS/CHANGELOG.md"
|
|
53
|
+
|
|
54
|
+
[tool.hatch.version]
|
|
55
|
+
path = "src/mdls/__init__.py"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/mdls"]
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.sdist]
|
|
61
|
+
include = [
|
|
62
|
+
"/src/mdls",
|
|
63
|
+
"/tests",
|
|
64
|
+
"/examples",
|
|
65
|
+
"/README.md",
|
|
66
|
+
"/LICENSE",
|
|
67
|
+
"/CHANGELOG.md",
|
|
68
|
+
"/CITATION.cff",
|
|
69
|
+
"/user_guide.md",
|
|
70
|
+
"/pyproject.toml",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
addopts = "-ra"
|
|
76
|
+
pythonpath = ["src"]
|