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.
@@ -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