mosade 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.
- mosade/__init__.py +3 -0
- mosade/algorithm/__init__.py +32 -0
- mosade/algorithm/adaptation.py +186 -0
- mosade/algorithm/archive.py +125 -0
- mosade/algorithm/decomposition.py +139 -0
- mosade/algorithm/moead.py +401 -0
- mosade/algorithm/mosade.py +1385 -0
- mosade/algorithm/nsga2.py +531 -0
- mosade/algorithm/pymoo_wrapper.py +279 -0
- mosade/algorithm/registry.py +99 -0
- mosade/algorithm/selection.py +353 -0
- mosade/algorithm/strategies.py +152 -0
- mosade/metrics/__init__.py +8 -0
- mosade/metrics/gd.py +45 -0
- mosade/metrics/hypervolume.py +122 -0
- mosade/metrics/igd.py +62 -0
- mosade/metrics/spread.py +95 -0
- mosade/problems/__init__.py +45 -0
- mosade/problems/base.py +142 -0
- mosade/problems/dascmop.py +387 -0
- mosade/problems/dtlz.py +240 -0
- mosade/problems/realworld_cre.py +303 -0
- mosade/problems/wfg.py +501 -0
- mosade/problems/zdt.py +119 -0
- mosade/runner/__init__.py +5 -0
- mosade/runner/experiment.py +1203 -0
- mosade/utils/__init__.py +6 -0
- mosade/utils/io.py +58 -0
- mosade/utils/logging.py +56 -0
- mosade/utils/seeding.py +31 -0
- mosade-0.1.0.dist-info/METADATA +94 -0
- mosade-0.1.0.dist-info/RECORD +35 -0
- mosade-0.1.0.dist-info/WHEEL +5 -0
- mosade-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosade-0.1.0.dist-info/top_level.txt +1 -0
mosade/__init__.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Weight vector generation and neighborhood management for decomposition.
|
|
2
|
+
|
|
3
|
+
Implements the Das-Dennis method for uniform weight vector generation
|
|
4
|
+
and neighborhood computation by Euclidean distance in weight space.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def das_dennis(n_partitions: int, n_obj: int) -> np.ndarray:
|
|
13
|
+
"""Generate uniformly distributed weight vectors on the unit simplex.
|
|
14
|
+
|
|
15
|
+
Uses the Das-Dennis systematic approach. The number of vectors
|
|
16
|
+
produced is C(n_partitions + n_obj - 1, n_obj - 1).
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
n_partitions : int
|
|
21
|
+
Number of divisions along each objective axis (H).
|
|
22
|
+
n_obj : int
|
|
23
|
+
Number of objectives (M).
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
ndarray, shape (N, n_obj)
|
|
28
|
+
Weight vectors that sum to 1.
|
|
29
|
+
"""
|
|
30
|
+
# Generate all combinations of n_obj non-negative integers that sum to n_partitions
|
|
31
|
+
def _recurse(m: int, h: int) -> list[list[int]]:
|
|
32
|
+
if m == 1:
|
|
33
|
+
return [[h]]
|
|
34
|
+
result = []
|
|
35
|
+
for i in range(h + 1):
|
|
36
|
+
for tail in _recurse(m - 1, h - i):
|
|
37
|
+
result.append([i] + tail)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
raw = np.array(_recurse(n_obj, n_partitions), dtype=float)
|
|
41
|
+
return raw / n_partitions
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def compute_neighbors(weights: np.ndarray, T: int) -> np.ndarray:
|
|
45
|
+
"""Compute T-nearest neighbors for each weight vector.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
weights : ndarray, shape (N, M)
|
|
50
|
+
T : int
|
|
51
|
+
Neighborhood size (clamped to N-1 if needed).
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
ndarray, shape (N, T)
|
|
56
|
+
Index array of neighbors for each weight vector.
|
|
57
|
+
"""
|
|
58
|
+
N = weights.shape[0]
|
|
59
|
+
T = min(T, N - 1)
|
|
60
|
+
# Pairwise Euclidean distances
|
|
61
|
+
dists = np.linalg.norm(weights[:, None, :] - weights[None, :, :], axis=2)
|
|
62
|
+
# For each row, sort and take indices 1..T (skip self at index 0)
|
|
63
|
+
neighbors = np.argsort(dists, axis=1)[:, 1 : T + 1]
|
|
64
|
+
return neighbors
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def associate_to_weights(
|
|
68
|
+
F_norm: np.ndarray, weights: np.ndarray
|
|
69
|
+
) -> np.ndarray:
|
|
70
|
+
"""Associate each solution to its nearest weight vector by perpendicular distance.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
F_norm : ndarray, shape (N, M)
|
|
75
|
+
Normalised objective values.
|
|
76
|
+
weights : ndarray, shape (W, M)
|
|
77
|
+
Weight vectors (unit-simplex).
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
ndarray, shape (N,)
|
|
82
|
+
Index of the nearest weight vector for each solution.
|
|
83
|
+
"""
|
|
84
|
+
# Perpendicular distance from each point to each reference line (weight direction)
|
|
85
|
+
# d_perp(f, w) = ||f - (f·w / w·w) * w||
|
|
86
|
+
# Since weights are on the simplex they are not unit vectors, so we normalise.
|
|
87
|
+
w_norm = weights / np.linalg.norm(weights, axis=1, keepdims=True)
|
|
88
|
+
# Projection lengths: shape (N, W)
|
|
89
|
+
proj = F_norm @ w_norm.T # (N, W)
|
|
90
|
+
# Projected points: for each (i, j), proj_point = proj[i,j] * w_norm[j]
|
|
91
|
+
# Distance: ||F_norm[i] - proj_point||
|
|
92
|
+
# Expand for broadcasting
|
|
93
|
+
F_exp = F_norm[:, None, :] # (N, 1, M)
|
|
94
|
+
w_exp = w_norm[None, :, :] # (1, W, M)
|
|
95
|
+
proj_exp = proj[:, :, None] # (N, W, 1)
|
|
96
|
+
diff = F_exp - proj_exp * w_exp # (N, W, M)
|
|
97
|
+
d_perp = np.linalg.norm(diff, axis=2) # (N, W)
|
|
98
|
+
return np.argmin(d_perp, axis=1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def tchebycheff(
|
|
102
|
+
F: np.ndarray, weight: np.ndarray, z_ideal: np.ndarray
|
|
103
|
+
) -> np.ndarray | float:
|
|
104
|
+
"""Tchebycheff scalarizing function.
|
|
105
|
+
|
|
106
|
+
g^tch(x | λ, z*) = max_k { λ_k * |f_k(x) - z*_k| }
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
F : ndarray, shape (N, M) or (M,)
|
|
111
|
+
weight : ndarray, shape (M,)
|
|
112
|
+
z_ideal : ndarray, shape (M,)
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
ndarray, shape (N,) when F is 2-D; numpy scalar when F is 1-D.
|
|
117
|
+
|
|
118
|
+
Notes
|
|
119
|
+
-----
|
|
120
|
+
FIX(audit B11): return annotation updated from ``np.ndarray`` to
|
|
121
|
+
``np.ndarray | float`` to reflect that a 1-D input produces a scalar.
|
|
122
|
+
"""
|
|
123
|
+
diff = np.abs(F - z_ideal)
|
|
124
|
+
# Avoid zero weights causing issues
|
|
125
|
+
w = np.maximum(weight, 1e-6)
|
|
126
|
+
return np.max(w * diff, axis=-1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def auto_partitions(n_pop: int, n_obj: int) -> int:
|
|
130
|
+
"""Find the largest H such that C(H+M-1, M-1) <= n_pop.
|
|
131
|
+
|
|
132
|
+
This lets us set H from population size automatically.
|
|
133
|
+
"""
|
|
134
|
+
from math import comb
|
|
135
|
+
|
|
136
|
+
H = 1
|
|
137
|
+
while comb(H + n_obj - 1, n_obj - 1) <= n_pop:
|
|
138
|
+
H += 1
|
|
139
|
+
return H - 1
|