Skip to content

Commit e6c591a

Browse files
committed
feat: add geometrically valid simplex crossover operator
1 parent 0425509 commit e6c591a

2 files changed

Lines changed: 45 additions & 16 deletions

File tree

src/portfolio/moead.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def get_pareto_parameters(self, num_points, **kwargs):
1818
# ── Public interface ──────────────────────────────────────────────────────
1919

2020
def generate_pareto_front(
21-
self, num_points=100, generations=100, T=10, nr=2, verbose=False, **kwargs
21+
self, num_points=100, generations=100, T=10, nr=2, verbose=False, crossover_operator="sbx", **kwargs
2222
):
2323
"""
2424
MOEA/D with Tchebycheff decomposition, stable normalisation, and
@@ -143,21 +143,23 @@ def normalise(f):
143143
nb_idx = neighbors[i]
144144
p1_idx, p2_idx = np.random.choice(nb_idx, 2, replace=False)
145145

146-
# Simulated Binary Crossover (SBX)
147-
offspring = self._sbx_crossover(population[p1_idx], population[p2_idx])
148-
149-
# CRITICAL: We MUST repair immediately after SBX because it
150-
# violates the simplex (sum != 1, potential negative weights)
151-
offspring = self._repair(offspring)
152-
153-
# Polynomial Mutation
154-
# (Replaces the 10% vector-level Gaussian mutation with
155-
# mathematically standard component-level Polynomial Mutation)
156-
offspring = self._polynomial_mutation(offspring)
157-
158-
# CRITICAL: Repair again because polynomial mutation pushes
159-
# bounds independently, breaking the sum-to-1 constraint.
160-
offspring = self._repair(offspring)
146+
if crossover_operator == "sbx":
147+
# Simulated Binary Crossover (SBX)
148+
offspring = self._sbx_crossover(population[p1_idx], population[p2_idx])
149+
# CRITICAL: We MUST repair immediately after SBX
150+
offspring = self._repair(offspring)
151+
152+
# Polynomial Mutation
153+
offspring = self._polynomial_mutation(offspring)
154+
# CRITICAL: Repair again because polynomial mutation pushes bounds
155+
offspring = self._repair(offspring)
156+
157+
elif crossover_operator == "simplex":
158+
# Linear mix crossing the full valid line segment on the simplex.
159+
# It natively avoids breaking the simplex geometry.
160+
offspring = self._simplex_crossover(population[p1_idx], population[p2_idx])
161+
else:
162+
raise ValueError(f"Unknown crossover operator: {crossover_operator}")
161163

162164
# Evaluate offspring.
163165
off_f = np.array(

src/portfolio/moead_base.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,33 @@ def _differential_evolution_crossover(self, p1, p2, p3, F=0.5, CR=1.0):
115115
offspring = np.where(mask, v, p1)
116116
return offspring
117117

118+
def _simplex_crossover(self, p1, p2):
119+
"""
120+
Linear combination of two solutions spanning the entire valid segment
121+
on the simplex. Does not require repair on its own.
122+
"""
123+
diff = p1 - p2
124+
125+
pos_mask = diff > 1e-9
126+
neg_mask = diff < -1e-9
127+
128+
alpha_min = np.max(-p2[pos_mask] / diff[pos_mask]) if np.any(pos_mask) else 0.0
129+
alpha_max = np.min(-p2[neg_mask] / diff[neg_mask]) if np.any(neg_mask) else 1.0
130+
131+
alpha = np.random.uniform(alpha_min, alpha_max)
132+
offspring = alpha * p1 + (1.0 - alpha) * p2
133+
134+
# Ensure constraints due to possible floating point inaccuracies
135+
offspring = np.clip(offspring, 0.0, 1.0)
136+
sum_weights = np.sum(offspring)
137+
if sum_weights > 0:
138+
offspring = offspring / sum_weights
139+
else:
140+
# Fallback (should theoretically never happen)
141+
offspring = np.ones(len(p1)) / len(p1)
142+
143+
return offspring
144+
118145
def optimize(self, param, **kwargs):
119146
raise NotImplementedError("MOEA/D generates the whole front. Use generate_pareto_front.")
120147

0 commit comments

Comments
 (0)