mosade 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.
Files changed (57) hide show
  1. mosade-0.1.0/LICENSE +21 -0
  2. mosade-0.1.0/PKG-INFO +94 -0
  3. mosade-0.1.0/README.md +56 -0
  4. mosade-0.1.0/pyproject.toml +97 -0
  5. mosade-0.1.0/setup.cfg +4 -0
  6. mosade-0.1.0/src/mosade/__init__.py +3 -0
  7. mosade-0.1.0/src/mosade/algorithm/__init__.py +32 -0
  8. mosade-0.1.0/src/mosade/algorithm/adaptation.py +186 -0
  9. mosade-0.1.0/src/mosade/algorithm/archive.py +125 -0
  10. mosade-0.1.0/src/mosade/algorithm/decomposition.py +139 -0
  11. mosade-0.1.0/src/mosade/algorithm/moead.py +401 -0
  12. mosade-0.1.0/src/mosade/algorithm/mosade.py +1385 -0
  13. mosade-0.1.0/src/mosade/algorithm/nsga2.py +531 -0
  14. mosade-0.1.0/src/mosade/algorithm/pymoo_wrapper.py +279 -0
  15. mosade-0.1.0/src/mosade/algorithm/registry.py +99 -0
  16. mosade-0.1.0/src/mosade/algorithm/selection.py +353 -0
  17. mosade-0.1.0/src/mosade/algorithm/strategies.py +152 -0
  18. mosade-0.1.0/src/mosade/metrics/__init__.py +8 -0
  19. mosade-0.1.0/src/mosade/metrics/gd.py +45 -0
  20. mosade-0.1.0/src/mosade/metrics/hypervolume.py +122 -0
  21. mosade-0.1.0/src/mosade/metrics/igd.py +62 -0
  22. mosade-0.1.0/src/mosade/metrics/spread.py +95 -0
  23. mosade-0.1.0/src/mosade/problems/__init__.py +45 -0
  24. mosade-0.1.0/src/mosade/problems/base.py +142 -0
  25. mosade-0.1.0/src/mosade/problems/dascmop.py +387 -0
  26. mosade-0.1.0/src/mosade/problems/dtlz.py +240 -0
  27. mosade-0.1.0/src/mosade/problems/realworld_cre.py +303 -0
  28. mosade-0.1.0/src/mosade/problems/wfg.py +501 -0
  29. mosade-0.1.0/src/mosade/problems/zdt.py +119 -0
  30. mosade-0.1.0/src/mosade/runner/__init__.py +5 -0
  31. mosade-0.1.0/src/mosade/runner/experiment.py +1203 -0
  32. mosade-0.1.0/src/mosade/utils/__init__.py +6 -0
  33. mosade-0.1.0/src/mosade/utils/io.py +58 -0
  34. mosade-0.1.0/src/mosade/utils/logging.py +56 -0
  35. mosade-0.1.0/src/mosade/utils/seeding.py +31 -0
  36. mosade-0.1.0/src/mosade.egg-info/PKG-INFO +94 -0
  37. mosade-0.1.0/src/mosade.egg-info/SOURCES.txt +55 -0
  38. mosade-0.1.0/src/mosade.egg-info/dependency_links.txt +1 -0
  39. mosade-0.1.0/src/mosade.egg-info/requires.txt +16 -0
  40. mosade-0.1.0/src/mosade.egg-info/top_level.txt +1 -0
  41. mosade-0.1.0/tests/test_algorithm_regressions.py +79 -0
  42. mosade-0.1.0/tests/test_dascmop.py +195 -0
  43. mosade-0.1.0/tests/test_decomposition.py +85 -0
  44. mosade-0.1.0/tests/test_dtlz7.py +139 -0
  45. mosade-0.1.0/tests/test_epsilon_modes.py +131 -0
  46. mosade-0.1.0/tests/test_metrics.py +176 -0
  47. mosade-0.1.0/tests/test_mosade_telemetry_smoke.py +33 -0
  48. mosade-0.1.0/tests/test_problems.py +189 -0
  49. mosade-0.1.0/tests/test_pymoo_wrapper.py +74 -0
  50. mosade-0.1.0/tests/test_realworld_cre_shapes.py +92 -0
  51. mosade-0.1.0/tests/test_realworld_cre_smoke.py +54 -0
  52. mosade-0.1.0/tests/test_reference_point_and_metrics.py +18 -0
  53. mosade-0.1.0/tests/test_registry_presets.py +43 -0
  54. mosade-0.1.0/tests/test_selection_modes.py +38 -0
  55. mosade-0.1.0/tests/test_smoke.py +260 -0
  56. mosade-0.1.0/tests/test_unsupported_handling.py +74 -0
  57. mosade-0.1.0/tests/test_wfg.py +217 -0
