Agent Conversation: openai/gpt-5.4-pro
Task: optimization-multi-objective
Multi-Objective Optimization: Custom Evolutionary Strategy Design
Research Question
Design a novel multi-objective evolutionary algorithm (MOEA) strategy that achieves better convergence, diversity, and spread on standard benchmark problems than classic approaches like NSGA-II, MOEA/D, and SPEA2.
Background
Multi-objective optimization aims to find a set of Pareto-optimal solutions that represent the best trade-offs among conflicting objectives. Evolutionary algorithms are the dominant approach, differing primarily in three components:
- Parent selection: How to choose individuals for mating (e.g., tournament with crowding distance, reference-vector-based).
- Variation: How to produce offspring via crossover and mutation operators.
- Environmental selection (survival): How to prune the combined parent+offspring pool back to population size (e.g., non-dominated sorting + crowding, decomposition into subproblems, indicator-based selection).
Classic algorithms include:
- NSGA-II (Deb et al., 2002): Non-dominated sorting + crowding distance for diversity.
- MOEA/D (Zhang & Li, 2007): Decomposes the problem into scalar subproblems using weight vectors.
- SPEA2 (Zitzler et al., 2001): Strength-based fitness with density estimation via k-nearest neighbors.
State-of-the-art methods include:
- NSGA-III (Deb & Jain, 2014): Reference-point-based selection for many-objective problems.
- RVEA (Cheng et al., 2016): Angle-penalized distance with adaptive reference vectors.
- AGE-MOEA (Panichella, 2019): Adaptive geometry estimation for survival selection.
There is active research into strategies that combine ideas across these paradigms, adapt to problem geometry, or use novel diversity maintenance mechanisms.
Task
Implement a custom multi-objective evolutionary strategy by modifying the CustomMOEA class in deap/custom_moea.py. You should implement the select, vary, survive, and optionally on_generation methods. The algorithm must work for both 2-objective and 3-objective problems.
Interface
class CustomMOEA:
def __init__(self, pop_size, n_obj, n_var, bounds, cx_eta=20.0, mut_eta=20.0, mut_prob=None):
"""Initialize the MOEA with problem parameters."""
def select(self, population: list, k: int) -> list:
"""Select k parents from the population for mating.
Returns: list of k selected individuals."""
def vary(self, parents: list) -> list:
"""Apply crossover and mutation to produce offspring.
Returns: list of offspring (fitness invalidated)."""
def survive(self, population: list, offspring: list) -> list:
"""Environmental selection: choose pop_size individuals from combined pool.
Returns: list of pop_size individuals for next generation."""
def on_generation(self, gen: int, population: list):
"""Optional per-generation callback for adaptive strategies."""
Ind
Tool Input
1{
2 "op": "replace",
3 "filename": "deap/custom_moea.py",
4 "content": "class CustomMOEA:\n \"\"\"Adaptive reference-guided max-min MOEA with archive-guided variation.\n\n Core idea:\n 1) parent selection favors low-rank solutions in under-filled reference niches,\n 2) variation mixes SBX with archive-guided differential steps,\n 3) survival uses dominance first, then a reference-aware greedy max-min truncation.\n\n This combines dominance, decomposition, and spacing preservation in one loop.\n \"\"\"\n\n def __init__(\n self,\n pop_size: int,\n n_obj: int,\n n_var: int,\n bounds: Tuple[float, float],\n cx_eta: float = 20.0,\n mut_eta: float = 20.0,\n mut_prob: Optional[float] = None,\n ):\n self.pop_size = pop_size\n self.n_obj = n_obj\n self.n_var = n_var\n self.bounds = bounds\n self.cx_eta = cx_eta\n self.mut_eta = mut_eta\n self.mut_prob = mut_prob if mut_prob is not None else 1.0 / n_var\n\n self.archive = []\n self.archive_cap = pop_size\n self.ideal = None\n self._current_gen = 0\n\n # Adaptive exploration parameters (updated in on_generation).\n self.dir_rate = 0.55\n self.diff_scale = 0.28\n self.guide_scale = 0.22\n self.theta = 3.0\n\n # Reference vectors: choose the smallest lattice with at least pop_size points.\n if n_obj == 2:\n refs = np.array(tools.uniform_reference_points(nobj=2, p=pop_size - 1), dtype=float)\n else:\n p = 1\n while math.comb(p + n_obj - 1, n_obj - 1) < pop_size:\n p += 1\n refs = np.array(tools.uniform_reference_points(nobj=n_obj, p=p), dtype=float)\n if len(refs) > pop_size:\n idx = np.linspace(0, len(refs) - 1, pop_size, dtype=int)\n refs = refs[idx]\n ref_norms = np.linalg.norm(refs, axis=1, keepdims=True)\n ref_norms[ref_norms < 1e-12] = 1.0\n self.ref_vectors = refs / ref_norms\n\n def _deduplicate(self, individuals: list) -> list:\n unique = []\n seen = set()\n for ind in individuals:\n if not ind.fitness.valid:\n continue\n key = tuple(round(float(v), 12) for v in ind.fitness.values)\n if key in seen:\n continue\n seen.add(key)\n unique.append(ind)\n return unique\n\n def _normalize(self, values: np.ndarray) -> np.ndarray:\n if len(values) == 0:\n return values.copy()\n lo = np.min(values, axis=0)\n if self.ideal is not None:\n lo = np.minimum(lo, self.ideal)\n hi = np.max(values, axis=0)\n scale = hi - lo\n scale[scale < 1e-12] = 1.0\n return (values - lo) / scale\n\n def _associate(self, norm_values: np.ndarray):\n if len(norm_values) == 0:\n return np.array([], dtype=int), np.array([]), np.array([]), np.array([])\n shifted = norm_values + 1e-12\n norms = np.linalg.norm(shifted, axis=1, keepdims=True)\n norms[norms < 1e-12] = 1.0\n unit = shifted / norms\n cosine = np.clip(unit @ self.ref_vectors.T, -1.0, 1.0)\n assoc = np.argmax(cosine, axis=1)\n ref = self.ref_vectors[assoc]\n d1 = np.sum(shifted * ref, axis=1)\n orth = shifted - d1[:, None] * ref\n d2 = np.sqrt(np.sum(orth * orth, axis=1))\n pbi = d1 + self.theta * d2\n return assoc, d1, d2, pbi\n\n def _nn_distance(self, a: np.ndarray, b: Optional[np.ndarray] = None) -> np.ndarray:\n if len(a) == 0:\n return np.array([])\n if b is None:\n if len(a) == 1:\n return np.ones(1)\n diff = a[:, None, :] - a[None, :, :]\n dist = np.sqrt(np.sum(diff * diff, axis=2))\n np.fill_diagonal(dist, np.inf)\n return np.min(dist, axis=1)\n if len(b) == 0:\n return np.full(len(a), np.inf)\n diff = a[:, None, :] - b[None, :, :]\n dist = np.sqrt(np.sum(diff * diff, axis=2))\n return np.min(dist, axis=1)\n\n def _truncate_front(self, candidates: list, n_keep: int, seed_selected: Optional[list] = None) -> list:\n if n_keep <= 0:\n return []\n if len(candidates) <= n_keep:\n return list(candidates)\n\n seed_selected = [] if seed_selected is None else list(seed_selected)\n cand_vals = np.array([ind.fitness.values for ind in candidates], dtype=float)\n seed_vals = (\n np.array([ind.fitness.values for ind in seed_selected], dtype=float)\n if len(seed_selected) > 0\n else np.empty((0, self.n_obj), dtype=float)\n )\n all_vals = cand_vals if len(seed_vals) == 0 else np.vstack([seed_vals, cand_vals])\n all_norm = self._normalize(all_vals)\n seed_norm = all_norm[: len(seed_vals)] if len(seed_vals) > 0 else np.empty((0, self.n_obj), dtype=float)\n cand_norm = all_norm[len(seed_vals) :]\n\n assoc, _, _, pbi = self._associate(cand_norm)\n pbi_scale = np.max(pbi) + 1e-12\n pbi_norm = pbi / pbi_scale\n base_nn = self._nn_distance(cand_norm)\n\n niche_counts = np.zeros(len(self.ref_vectors), dtype=int)\n if len(seed_norm) > 0:\n seed_assoc, _, _, _ = self._associate(seed_norm)\n niche_counts += np.bincount(seed_assoc, minlength=len(self.ref_vectors))\n\n chosen = []\n chosen_mask = np.zeros(len(candidates), dtype=bool)\n chosen_norm = seed_norm.copy()\n\n # Preserve objective extremes before greedy truncation.\n extreme_idx = []\n for m in range(self.n_obj):\n idx = int(np.argmin(cand_vals[:, m]))\n if idx not in extreme_idx:\n extreme_idx.append(idx)\n for idx in extreme_idx:\n if len(chosen) >= n_keep or chosen_mask[idx]:\n continue\n chosen.append(candidates[idx])\n chosen_mask[idx] = True\n niche_counts[assoc[idx]] += 1\n chosen_norm = cand_norm[idx][None, :] if len(chosen_norm) == 0 else np.vstack([chosen_norm, cand_norm[idx]])\n\n while len(chosen) < n_keep:\n remaining = np.where(~chosen_mask)[0]\n if len(remaining) == 0:\n break\n\n empty_niche = remaining[niche_counts[assoc[remaining]] == 0]\n pool = empty_niche if len(empty_niche) > 0 else remaining\n\n div = self._nn_distance(cand_norm[pool], chosen_norm)\n div = np.where(np.isfinite(div), div, base_nn[pool])\n div_scale = np.max(div) + 1e-12\n div_norm = div / div_scale\n rarity = 1.0 / (1.0 + niche_counts[assoc[pool]])\n local_nn = base_nn[pool] / (np.max(base_nn[pool]) + 1e-12)\n\n # Max-min diversity with reference occupancy and mild convergence pressure.\n score = 0.50 * div_norm + 0.30 * rarity + 0.10 * local_nn - 0.10 * pbi_norm[pool]\n best = int(pool[np.argmax(score + 1e-12 * np.random.random(len(pool)))])\n\n chosen.append(candidates[best])\n chosen_mask[best] = True\n niche_counts[assoc[best]] += 1\n chosen_norm = cand_norm[best][None, :] if len(chosen_norm) == 0 else np.vstack([chosen_norm, cand_norm[best]])\n\n return chosen[:n_keep]\n\n def _update_archive(self, individuals: list):\n pool = self._deduplicate(individuals)\n if len(pool) == 0:\n self.archive = []\n return\n nd = get_nondominated(pool)\n if len(nd) <= self.archive_cap:\n self.archive = [deepcopy(ind) for ind in nd]\n else:\n self.archive = [deepcopy(ind) for ind in self._truncate_front(nd, self.archive_cap)]\n\n def select(self, population: list, k: int) -> list:\n \"\"\"Rarity-aware tournament selection with reference-niche pairing.\"\"\"\n if len(population) == 0:\n return []\n\n pool = list(population)\n if len(self.archive) >= max(4, self.n_obj + 1):\n n_extra = min(len(self.archive), max(6, self.pop_size // 5))\n pool = self._deduplicate(pool + random.sample(self.archive, n_extra))\n\n fronts = tools.sortNondominated(pool, len(pool), first_front_only=False)\n rank = {}\n for r, front in enumerate(fronts):\n compute_crowding_distance(front)\n for ind in front:\n rank[id(ind)] = r\n\n values = np.array([ind.fitness.values for ind in pool], dtype=float)\n norm_values = self._normalize(values)\n assoc, _, _, pbi = self._associate(norm_values)\n nn = self._nn_distance(norm_values)\n niche_counts = np.bincount(assoc, minlength=len(self.ref_vectors))\n crowd = np.array([getattr(ind.fitness, \"crowding_dist\", 0.0) for ind in pool], dtype=float)\n ranks = np.array([rank[id(ind)] for ind in pool], dtype=int)\n\n def better(i: int, j: int) -> int:\n if ranks[i] != ranks[j]:\n return i if ranks[i] < ranks[j] else j\n if niche_counts[assoc[i]] != niche_counts[assoc[j]]:\n return i if niche_counts[assoc[i]] < niche_counts[assoc[j]] else j\n if abs(nn[i] - nn[j]) > 1e-12:\n return i if nn[i] > nn[j] else j\n if abs(crowd[i] - crowd[j]) > 1e-12:\n return i if crowd[i] > crowd[j] else j\n if abs(pbi[i] - pbi[j]) > 1e-12:\n return i if pbi[i] < pbi[j] else j\n return i if random.random() < 0.5 else j\n\n selected_idx = []\n for _ in range(k):\n i, j = random.sample(range(len(pool)), 2)\n selected_idx.append(better(i, j))\n\n chosen_assoc = assoc[selected_idx]\n order = np.argsort(chosen_assoc)\n ordered = [pool[selected_idx[idx]] for idx in order]\n\n # Weave low- and high-angle niches to encourage complementary mating.\n parents = []\n left, right = 0, len(ordered) - 1\n while left <= right:\n parents.append(ordered[left])\n left += 1\n\n... (truncated)",
5 "after_line": 0,
6 "start_line": 297,
7 "end_line": 441
8}OK: Replaced lines 297..441 with 362 line(s) in deap/custom_moea.py. Editable range: 297–658. [Current file: deap/custom_moea.py | editable: 297–658 | total: 807 lines] 294| # ================================================================ 295| 296| 297 class CustomMOEA: 298 """Adaptive reference-guided max-min MOEA with archive-guided variation. 299 300 Core idea: 301 1) parent selection favors low-rank solutions in under-filled reference niches, 302 2) variation mixes SBX with archive-guided differential steps, 303 3) survival uses dominance first, then a reference-aware greedy max-min truncation. 304 305 This combines dominance, decomposition, and spacing preservation in one loop. 306 """ 307 308 def __init__( 309 self, 310 pop_size: int, 311 n_obj: int, 312 n_var: int, 313 bounds: Tuple[float, float], 314 cx_et
[Test #1] (2 tests remaining; call submit(n=1) to submit this result) ### zdt1 (scripts/zdt1.sh) Training progress (last generations): TRAIN_METRICS gen=120 hv=0.856989 igd=0.010697 spread=0.291043 front_size=100 TRAIN_METRICS gen=140 hv=0.862445 igd=0.007879 spread=0.336436 front_size=100 TRAIN_METRICS gen=160 hv=0.864965 igd=0.006550 spread=0.359630 front_size=100 TRAIN_METRICS gen=180 hv=0.867122 igd=0.005445 spread=0.404241 front_size=100 TRAIN_METRICS gen=200 hv=0.868177 igd=0.004992 spread=0.390308 front_size=100 Test results (zdt1): hv: 0.868177 igd: 0.004992 spread: 0.390308 ### zdt3 (scripts/zdt3.sh) Training progress (last generations): TRAIN_METRICS gen=120 hv=1.303262 igd=0.009647 spread=0.489677 front_size=100 TRAIN_METRICS gen=140 hv=1.312029 igd=0.007416 spread=0.493772 front_size=100 TRAIN_METRICS gen=160 hv=1.318425 igd=0.006275 spread=0.484663 front_size=100 TRAIN_METRICS gen=180 hv=1.321507 igd=0.005563 spread=0.462774 front_size=100 TRAIN_METRICS gen=200 hv=
Tool Input
1{
2 "op": "replace",
3 "filename": "deap/custom_moea.py",
4 "content": " while len(chosen) < n_keep:\n remaining = np.where(~chosen_mask)[0]\n if len(remaining) == 0:\n break\n\n empty_niche = remaining[niche_counts[assoc[remaining]] == 0]\n if len(empty_niche) > 0:\n rep_pool = []\n empty_assoc = assoc[empty_niche]\n for niche in np.unique(empty_assoc):\n members = empty_niche[empty_assoc == niche]\n rep_pool.append(int(members[np.argmin(pbi[members] - 0.05 * base_nn[members])]))\n pool = np.array(rep_pool, dtype=int)\n else:\n pool = remaining\n\n div = self._nn_distance(cand_norm[pool], chosen_norm)\n div = np.where(np.isfinite(div), div, base_nn[pool])\n div_scale = np.max(div) + 1e-12\n div_norm = div / div_scale\n rarity = 1.0 / (1.0 + niche_counts[assoc[pool]])\n local_nn = base_nn[pool] / (np.max(base_nn[pool]) + 1e-12)\n\n if self.n_obj == 2:\n score = 0.40 * div_norm + 0.34 * rarity + 0.08 * local_nn - 0.18 * pbi_norm[pool]\n else:\n score = 0.46 * div_norm + 0.22 * rarity + 0.10 * local_nn - 0.22 * pbi_norm[pool]\n best = int(pool[np.argmax(score + 1e-12 * np.random.random(len(pool)))])\n",
5 "after_line": 0,
6 "start_line": 454,
7 "end_line": 471
8}OK: Replaced lines 454..471 with 28 line(s) in deap/custom_moea.py. Editable range: 297–668. [Current file: deap/custom_moea.py | editable: 297–668 | total: 817 lines] 294| # ================================================================ 295| 296| 297 class CustomMOEA: 298 """Adaptive reference-guided max-min MOEA with archive-guided variation. 299 300 Core idea: 301 1) parent selection favors low-rank solutions in under-filled reference niches, 302 2) variation mixes SBX with archive-guided differential steps, 303 3) survival uses dominance first, then a reference-aware greedy max-min truncation. 304 305 This combines dominance, decomposition, and spacing preservation in one loop. 306 """ 307 308 def __init__( 309 self, 310 pop_size: int, 311 n_obj: int, 312 n_var: int, 313 bounds: Tuple[float, float], 314 cx_eta
Tool Input
1{
2 "op": "replace",
3 "filename": "deap/custom_moea.py",
4 "content": " # Early search: stronger directional exploration. Later: tighter refinement.\n anneal = math.exp(-gen / 80.0)\n diversity_need = max(0.0, 1.0 - occupancy) + 0.5 * min(spacing_irregularity, 1.0)\n self.dir_rate = min(0.75, max(0.10, 0.12 + 0.30 * anneal + 0.18 * diversity_need))\n self.diff_scale = min(0.32, max(0.05, 0.06 + 0.12 * anneal + 0.06 * diversity_need))\n self.guide_scale = min(0.28, max(0.08, 0.08 + 0.08 * anneal + 0.04 * diversity_need))\n self.theta = min(5.5, max(2.0, 2.3 + 1.8 * min(spacing_irregularity, 1.5)))\n",
5 "after_line": 0,
6 "start_line": 660,
7 "end_line": 666
8}OK: Replaced lines 660..666 with 7 line(s) in deap/custom_moea.py. Editable range: 297–668. [Current file: deap/custom_moea.py | editable: 297–668 | total: 817 lines] 294| # ================================================================ 295| 296| 297 class CustomMOEA: 298 """Adaptive reference-guided max-min MOEA with archive-guided variation. 299 300 Core idea: 301 1) parent selection favors low-rank solutions in under-filled reference niches, 302 2) variation mixes SBX with archive-guided differential steps, 303 3) survival uses dominance first, then a reference-aware greedy max-min truncation. 304 305 This combines dominance, decomposition, and spacing preservation in one loop. 306 """ 307 308 def __init__( 309 self, 310 pop_size: int, 311 n_obj: int, 312 n_var: int, 313 bounds: Tuple[float, float], 314 cx_eta:
[Test #2] (1 test remaining; call submit(n=2) to submit this result) ### zdt1 (scripts/zdt1.sh) Training progress (last generations): TRAIN_METRICS gen=120 hv=0.857022 igd=0.010998 spread=0.377198 front_size=100 TRAIN_METRICS gen=140 hv=0.863021 igd=0.007472 spread=0.327407 front_size=100 TRAIN_METRICS gen=160 hv=0.865941 igd=0.006007 spread=0.323936 front_size=100 TRAIN_METRICS gen=180 hv=0.868220 igd=0.004862 spread=0.288131 front_size=100 TRAIN_METRICS gen=200 hv=0.868709 igd=0.004590 spread=0.290235 front_size=100 Test results (zdt1): hv: 0.868709 igd: 0.004590 spread: 0.290235 ### zdt3 (scripts/zdt3.sh) Training progress (last generations): TRAIN_METRICS gen=120 hv=1.287637 igd=0.013918 spread=0.558185 front_size=100 TRAIN_METRICS gen=140 hv=1.306573 igd=0.008768 spread=0.519327 front_size=100 TRAIN_METRICS gen=160 hv=1.315574 igd=0.006635 spread=0.487185 front_size=100 TRAIN_METRICS gen=180 hv=1.319809 igd=0.005707 spread=0.440623 front_size=100 TRAIN_METRICS gen=200 hv=1
Tool Input
1{
2 "n": 2
3}[submit] Submitting result from test #2 as final. ### zdt1 (scripts/zdt1.sh) Training progress (last generations): TRAIN_METRICS gen=120 hv=0.857022 igd=0.010998 spread=0.377198 front_size=100 TRAIN_METRICS gen=140 hv=0.863021 igd=0.007472 spread=0.327407 front_size=100 TRAIN_METRICS gen=160 hv=0.865941 igd=0.006007 spread=0.323936 front_size=100 TRAIN_METRICS gen=180 hv=0.868220 igd=0.004862 spread=0.288131 front_size=100 TRAIN_METRICS gen=200 hv=0.868709 igd=0.004590 spread=0.290235 front_size=100 Test results (zdt1): hv: 0.868709 igd: 0.004590 spread: 0.290235 ### zdt3 (scripts/zdt3.sh) Training progress (last generations): TRAIN_METRICS gen=120 hv=1.287637 igd=0.013918 spread=0.558185 front_size=100 TRAIN_METRICS gen=140 hv=1.306573 igd=0.008768 spread=0.519327 front_size=100 TRAIN_METRICS gen=160 hv=1.315574 igd=0.006635 spread=0.487185 front_size=100 TRAIN_METRICS gen=180 hv=1.319809 igd=0.005707 spread=0.440623 front_size=100 TRAIN_METRICS gen=200 hv=1.321029 igd=0.00555