ctrl-freak 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.
- ctrl_freak/__init__.py +100 -0
- ctrl_freak/algorithms/__init__.py +10 -0
- ctrl_freak/algorithms/ga.py +233 -0
- ctrl_freak/algorithms/nsga2.py +215 -0
- ctrl_freak/operators/__init__.py +15 -0
- ctrl_freak/operators/base.py +81 -0
- ctrl_freak/operators/selection.py +144 -0
- ctrl_freak/operators/standard.py +275 -0
- ctrl_freak/population.py +203 -0
- ctrl_freak/primitives/__init__.py +23 -0
- ctrl_freak/primitives/pareto.py +222 -0
- ctrl_freak/protocols.py +186 -0
- ctrl_freak/py.typed +0 -0
- ctrl_freak/registry.py +303 -0
- ctrl_freak/results.py +246 -0
- ctrl_freak/selection/__init__.py +13 -0
- ctrl_freak/selection/crowded.py +117 -0
- ctrl_freak/selection/roulette.py +115 -0
- ctrl_freak/selection/tournament.py +104 -0
- ctrl_freak/survival/__init__.py +13 -0
- ctrl_freak/survival/elitist.py +147 -0
- ctrl_freak/survival/nsga2.py +140 -0
- ctrl_freak/survival/truncation.py +104 -0
- ctrl_freak-0.1.0.dist-info/METADATA +238 -0
- ctrl_freak-0.1.0.dist-info/RECORD +27 -0
- ctrl_freak-0.1.0.dist-info/WHEEL +4 -0
- ctrl_freak-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Roulette wheel (fitness-proportionate) selection for single-objective optimization."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ctrl_freak.population import Population
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def roulette_wheel():
|
|
9
|
+
"""Create a roulette wheel (fitness-proportionate) parent selector.
|
|
10
|
+
|
|
11
|
+
Selection probability is inversely proportional to fitness because
|
|
12
|
+
lower fitness is better (minimization). Uses max_fitness - fitness
|
|
13
|
+
to convert minimization to maximization probabilities.
|
|
14
|
+
|
|
15
|
+
For fitness values f_i, the selection probability p_i is computed as:
|
|
16
|
+
weights_i = max_fitness - f_i + ε
|
|
17
|
+
p_i = weights_i / Σ(weights_j)
|
|
18
|
+
|
|
19
|
+
where ε is a small constant to ensure the worst individual has non-zero probability.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
callable
|
|
24
|
+
Parent selector that performs fitness-proportionate selection.
|
|
25
|
+
|
|
26
|
+
Examples
|
|
27
|
+
--------
|
|
28
|
+
>>> import numpy as np
|
|
29
|
+
>>> from ctrl_freak.population import Population
|
|
30
|
+
>>> from ctrl_freak.selection.roulette import roulette_wheel
|
|
31
|
+
>>> pop = Population(x=np.zeros((4, 2)), objectives=np.array([[4.0], [1.0], [2.0], [3.0]]))
|
|
32
|
+
>>> selector = roulette_wheel()
|
|
33
|
+
>>> parents = selector(pop, 5, np.random.default_rng(0))
|
|
34
|
+
>>> parents.shape
|
|
35
|
+
(5,)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def selector(
|
|
39
|
+
pop: Population,
|
|
40
|
+
n_parents: int,
|
|
41
|
+
rng: np.random.Generator,
|
|
42
|
+
**kwargs: np.ndarray,
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
"""Select parents using fitness-proportionate (roulette wheel) selection.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
pop
|
|
49
|
+
Population to select from.
|
|
50
|
+
n_parents
|
|
51
|
+
Number of parents to select.
|
|
52
|
+
rng
|
|
53
|
+
Random number generator.
|
|
54
|
+
**kwargs
|
|
55
|
+
Optional ``fitness`` array. If omitted, fitness is extracted from a
|
|
56
|
+
single-objective population.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
numpy.ndarray
|
|
61
|
+
Selected parent indices with shape ``(n_parents,)`` and dtype
|
|
62
|
+
``np.intp``.
|
|
63
|
+
|
|
64
|
+
Raises
|
|
65
|
+
------
|
|
66
|
+
ValueError
|
|
67
|
+
If no valid fitness source is available.
|
|
68
|
+
|
|
69
|
+
Examples
|
|
70
|
+
--------
|
|
71
|
+
>>> import numpy as np
|
|
72
|
+
>>> from ctrl_freak.population import Population
|
|
73
|
+
>>> from ctrl_freak.selection.roulette import roulette_wheel
|
|
74
|
+
>>> pop = Population(x=np.zeros((3, 1)), objectives=np.array([[3.0], [1.0], [2.0]]))
|
|
75
|
+
>>> selector = roulette_wheel()
|
|
76
|
+
>>> out = selector(pop, 4, np.random.default_rng(2))
|
|
77
|
+
>>> out.shape
|
|
78
|
+
(4,)
|
|
79
|
+
"""
|
|
80
|
+
# Get fitness array
|
|
81
|
+
if "fitness" in kwargs:
|
|
82
|
+
fitness = kwargs["fitness"]
|
|
83
|
+
elif pop.objectives is not None and pop.objectives.shape[1] == 1:
|
|
84
|
+
# Extract from single-column objectives
|
|
85
|
+
fitness = pop.objectives[:, 0]
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"roulette wheel selection requires 'fitness' in kwargs or single-column objectives in population"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
pop_size = len(pop)
|
|
92
|
+
|
|
93
|
+
# Handle edge case: all equal fitness -> uniform selection
|
|
94
|
+
if np.all(fitness == fitness[0]):
|
|
95
|
+
# All individuals have equal fitness, select uniformly
|
|
96
|
+
return rng.choice(pop_size, size=n_parents, replace=True).astype(np.intp)
|
|
97
|
+
|
|
98
|
+
# Convert minimization to maximization: lower fitness -> higher selection probability
|
|
99
|
+
# Use max - fitness approach to avoid division by near-zero values
|
|
100
|
+
max_fitness = np.max(fitness)
|
|
101
|
+
epsilon = 1e-10 # Small constant to handle max fitness case
|
|
102
|
+
|
|
103
|
+
# Compute selection weights: (max_fitness - fitness + epsilon)
|
|
104
|
+
# This ensures the best individual (lowest fitness) gets highest weight
|
|
105
|
+
weights = max_fitness - fitness + epsilon
|
|
106
|
+
|
|
107
|
+
# Normalize to probabilities
|
|
108
|
+
probs = weights / weights.sum()
|
|
109
|
+
|
|
110
|
+
# Select n_parents indices with replacement using roulette wheel
|
|
111
|
+
selected = rng.choice(pop_size, size=n_parents, replace=True, p=probs)
|
|
112
|
+
|
|
113
|
+
return selected.astype(np.intp)
|
|
114
|
+
|
|
115
|
+
return selector
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Fitness tournament selection for single-objective optimization."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ctrl_freak.population import Population
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fitness_tournament(tournament_size: int = 2):
|
|
9
|
+
"""Create a fitness-based tournament parent selector for single-objective optimization.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
tournament_size
|
|
14
|
+
Number of individuals competing in each tournament.
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
callable
|
|
19
|
+
Parent selector that returns selected parent indices.
|
|
20
|
+
|
|
21
|
+
Examples
|
|
22
|
+
--------
|
|
23
|
+
>>> import numpy as np
|
|
24
|
+
>>> from ctrl_freak.population import Population
|
|
25
|
+
>>> from ctrl_freak.selection.tournament import fitness_tournament
|
|
26
|
+
>>> pop = Population(x=np.zeros((4, 2)), objectives=np.array([[3.0], [1.0], [2.0], [4.0]]))
|
|
27
|
+
>>> selector = fitness_tournament(tournament_size=2)
|
|
28
|
+
>>> parents = selector(pop, 5, np.random.default_rng(0))
|
|
29
|
+
>>> parents.shape
|
|
30
|
+
(5,)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def selector(
|
|
34
|
+
pop: Population,
|
|
35
|
+
n_parents: int,
|
|
36
|
+
rng: np.random.Generator,
|
|
37
|
+
**kwargs: np.ndarray,
|
|
38
|
+
) -> np.ndarray:
|
|
39
|
+
"""Select parents using fitness tournament selection.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
pop
|
|
44
|
+
Population to select from.
|
|
45
|
+
n_parents
|
|
46
|
+
Number of parents to select.
|
|
47
|
+
rng
|
|
48
|
+
Random number generator.
|
|
49
|
+
**kwargs
|
|
50
|
+
Optional ``fitness`` array. If omitted, fitness is extracted from a
|
|
51
|
+
single-objective population.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
numpy.ndarray
|
|
56
|
+
Selected parent indices.
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
ValueError
|
|
61
|
+
If no valid fitness source is available.
|
|
62
|
+
|
|
63
|
+
Examples
|
|
64
|
+
--------
|
|
65
|
+
>>> import numpy as np
|
|
66
|
+
>>> from ctrl_freak.population import Population
|
|
67
|
+
>>> from ctrl_freak.selection.tournament import fitness_tournament
|
|
68
|
+
>>> pop = Population(x=np.zeros((3, 1)), objectives=np.array([[2.0], [1.0], [3.0]]))
|
|
69
|
+
>>> selector = fitness_tournament()
|
|
70
|
+
>>> out = selector(pop, 4, np.random.default_rng(2))
|
|
71
|
+
>>> out.shape
|
|
72
|
+
(4,)
|
|
73
|
+
"""
|
|
74
|
+
# Get fitness array
|
|
75
|
+
if "fitness" in kwargs:
|
|
76
|
+
fitness = kwargs["fitness"]
|
|
77
|
+
elif pop.objectives is not None and pop.objectives.shape[1] == 1:
|
|
78
|
+
# Extract from single-column objectives
|
|
79
|
+
fitness = pop.objectives[:, 0]
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"fitness tournament selection requires 'fitness' in kwargs or single-column objectives in population"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
pop_size = len(pop)
|
|
86
|
+
|
|
87
|
+
# Select n_parents winners via tournament
|
|
88
|
+
selected = np.empty(n_parents, dtype=np.intp)
|
|
89
|
+
|
|
90
|
+
for i in range(n_parents):
|
|
91
|
+
# Pick tournament_size random individuals
|
|
92
|
+
candidates = rng.integers(0, pop_size, size=tournament_size)
|
|
93
|
+
|
|
94
|
+
# Find winner: prefer lower fitness (minimization)
|
|
95
|
+
best_idx = candidates[0]
|
|
96
|
+
for c in candidates[1:]:
|
|
97
|
+
if fitness[c] < fitness[best_idx]:
|
|
98
|
+
best_idx = c
|
|
99
|
+
|
|
100
|
+
selected[i] = best_idx
|
|
101
|
+
|
|
102
|
+
return selected
|
|
103
|
+
|
|
104
|
+
return selector
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Survival strategies for evolutionary algorithms."""
|
|
2
|
+
|
|
3
|
+
from ctrl_freak.registry import SurvivalRegistry
|
|
4
|
+
from ctrl_freak.survival.elitist import elitist_survival
|
|
5
|
+
from ctrl_freak.survival.nsga2 import nsga2_survival
|
|
6
|
+
from ctrl_freak.survival.truncation import truncation_survival
|
|
7
|
+
|
|
8
|
+
# Register built-in survival strategies
|
|
9
|
+
SurvivalRegistry.register("elitist", elitist_survival)
|
|
10
|
+
SurvivalRegistry.register("nsga2", nsga2_survival)
|
|
11
|
+
SurvivalRegistry.register("truncation", truncation_survival)
|
|
12
|
+
|
|
13
|
+
__all__ = ["elitist_survival", "nsga2_survival", "truncation_survival"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Elitist survival selection for single-objective optimization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ctrl_freak.population import Population
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def elitist_survival(elite_count: int = 1):
|
|
11
|
+
"""Create elitist survivor selector.
|
|
12
|
+
|
|
13
|
+
Elitist survival always keeps the best `elite_count` individuals from the
|
|
14
|
+
parent population, filling remaining slots with the best offspring.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
elite_count
|
|
19
|
+
Number of elite parents to preserve.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
callable
|
|
24
|
+
Survivor selector function.
|
|
25
|
+
|
|
26
|
+
Examples
|
|
27
|
+
--------
|
|
28
|
+
>>> import numpy as np
|
|
29
|
+
>>> from ctrl_freak.population import Population
|
|
30
|
+
>>> from ctrl_freak.survival.elitist import elitist_survival
|
|
31
|
+
>>> obj = np.array([[2.0], [1.0], [4.0], [0.5]])
|
|
32
|
+
>>> pop = Population(x=np.zeros((4, 1)), objectives=obj)
|
|
33
|
+
>>> indices, state = elitist_survival(elite_count=1)(pop, 2, parent_size=2)
|
|
34
|
+
>>> indices.shape
|
|
35
|
+
(2,)
|
|
36
|
+
>>> state["fitness"].shape
|
|
37
|
+
(2,)
|
|
38
|
+
"""
|
|
39
|
+
if elite_count <= 0:
|
|
40
|
+
raise ValueError(f"elite_count must be positive, got {elite_count}")
|
|
41
|
+
|
|
42
|
+
def selector(
|
|
43
|
+
pop: Population,
|
|
44
|
+
n_survivors: int,
|
|
45
|
+
**kwargs: np.ndarray,
|
|
46
|
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
47
|
+
"""Select survivors using elitist selection.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
pop
|
|
52
|
+
Combined parent and offspring population.
|
|
53
|
+
n_survivors
|
|
54
|
+
Number of survivors to select.
|
|
55
|
+
**kwargs
|
|
56
|
+
Must include ``parent_size``. May include explicit ``fitness``.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
tuple[numpy.ndarray, dict[str, numpy.ndarray]]
|
|
61
|
+
Selected indices and state containing the selected ``fitness`` values.
|
|
62
|
+
|
|
63
|
+
Raises
|
|
64
|
+
------
|
|
65
|
+
ValueError
|
|
66
|
+
If inputs are invalid or no valid fitness source is available.
|
|
67
|
+
|
|
68
|
+
Examples
|
|
69
|
+
--------
|
|
70
|
+
>>> import numpy as np
|
|
71
|
+
>>> from ctrl_freak.population import Population
|
|
72
|
+
>>> from ctrl_freak.survival.elitist import elitist_survival
|
|
73
|
+
>>> obj = np.array([[2.0], [1.0], [4.0], [3.0], [0.5], [1.5]])
|
|
74
|
+
>>> pop = Population(x=np.zeros((6, 1)), objectives=obj)
|
|
75
|
+
>>> indices, state = elitist_survival(elite_count=1)(pop, 3, parent_size=3)
|
|
76
|
+
>>> indices
|
|
77
|
+
array([1, 4, 5])
|
|
78
|
+
>>> state["fitness"].shape
|
|
79
|
+
(3,)
|
|
80
|
+
"""
|
|
81
|
+
# Validate n_survivors
|
|
82
|
+
if n_survivors <= 0:
|
|
83
|
+
raise ValueError(f"n_survivors must be positive, got {n_survivors}")
|
|
84
|
+
if n_survivors > len(pop):
|
|
85
|
+
raise ValueError(f"n_survivors ({n_survivors}) cannot exceed population size ({len(pop)})")
|
|
86
|
+
|
|
87
|
+
# Validate parent_size kwarg
|
|
88
|
+
parent_size = kwargs.get("parent_size")
|
|
89
|
+
if parent_size is None:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"elitist survival requires 'parent_size' kwarg to distinguish "
|
|
92
|
+
"parents from offspring in the combined population"
|
|
93
|
+
)
|
|
94
|
+
if not isinstance(parent_size, (int, np.integer)):
|
|
95
|
+
raise ValueError(f"parent_size must be an integer, got {type(parent_size)}")
|
|
96
|
+
if parent_size <= 0:
|
|
97
|
+
raise ValueError(f"parent_size must be positive, got {parent_size}")
|
|
98
|
+
if parent_size > len(pop):
|
|
99
|
+
raise ValueError(f"parent_size ({parent_size}) cannot exceed population size ({len(pop)})")
|
|
100
|
+
|
|
101
|
+
# Validate elite_count constraints
|
|
102
|
+
if elite_count > parent_size:
|
|
103
|
+
raise ValueError(f"elite_count ({elite_count}) cannot exceed parent_size ({parent_size})")
|
|
104
|
+
if elite_count > n_survivors:
|
|
105
|
+
raise ValueError(f"elite_count ({elite_count}) cannot exceed n_survivors ({n_survivors})")
|
|
106
|
+
|
|
107
|
+
# Get fitness values
|
|
108
|
+
fitness = kwargs.get("fitness")
|
|
109
|
+
if fitness is None:
|
|
110
|
+
if pop.objectives is None:
|
|
111
|
+
raise ValueError("Population must have objectives computed for survivor selection")
|
|
112
|
+
if pop.objectives.shape[1] != 1:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"elitist survival requires single-objective optimization "
|
|
115
|
+
f"(got {pop.objectives.shape[1]} objectives). "
|
|
116
|
+
"Pass explicit 'fitness' kwarg for multi-objective."
|
|
117
|
+
)
|
|
118
|
+
fitness = pop.objectives[:, 0]
|
|
119
|
+
|
|
120
|
+
# Split population into parents and offspring
|
|
121
|
+
parent_fitness = fitness[:parent_size]
|
|
122
|
+
offspring_fitness = fitness[parent_size:]
|
|
123
|
+
|
|
124
|
+
# Select elite parents (best elite_count from parents)
|
|
125
|
+
# Use stable sort for deterministic tie-breaking
|
|
126
|
+
parent_sorted_indices = np.argsort(parent_fitness, kind="stable")
|
|
127
|
+
elite_indices = parent_sorted_indices[:elite_count].astype(np.intp)
|
|
128
|
+
|
|
129
|
+
# Select best offspring to fill remaining slots
|
|
130
|
+
n_offspring_needed = n_survivors - elite_count
|
|
131
|
+
if n_offspring_needed > 0:
|
|
132
|
+
# Use stable sort for deterministic tie-breaking
|
|
133
|
+
offspring_sorted_indices = np.argsort(offspring_fitness, kind="stable")
|
|
134
|
+
# Map back to original indices in combined population
|
|
135
|
+
best_offspring_indices = (offspring_sorted_indices[:n_offspring_needed] + parent_size).astype(np.intp)
|
|
136
|
+
|
|
137
|
+
# Combine elite parents and best offspring
|
|
138
|
+
survivor_indices = np.concatenate([elite_indices, best_offspring_indices])
|
|
139
|
+
else:
|
|
140
|
+
survivor_indices = elite_indices
|
|
141
|
+
|
|
142
|
+
# Compute state for survivors
|
|
143
|
+
survivor_fitness = fitness[survivor_indices].copy()
|
|
144
|
+
|
|
145
|
+
return survivor_indices, {"fitness": survivor_fitness}
|
|
146
|
+
|
|
147
|
+
return selector
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""NSGA-II survivor selection strategy.
|
|
2
|
+
|
|
3
|
+
Examples
|
|
4
|
+
--------
|
|
5
|
+
>>> import numpy as np
|
|
6
|
+
>>> from ctrl_freak.population import Population
|
|
7
|
+
>>> from ctrl_freak.survival.nsga2 import nsga2_survival
|
|
8
|
+
>>> obj = np.array([[1.0, 4.0], [2.0, 3.0], [3.0, 2.0], [4.0, 1.0]])
|
|
9
|
+
>>> pop = Population(x=np.zeros((4, 1)), objectives=obj)
|
|
10
|
+
>>> selector = nsga2_survival()
|
|
11
|
+
>>> indices, state = selector(pop, n_survivors=2)
|
|
12
|
+
>>> indices.shape
|
|
13
|
+
(2,)
|
|
14
|
+
>>> sorted(state)
|
|
15
|
+
['crowding_distance', 'rank']
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from ctrl_freak.population import Population
|
|
21
|
+
from ctrl_freak.primitives import crowding_distance, non_dominated_sort
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def nsga2_survival():
|
|
25
|
+
"""Create NSGA-II survivor selector.
|
|
26
|
+
|
|
27
|
+
The NSGA-II survival strategy implements elitist selection by:
|
|
28
|
+
1. Computing Pareto ranks using non-dominated sorting
|
|
29
|
+
2. Filling survivors front-by-front in rank order
|
|
30
|
+
3. When a front only partially fits, selecting individuals with highest
|
|
31
|
+
crowding distance (most isolated in objective space)
|
|
32
|
+
|
|
33
|
+
This preserves both convergence (keeping better fronts) and diversity
|
|
34
|
+
(preferring isolated individuals within a front).
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
callable
|
|
39
|
+
Survivor selector that returns selected indices and rank/crowding state.
|
|
40
|
+
|
|
41
|
+
Examples
|
|
42
|
+
--------
|
|
43
|
+
>>> import numpy as np
|
|
44
|
+
>>> from ctrl_freak.population import Population
|
|
45
|
+
>>> from ctrl_freak.survival.nsga2 import nsga2_survival
|
|
46
|
+
>>> obj = np.array([[1.0, 4.0], [2.0, 3.0], [3.0, 2.0], [4.0, 1.0]])
|
|
47
|
+
>>> pop = Population(x=np.zeros((4, 1)), objectives=obj)
|
|
48
|
+
>>> selector = nsga2_survival()
|
|
49
|
+
>>> indices, state = selector(pop, n_survivors=2)
|
|
50
|
+
>>> indices.shape
|
|
51
|
+
(2,)
|
|
52
|
+
>>> sorted(state)
|
|
53
|
+
['crowding_distance', 'rank']
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def selector(
|
|
57
|
+
pop: Population,
|
|
58
|
+
n_survivors: int,
|
|
59
|
+
**kwargs: np.ndarray,
|
|
60
|
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
61
|
+
"""Select survivors using NSGA-II crowded selection.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
pop
|
|
66
|
+
Population to select from.
|
|
67
|
+
n_survivors
|
|
68
|
+
Number of survivors to select.
|
|
69
|
+
**kwargs
|
|
70
|
+
Unused keyword arguments.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
tuple[numpy.ndarray, dict[str, numpy.ndarray]]
|
|
75
|
+
Selected indices and state containing ``rank`` and
|
|
76
|
+
``crowding_distance`` arrays.
|
|
77
|
+
|
|
78
|
+
Raises
|
|
79
|
+
------
|
|
80
|
+
ValueError
|
|
81
|
+
If objectives are missing or ``n_survivors`` is invalid.
|
|
82
|
+
|
|
83
|
+
Examples
|
|
84
|
+
--------
|
|
85
|
+
>>> import numpy as np
|
|
86
|
+
>>> from ctrl_freak.population import Population
|
|
87
|
+
>>> from ctrl_freak.survival.nsga2 import nsga2_survival
|
|
88
|
+
>>> obj = np.array([[1.0, 3.0], [2.0, 2.0], [3.0, 1.0]])
|
|
89
|
+
>>> pop = Population(x=np.zeros((3, 1)), objectives=obj)
|
|
90
|
+
>>> indices, state = nsga2_survival()(pop, n_survivors=2)
|
|
91
|
+
>>> indices.shape
|
|
92
|
+
(2,)
|
|
93
|
+
>>> state["rank"].shape
|
|
94
|
+
(2,)
|
|
95
|
+
"""
|
|
96
|
+
if pop.objectives is None:
|
|
97
|
+
raise ValueError("Population must have objectives computed for survivor selection")
|
|
98
|
+
if n_survivors <= 0:
|
|
99
|
+
raise ValueError(f"n_survivors must be positive, got {n_survivors}")
|
|
100
|
+
if n_survivors > len(pop):
|
|
101
|
+
raise ValueError(f"n_survivors ({n_survivors}) cannot exceed population size ({len(pop)})")
|
|
102
|
+
|
|
103
|
+
# Compute ranks for entire population
|
|
104
|
+
all_ranks = non_dominated_sort(pop.objectives)
|
|
105
|
+
|
|
106
|
+
# Fill survivors front-by-front, caching crowding distance as we go.
|
|
107
|
+
selected: list[int] = []
|
|
108
|
+
selected_cd_parts: list[np.ndarray] = []
|
|
109
|
+
current_rank = 0
|
|
110
|
+
|
|
111
|
+
while len(selected) < n_survivors:
|
|
112
|
+
front_idx = np.where(all_ranks == current_rank)[0]
|
|
113
|
+
|
|
114
|
+
if len(selected) + len(front_idx) <= n_survivors:
|
|
115
|
+
# Whole front fits: crowding over the full front equals crowding over the
|
|
116
|
+
# selected subset, so cache it directly.
|
|
117
|
+
selected.extend(front_idx.tolist())
|
|
118
|
+
selected_cd_parts.append(crowding_distance(pop.objectives[front_idx]))
|
|
119
|
+
else:
|
|
120
|
+
# Critical front: select by full-front crowding, then recompute over the
|
|
121
|
+
# selected subset to match the prior returned-state semantics.
|
|
122
|
+
remaining = n_survivors - len(selected)
|
|
123
|
+
cd = crowding_distance(pop.objectives[front_idx])
|
|
124
|
+
top_cd_indices = np.argsort(cd)[::-1][:remaining]
|
|
125
|
+
chosen = front_idx[top_cd_indices]
|
|
126
|
+
selected.extend(chosen.tolist())
|
|
127
|
+
selected_cd_parts.append(crowding_distance(pop.objectives[chosen]))
|
|
128
|
+
|
|
129
|
+
current_rank += 1
|
|
130
|
+
|
|
131
|
+
selected_arr = np.array(selected, dtype=np.intp)
|
|
132
|
+
selected_ranks = all_ranks[selected_arr]
|
|
133
|
+
selected_cd = np.concatenate(selected_cd_parts).astype(np.float64)
|
|
134
|
+
|
|
135
|
+
return selected_arr, {
|
|
136
|
+
"rank": selected_ranks,
|
|
137
|
+
"crowding_distance": selected_cd,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return selector
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Truncation survival selection for single-objective optimization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ctrl_freak.population import Population
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def truncation_survival():
|
|
11
|
+
"""Create truncation survivor selector.
|
|
12
|
+
|
|
13
|
+
Truncation survival keeps the k best individuals by fitness value.
|
|
14
|
+
Lower fitness is better (minimization).
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
callable
|
|
19
|
+
Survivor selector callable.
|
|
20
|
+
|
|
21
|
+
Examples
|
|
22
|
+
--------
|
|
23
|
+
>>> import numpy as np
|
|
24
|
+
>>> from ctrl_freak.population import Population
|
|
25
|
+
>>> from ctrl_freak.survival.truncation import truncation_survival
|
|
26
|
+
>>> obj = np.array([[4.0], [2.0], [3.0], [1.0]])
|
|
27
|
+
>>> pop = Population(x=np.zeros((4, 1)), objectives=obj)
|
|
28
|
+
>>> indices, state = truncation_survival()(pop, n_survivors=2)
|
|
29
|
+
>>> indices
|
|
30
|
+
array([3, 1])
|
|
31
|
+
>>> state["fitness"].shape
|
|
32
|
+
(2,)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def selector(
|
|
36
|
+
pop: Population,
|
|
37
|
+
n_survivors: int,
|
|
38
|
+
**kwargs: np.ndarray,
|
|
39
|
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
40
|
+
"""Select survivors using truncation selection.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
pop
|
|
45
|
+
Population to select from.
|
|
46
|
+
n_survivors
|
|
47
|
+
Number of survivors to select.
|
|
48
|
+
**kwargs
|
|
49
|
+
Optional ``fitness`` array. If omitted, fitness is extracted from a
|
|
50
|
+
single-objective population.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
tuple[numpy.ndarray, dict[str, numpy.ndarray]]
|
|
55
|
+
Selected indices and state containing the selected ``fitness`` values.
|
|
56
|
+
|
|
57
|
+
Raises
|
|
58
|
+
------
|
|
59
|
+
ValueError
|
|
60
|
+
If inputs are invalid or no valid fitness source is available.
|
|
61
|
+
|
|
62
|
+
Examples
|
|
63
|
+
--------
|
|
64
|
+
>>> import numpy as np
|
|
65
|
+
>>> from ctrl_freak.population import Population
|
|
66
|
+
>>> from ctrl_freak.survival.truncation import truncation_survival
|
|
67
|
+
>>> obj = np.array([[4.0], [2.0], [3.0], [1.0]])
|
|
68
|
+
>>> pop = Population(x=np.zeros((4, 1)), objectives=obj)
|
|
69
|
+
>>> indices, state = truncation_survival()(pop, n_survivors=2)
|
|
70
|
+
>>> indices
|
|
71
|
+
array([3, 1])
|
|
72
|
+
>>> state["fitness"]
|
|
73
|
+
array([1., 2.])
|
|
74
|
+
"""
|
|
75
|
+
# Validate n_survivors
|
|
76
|
+
if n_survivors <= 0:
|
|
77
|
+
raise ValueError(f"n_survivors must be positive, got {n_survivors}")
|
|
78
|
+
if n_survivors > len(pop):
|
|
79
|
+
raise ValueError(f"n_survivors ({n_survivors}) cannot exceed population size ({len(pop)})")
|
|
80
|
+
|
|
81
|
+
# Get fitness values
|
|
82
|
+
fitness = kwargs.get("fitness")
|
|
83
|
+
if fitness is None:
|
|
84
|
+
if pop.objectives is None:
|
|
85
|
+
raise ValueError("Population must have objectives computed for survivor selection")
|
|
86
|
+
if pop.objectives.shape[1] != 1:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"truncation requires single-objective optimization "
|
|
89
|
+
f"(got {pop.objectives.shape[1]} objectives). "
|
|
90
|
+
"Pass explicit 'fitness' kwarg for multi-objective."
|
|
91
|
+
)
|
|
92
|
+
fitness = pop.objectives[:, 0]
|
|
93
|
+
|
|
94
|
+
# Sort by fitness (ascending = best first for minimization)
|
|
95
|
+
# Use stable sort for deterministic tie-breaking
|
|
96
|
+
sorted_indices = np.argsort(fitness, kind="stable")
|
|
97
|
+
survivor_indices = sorted_indices[:n_survivors].astype(np.intp)
|
|
98
|
+
|
|
99
|
+
# Compute state for survivors
|
|
100
|
+
survivor_fitness = fitness[survivor_indices].copy()
|
|
101
|
+
|
|
102
|
+
return survivor_indices, {"fitness": survivor_fitness}
|
|
103
|
+
|
|
104
|
+
return selector
|