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 ADDED
@@ -0,0 +1,100 @@
1
+ """ctrl-freak: Extensible Genetic Algorithm Framework.
2
+
3
+ A pure numpy implementation of genetic algorithms including NSGA-II for
4
+ multi-objective optimization and standard GA for single-objective optimization.
5
+
6
+ Example (multi-objective with NSGA-II):
7
+ >>> from ctrl_freak import nsga2, Population
8
+ >>> import numpy as np
9
+ >>> def init(rng): return rng.uniform(0, 1, size=3)
10
+ >>> def evaluate(x): return np.array([x.sum(), (1 - x).sum()])
11
+ >>> def crossover(p1, p2): return (p1 + p2) / 2
12
+ >>> def mutate(x): return np.clip(x + 0.01, 0, 1)
13
+ >>> result = nsga2(init, evaluate, crossover, mutate, pop_size=10, n_generations=5, seed=42)
14
+ >>> len(result.population)
15
+ 10
16
+
17
+ Example (single-objective with GA):
18
+ >>> from ctrl_freak import ga
19
+ >>> import numpy as np
20
+ >>> def init(rng): return rng.uniform(0, 1, size=3)
21
+ >>> def evaluate(x): return x.sum() # Single objective
22
+ >>> def crossover(p1, p2): return (p1 + p2) / 2
23
+ >>> def mutate(x): return np.clip(x + 0.01, 0, 1)
24
+ >>> result = ga(init, evaluate, crossover, mutate, pop_size=10, n_generations=5, seed=42)
25
+ >>> len(result.population)
26
+ 10
27
+ """
28
+
29
+ from importlib.metadata import version
30
+
31
+ from ctrl_freak.algorithms import ga, nsga2
32
+ from ctrl_freak.operators import (
33
+ create_offspring,
34
+ lift,
35
+ lift_parallel,
36
+ polynomial_mutation,
37
+ sbx_crossover,
38
+ select_parents,
39
+ )
40
+ from ctrl_freak.population import IndividualView, Population
41
+ from ctrl_freak.primitives import (
42
+ crowding_distance,
43
+ dominates,
44
+ dominates_matrix,
45
+ non_dominated_sort,
46
+ )
47
+ from ctrl_freak.protocols import ParentSelector, SurvivorSelector
48
+ from ctrl_freak.registry import (
49
+ SelectionRegistry,
50
+ SurvivalRegistry,
51
+ list_selections,
52
+ list_survivals,
53
+ )
54
+ from ctrl_freak.results import GAResult, NSGA2Result
55
+ from ctrl_freak.selection import crowded_tournament, fitness_tournament, roulette_wheel
56
+ from ctrl_freak.survival import elitist_survival, nsga2_survival, truncation_survival
57
+
58
+ __version__ = version("ctrl-freak")
59
+
60
+ __all__ = [
61
+ # Algorithms
62
+ "nsga2",
63
+ "ga",
64
+ # Selection strategies
65
+ "crowded_tournament",
66
+ "fitness_tournament",
67
+ "roulette_wheel",
68
+ # Survival strategies
69
+ "nsga2_survival",
70
+ "truncation_survival",
71
+ "elitist_survival",
72
+ # Genetic operators
73
+ "lift",
74
+ "lift_parallel",
75
+ "select_parents",
76
+ "create_offspring",
77
+ "sbx_crossover",
78
+ "polynomial_mutation",
79
+ # Primitives
80
+ "dominates",
81
+ "dominates_matrix",
82
+ "non_dominated_sort",
83
+ "crowding_distance",
84
+ # Registry system
85
+ "SelectionRegistry",
86
+ "SurvivalRegistry",
87
+ "list_selections",
88
+ "list_survivals",
89
+ # Protocols
90
+ "ParentSelector",
91
+ "SurvivorSelector",
92
+ # Data structures
93
+ "Population",
94
+ "IndividualView",
95
+ # Result types
96
+ "NSGA2Result",
97
+ "GAResult",
98
+ # Version
99
+ "__version__",
100
+ ]
@@ -0,0 +1,10 @@
1
+ """Evolutionary algorithm implementations.
2
+
3
+ This module provides pluggable algorithm implementations with configurable
4
+ selection and survival strategies.
5
+ """
6
+
7
+ from ctrl_freak.algorithms.ga import ga
8
+ from ctrl_freak.algorithms.nsga2 import nsga2
9
+
10
+ __all__ = ["ga", "nsga2"]
@@ -0,0 +1,233 @@
1
+ """Standard genetic algorithm with pluggable strategies.
2
+
3
+ Examples
4
+ --------
5
+ >>> import numpy as np
6
+ >>> from ctrl_freak.algorithms.ga import ga
7
+ >>> def init(rng):
8
+ ... return rng.uniform(0.0, 1.0, size=3)
9
+ >>> def evaluate(x):
10
+ ... return float(np.sum(x**2))
11
+ >>> result = ga(
12
+ ... init=init,
13
+ ... evaluate=evaluate,
14
+ ... crossover=lambda p1, p2: (p1 + p2) / 2,
15
+ ... mutate=lambda x: np.clip(x + 0.01, 0.0, 1.0),
16
+ ... pop_size=10,
17
+ ... n_generations=2,
18
+ ... seed=42,
19
+ ... )
20
+ >>> result.population.x.shape
21
+ (10, 3)
22
+ """
23
+
24
+ from collections.abc import Callable
25
+
26
+ import numpy as np
27
+
28
+ # Import selection and survival modules to trigger strategy registration
29
+ import ctrl_freak.selection # noqa: F401
30
+ import ctrl_freak.survival # noqa: F401
31
+ from ctrl_freak.operators import lift, lift_parallel
32
+ from ctrl_freak.population import Population
33
+ from ctrl_freak.protocols import ParentSelector, SurvivorSelector
34
+ from ctrl_freak.registry import SelectionRegistry, SurvivalRegistry
35
+ from ctrl_freak.results import GAResult
36
+
37
+
38
+ def ga(
39
+ init: Callable[[np.random.Generator], np.ndarray],
40
+ evaluate: Callable[[np.ndarray], float],
41
+ crossover: Callable[[np.ndarray, np.ndarray], np.ndarray],
42
+ mutate: Callable[[np.ndarray], np.ndarray],
43
+ pop_size: int,
44
+ n_generations: int,
45
+ seed: int | None = None,
46
+ callback: Callable[[GAResult, int], bool] | None = None,
47
+ select: str | ParentSelector = "tournament",
48
+ survive: str | SurvivorSelector = "elitist",
49
+ n_workers: int = 1,
50
+ ) -> GAResult:
51
+ """Run a single-objective genetic algorithm.
52
+
53
+ Parameters
54
+ ----------
55
+ init
56
+ Callable that initializes one individual from a random generator.
57
+ evaluate
58
+ Callable that evaluates one individual and returns a scalar objective.
59
+ Lower objective values are better.
60
+ crossover
61
+ Callable that crosses two parents to produce one child.
62
+ mutate
63
+ Callable that mutates one individual.
64
+ pop_size
65
+ Population size. Must be positive and even.
66
+ n_generations
67
+ Number of generations to run.
68
+ seed
69
+ Master random seed. If ``None``, system entropy is used.
70
+ callback
71
+ Optional callback called before each generation. Return ``True`` to stop.
72
+ select
73
+ Parent selection strategy name or callable.
74
+ survive
75
+ Survivor selection strategy name or callable.
76
+ n_workers
77
+ Number of workers for objective evaluation. Parallel evaluation is
78
+ deterministic only when ``evaluate`` is pure.
79
+
80
+ Returns
81
+ -------
82
+ GAResult
83
+ Final population, fitness vector, best index, generation count, and
84
+ evaluation count.
85
+
86
+ Raises
87
+ ------
88
+ ValueError
89
+ If size, generation, or worker arguments are invalid.
90
+ KeyError
91
+ If a named strategy is not registered.
92
+
93
+ Examples
94
+ --------
95
+ >>> import numpy as np
96
+ >>> from ctrl_freak.algorithms.ga import ga
97
+ >>> def init(rng):
98
+ ... return rng.uniform(0.0, 1.0, size=2)
99
+ >>> def evaluate(x):
100
+ ... return float(np.sum(x**2))
101
+ >>> result = ga(
102
+ ... init=init,
103
+ ... evaluate=evaluate,
104
+ ... crossover=lambda p1, p2: (p1 + p2) / 2,
105
+ ... mutate=lambda x: x.copy(),
106
+ ... pop_size=10,
107
+ ... n_generations=2,
108
+ ... seed=1,
109
+ ... )
110
+ >>> result.generations
111
+ 2
112
+ """
113
+ # Validate inputs
114
+ if pop_size <= 0:
115
+ raise ValueError(f"pop_size must be positive, got {pop_size}")
116
+ if pop_size % 2 != 0:
117
+ raise ValueError(f"pop_size must be even for proper parent pairing, got {pop_size}")
118
+ if n_generations < 0:
119
+ raise ValueError(f"n_generations must be non-negative, got {n_generations}")
120
+ if n_workers < 1 and n_workers != -1:
121
+ raise ValueError(f"n_workers must be positive or -1 (all cores), got {n_workers}")
122
+
123
+ # Resolve selection and survival strategies
124
+ parent_selector = SelectionRegistry.get(select) if isinstance(select, str) else select
125
+ survivor_selector = SurvivalRegistry.get(survive) if isinstance(survive, str) else survive
126
+
127
+ # Derive independent per-phase RNG streams from the single master seed so one seed
128
+ # reproduces init + parent selection + crossover + mutation bit-identically.
129
+ # Child order is the reproducibility contract: [init, select, crossover, mutate]. Never reorder.
130
+ init_ss, select_ss, crossover_ss, mutate_ss = np.random.SeedSequence(seed).spawn(4)
131
+ rng = np.random.default_rng(init_ss)
132
+ select_rng = np.random.default_rng(select_ss)
133
+ set_crossover_rng = getattr(crossover, "set_rng", None)
134
+ if callable(set_crossover_rng):
135
+ set_crossover_rng(np.random.default_rng(crossover_ss))
136
+ set_mutate_rng = getattr(mutate, "set_rng", None)
137
+ if callable(set_mutate_rng):
138
+ set_mutate_rng(np.random.default_rng(mutate_ss))
139
+
140
+ def evaluate_array(x: np.ndarray) -> np.ndarray:
141
+ return np.asarray(evaluate(x))
142
+
143
+ # Shared lifted evaluation path. Parallel determinism assumes evaluate is pure.
144
+ lifted_evaluate = lift_parallel(evaluate_array, n_workers) if n_workers != 1 else lift(evaluate_array)
145
+
146
+ # Initialize population
147
+ init_x = np.stack([init(rng) for _ in range(pop_size)])
148
+ init_obj = lifted_evaluate(init_x)
149
+ if init_obj.ndim == 1:
150
+ init_obj = init_obj.reshape(-1, 1)
151
+
152
+ pop = Population(x=init_x, objectives=init_obj)
153
+
154
+ # Compute initial fitness state
155
+ # Extract fitness from single-objective population
156
+ assert pop.objectives is not None # Guaranteed by initialization above
157
+ state: dict[str, np.ndarray] = {"fitness": pop.objectives[:, 0].copy()}
158
+
159
+ # Track evaluations: initial population
160
+ total_evaluations = pop_size
161
+ generations_completed = 0
162
+
163
+ # Main evolutionary loop
164
+ for gen in range(n_generations):
165
+ # Extract fitness from state for current GAResult
166
+ fitness = state["fitness"]
167
+ best_idx = int(np.argmin(fitness))
168
+
169
+ # Create current GAResult for callback
170
+ current_result = GAResult(
171
+ population=pop,
172
+ fitness=fitness,
173
+ best_idx=best_idx,
174
+ generations=generations_completed,
175
+ evaluations=total_evaluations,
176
+ )
177
+
178
+ # Check callback for early stopping
179
+ if callback is not None and callback(current_result, gen):
180
+ break
181
+
182
+ # Select parents using current state
183
+ parent_indices = parent_selector(pop, pop_size, select_rng, **state)
184
+
185
+ # Create offspring via crossover and mutation
186
+ offspring_x = np.empty_like(pop.x)
187
+ for i in range(0, pop_size, 2):
188
+ p1_idx, p2_idx = parent_indices[i], parent_indices[i + 1]
189
+ child1 = crossover(pop.x[p1_idx], pop.x[p2_idx])
190
+ child2 = crossover(pop.x[p2_idx], pop.x[p1_idx])
191
+ offspring_x[i] = mutate(child1)
192
+ offspring_x[i + 1] = mutate(child2)
193
+
194
+ offspring_obj = lifted_evaluate(offspring_x)
195
+ if offspring_obj.ndim == 1:
196
+ offspring_obj = offspring_obj.reshape(-1, 1)
197
+ total_evaluations += pop_size
198
+
199
+ # Combine parent and offspring populations
200
+ assert pop.objectives is not None # Guaranteed by initialization
201
+ combined = Population(
202
+ x=np.concatenate([pop.x, offspring_x]),
203
+ objectives=np.concatenate([pop.objectives, offspring_obj]),
204
+ )
205
+
206
+ survivor_indices, state = survivor_selector(
207
+ combined,
208
+ pop_size,
209
+ parent_size=pop_size,
210
+ )
211
+
212
+ # Update population
213
+ assert combined.objectives is not None # Guaranteed by Population construction above
214
+ pop = Population(
215
+ x=combined.x[survivor_indices],
216
+ objectives=combined.objectives[survivor_indices],
217
+ )
218
+
219
+ generations_completed += 1
220
+
221
+ # Return final result
222
+ fitness = state["fitness"]
223
+ best_idx = int(np.argmin(fitness))
224
+
225
+ final_result = GAResult(
226
+ population=pop,
227
+ fitness=fitness,
228
+ best_idx=best_idx,
229
+ generations=generations_completed,
230
+ evaluations=total_evaluations,
231
+ )
232
+
233
+ return final_result
@@ -0,0 +1,215 @@
1
+ """NSGA-II algorithm with pluggable strategies.
2
+
3
+ Examples
4
+ --------
5
+ >>> import numpy as np
6
+ >>> from ctrl_freak.algorithms.nsga2 import nsga2
7
+ >>> def init(rng):
8
+ ... return rng.uniform(0.0, 1.0, size=3)
9
+ >>> def evaluate(x):
10
+ ... return np.array([np.sum(x), np.sum(1.0 - x)])
11
+ >>> result = nsga2(
12
+ ... init=init,
13
+ ... evaluate=evaluate,
14
+ ... crossover=lambda p1, p2: (p1 + p2) / 2,
15
+ ... mutate=lambda x: np.clip(x + 0.01, 0.0, 1.0),
16
+ ... pop_size=10,
17
+ ... n_generations=2,
18
+ ... seed=42,
19
+ ... )
20
+ >>> result.population.x.shape
21
+ (10, 3)
22
+ """
23
+
24
+ from collections.abc import Callable
25
+
26
+ import numpy as np
27
+
28
+ # Import selection and survival modules to trigger strategy registration
29
+ import ctrl_freak.selection # noqa: F401
30
+ import ctrl_freak.survival # noqa: F401
31
+ from ctrl_freak.operators import lift, lift_parallel
32
+ from ctrl_freak.population import Population
33
+ from ctrl_freak.protocols import ParentSelector, SurvivorSelector
34
+ from ctrl_freak.registry import SelectionRegistry, SurvivalRegistry
35
+ from ctrl_freak.results import NSGA2Result
36
+
37
+
38
+ def nsga2(
39
+ init: Callable[[np.random.Generator], np.ndarray],
40
+ evaluate: Callable[[np.ndarray], np.ndarray],
41
+ crossover: Callable[[np.ndarray, np.ndarray], np.ndarray],
42
+ mutate: Callable[[np.ndarray], np.ndarray],
43
+ pop_size: int,
44
+ n_generations: int,
45
+ seed: int | None = None,
46
+ callback: Callable[[NSGA2Result, int], bool] | None = None,
47
+ select: str | ParentSelector = "crowded",
48
+ survive: str | SurvivorSelector = "nsga2",
49
+ n_workers: int = 1,
50
+ ) -> NSGA2Result:
51
+ """Run NSGA-II multi-objective optimization.
52
+
53
+ Parameters
54
+ ----------
55
+ init
56
+ Callable that initializes one individual from a random generator.
57
+ evaluate
58
+ Callable that evaluates one individual and returns objective values.
59
+ crossover
60
+ Callable that crosses two parents to produce one child.
61
+ mutate
62
+ Callable that mutates one individual.
63
+ pop_size
64
+ Population size. Must be positive and even.
65
+ n_generations
66
+ Number of generations to run.
67
+ seed
68
+ Master random seed. If ``None``, system entropy is used.
69
+ callback
70
+ Optional callback called before each generation. Return ``True`` to stop.
71
+ select
72
+ Parent selection strategy name or callable.
73
+ survive
74
+ Survivor selection strategy name or callable.
75
+ n_workers
76
+ Number of workers for objective evaluation. Parallel evaluation is
77
+ deterministic only when ``evaluate`` is pure.
78
+
79
+ Returns
80
+ -------
81
+ NSGA2Result
82
+ Final population, ranks, crowding distances, generation count, and
83
+ evaluation count.
84
+
85
+ Raises
86
+ ------
87
+ ValueError
88
+ If size, generation, or worker arguments are invalid.
89
+ KeyError
90
+ If a named strategy is not registered.
91
+
92
+ Examples
93
+ --------
94
+ >>> import numpy as np
95
+ >>> from ctrl_freak.algorithms.nsga2 import nsga2
96
+ >>> def init(rng):
97
+ ... return rng.uniform(0.0, 1.0, size=2)
98
+ >>> def evaluate(x):
99
+ ... return np.array([np.sum(x), np.sum(1.0 - x)])
100
+ >>> result = nsga2(
101
+ ... init=init,
102
+ ... evaluate=evaluate,
103
+ ... crossover=lambda p1, p2: (p1 + p2) / 2,
104
+ ... mutate=lambda x: x.copy(),
105
+ ... pop_size=10,
106
+ ... n_generations=2,
107
+ ... seed=1,
108
+ ... )
109
+ >>> result.generations
110
+ 2
111
+ """
112
+ # Validate inputs
113
+ if pop_size <= 0:
114
+ raise ValueError(f"pop_size must be positive, got {pop_size}")
115
+ if pop_size % 2 != 0:
116
+ raise ValueError(f"pop_size must be even for proper parent pairing, got {pop_size}")
117
+ if n_generations < 0:
118
+ raise ValueError(f"n_generations must be non-negative, got {n_generations}")
119
+ if n_workers < 1 and n_workers != -1:
120
+ raise ValueError(f"n_workers must be positive or -1 (all cores), got {n_workers}")
121
+
122
+ # Resolve selection and survival strategies
123
+ parent_selector = SelectionRegistry.get(select) if isinstance(select, str) else select
124
+
125
+ survivor_selector = SurvivalRegistry.get(survive) if isinstance(survive, str) else survive
126
+
127
+ # Create evaluator (parallel or sequential)
128
+ lifted_evaluate = lift_parallel(evaluate, n_workers) if n_workers != 1 else lift(evaluate)
129
+
130
+ # Derive independent per-phase RNG streams from the single master seed so one seed
131
+ # reproduces init + parent selection + crossover + mutation bit-identically.
132
+ # Child order is the reproducibility contract: [init, select, crossover, mutate]. Never reorder.
133
+ init_ss, select_ss, crossover_ss, mutate_ss = np.random.SeedSequence(seed).spawn(4)
134
+ rng = np.random.default_rng(init_ss)
135
+ select_rng = np.random.default_rng(select_ss)
136
+ set_crossover_rng = getattr(crossover, "set_rng", None)
137
+ if callable(set_crossover_rng):
138
+ set_crossover_rng(np.random.default_rng(crossover_ss))
139
+ set_mutate_rng = getattr(mutate, "set_rng", None)
140
+ if callable(set_mutate_rng):
141
+ set_mutate_rng(np.random.default_rng(mutate_ss))
142
+
143
+ # Initialize population
144
+ init_x = np.stack([init(rng) for _ in range(pop_size)])
145
+ init_obj = lifted_evaluate(init_x)
146
+ pop = Population(x=init_x, objectives=init_obj)
147
+
148
+ # Compute initial state via survival strategy
149
+ # This gives us initial rank and crowding distance
150
+ _, state = survivor_selector(pop, pop_size)
151
+
152
+ # Track evaluations: initial population
153
+ total_evaluations = pop_size
154
+ generations_completed = 0
155
+
156
+ # Main evolutionary loop
157
+ for gen in range(n_generations):
158
+ # Create current NSGA2Result for callback
159
+ current_result = NSGA2Result(
160
+ population=pop,
161
+ rank=state["rank"],
162
+ crowding_distance=state["crowding_distance"],
163
+ generations=generations_completed,
164
+ evaluations=total_evaluations,
165
+ )
166
+
167
+ # Check callback for early stopping
168
+ if callback is not None and callback(current_result, gen):
169
+ break
170
+
171
+ # Select parents using current state
172
+ parent_indices = parent_selector(pop, pop_size, select_rng, **state)
173
+
174
+ # Create offspring via crossover and mutation
175
+ offspring_x = np.empty_like(pop.x)
176
+ for i in range(0, pop_size, 2):
177
+ p1_idx, p2_idx = parent_indices[i], parent_indices[i + 1]
178
+ child1 = crossover(pop.x[p1_idx], pop.x[p2_idx])
179
+ child2 = crossover(pop.x[p2_idx], pop.x[p1_idx])
180
+ offspring_x[i] = mutate(child1)
181
+ offspring_x[i + 1] = mutate(child2)
182
+
183
+ # Evaluate offspring
184
+ offspring_obj = lifted_evaluate(offspring_x)
185
+ total_evaluations += pop_size
186
+
187
+ # Combine parent and offspring populations
188
+ assert pop.objectives is not None # Guaranteed by initialization
189
+ combined = Population(
190
+ x=np.concatenate([pop.x, offspring_x]),
191
+ objectives=np.concatenate([pop.objectives, offspring_obj]),
192
+ )
193
+
194
+ # Select survivors for next generation
195
+ survivor_indices, state = survivor_selector(combined, pop_size)
196
+
197
+ # Update population
198
+ assert combined.objectives is not None # Guaranteed by Population construction above
199
+ pop = Population(
200
+ x=combined.x[survivor_indices],
201
+ objectives=combined.objectives[survivor_indices],
202
+ )
203
+
204
+ generations_completed += 1
205
+
206
+ # Return final result
207
+ final_result = NSGA2Result(
208
+ population=pop,
209
+ rank=state["rank"],
210
+ crowding_distance=state["crowding_distance"],
211
+ generations=generations_completed,
212
+ evaluations=total_evaluations,
213
+ )
214
+
215
+ return final_result
@@ -0,0 +1,15 @@
1
+ """Genetic operators for evolutionary algorithms.
2
+
3
+ This module provides:
4
+ - lift: decorator to lift per-individual functions to population level
5
+ - sbx_crossover: Simulated Binary Crossover factory
6
+ - polynomial_mutation: Polynomial mutation factory
7
+ - select_parents: Binary tournament selection
8
+ - create_offspring: Create offspring via selection, crossover, and mutation
9
+ """
10
+
11
+ from ctrl_freak.operators.base import lift, lift_parallel
12
+ from ctrl_freak.operators.selection import create_offspring, select_parents
13
+ from ctrl_freak.operators.standard import polynomial_mutation, sbx_crossover
14
+
15
+ __all__ = ["lift", "lift_parallel", "sbx_crossover", "polynomial_mutation", "select_parents", "create_offspring"]
@@ -0,0 +1,81 @@
1
+ """Base genetic-operator adapters."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ import numpy as np
6
+
7
+
8
+ def lift(fn: Callable[[np.ndarray], np.ndarray]) -> Callable[[np.ndarray], np.ndarray]:
9
+ """Lift a per-individual function to work on a population.
10
+
11
+ This utility allows users to write simple per-individual functions
12
+ while the framework handles batching/vectorization.
13
+
14
+ Parameters
15
+ ----------
16
+ fn : collections.abc.Callable
17
+ Function that operates on one individual with signature
18
+ ``(n_vars,) -> (n_out,)``.
19
+
20
+ Returns
21
+ -------
22
+ collections.abc.Callable
23
+ Function that operates on a population with signature
24
+ ``(n, n_vars) -> (n, n_out)``.
25
+
26
+ Examples
27
+ --------
28
+ >>> def evaluate_one(x: np.ndarray) -> np.ndarray:
29
+ ... return np.array([x.sum(), x.prod()])
30
+ >>> evaluate = lift(evaluate_one)
31
+ >>> pop_x = np.array([[1.0, 2.0], [3.0, 4.0]])
32
+ >>> evaluate(pop_x)
33
+ array([[ 3., 2.],
34
+ [ 7., 12.]])
35
+ """
36
+
37
+ def lifted(x: np.ndarray) -> np.ndarray:
38
+ return np.stack([fn(x[i]) for i in range(x.shape[0])])
39
+
40
+ return lifted
41
+
42
+
43
+ def lift_parallel(fn: Callable[[np.ndarray], np.ndarray], n_workers: int) -> Callable[[np.ndarray], np.ndarray]:
44
+ """Lift a per-individual function to work on a population with parallel execution.
45
+
46
+ This utility allows users to write simple per-individual functions
47
+ while the framework handles batching/vectorization with parallel workers.
48
+
49
+ Parameters
50
+ ----------
51
+ fn : collections.abc.Callable
52
+ Function that operates on one individual with signature
53
+ ``(n_vars,) -> (n_out,)``. It must be picklable for multiprocessing.
54
+ n_workers : int
55
+ Number of parallel workers. Use ``-1`` for all CPU cores.
56
+
57
+ Returns
58
+ -------
59
+ collections.abc.Callable
60
+ Function that operates on a population in parallel with signature
61
+ ``(n, n_vars) -> (n, n_out)``.
62
+
63
+ Examples
64
+ --------
65
+ >>> def evaluate_one(x: np.ndarray) -> np.ndarray:
66
+ ... return np.array([x.sum(), x.prod()])
67
+ >>> evaluate = lift_parallel(evaluate_one, n_workers=4)
68
+ >>> pop_x = np.array([[1.0, 2.0], [3.0, 4.0]])
69
+ >>> evaluate(pop_x)
70
+ array([[ 3., 2.],
71
+ [ 7., 12.]])
72
+ """
73
+ from joblib import Parallel, delayed
74
+
75
+ def lifted(x: np.ndarray) -> np.ndarray:
76
+ results: list[np.ndarray] = Parallel(n_jobs=n_workers)( # type: ignore[assignment]
77
+ delayed(fn)(x[i]) for i in range(x.shape[0])
78
+ )
79
+ return np.stack(results)
80
+
81
+ return lifted