mosade-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Li Jiawei
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.
mosade-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: mosade
3
+ Version: 0.1.0
4
+ Summary: MOSADE: Multi-Objective Self-Adaptive Differential Evolution
5
+ Author: Li Jiawei
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Levvvi/MOSADE
8
+ Project-URL: Repository, https://github.com/Levvvi/MOSADE
9
+ Project-URL: Issues, https://github.com/Levvvi/MOSADE/issues
10
+ Keywords: multi-objective optimization,evolutionary algorithms,differential evolution,MOSADE,constrained optimization
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.24
25
+ Requires-Dist: pyyaml>=6.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2; extra == "dev"
28
+ Requires-Dist: pytest>=7.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
31
+ Provides-Extra: analysis
32
+ Requires-Dist: matplotlib>=3.9; extra == "analysis"
33
+ Requires-Dist: scipy>=1.10; extra == "analysis"
34
+ Requires-Dist: pandas>=2.0; extra == "analysis"
35
+ Provides-Extra: baselines
36
+ Requires-Dist: pymoo>=0.6.1; extra == "baselines"
37
+ Dynamic: license-file
38
+
39
+ # MOSADE
40
+
41
+ MOSADE (Multi-Objective Self-Adaptive Differential Evolution) is a Python
42
+ framework for multi-objective optimisation with real-valued decision variables.
43
+ It combines multiple differential-evolution mutation strategies,
44
+ decomposition-based environmental selection, strategy-level parameter memories,
45
+ and epsilon-constraint handling for constrained problems.
46
+
47
+ ## Repository Contents
48
+
49
+ - `src/mosade/algorithm/`: MOSADE, differential-evolution strategies,
50
+ decomposition, selection, adaptation, archive management, and baseline
51
+ implementations.
52
+ - `src/mosade/problems/`: ZDT, DTLZ, WFG, DASCMOP, and real-world CRE benchmark
53
+ problems.
54
+ - `src/mosade/metrics/`: hypervolume, IGD, IGD+, GD, spread, and spacing.
55
+ - `src/mosade/analysis/`: result merging, plotting, sensitivity analysis, and
56
+ statistical utilities.
57
+ - `configs/`: reproducible experiment configurations.
58
+ - `scripts/`: command-line runners and result post-processing utilities.
59
+ - `tests/`: unit and smoke tests.
60
+ - `figures/`, `tables/`, `validation/`: generated outputs and summary data.
61
+
62
+ Raw per-run experiment directories are written to `results/` and are not tracked
63
+ by Git because complete benchmark runs can be large.
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ pip install -e ".[dev,analysis]"
69
+ ```
70
+
71
+ Python 3.10 or newer is required.
72
+
73
+ ## Quick Start
74
+
75
+ Run the default smoke experiment:
76
+
77
+ ```bash
78
+ python scripts/run_experiment.py
79
+ ```
80
+
81
+ Run a specific configuration:
82
+
83
+ ```bash
84
+ python scripts/run_experiment.py --config configs/smoke_test.yaml
85
+ ```
86
+
87
+ Run the test suite:
88
+
89
+ ```bash
90
+ pytest
91
+ ```
92
+
93
+ See `REPRODUCIBILITY.md` for the recommended workflow for reproducing generated
94
+ results.
mosade-0.1.0/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # MOSADE
2
+
3
+ MOSADE (Multi-Objective Self-Adaptive Differential Evolution) is a Python
4
+ framework for multi-objective optimisation with real-valued decision variables.
5
+ It combines multiple differential-evolution mutation strategies,
6
+ decomposition-based environmental selection, strategy-level parameter memories,
7
+ and epsilon-constraint handling for constrained problems.
8
+
9
+ ## Repository Contents
10
+
11
+ - `src/mosade/algorithm/`: MOSADE, differential-evolution strategies,
12
+ decomposition, selection, adaptation, archive management, and baseline
13
+ implementations.
14
+ - `src/mosade/problems/`: ZDT, DTLZ, WFG, DASCMOP, and real-world CRE benchmark
15
+ problems.
16
+ - `src/mosade/metrics/`: hypervolume, IGD, IGD+, GD, spread, and spacing.
17
+ - `src/mosade/analysis/`: result merging, plotting, sensitivity analysis, and
18
+ statistical utilities.
19
+ - `configs/`: reproducible experiment configurations.
20
+ - `scripts/`: command-line runners and result post-processing utilities.
21
+ - `tests/`: unit and smoke tests.
22
+ - `figures/`, `tables/`, `validation/`: generated outputs and summary data.
23
+
24
+ Raw per-run experiment directories are written to `results/` and are not tracked
25
+ by Git because complete benchmark runs can be large.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install -e ".[dev,analysis]"
31
+ ```
32
+
33
+ Python 3.10 or newer is required.
34
+
35
+ ## Quick Start
36
+
37
+ Run the default smoke experiment:
38
+
39
+ ```bash
40
+ python scripts/run_experiment.py
41
+ ```
42
+
43
+ Run a specific configuration:
44
+
45
+ ```bash
46
+ python scripts/run_experiment.py --config configs/smoke_test.yaml
47
+ ```
48
+
49
+ Run the test suite:
50
+
51
+ ```bash
52
+ pytest
53
+ ```
54
+
55
+ See `REPRODUCIBILITY.md` for the recommended workflow for reproducing generated
56
+ results.
@@ -0,0 +1,97 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mosade"
7
+ version = "0.1.0"
8
+ description = "MOSADE: Multi-Objective Self-Adaptive Differential Evolution"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Li Jiawei" },
15
+ ]
16
+ keywords = [
17
+ "multi-objective optimization",
18
+ "evolutionary algorithms",
19
+ "differential evolution",
20
+ "MOSADE",
21
+ "constrained optimization",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "Intended Audience :: Science/Research",
26
+ "Intended Audience :: Developers",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Topic :: Scientific/Engineering",
33
+ "Topic :: Software Development :: Libraries :: Python Modules",
34
+ ]
35
+ dependencies = [
36
+ "numpy>=1.24",
37
+ "pyyaml>=6.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/Levvvi/MOSADE"
42
+ Repository = "https://github.com/Levvvi/MOSADE"
43
+ Issues = "https://github.com/Levvvi/MOSADE/issues"
44
+
45
+ [project.optional-dependencies]
46
+ dev = [
47
+ "build>=1.2",
48
+ "pytest>=7.0",
49
+ "pytest-cov>=4.0",
50
+ "ruff>=0.6.0",
51
+ ]
52
+ analysis = [
53
+ "matplotlib>=3.9", # Axes.boxplot(tick_labels=) and Colormap.with_extremes()
54
+ "scipy>=1.10",
55
+ "pandas>=2.0",
56
+ ]
57
+ baselines = [
58
+ "pymoo>=0.6.1",
59
+ ]
60
+
61
+ # Only the shippable `mosade` library under src/ is packaged into the wheel.
62
+ # The thesis tooling in experiments/ (the `mosade_experiments` package) is
63
+ # deliberately excluded from the distribution.
64
+ [tool.setuptools.packages.find]
65
+ where = ["src"]
66
+ include = ["mosade*"]
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ # experiments/ is on the import path so the top-level suite (which keeps
71
+ # test_epsilon_modes.py) and `pytest experiments/tests/` can import the
72
+ # mosade_experiments tooling, even though it is never installed into the wheel.
73
+ pythonpath = ["src", "experiments"]
74
+
75
+ [tool.coverage.run]
76
+ source = ["src/mosade"]
77
+
78
+ [tool.coverage.report]
79
+ show_missing = true
80
+ skip_covered = false
81
+
82
+ [tool.ruff]
83
+ exclude = [
84
+ "experiments",
85
+ "build",
86
+ "dist",
87
+ ".eggs",
88
+ "*.egg-info",
89
+ ".git",
90
+ ".mypy_cache",
91
+ ".pytest_cache",
92
+ ".ruff_cache",
93
+ ".venv",
94
+ "htmlcov",
95
+ ]
96
+ line-length = 100
97
+ target-version = "py310"
mosade-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """MOSADE: Multi-Objective Self-Adaptive Differential Evolution."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,32 @@
1
+ """MOSADE algorithm components.
2
+
3
+ ``ALGORITHM_REGISTRY`` maps string type names (as used in the ``type:`` YAML
4
+ key) to callables so the experiment runner can instantiate algorithms by name
5
+ from YAML configs::
6
+
7
+ algorithms:
8
+ - name: MOSADE_run
9
+ type: MOSADE # optional; defaults to the name if omitted
10
+ pop_size: 100
11
+ - name: NSGA3_baseline
12
+ type: NSGA3
13
+ pop_size: 100
14
+
15
+ See :mod:`mosade.algorithm.registry` for the full registry and instructions on
16
+ adding new algorithms.
17
+ """
18
+
19
+ from mosade.algorithm.mosade import MOSADE, MOSADEResult
20
+ from mosade.algorithm.nsga2 import NSGA2
21
+ from mosade.algorithm.moead import MOEAD
22
+ from mosade.algorithm.pymoo_wrapper import PymooAlgorithm
23
+ from mosade.algorithm.registry import ALGORITHM_REGISTRY
24
+
25
+ __all__ = [
26
+ "MOSADE",
27
+ "MOSADEResult",
28
+ "NSGA2",
29
+ "MOEAD",
30
+ "PymooAlgorithm",
31
+ "ALGORITHM_REGISTRY",
32
+ ]
@@ -0,0 +1,186 @@
1
+ """Self-adaptation mechanisms for MOSADE.
2
+
3
+ - Per-strategy LSHADE-style success memories for F and CR
4
+ - Credit-based sliding-window strategy selection probabilities
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import deque
10
+ from dataclasses import dataclass, field
11
+
12
+ import numpy as np
13
+
14
+ from mosade.algorithm.strategies import NUM_STRATEGIES
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # LSHADE success memory
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ class LSHADEMemory:
23
+ """Independent LSHADE-style success memory for one strategy.
24
+
25
+ Stores H historical mean values for F and CR, updated by weighted
26
+ Lehmer / weighted arithmetic means of successful parameter values.
27
+ """
28
+
29
+ def __init__(self, H: int = 5, init_F: float = 0.5, init_CR: float = 0.5) -> None:
30
+ self.H = H
31
+ self.M_F = np.full(H, init_F)
32
+ self.M_CR = np.full(H, init_CR)
33
+ self._k = 0 # circular write index
34
+
35
+ def sample(self, rng: np.random.Generator) -> tuple[float, float]:
36
+ """Sample (F, CR) from the memory."""
37
+ r = rng.integers(self.H)
38
+ F = float(np.clip(rng.standard_cauchy() * 0.1 + self.M_F[r], 1e-6, 1.0))
39
+ CR = float(np.clip(rng.normal(self.M_CR[r], 0.1), 0.0, 1.0))
40
+ return F, CR
41
+
42
+ def sample_batch(
43
+ self, n: int, rng: np.random.Generator
44
+ ) -> tuple[np.ndarray, np.ndarray]:
45
+ """Sample n (F, CR) pairs from the memory in one vectorised call.
46
+
47
+ Parameters
48
+ ----------
49
+ n : int
50
+ Number of pairs to sample.
51
+ rng : np.random.Generator
52
+ Random number generator.
53
+
54
+ Returns
55
+ -------
56
+ F_vals : ndarray, shape (n,)
57
+ CR_vals : ndarray, shape (n,)
58
+ """
59
+ if n == 0:
60
+ return np.empty(0, dtype=float), np.empty(0, dtype=float)
61
+ r = rng.integers(self.H, size=n)
62
+ F_vals = np.clip(rng.standard_cauchy(n) * 0.1 + self.M_F[r], 1e-6, 1.0)
63
+ CR_vals = np.clip(rng.normal(self.M_CR[r], 0.1, size=n), 0.0, 1.0)
64
+ return F_vals, CR_vals
65
+
66
+ def update(self, S_F: list[float], S_CR: list[float], weights: list[float]) -> None:
67
+ """Update memory with successful F/CR pairs.
68
+
69
+ Parameters
70
+ ----------
71
+ S_F, S_CR : lists of successful F and CR values
72
+ weights : improvement-based weights (same length as S_F/S_CR)
73
+ """
74
+ if len(S_F) == 0:
75
+ return
76
+ w = np.array(weights)
77
+ w = w / (w.sum() + 1e-30) # normalise
78
+
79
+ sf = np.array(S_F)
80
+ scr = np.array(S_CR)
81
+
82
+ # Weighted Lehmer mean for F
83
+ mean_F = float(np.sum(w * sf**2) / (np.sum(w * sf) + 1e-30))
84
+ # Weighted arithmetic mean for CR
85
+ mean_CR = float(np.sum(w * scr))
86
+
87
+ self.M_F[self._k] = mean_F
88
+ self.M_CR[self._k] = mean_CR
89
+ self._k = (self._k + 1) % self.H
90
+
91
+ def reset(self, init_F: float = 0.5, init_CR: float = 0.5) -> None:
92
+ self.M_F[:] = init_F
93
+ self.M_CR[:] = init_CR
94
+ self._k = 0
95
+
96
+ @property
97
+ def mean_F(self) -> float:
98
+ """Return the current mean of the F memory.
99
+
100
+ Used by run-history telemetry so we can verify that each strategy keeps
101
+ an independent success memory and that the memories are actually moving
102
+ over time.
103
+ """
104
+ return float(np.mean(self.M_F))
105
+
106
+ @property
107
+ def mean_CR(self) -> float:
108
+ """Return the current mean of the CR memory."""
109
+ return float(np.mean(self.M_CR))
110
+
111
+ def snapshot(self) -> dict[str, list[float] | float]:
112
+ """Return a lightweight serialisable view of the memory state."""
113
+ return {
114
+ "M_F": self.M_F.tolist(),
115
+ "M_CR": self.M_CR.tolist(),
116
+ "mean_F": self.mean_F,
117
+ "mean_CR": self.mean_CR,
118
+ }
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Strategy selection via credit assignment
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ @dataclass
127
+ class StrategySelector:
128
+ """Credit-based adaptive strategy selection with sliding window.
129
+
130
+ Maintains selection probabilities π_k for each strategy, updated
131
+ from cumulative credit over a sliding window of recent generations.
132
+ """
133
+
134
+ n_strategies: int = NUM_STRATEGIES
135
+ window_size: int = 50 # LP in the design doc
136
+ pi_min: float = 0.05
137
+
138
+ # internal
139
+ _pi: np.ndarray = field(init=False)
140
+ _credit_history: deque = field(init=False)
141
+
142
+ def __post_init__(self) -> None:
143
+ self._pi = np.full(self.n_strategies, 1.0 / self.n_strategies)
144
+ self._credit_history = deque(maxlen=self.window_size)
145
+
146
+ @property
147
+ def probabilities(self) -> np.ndarray:
148
+ return self._pi.copy()
149
+
150
+ @property
151
+ def credit_totals(self) -> np.ndarray:
152
+ """Return sliding-window cumulative credits for logging/debugging."""
153
+ totals = np.zeros(self.n_strategies)
154
+ for c in self._credit_history:
155
+ totals += c
156
+ return totals
157
+
158
+ def select(self, rng: np.random.Generator) -> int:
159
+ """Sample a strategy index using roulette-wheel on current probabilities."""
160
+ return int(rng.choice(self.n_strategies, p=self._pi))
161
+
162
+ def update(self, credits: np.ndarray) -> None:
163
+ """Record per-strategy credits for this generation and update π.
164
+
165
+ Parameters
166
+ ----------
167
+ credits : ndarray, shape (n_strategies,)
168
+ Sum of credit values earned by each strategy this generation.
169
+ """
170
+ self._credit_history.append(credits.copy())
171
+
172
+ # Sum over sliding window
173
+ totals = np.zeros(self.n_strategies)
174
+ for c in self._credit_history:
175
+ totals += c
176
+
177
+ if totals.sum() > 0:
178
+ self._pi = totals / totals.sum()
179
+ self._pi = np.maximum(self._pi, self.pi_min)
180
+ self._pi /= self._pi.sum() # re-normalise after floor
181
+ else:
182
+ self._pi[:] = 1.0 / self.n_strategies
183
+
184
+ def reset(self) -> None:
185
+ self._pi[:] = 1.0 / self.n_strategies
186
+ self._credit_history.clear()
@@ -0,0 +1,125 @@
1
+ """External archive: passive elite storage with crowding-based truncation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from mosade.algorithm.selection import dominates
8
+
9
+
10
+ class Archive:
11
+ """Bounded external archive of nondominated feasible solutions.
12
+
13
+ Used for:
14
+ - Providing pbest candidates to mutation strategies S1 and S4.
15
+ - Final output of the algorithm.
16
+
17
+ Truncation is by crowding distance (removing least-crowded member).
18
+ """
19
+
20
+ def __init__(self, max_size: int) -> None:
21
+ self.max_size = max_size
22
+ self.X: list[np.ndarray] = []
23
+ self.F: list[np.ndarray] = []
24
+
25
+ @property
26
+ def size(self) -> int:
27
+ return len(self.X)
28
+
29
+ @property
30
+ def is_empty(self) -> bool:
31
+ return self.size == 0
32
+
33
+ def get_objectives(self) -> np.ndarray:
34
+ """Return objective matrix, shape (size, M)."""
35
+ if self.is_empty:
36
+ raise ValueError("Archive is empty.")
37
+ return np.array(self.F)
38
+
39
+ def get_decisions(self) -> np.ndarray:
40
+ """Return decision matrix, shape (size, D)."""
41
+ if self.is_empty:
42
+ raise ValueError("Archive is empty.")
43
+ return np.array(self.X)
44
+
45
+ def update(self, X_new: np.ndarray, F_new: np.ndarray, CV_new: np.ndarray) -> None:
46
+ """Add feasible nondominated solutions and truncate if needed.
47
+
48
+ Parameters
49
+ ----------
50
+ X_new : ndarray, shape (K, D)
51
+ F_new : ndarray, shape (K, M)
52
+ CV_new : ndarray, shape (K,)
53
+ """
54
+ # Only consider feasible solutions
55
+ feas = CV_new <= 0.0
56
+ if not np.any(feas):
57
+ return
58
+
59
+ X_cand = X_new[feas]
60
+ F_cand = F_new[feas]
61
+
62
+ # Skip candidates with non-finite objectives or decision variables.
63
+ finite = np.all(np.isfinite(F_cand), axis=1) & np.all(np.isfinite(X_cand), axis=1)
64
+ X_cand = X_cand[finite]
65
+ F_cand = F_cand[finite]
66
+ if len(X_cand) == 0:
67
+ return
68
+
69
+ for i in range(len(X_cand)):
70
+ # Check if candidate is dominated by any archive member
71
+ dominated_by_archive = False
72
+ to_remove = []
73
+ for j in range(len(self.F)):
74
+ if dominates(np.array(self.F[j]), F_cand[i]):
75
+ dominated_by_archive = True
76
+ break
77
+ if dominates(F_cand[i], np.array(self.F[j])):
78
+ to_remove.append(j)
79
+
80
+ if dominated_by_archive:
81
+ continue
82
+
83
+ # Remove dominated archive members (in reverse order to preserve indices)
84
+ for j in sorted(to_remove, reverse=True):
85
+ self.X.pop(j)
86
+ self.F.pop(j)
87
+
88
+ self.X.append(X_cand[i].copy())
89
+ self.F.append(F_cand[i].copy())
90
+
91
+ # Truncate by crowding distance
92
+ while self.size > self.max_size:
93
+ self._remove_least_crowded()
94
+
95
+ def _remove_least_crowded(self) -> None:
96
+ """Remove the archive member with smallest crowding distance."""
97
+ F_arr = np.array(self.F)
98
+ cd = _crowding_distance(F_arr)
99
+ worst = int(np.argmin(cd))
100
+ self.X.pop(worst)
101
+ self.F.pop(worst)
102
+
103
+ def random_member(self, rng: np.random.Generator) -> np.ndarray:
104
+ """Return decision vector of a uniformly random archive member."""
105
+ idx = rng.integers(self.size)
106
+ return np.array(self.X[idx])
107
+
108
+
109
+ def _crowding_distance(F: np.ndarray) -> np.ndarray:
110
+ """Compute crowding distance for a set of objective vectors."""
111
+ N, M = F.shape
112
+ if N <= 2:
113
+ return np.full(N, np.inf)
114
+
115
+ cd = np.zeros(N)
116
+ for m in range(M):
117
+ order = np.argsort(F[:, m])
118
+ cd[order[0]] = np.inf
119
+ cd[order[-1]] = np.inf
120
+ f_range = F[order[-1], m] - F[order[0], m]
121
+ if f_range < 1e-30:
122
+ continue
123
+ for k in range(1, N - 1):
124
+ cd[order[k]] += (F[order[k + 1], m] - F[order[k - 1], m]) / f_range
125
+ return cd