11import numpy as np
22from pydantic import Field , Discriminator
3- from typing import Dict , List , Optional , Annotated , Union
3+ from typing import Annotated
44import pandas as pd
55import os
66from datetime import datetime
2525########################################################################################################################
2626
2727
28+ def vocs_data_to_arr (data : list | np .ndarray ) -> np .ndarray :
29+ """Force data coming from VOCS object into 2D numpy array (or None) for compatibility with helper functions"""
30+ if isinstance (data , list ):
31+ data = np .ndarray (list )
32+ if data .size == 0 :
33+ return None
34+ if len (data .shape ) == 1 :
35+ return data [:, None ]
36+ if len (data .shape ) == 2 :
37+ return data
38+ raise ValueError (f"Unrecognized shape from VOCS data: { data .shape } " )
39+
40+
2841def get_crowding_distance (pop_f : np .ndarray ) -> np .ndarray :
2942 """
3043 Calculates NSGA-II style crowding distance as described in [1].
@@ -66,16 +79,20 @@ def get_crowding_distance(pop_f: np.ndarray) -> np.ndarray:
6679
6780
6881def crowded_comparison_argsort (
69- pop_f : np .ndarray , pop_g : Optional [ np .ndarray ] = None
82+ pop_f : np .ndarray , pop_g : np .ndarray | None = None
7083) -> np .ndarray :
7184 """
7285 Sorts the objective functions by domination rank and then by crowding distance (crowded comparison operator).
73- Indices to individuals are returned in order of increasing value by crowded comparison operator.
86+ Indices to individuals are returned in order of increasing value of fitness by crowded comparison operator.
87+ That is, the least fit individuals are returned first.
88+
89+ Notes: NaN values are removed from the comparison and added back at the beginning (least fit direction) of
90+ the sorted indices.
7491
7592 Parameters
7693 ----------
7794 pop_f : np.ndarray
78- (M, N ) numpy array where N is the number of individuals and M is the number of objectives
95+ (N, M ) numpy array where N is the number of individuals and M is the number of objectives
7996 pop_g : np.ndarray, optional
8097 The constraints, by default None
8198
@@ -84,51 +101,69 @@ def crowded_comparison_argsort(
84101 np.ndarray
85102 Numpy array of indices to sorted individuals
86103 """
87- # Deal with NaNs
88- pop_f = np .copy (pop_f )
89- pop_f [~ np .isfinite (pop_g )] = 1e300
104+ # Check for non-finite values in both pop_f and pop_g
105+ has_nan = np .any (~ np .isfinite (pop_f ), axis = 1 )
90106 if pop_g is not None :
91- pop_g = np .copy (pop_g )
92- pop_g [~ np .isfinite (pop_g )] = 1e300
107+ has_nan = has_nan | np .any (~ np .isfinite (pop_g ), axis = 1 )
108+ nan_indices = np .where (has_nan )[0 ]
109+ finite_indices = np .where (~ has_nan )[0 ]
93110
94- ranks = fast_dominated_argsort (pop_f , pop_g )
95- inds = []
111+ # If all values are non-finite, return the original indices
112+ if len (finite_indices ) == 0 :
113+ return np .arange (pop_f .shape [0 ])
114+
115+ # Extract only finite values for processing
116+ pop_f_finite = pop_f [finite_indices , :]
117+
118+ # Handle constraints if provided
119+ pop_g_finite = None
120+ if pop_g is not None :
121+ pop_g_finite = pop_g [finite_indices , :]
122+
123+ # Apply domination ranking
124+ ranks = fast_dominated_argsort (pop_f_finite , pop_g_finite )
125+
126+ # Calculate crowding distance and sort within each rank
127+ sorted_finite_indices = []
96128 for rank in ranks :
97- dist = get_crowding_distance (pop_f [rank , :])
98- inds .extend (np .array (rank )[np .argsort (dist )[::- 1 ]])
129+ dist = get_crowding_distance (pop_f_finite [rank , :])
130+ sorted_rank = np .array (rank )[np .argsort (dist )[::- 1 ]]
131+ sorted_finite_indices .extend (sorted_rank )
132+
133+ # Map back to original indices and put nans at end
134+ sorted_original_indices = finite_indices [sorted_finite_indices ]
135+ final_sorted_indices = np .concatenate ([sorted_original_indices , nan_indices ])
99136
100- return np . array ( inds ) [::- 1 ]
137+ return final_sorted_indices [::- 1 ]
101138
102139
103- def get_fitness (pop_f : np .ndarray , pop_g : np .ndarray ) -> np .ndarray :
140+ def get_fitness (pop_f : np .ndarray , pop_g : np .ndarray | None = None ) -> np .ndarray :
104141 """
105142 Get the "fitness" of each individual according to domination and crowding distance.
106143
107144 Parameters
108145 ----------
109146 pop_f : np.ndarray
110147 The objectives
111- pop_g : np.ndarray
112- The constraints
148+ pop_g : np.ndarray / None
149+ The constraints, or None of no constraints
113150
114151 Returns
115152 -------
116153 np.ndarray
117154 The fitness of each individual
118155 """
119- sort_ind = crowded_comparison_argsort (pop_f , pop_g )
120- fitness = np .argsort (sort_ind )
121- return fitness
156+ return np .argsort (crowded_comparison_argsort (pop_f , pop_g ))
122157
123158
124159def generate_child_binary_tournament (
125160 pop_x : np .ndarray ,
126161 pop_f : np .ndarray ,
127- pop_g : np .ndarray ,
162+ pop_g : np .ndarray | None ,
128163 bounds : np .ndarray ,
129164 mutate : MutationOperator ,
130165 crossover : CrossoverOperator ,
131- fitness : Optional [ np .ndarray ] = None ,
166+ fitness : np .ndarray | None = None ,
132167) -> np .ndarray :
133168 """
134169 Creates a single child from the population using binary tournament selection, crossover, and mutation.
@@ -143,8 +178,9 @@ def generate_child_binary_tournament(
143178 Decision variables of the population, shape (n_individuals, n_variables).
144179 pop_f : numpy.ndarray
145180 Objective function values of the population, shape (n_individuals, n_objectives).
146- pop_g : numpy.ndarray
181+ pop_g : numpy.ndarray / None
147182 Constraint violation values of the population, shape (n_individuals, n_constraints).
183+ None if no constraints.
148184 bounds : numpy.ndarray
149185 Bounds for decision variables, shape (2, n_variables) where bounds[0] are lower bounds
150186 and bounds[1] are upper bounds.
@@ -186,7 +222,7 @@ def generate_child_binary_tournament(
186222
187223
188224def cull_population (
189- pop_x : np .ndarray , pop_f : np .ndarray , pop_g : np .ndarray , population_size : int
225+ pop_x : np .ndarray , pop_f : np .ndarray , pop_g : np .ndarray | None , population_size : int
190226) -> np .ndarray :
191227 """
192228 Reduce population size by selecting the best individuals based on crowded comparison.
@@ -198,8 +234,8 @@ def cull_population(
198234 ----------
199235 pop_x : numpy.ndarray
200236 Decision variables of the population, shape (n_individuals, n_variables).
201- pop_f : numpy.ndarray
202- Objective function values of the population, shape (n_individuals, n_objectives).
237+ pop_f : numpy.ndarray / None
238+ Objective function values of the population, shape (n_individuals, n_objectives), None if no constraints .
203239 pop_g : numpy.ndarray
204240 Constraint violation values of the population, shape (n_individuals, n_constraints).
205241 population_size : int
@@ -210,9 +246,7 @@ def cull_population(
210246 numpy.ndarray
211247 Indices of selected individuals, shape (population_size,).
212248 """
213- inds = crowded_comparison_argsort (pop_f , pop_g )[::- 1 ]
214- inds = inds [:population_size ]
215- return inds
249+ return crowded_comparison_argsort (pop_f , pop_g )[- population_size :]
216250
217251
218252########################################################################################################################
@@ -278,20 +312,20 @@ class NSGA2Generator(DeduplicatedGeneratorBase, StateOwner):
278312
279313 population_size : int = Field (50 , description = "Population size" )
280314 crossover_operator : Annotated [
281- Union [
282- SimulatedBinaryCrossover , DummyCrossover
283- ] , # Dummy placeholder to keep discriminator code from failing
315+ (
316+ SimulatedBinaryCrossover | DummyCrossover
317+ ) , # Dummy placeholder to keep discriminator code from failing
284318 Discriminator ("name" ),
285319 ] = SimulatedBinaryCrossover ()
286320 mutation_operator : Annotated [
287- Union [
288- PolynomialMutation , DummyMutation
289- ] , # Dummy placeholder to keep discriminator code from failing
321+ (
322+ PolynomialMutation | DummyMutation
323+ ) , # Dummy placeholder to keep discriminator code from failing
290324 Discriminator ("name" ),
291325 ] = PolynomialMutation ()
292326
293327 # Output options
294- output_dir : Optional [ str ] = None
328+ output_dir : str | None = None
295329 checkpoint_freq : int = Field (
296330 - 1 ,
297331 description = "How often (in generations) to save checkpoints (set to -1 to disable)" ,
@@ -302,7 +336,7 @@ class NSGA2Generator(DeduplicatedGeneratorBase, StateOwner):
302336 _output_dir_setup : bool = (
303337 False # Used in initializing the directory. PLEASE DO NOT CHANGE
304338 )
305- _logger : Optional [ logging .Logger ] = None
339+ _logger : logging .Logger | None = None
306340
307341 # Metadata
308342 fevals : int = Field (
@@ -315,7 +349,7 @@ class NSGA2Generator(DeduplicatedGeneratorBase, StateOwner):
315349 n_candidates : int = Field (
316350 0 , description = "The number of candidate solutions generated so far"
317351 )
318- history_idx : List [ List [int ]] = Field (
352+ history_idx : list [ list [int ]] = Field (
319353 default = [],
320354 description = "Xopt indices of the individuals in each population" ,
321355 )
@@ -326,15 +360,15 @@ class NSGA2Generator(DeduplicatedGeneratorBase, StateOwner):
326360 )
327361
328362 # The population and returned children
329- pop : List [ Dict ] = Field (default = [])
330- child : List [ Dict ] = Field (default = [])
363+ pop : list [ dict ] = Field (default = [])
364+ child : list [ dict ] = Field (default = [])
331365
332366 def model_post_init (self , context ):
333367 # Get a unique logger per object
334368 self ._logger = logging .getLogger (f"{ __name__ } .NSGA2Generator.{ id (self )} " )
335369 self ._logger .setLevel (self .log_level )
336370
337- def _generate (self , n_candidates : int ) -> List [ Dict ]:
371+ def _generate (self , n_candidates : int ) -> list [ dict ]:
338372 self .ensure_output_dir_setup ()
339373 start_t = time .perf_counter ()
340374
@@ -347,7 +381,7 @@ def _generate(self, n_candidates: int) -> List[Dict]:
347381 candidates = []
348382 pop_x = self .vocs .variable_data (self .pop ).to_numpy ()
349383 pop_f = self .vocs .objective_data (self .pop ).to_numpy ()
350- pop_g = self .vocs .constraint_data (self .pop ).to_numpy ()
384+ pop_g = vocs_data_to_arr ( self .vocs .constraint_data (self .pop ).to_numpy () )
351385 fitness = get_fitness (pop_f , pop_g )
352386 for _ in range (n_candidates ):
353387 candidates .append (
@@ -419,7 +453,7 @@ def add_data(self, new_data: pd.DataFrame):
419453 idx = cull_population (
420454 self .vocs .variable_data (self .pop ).to_numpy (),
421455 self .vocs .objective_data (self .pop ).to_numpy (),
422- self .vocs .constraint_data (self .pop ).to_numpy (),
456+ vocs_data_to_arr ( self .vocs .constraint_data (self .pop ).to_numpy () ),
423457 self .population_size ,
424458 )
425459 self .pop = [self .pop [i ] for i in idx ]
0 commit comments