hdim-opt 1.0.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.
hdim_opt/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ # hdim_opt/__init__.py
2
+
3
+ # package version
4
+ __version__ = "1.0.0"
5
+
6
+ # import core components
7
+ from .quasar_optimization import optimize as quasar
8
+ from .hyperellipsoid_sampling import sample as hds
9
+
10
+ # list what is available for star-imports
11
+ __all__ = ['quasar', 'hds']
@@ -0,0 +1,579 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy import stats
4
+ from joblib import Parallel, delayed
5
+ from sklearn.neighbors import BallTree
6
+ from sklearn.cluster import MiniBatchKMeans, AgglomerativeClustering
7
+ from sklearn.random_projection import GaussianRandomProjection
8
+ from sklearn.decomposition import PCA
9
+ import scipy.cluster.hierarchy as shc
10
+ import time
11
+ import warnings
12
+
13
+ # globals
14
+ warnings.filterwarnings('ignore', category=UserWarning)
15
+ epsilon = 1e-12
16
+
17
+ ### misc helper functions
18
+
19
+ def sample_hypersphere(n_dimensions, radius, n_samples_in_sphere, radius_qmc_sequence):
20
+ '''
21
+ Objective:
22
+ - Samples unit hyperspheres using Marsaglia polar vectors scaled by a QMC sequence.
23
+ '''
24
+
25
+ # generate normal distribution (for angular direction)
26
+ samples = np.random.normal(size=(n_samples_in_sphere, n_dimensions))
27
+
28
+ # normalize vectors to get points on the surface of a unit sphere (direction)
29
+ squared_norms = np.sum(samples**2, axis=1)
30
+ inv_norms = 1.0 / (np.sqrt(squared_norms) + epsilon)
31
+
32
+ # efficiently apply directions (broadcasting faster than division)
33
+ samples = samples * inv_norms[:, np.newaxis]
34
+
35
+ # use input QMC sequence for radius scaling (r^(1/n))
36
+ random_radii_qmc = np.power(radius_qmc_sequence, (1.0 / n_dimensions))
37
+
38
+ # scale and apply final radius
39
+ samples = samples * (random_radii_qmc * radius)[:, np.newaxis]
40
+
41
+ return samples
42
+
43
+ def sample_hyperellipsoid(n_dimensions, n_samples_in_ellipsoid, origin, pca_components, pca_variances, scaling_factor, radius_qmc_sequence=None):
44
+ '''
45
+ Objective:
46
+ - Generates samples inside the hyperellipsoid.
47
+ - Calls the function to sample unit hyperspheres.
48
+ - Transforms the hyperspherical samples to the ellipsoid axes defined using the PCA variances.
49
+ '''
50
+
51
+ # generate samples in unit hypersphere
52
+ unit_sphere_samples = sample_hypersphere(n_dimensions, 1.0, n_samples_in_ellipsoid, radius_qmc_sequence)
53
+
54
+ # axis lengths: L = sqrt(variance + epsilon) * scaling_factor
55
+ axis_lengths = np.sqrt(pca_variances + epsilon) * scaling_factor
56
+
57
+ # scale samples in PCA space and rotates back to original parameter space
58
+ scaled_and_rotated = (unit_sphere_samples * axis_lengths) @ pca_components
59
+
60
+ # translate samples to cluster origin
61
+ ellipsoid_samples = scaled_and_rotated + origin
62
+
63
+ return ellipsoid_samples
64
+
65
+ def sample_in_voids(existing_samples, n_to_fill, bounds_min, bounds_max,
66
+ k_neighbors=5, spread_factor=0.5,
67
+ n_query_max=1000, n_tree_max=10000):
68
+ '''
69
+ Objective:
70
+ - Identify & fill voids in the sample space, using the out-of-bounds sample set.
71
+ - Uses BallTree K-NearestNeighbors to identify voids.
72
+ '''
73
+
74
+ # extract shape
75
+ n_existing, n_dimensions = existing_samples.shape
76
+
77
+ # if no samples to replace
78
+ if n_to_fill <= 0:
79
+ return np.zeros((0, n_dimensions))
80
+
81
+ # if number of neighbors exceeds number of existing samples
82
+ if n_existing < k_neighbors + 1:
83
+ return stats.uniform.rvs(loc=bounds_min, scale=bounds_max - bounds_min, size=(n_to_fill, n_dimensions))
84
+
85
+ # for number of points > 100,000, reduce size to 10,000 for speed
86
+ if n_existing > n_tree_max:
87
+ tree_indices = np.random.choice(n_existing, size=n_tree_max, replace=False)
88
+ tree_samples = existing_samples[tree_indices]
89
+ else:
90
+ tree_indices = np.arange(n_existing)
91
+ tree_samples = existing_samples
92
+
93
+ # recalculate n_existing for reduced set
94
+ n_existing_for_tree = tree_samples.shape[0]
95
+
96
+ # reduce dimensionality for speed
97
+ n_rp_components = min(max(10, int(2*np.log2(n_dimensions))), n_dimensions)
98
+ if n_rp_components < n_dimensions:
99
+ rp = GaussianRandomProjection(n_components=n_rp_components)
100
+ existing_samples_reduced = rp.fit_transform(tree_samples)
101
+ else:
102
+ existing_samples_reduced = tree_samples
103
+
104
+ # build BallTree
105
+ start_kdtree_build = time.time()
106
+ tree = BallTree(existing_samples_reduced)
107
+
108
+ # BallTree query on subset
109
+ k_to_query = k_neighbors + 1 # k-th neighbor distance
110
+
111
+ # select random subset of centers to calculate void probability for (query set)
112
+ # query set sampled from the reduced set (existing_samples_reduced)
113
+ if n_existing_for_tree > n_query_max:
114
+ query_subset_indices = np.random.choice(n_existing_for_tree, size=n_query_max, replace=False)
115
+ existing_samples_query = existing_samples_reduced[query_subset_indices]
116
+ else:
117
+ query_subset_indices = np.arange(n_existing_for_tree)
118
+ existing_samples_query = existing_samples_reduced
119
+
120
+ # query the subset against the reduced-sample tree
121
+ # returns distances first, then indices.
122
+ kth_nn_distances_subset, _ = tree.query(existing_samples_query, k=k_to_query,)
123
+
124
+ # extract distance to the k-th nearest neighbor
125
+ kth_nn_distances_subset = kth_nn_distances_subset[:, k_neighbors]
126
+
127
+ # identify void centers
128
+ probabilities = kth_nn_distances_subset / (kth_nn_distances_subset.sum() + epsilon)
129
+ void_center_query_indices = np.random.choice(
130
+ len(query_subset_indices),
131
+ size=n_to_fill,
132
+ p=probabilities,
133
+ replace=True
134
+ )
135
+
136
+ # map back to indices in the tree_samples set
137
+ void_center_tree_indices = query_subset_indices[void_center_query_indices]
138
+
139
+ # map the indices back to the original full-sized existing_samples array
140
+ void_center_full_indices = tree_indices[void_center_tree_indices]
141
+
142
+ # extract centers and spreads from original, full-D data
143
+ chosen_centers = existing_samples[void_center_full_indices]
144
+ # kth_nn_distances_subset is still correct as it corresponds to the query points
145
+ chosen_kth_distances = kth_nn_distances_subset[void_center_query_indices]
146
+
147
+ # calculate spreads and generate samples (in full-D)
148
+ spreads = chosen_kth_distances[:, np.newaxis] * spread_factor + epsilon
149
+ a_params = (bounds_min - chosen_centers) / spreads
150
+ b_params = (bounds_max - chosen_centers) / spreads
151
+ new_samples = stats.truncnorm.rvs(a=a_params, b=b_params, loc=chosen_centers, scale=spreads)
152
+
153
+ return new_samples
154
+
155
+ def fit_pca_for_cluster(cluster_samples, current_origin, initial_samples_std, n_dimensions):
156
+ '''
157
+ Performs PCA on a single cluster's samples or returns a default,
158
+ called in parallel.
159
+ '''
160
+
161
+ # extract shape
162
+ n_cluster_samples = len(cluster_samples)
163
+
164
+ if n_cluster_samples > n_dimensions * 2 and n_cluster_samples > 0:
165
+ pca = PCA(n_components=n_dimensions)
166
+ pca.fit(cluster_samples)
167
+
168
+ return {'origin': current_origin,
169
+ 'components': pca.components_.T,
170
+ 'variances': pca.explained_variance_}
171
+ else:
172
+ # handle empty/too small clusters
173
+ fixed_variance = np.ones(n_dimensions) * initial_samples_std
174
+
175
+ return {'origin': current_origin,
176
+ 'components': np.eye(n_dimensions),
177
+ 'variances': fixed_variance}
178
+
179
+
180
+ # main sample function
181
+
182
+ def sample(n_samples, bounds,
183
+ weights=None, normalize=False,
184
+ n_ellipsoids=None, n_initial_clusters=None, n_initial_qmc=None,
185
+ seed=None, verbose=False):
186
+ '''
187
+ Objective:
188
+ - Generates a Hyperellipsoid Density sample sequence over the specified parameter range.
189
+ Inputs:
190
+ - n_samples: Number of samples to generate.
191
+ - bounds: Bounds of the parameter range.
192
+ - weights: Gaussian weights to influence clusters and final sample locations.
193
+ - normalize: Boolean to scale samples to the original parameter space, or leave normalized from [0,1].
194
+ - n_ellipsoids: Number of hyperellipsoids to sample.
195
+ - Replaces and skips the Agglomerative Hierarchical Clustering (AHC) step.
196
+ - n_initial_clusters: Number of initial clusters to use in calculating number of hyperellipsoids.
197
+ - Redunant if n_ellipsoids is specified.
198
+ - n_initial_qmc: Number of initial QMC samples to use for cluster analysis.
199
+ - seed: Random seed.
200
+ - verbose: Boolean to display stats and plots.
201
+ Outputs:
202
+ - hds_samples: Hyperellipsoid Density sample sequence.
203
+ '''
204
+
205
+ # initialize misc parameters:
206
+ start_time = time.time()
207
+ if seed is None:
208
+ seed = time.time()
209
+ seed = int(round(seed))
210
+ np.random.seed(seed)
211
+
212
+ # initialize sampling parameters:
213
+ n_samples = int(n_samples)
214
+ bounds = np.array(bounds)
215
+ n_dimensions = bounds.shape[0]
216
+
217
+ # n_initial_clusters scaling for D > 100
218
+ if n_initial_clusters is None:
219
+ if n_dimensions <= 500:
220
+ n_initial_clusters = 100 # 100 clusters for <= 500D
221
+ elif n_dimensions < 1000:
222
+ n_initial_clusters = 50 # 50 clusters sees same stddev as 100 for > 500D
223
+ else:
224
+ n_initial_clusters = 25 # 25 clusters sees same stddev as 100 for > 1000D
225
+ n_initial_clusters = int(n_initial_clusters)
226
+
227
+ # number of qmc samples scaling
228
+ n_initial_qmc_max = 2**15
229
+ if n_initial_qmc is None:
230
+ min_qmc_dimensions = int(2**np.ceil(np.log2(n_dimensions*200)))
231
+ n_initial_qmc = min(min_qmc_dimensions, n_initial_qmc_max)
232
+
233
+ # keep data normalized (0 to 1) for clustering
234
+ bounds_min_orig = bounds[:, 0]
235
+ bounds_max_orig = bounds[:, 1]
236
+ working_bounds_min = np.zeros(n_dimensions)
237
+ working_bounds_max = np.ones(n_dimensions)
238
+
239
+ # generate initial QMC samples:
240
+ qmc_start_time = time.time()
241
+ initial_sobol_sampler = stats.qmc.Sobol(d=n_dimensions, seed=np.random.randint(0, 1000))
242
+ initial_samples_unit = initial_sobol_sampler.random(n=n_initial_qmc)
243
+ initial_samples = initial_samples_unit
244
+
245
+ # calculate sample weights based on input
246
+ sample_weights = np.ones(initial_samples.shape[0])
247
+ if weights:
248
+ initial_samples_denorm = stats.qmc.scale(initial_samples, bounds_min_orig, bounds_max_orig)
249
+ for dim, info in weights.items():
250
+ center = info['center']
251
+ std = info['std']
252
+ if not std > 0:
253
+ raise ValueError(f'Gaussian weight stddevs must be > 0.')
254
+ return None
255
+ dim_values = initial_samples_denorm[:, dim]
256
+ gaussian_weights = stats.norm.pdf(dim_values, loc=center, scale=std)
257
+
258
+ gaussian_weights += epsilon
259
+ sample_weights *= gaussian_weights
260
+
261
+ if verbose and n_ellipsoids is None:
262
+ print('Calculating ellipsoid density.')
263
+
264
+ # determine number of ellipsoids via agglomerative clustering
265
+ # KMeans to get stable sub-cluster centers
266
+ kmeans_initial = MiniBatchKMeans(n_clusters=n_initial_clusters, random_state=seed, n_init=6)
267
+ kmeans_initial.fit(initial_samples, sample_weight=sample_weights)
268
+ initial_centroids = kmeans_initial.cluster_centers_
269
+
270
+ # skip agglomerative clustering if number of initial clusters is provided
271
+ linkage_matrix = None
272
+ optimal_distance = None
273
+ if n_ellipsoids is not None and n_ellipsoids >= 1:
274
+ n_hyperellipsoids = n_ellipsoids
275
+ else:
276
+ # hierarchical clustering on the centroids to find natural grouping
277
+ linkage_matrix = shc.linkage(initial_centroids, method='ward')
278
+
279
+ # find optimal cut-off distance (d) based on largest jump
280
+ distances = linkage_matrix[:, 2]
281
+ optimal_distance = 0
282
+ if len(distances) > 2:
283
+ diffs = distances[1:] - distances[:-1]
284
+ max_diff_index = np.argmax(diffs)
285
+ optimal_distance = distances[max_diff_index + 1]
286
+
287
+ # in case initial sample clusters are uniform
288
+ if optimal_distance < 0.1:
289
+ optimal_distance = 0.5
290
+
291
+ # agglomerative clustering to determine k at the optimal distance
292
+ agg_model = AgglomerativeClustering(n_clusters=None, distance_threshold=optimal_distance, linkage='ward')
293
+ agg_model.fit(initial_centroids)
294
+ n_hyperellipsoids = agg_model.n_clusters_
295
+
296
+ else:
297
+ n_hyperellipsoids = 1
298
+ n_hyperellipsoids = max(1, n_hyperellipsoids)
299
+
300
+ # K-Means again with optimal n_hyperellipsoids to find final centers
301
+ kmeans = MiniBatchKMeans(n_clusters=n_hyperellipsoids, random_state=seed, n_init=6)
302
+ kmeans.fit(initial_samples, sample_weight=sample_weights)
303
+ origins = kmeans.cluster_centers_
304
+ cluster_labels = kmeans.labels_
305
+
306
+ # pre-calculate sample stddev
307
+ initial_samples_std = np.std(initial_samples)
308
+
309
+ # prepare inputs for the parallel loop
310
+ cluster_data_inputs = []
311
+ for i in range(n_hyperellipsoids):
312
+ cluster_samples = initial_samples[cluster_labels == i]
313
+ current_origin = origins[i]
314
+
315
+ # recenter origin if the cluster is empty
316
+ if len(cluster_samples) == 0:
317
+ current_origin = initial_samples[np.random.randint(len(initial_samples))]
318
+
319
+ cluster_data_inputs.append((cluster_samples, current_origin, initial_samples_std, n_dimensions))
320
+
321
+ # calculate hyperellipsoid shapes via PCA (parallelized)
322
+ if verbose:
323
+ print(f'Orienting axes.')
324
+ ellipsoid_params = Parallel(n_jobs=-1)(
325
+ delayed(fit_pca_for_cluster)(*args) for args in cluster_data_inputs
326
+ )
327
+
328
+ # recalculate cluster counts from labels, for proportional sampling
329
+ cluster_sample_counts = np.array([np.sum(cluster_labels == i) for i in range(n_hyperellipsoids)])
330
+
331
+ # distribute samples proportionally to cluster size
332
+ total_cluster_count = np.sum(cluster_sample_counts)
333
+ if total_cluster_count == 0:
334
+ n_samples_per_ellipsoid = np.ones(n_hyperellipsoids, dtype=int)
335
+ else:
336
+ n_samples_per_ellipsoid = np.round(n_samples * (cluster_sample_counts / total_cluster_count)).astype(int)
337
+ n_samples_per_ellipsoid[-1] += n_samples - np.sum(n_samples_per_ellipsoid)
338
+ n_samples_per_ellipsoid = np.maximum(0, n_samples_per_ellipsoid)
339
+
340
+ # generate hyperellipsoid samples (sobol distributed radius):
341
+ hds_samples_normalized = np.zeros((0, n_dimensions))
342
+
343
+ # radius scaling factor; scales with dimension
344
+ confidence_level = 0.9999 # captures 99.99% of cluster's samples
345
+
346
+ # critical value (the statistical radius squared)
347
+ chi2_critical_value = stats.chi2.ppf(confidence_level, df=n_dimensions)
348
+ baseline_factor = 0.55 - 0.01*np.log(n_dimensions) # empirically derived to resample out-of-bounds points
349
+
350
+ # square root as the scaling factor (Mahalanobis distance)
351
+ ellipsoid_scaling_factor = baseline_factor * np.sqrt(chi2_critical_value)
352
+
353
+ # QMC sequence for radius scaling
354
+ radius_qmc_sampler = stats.qmc.Sobol(d=1, seed=seed+1) # offset seed from initial qmc
355
+ radius_qmc_sequence_base = radius_qmc_sampler.random(n=int(n_samples * 2.5)) # generate extra samples
356
+ radius_start_idx = 0
357
+
358
+ # sequentially generate samples from each ellipsoid
359
+ collected_samples = []
360
+ for i, params in enumerate(ellipsoid_params):
361
+ n_to_generate = n_samples_per_ellipsoid[i] * 2
362
+
363
+ # select next chunk of QMC radius sequence
364
+ radius_end_idx = radius_start_idx + n_to_generate
365
+
366
+ # prevent index out of bounds
367
+ if radius_end_idx > len(radius_qmc_sequence_base):
368
+ # use the remainder
369
+ radius_qmc_chunk = radius_qmc_sequence_base[radius_start_idx:].flatten()
370
+ else:
371
+ radius_qmc_chunk = radius_qmc_sequence_base[radius_start_idx:radius_end_idx].flatten()
372
+
373
+ radius_start_idx = radius_end_idx
374
+
375
+ # prevent ValueError from empty array
376
+ if n_to_generate > 0 and radius_qmc_chunk.size == 0:
377
+ continue # skip ellipsoid if this chunk is empty
378
+
379
+ # generate samples inside current ellipsoid
380
+ ellipsoid_samples = sample_hyperellipsoid(n_dimensions,
381
+ n_to_generate,
382
+ params['origin'],
383
+ params['components'],
384
+ params['variances'],
385
+ scaling_factor=ellipsoid_scaling_factor,
386
+ radius_qmc_sequence=radius_qmc_chunk
387
+ )
388
+
389
+ # identify points outside boundaries ([0,1] hypercube)
390
+ in_bounds_mask = np.all(ellipsoid_samples >= 0, axis=1) & np.all(ellipsoid_samples <= 1, axis=1)
391
+ valid_samples = ellipsoid_samples[in_bounds_mask]
392
+
393
+ # add required number of valid samples
394
+ num_to_add = min(n_samples_per_ellipsoid[i], len(valid_samples))
395
+ collected_samples.append(valid_samples[:num_to_add])
396
+
397
+ # vstack
398
+ if collected_samples:
399
+ hds_samples_normalized = np.vstack(collected_samples)
400
+ else:
401
+ hds_samples_normalized = np.zeros((0, n_dimensions))
402
+
403
+ # identify number of points to resample
404
+ n_to_fill = n_samples - len(hds_samples_normalized)
405
+ if n_to_fill > 0:
406
+ if verbose:
407
+ print(f'Geometric void filling {n_to_fill} samples.')
408
+
409
+ # use the existing collected hds samples to find the voids
410
+ k_void_neighbors = min(max(5, int(n_dimensions / 10)), 10)
411
+ void_resamples = sample_in_voids(
412
+ existing_samples=hds_samples_normalized,
413
+ n_to_fill=n_to_fill,
414
+ bounds_min=working_bounds_min,
415
+ bounds_max=working_bounds_max,
416
+ k_neighbors=k_void_neighbors, # k scales with dimension
417
+ spread_factor=0.25 # stay local
418
+ )
419
+
420
+ # combine original hds samples with new void samples
421
+ hds_samples_normalized = np.vstack([hds_samples_normalized, void_resamples])
422
+
423
+ hds_samples_normalized = hds_samples_normalized[:n_samples]
424
+
425
+ if normalize:
426
+ # leave in [0,1] space
427
+ hds_sequence = hds_samples_normalized
428
+ else:
429
+ # scale samples to original bounds
430
+ hds_sequence = hds_samples_normalized * (bounds_max_orig - bounds_min_orig) + bounds_min_orig
431
+
432
+ ### print & plot results:
433
+ if verbose:
434
+ end_time = time.time()
435
+ sample_generation_time = end_time - start_time
436
+
437
+ # visualization imports
438
+ import matplotlib.pyplot as plt
439
+ import seaborn as sns
440
+ from matplotlib.patches import Circle, Rectangle
441
+ sns.set_style('dark')
442
+
443
+ # print results
444
+ print('\nresults:')
445
+ print(' - number of samples:', len(hds_sequence))
446
+ print(f' - sample generation time: {sample_generation_time:.2f}')
447
+ print(f' - number of hyperellipsoids: {n_hyperellipsoids}')
448
+ print(f' - number of initial QMC: {n_initial_qmc}')
449
+ print(f' - number of initial clusters: {n_initial_clusters}')
450
+ if weights:
451
+ print(f' - gaussian weights: {weights}')
452
+
453
+ # generate a sobol sequence for comparison
454
+ sobol_sampler = stats.qmc.Sobol(d=n_dimensions, seed=seed+2) # offset seed to be different from initial qmc
455
+ sobol_samples_unit = sobol_sampler.random(n=n_samples)
456
+ if normalize:
457
+ sobol_samples = sobol_samples_unit
458
+ else:
459
+ sobol_samples = stats.qmc.scale(sobol_samples_unit, bounds_min_orig, bounds_max_orig)
460
+
461
+ # samples stats:
462
+ hds_mean = np.mean(hds_sequence)
463
+ sobol_mean = np.mean(sobol_samples)
464
+ hds_std = np.std(hds_sequence)
465
+ sobol_std = np.std(sobol_samples)
466
+
467
+ print('\nstats:')
468
+ print(f' - mean HDS: {hds_mean:.2f}')
469
+ print(f' - mean comparison QMC: {sobol_mean:.2f}')
470
+ print(f' - stdev HDS: {hds_std:.2f}')
471
+ print(f' - stdev comparison QMC: {sobol_std:.2f}\n')
472
+
473
+ # dendrogram of centroids
474
+ if linkage_matrix is not None:
475
+ plt.figure(figsize=(9, 7))
476
+ plt.title(f'Dendrogram of Initial Centroids: {n_dimensions}D')
477
+
478
+ # using pre-calculated linkage matrix
479
+ shc.dendrogram(linkage_matrix, color_threshold=optimal_distance, above_threshold_color='gray')
480
+ plt.axhline(y=optimal_distance, color='r', linestyle='--', label=f'Optimal Cutoff (k={n_hyperellipsoids})')
481
+ plt.ylabel('Dissimilarity Distance')
482
+ plt.xticks([])
483
+
484
+ plt.legend(loc='upper right')
485
+ plt.show()
486
+
487
+ # plot for 1d samples
488
+ if n_dimensions == 1:
489
+ plt.figure(figsize=(6,5))
490
+ plt.hist(hds_sequence, bins=30, color='deepskyblue', alpha=0.9, label='HDS Samples')
491
+ plt.hist(sobol_samples, bins=30, color='red', alpha=0.5, label='Sobol Samples')
492
+ plt.title('HDS Sample Distribution')
493
+ plt.xlabel('Value')
494
+ plt.ylabel('Frequency')
495
+ plt.legend()
496
+ plt.show()
497
+ return hds_sequence
498
+
499
+
500
+ ### PCA for n_dim > 2:
501
+
502
+ if normalize:
503
+ data_to_plot_raw = initial_samples
504
+ else:
505
+ data_to_plot_raw = stats.qmc.scale(initial_samples, bounds_min_orig, bounds_max_orig)
506
+
507
+ if n_dimensions > 2:
508
+ pca = PCA(n_components=2)
509
+ data_to_plot = pca.fit_transform(data_to_plot_raw)
510
+ hds_sequence_plot = pca.transform(hds_sequence)
511
+ sobol_samples_plot = pca.transform(sobol_samples)
512
+ origins_plot = pca.transform(origins)
513
+ title_str = f'Parameter Space (PCA): n={n_samples}, D={n_dimensions}'
514
+ xlabel_str = f'Principal Component 1 ({pca.explained_variance_ratio_[0]:.1%})'
515
+ ylabel_str = f'Principal Component 2 ({pca.explained_variance_ratio_[1]:.1%})'
516
+ else:
517
+ data_to_plot = data_to_plot_raw
518
+ hds_sequence_plot = hds_sequence
519
+ sobol_samples_plot = sobol_samples
520
+ origins_plot = origins
521
+ title_str = f'Parameter Space: n={n_samples}, D={n_dimensions}'
522
+ xlabel_str = f'Dimension 0'
523
+ ylabel_str = f'Dimension 1'
524
+
525
+ # plot histograms
526
+ fig, ax = plt.subplots(1,2,figsize=(9,5))
527
+ ax[0].hist(hds_sequence.flatten(), bins=30, label='HDS Samples')
528
+ ax[0].set_title('HDS Distribution')
529
+ ax[0].set_ylabel('')
530
+ ax[0].set_yticks([])
531
+ ax[0].set_xticks([])
532
+ ax[0].set_xlabel('')
533
+ ax[1].hist(sobol_samples.flatten(), bins=30, label='Sobol Samples')
534
+ ax[1].set_title('Sobol Distribution')
535
+ ax[1].set_ylabel('')
536
+ ax[1].set_xlabel('')
537
+ ax[1].set_xticks([])
538
+ ax[1].set_yticks([])
539
+
540
+ plt.tight_layout()
541
+ plt.show()
542
+
543
+ # dark visualization parameters for better sample visuals
544
+ plt.rcParams['figure.facecolor'] = 'black'
545
+ plt.rcParams['axes.facecolor'] = 'black'
546
+ plt.rcParams['text.color'] = 'white'
547
+ plt.rcParams['axes.labelcolor'] = 'white'
548
+ plt.rcParams['xtick.color'] = 'white'
549
+ plt.rcParams['ytick.color'] = 'white'
550
+ plt.rcParams['axes.edgecolor'] = 'white'
551
+ plt.rcParams['grid.color'] = 'white'
552
+ plt.rcParams['lines.color'] = 'white'
553
+
554
+ # plot samples in PCA space
555
+ fig, ax = plt.subplots(figsize=(7, 6))
556
+
557
+ # samples
558
+ ax.scatter(data_to_plot[:, 0], data_to_plot[:, 1], zorder=0, color='deepskyblue', label='Initial QMC Data', alpha=0.5, s=1.5)
559
+ ax.scatter(hds_sequence_plot[:, 0], hds_sequence_plot[:, 1], color='yellow', s=5, zorder=5, label='HDS Samples')
560
+ ax.scatter(sobol_samples_plot[:, 0], sobol_samples_plot[:, 1], color='red', s=8, label='Sobol Samples')
561
+
562
+ # data hypercube boundary
563
+ min_plot = np.min(data_to_plot, axis=0)
564
+ max_plot = np.max(data_to_plot, axis=0)
565
+ width = max_plot[0] - min_plot[0]
566
+ height = max_plot[1] - min_plot[1]
567
+ hypercube_boundary = Rectangle((min_plot[0], min_plot[1]), width, height, fill=False, color='deepskyblue', linewidth=1.5, linestyle='--', label='Bounds', zorder=6)
568
+
569
+ ax.add_patch(hypercube_boundary)
570
+ ax.set_title(title_str, fontweight='bold', fontsize=14)
571
+ ax.set_xlabel(xlabel_str)
572
+ ax.set_ylabel(ylabel_str)
573
+ ax.axis(False)
574
+ ax.legend(loc=(0.7,0.75), fontsize=11)
575
+
576
+ plt.tight_layout()
577
+ plt.show()
578
+
579
+ return hds_sequence
@@ -0,0 +1,740 @@
1
+ # global imports
2
+ import numpy as np
3
+ from scipy import stats
4
+ epsilon = 1e-12 # small epsilon to prevent zero-point errors
5
+
6
+
7
+ ### test functions, for local testing ###
8
+
9
+ def rastrigin(x, vectorized=False):
10
+ '''Rastrigin test function, for local testing.'''
11
+
12
+ A = 10 # rastrigin coefficient
13
+
14
+ # check if x is a matrix (2D) or a single vector (1D)
15
+ matrix_flag = x.ndim > 1
16
+ if matrix_flag:
17
+ # for x of (popsize, dimensions)
18
+ n = x.shape[1]
19
+ rastrigin_value = A * n + np.sum(x**2 - A * np.cos(2 * np.pi * x), axis=1)
20
+ else:
21
+ # for single solution vector x
22
+ n = x.shape[0]
23
+ rastrigin_value = A * n + np.sum(x**2 - A * np.cos(2 * np.pi * x))
24
+
25
+ return rastrigin_value
26
+
27
+ def ackley(x, vectorized=False):
28
+ '''Ackley test function, for local testing.'''
29
+
30
+ # check if x is a matrix (2D) or a single vector (1D)
31
+ matrix_flag = x.ndim > 1
32
+ if matrix_flag:
33
+ # for x of (popsize, dimensions)
34
+ n = x.shape[1]
35
+ arg1 = -0.2 * np.sqrt(1/n * np.sum(x**2, axis=1))
36
+ arg2 = 1/n * np.sum(np.cos(2 * np.pi * x), axis=1)
37
+ else:
38
+ # for single solution vector x
39
+ n = x.shape[0]
40
+ arg1 = -0.2 * np.sqrt(1/n * np.sum(x**2))
41
+ arg2 = 1/n * np.sum(np.cos(2 * np.pi * x))
42
+
43
+ ackley_val = -20 * np.exp(arg1) - np.exp(arg2) + 20 + np.exp(1)
44
+
45
+ return ackley_val
46
+
47
+ def sphere(x, vectorized=False):
48
+ '''Sphere test function, for local testing.'''
49
+
50
+ # check if x is a matrix (2D) or a single vector (1D)
51
+ matrix_flag = x.ndim > 1
52
+ if matrix_flag:
53
+ sphere_val = np.sum(x**2, axis=1)
54
+ else:
55
+ sphere_val = np.sum(x**2)
56
+
57
+ return sphere_val
58
+
59
+ def shifted_function(func, shift_vector, vectorized=False):
60
+ '''
61
+ Objective:
62
+ - Shifts the global optimum of the given test function.
63
+
64
+ Inputs:
65
+ - func: Original test function.
66
+ - shift_vector: 1D array of the new optimum.
67
+
68
+ Outputs:
69
+ - shifted_func: New function with the shifted optimum.
70
+ '''
71
+
72
+ def shifted_func(x, vectorized=False):
73
+ return func(x - shift_vector, vectorized=vectorized)
74
+ return shifted_func
75
+
76
+
77
+ ### verbose plotting functions ###
78
+
79
+ def plot_mutations(n_points=100000):
80
+ '''
81
+ Plots the distribution of mutation factors for all mutation strategies.
82
+ '''
83
+
84
+ # ensure integer n_points
85
+ n_points = int(n_points)
86
+
87
+ # import matplotlib
88
+ import matplotlib.pyplot as plt
89
+
90
+ # mutation plot params
91
+ dimensions = 1 # for plotting
92
+ peak_loc = 0.5
93
+ initial_std_loc = 0.25
94
+ local_std = 0.33
95
+
96
+ loc_signs = np.random.choice([-1.0, 1.0], size=(n_points, 1), p=[0.5, 0.5])
97
+ locs = loc_signs * peak_loc
98
+ base_mutations = np.random.normal(loc=0.0, scale=initial_std_loc, size=(n_points, dimensions))
99
+ global_muts = base_mutations + locs
100
+ global_muts_flat = global_muts.flatten()
101
+
102
+ local_muts = np.random.normal(loc=0.0, scale=local_std, size=(n_points, dimensions))
103
+ local_muts_flat = local_muts.flatten()
104
+
105
+ try: # in case seaborn is not imported
106
+ import seaborn as sns
107
+ sns.set_style('dark')
108
+ sns.histplot(x=global_muts_flat, bins=50, edgecolor='black',stat='density',kde=True,color='deepskyblue',alpha=0.85,label='Global')
109
+ sns.histplot(x=local_muts_flat, bins=50, edgecolor='black',stat='density',kde=True,color='darkorange',alpha=0.85,label='Local')
110
+ except:
111
+ plt.rcParams['figure.facecolor'] = 'black'
112
+ plt.rcParams['axes.facecolor'] = 'black'
113
+ plt.rcParams['text.color'] = 'white'
114
+ plt.rcParams['axes.labelcolor'] = 'white'
115
+ plt.rcParams['xtick.color'] = 'white'
116
+ plt.rcParams['ytick.color'] = 'white'
117
+ plt.rcParams['axes.edgecolor'] = 'white'
118
+ plt.rcParams['grid.color'] = 'white'
119
+ plt.rcParams['lines.color'] = 'white'
120
+ plt.hist(global_muts_flat, bins=50, edgecolor='black',density=True,color='deepskyblue',alpha=0.85,label='Global')
121
+ plt.hist(local_muts_flat, bins=50, edgecolor='black',density=True,color='darkorange',alpha=0.85,label='Local')
122
+ finally:
123
+ plt.title('Mutation Factor Distribution',fontsize=16)
124
+ plt.xlabel('Mutation Factor',fontsize=15)
125
+ plt.ylabel('Frequency',fontsize=15)
126
+ plt.legend(fontsize=15)
127
+
128
+ plt.tight_layout()
129
+ plt.show()
130
+
131
+
132
+ def plot_trajectories(obj_function, pop_history, best_history, bounds, num_to_plot):
133
+ '''
134
+ Plots the solution position history.
135
+ '''
136
+
137
+ from sklearn.decomposition import PCA
138
+ import matplotlib.pyplot as plt
139
+
140
+ # visualization params
141
+ plt.rcParams['figure.facecolor'] = 'black'
142
+ plt.rcParams['axes.facecolor'] = 'black'
143
+ plt.rcParams['text.color'] = 'white'
144
+ plt.rcParams['axes.labelcolor'] = 'white'
145
+ plt.rcParams['xtick.color'] = 'white'
146
+ plt.rcParams['ytick.color'] = 'white'
147
+ plt.rcParams['axes.edgecolor'] = 'white'
148
+ plt.rcParams['grid.color'] = 'white'
149
+ plt.rcParams['lines.color'] = 'white'
150
+
151
+ original_dims = bounds.shape[0]
152
+
153
+ # convert to arrays
154
+ plot_pop_history = np.array(pop_history)
155
+ plot_best_history = np.array(best_history)
156
+
157
+ # ensure bounds array has more than 2 dimensions
158
+ if original_dims > 2:
159
+ pca = PCA(n_components=2)
160
+
161
+ # reshape data to fit PCA
162
+ all_data = plot_pop_history.reshape(-1, original_dims)
163
+
164
+ # fit PCA on population history
165
+ pca.fit(all_data)
166
+
167
+ # reshape data
168
+ num_generations = plot_pop_history.shape[0]
169
+ popsize = plot_pop_history.shape[1]
170
+
171
+ # transform and reshape dadta
172
+ plot_pop_history = pca.transform(all_data).reshape(num_generations, popsize, 2)
173
+ plot_best_history = pca.transform(plot_best_history)
174
+
175
+ # adjust bounds
176
+ combined_transformed_data = np.concatenate([plot_pop_history.reshape(-1, 2), plot_best_history], axis=0)
177
+
178
+ # ensure combined data is not empty
179
+ if combined_transformed_data.size > 0:
180
+ min_vals = np.min(combined_transformed_data, axis=0)
181
+ max_vals = np.max(combined_transformed_data, axis=0)
182
+ x_min, x_max = min_vals[0], max_vals[0]
183
+ y_min, y_max = min_vals[1], max_vals[1]
184
+ else:
185
+ x_min, x_max = -1, 1
186
+ y_min, y_max = -1, 1
187
+
188
+ else:
189
+ x_min, x_max = bounds[0, 0], bounds[0, 1]
190
+ y_min, y_max = bounds[1, 0], bounds[1, 1]
191
+
192
+ plt.figure(figsize=(9, 7))
193
+ plt.xlabel('Principal Component 0')
194
+ plt.ylabel('Principal Component 1')
195
+ plt.title('Solution Trajectories')
196
+
197
+ # objective function contour plot
198
+ x = np.linspace(x_min, x_max, 100)
199
+ y = np.linspace(y_min, y_max, 100)
200
+ X, Y = np.meshgrid(x, y)
201
+ xy_coords = np.vstack([X.ravel(), Y.ravel()]).T
202
+
203
+ if plot_pop_history is not None:
204
+ indices_to_plot = np.random.choice(plot_pop_history.shape[1], min(num_to_plot, plot_pop_history.shape[1]), replace=False)
205
+
206
+ for i in indices_to_plot:
207
+ x_coords = plot_pop_history[:, i, 0]
208
+ y_coords = plot_pop_history[:, i, 1]
209
+ plt.plot(x_coords, y_coords, linestyle='-', marker='o', markersize=3, alpha=0.67, zorder=1)
210
+
211
+ # plot path of best solution
212
+ if plot_best_history is not None:
213
+ x_coords = plot_best_history[:, 0]
214
+ y_coords = plot_best_history[:, 1]
215
+ plt.plot(x_coords, y_coords, linestyle='-', marker='x', markersize=8, color='red', label='Best Solution Trajectory',
216
+ alpha=0.85, zorder=2)
217
+ plt.scatter(x_coords[0], y_coords[0], color='cyan', marker='d', s=300, label='Initial Best Solution', alpha=0.8, zorder=5)
218
+ plt.scatter(x_coords[-1], y_coords[-1], color='cyan', marker='X', s=350, label='Final Best Solution', alpha=0.8, zorder=5)
219
+
220
+ plt.legend(fontsize=10,markerscale=0.67)
221
+ plt.grid(False)
222
+ plt.show()
223
+
224
+
225
+ ### evolution algorithm ###
226
+
227
+ def initialize_population(popsize, bounds, init, hds_weights, seed, verbose):
228
+ '''
229
+ Objective:
230
+ - Initializes a population using Sobol, Adaptive Hyperellipsoid, or a custom population.
231
+ '''
232
+
233
+ # misc extracts
234
+ n_dimensions = bounds.shape[0]
235
+
236
+ # if input is not a string assume it is the initial population
237
+ if isinstance(init, str):
238
+ init = init.lower() # ensure lowercase string
239
+
240
+ # generate adaptive hypersphere sequence
241
+ if init == 'hds':
242
+ # import hds
243
+ try:
244
+ from . import hyperellipsoid_sampling as hds
245
+ except ImportError:
246
+ import hyperellipsoid_sampling as hds
247
+
248
+ # generate samples
249
+ if verbose:
250
+ print('Initializing Hyperellipsoid vectors.')
251
+ initial_population = hds.sample(popsize, bounds, weights=hds_weights,
252
+ seed=seed, verbose=False)
253
+
254
+ # generate sobol sequence
255
+ elif init == 'sobol':
256
+ if verbose:
257
+ print('Initializing Sobol vectors.')
258
+ import warnings
259
+ warnings.filterwarnings('ignore', category=UserWarning) # ignore power-of-2 warning
260
+ sobol_sampler = stats.qmc.Sobol(d=n_dimensions, seed=seed)
261
+ sobol_samples_unit = sobol_sampler.random(n=popsize)
262
+ initial_population = stats.qmc.scale(sobol_samples_unit, bounds[:, 0], bounds[:, 1])
263
+
264
+ elif init == 'lhs':
265
+ if verbose:
266
+ print('Initializing Latin Hypercube vectors.')
267
+ lhs_sampler = stats.qmc.LatinHypercube(d=n_dimensions, seed=seed)
268
+ lhs_samples_unit = lhs_sampler.random(n=popsize)
269
+ initial_population = stats.qmc.scale(lhs_samples_unit, bounds[:, 0], bounds[:, 1])
270
+
271
+ elif init == 'random':
272
+ if verbose:
273
+ print('Initializing random vectors.')
274
+ initial_population = np.random.uniform(low=bounds[:, 0], high=bounds[:, 1], size=(popsize, n_dimensions))
275
+ else:
276
+ initial_population = init
277
+ if verbose:
278
+ print('Initializing custom population.')
279
+
280
+ return initial_population
281
+
282
+ def evolve_generation(obj_function, population, fitnesses, best_solution,
283
+ bounds, entangle_rate, generation, maxiter,
284
+ vectorized, *args):
285
+ '''
286
+ Objective:
287
+ - Evolves the population for the current generation.
288
+ - Dynamic crossover rate:
289
+ - Worst solution CR = 1.0, best solution CR = 0.33.
290
+ - Local & global mutation factor distributions.
291
+ - Can be displayed using the 'plot_mutations' function.
292
+ - Greedy selection:
293
+ - New solution vector is chosen as the better between of trial and current vectors.
294
+ - Covariance reinitialization is handled externally.
295
+ '''
296
+
297
+ # crossover parameters
298
+ base_crossover_proba = 1.0
299
+ min_crossover_proba = 0.33
300
+
301
+ if vectorized:
302
+ dimensions, popsize = population.shape
303
+
304
+ # select random entangled partners for all solutions
305
+ random_indices = np.random.randint(0, popsize, size=popsize)
306
+ entangled_partners = population[:, random_indices]
307
+
308
+ # global mutation factor:
309
+ global_std = 0.25
310
+ global_peaks = 0.5
311
+ is_positive_peak = np.random.choice([True, False], p=[0.5, 0.5])
312
+ global_mutation = np.random.normal(loc=global_peaks, scale=global_std,
313
+ size=(dimensions,1)) if is_positive_peak else np.random.normal(loc=-global_peaks,
314
+ scale=global_std, size=(dimensions,1))
315
+
316
+ # local mutation factor:
317
+ local_mutation_std = 0.33
318
+ local_mutation = np.random.normal(0.0, local_mutation_std, size=(dimensions,1))
319
+
320
+ # best solution as a column vector (dimensions, 1)
321
+ best_solution_broadcast = best_solution[:, np.newaxis]
322
+
323
+ # identify solution indices to use Spooky-Best strategy
324
+ local_indices = np.random.rand(1, popsize) < entangle_rate
325
+
326
+ # global mutations
327
+ mutant_vectors_current = population + global_mutation * (best_solution_broadcast - entangled_partners)
328
+ mutant_vectors_random = entangled_partners + global_mutation * (population - entangled_partners)
329
+
330
+ # 50% chance of using Spooky-Random, otherwise Spooky-Current
331
+ entangled_random_indices = np.random.rand(1, popsize) < 0.5
332
+
333
+ # select between the two global mutations
334
+ global_mutants = np.where(entangled_random_indices, mutant_vectors_random, mutant_vectors_current)
335
+
336
+ # select between local and global mutations
337
+ mutant_vectors = np.where(local_indices, best_solution_broadcast + local_mutation * (population - entangled_partners),
338
+ global_mutants)
339
+
340
+ # rank solutions by fitness
341
+ sorted_indices = np.argsort(fitnesses)
342
+ ranks = np.zeros_like(fitnesses)
343
+ ranks[sorted_indices] = np.arange(popsize)
344
+ max_rank = popsize - 1
345
+
346
+ # calculate adaptive crossover rates
347
+ relative_fitness = (max_rank - ranks) / max_rank
348
+ adaptive_crossover_proba_raw = (1 - base_crossover_proba) + base_crossover_proba * relative_fitness
349
+ adaptive_crossover_proba = np.maximum(adaptive_crossover_proba_raw, min_crossover_proba)
350
+
351
+ # apply crossover to create trial vectors
352
+ crossover_indices = np.random.rand(dimensions, popsize) < adaptive_crossover_proba
353
+ trial_vectors = np.where(crossover_indices, mutant_vectors, population)
354
+
355
+ # clip trial vectors to bounds
356
+ trial_vectors = np.clip(trial_vectors, bounds[:, np.newaxis, 0], bounds[:, np.newaxis, 1])
357
+
358
+ # steady-state elitism selection
359
+ trial_fitnesses = obj_function(trial_vectors.T, *args)
360
+ selection_indices = trial_fitnesses < fitnesses
361
+
362
+ new_population = np.where(selection_indices[np.newaxis, :], trial_vectors, population)
363
+ new_fitnesses = np.where(selection_indices, trial_fitnesses, fitnesses)
364
+
365
+ return new_population, new_fitnesses
366
+
367
+ else:
368
+ # extract shape
369
+ popsize, dimensions = population.shape
370
+
371
+ # initialize arrays
372
+ new_population = np.zeros_like(population)
373
+ new_fitnesses = np.zeros_like(fitnesses)
374
+
375
+ # global mutation factor
376
+ global_std = 0.25
377
+ global_peaks = 0.5
378
+ is_positive_peak = np.random.choice([True, False], p=[0.5, 0.5])
379
+ global_mutation = np.random.normal(loc=global_peaks, scale=global_std,
380
+ size=dimensions) if is_positive_peak else np.random.normal(loc=-global_peaks,
381
+ scale=global_std, size=dimensions)
382
+
383
+ # local mutation factor
384
+ local_mutation_std = 0.33
385
+ local_mutation = np.random.normal(0.0, local_mutation_std, size=dimensions)
386
+
387
+ # adaptive crossover calculations
388
+ sorted_indices = np.argsort(fitnesses)
389
+ ranks = np.zeros_like(fitnesses)
390
+ ranks[sorted_indices] = np.arange(popsize)
391
+ max_rank = popsize - 1
392
+ relative_fitness = (max_rank - ranks) / max_rank
393
+ adaptive_crossover_proba_raw = (1 - base_crossover_proba) + base_crossover_proba * relative_fitness
394
+ adaptive_crossover_proba = np.maximum(adaptive_crossover_proba_raw, min_crossover_proba)
395
+
396
+ # loop through each solution in population
397
+ for i in range(popsize):
398
+ solution = population[i]
399
+ current_fitness = fitnesses[i]
400
+
401
+ # select random 'entangled' partner indices
402
+ random_index = np.random.randint(0, popsize)
403
+ entangled_partner = population[random_index]
404
+
405
+ # apply mutations
406
+ if np.random.rand() < entangle_rate:
407
+ mutant_vector = best_solution + local_mutation * (solution - entangled_partner) # original
408
+ else:
409
+ # 50% chance of moving around current location
410
+ mutant_vector = solution + global_mutation * (best_solution - entangled_partner) # original
411
+ # 50% chance of moving to entangled partner
412
+ if np.random.rand() < 0.5:
413
+ mutant_vector = entangled_partner + global_mutation * (solution - entangled_partner) # original
414
+
415
+ crossover_indices = np.random.rand(dimensions) < adaptive_crossover_proba[i]
416
+ trial_vector = np.where(crossover_indices, mutant_vector, solution)
417
+
418
+ # clip trial vectors to bounds
419
+ trial_vector = np.clip(trial_vector, bounds[:, 0], bounds[:, 1])
420
+
421
+ # steady-state elitism selection
422
+ trial_fitness = obj_function(trial_vector, *args)
423
+
424
+ if trial_fitness < current_fitness:
425
+ new_population[i] = trial_vector
426
+ new_fitnesses[i] = trial_fitness
427
+ else:
428
+ new_population[i] = solution
429
+ new_fitnesses[i] = current_fitness
430
+
431
+ return new_population, new_fitnesses
432
+
433
+ def covariance_reinit(population, current_fitnesses, bounds, vectorized):
434
+ '''
435
+ Objective:
436
+ - Reinitializes the worst 33% solutions in the population.
437
+ - Locations are determined based on a Gaussian distribution from the covariance matrix of 25% best solutions.
438
+ - Noise is added to enhance diversity.
439
+ - Probability decays to 33% at the 33% generation.
440
+ - Conceptualizes particles tunneling to a more stable location.
441
+ '''
442
+
443
+ # reshape depending on vectorized input
444
+ if vectorized:
445
+ dimensions, popsize = population.shape
446
+ else:
447
+ popsize, dimensions = population.shape
448
+
449
+ # handle case where not enough points for covariance matrix
450
+ if popsize < dimensions + 1:
451
+ return population
452
+
453
+ # keep 25% of best solutions
454
+ num_to_keep_factor = 0.25
455
+ num_to_keep = int(popsize * num_to_keep_factor)
456
+ if num_to_keep <= dimensions:
457
+ num_to_keep = dimensions + 1 # minimum sample size scaled by dimensions
458
+
459
+ # identify best solutions to calculate covariance gaussian model
460
+ sorted_indices = np.argsort(current_fitnesses)
461
+ best_indices = sorted_indices[:num_to_keep]
462
+ if vectorized:
463
+ best_solutions = population[:, best_indices]
464
+ else:
465
+ best_solutions = population[best_indices]
466
+
467
+ # learn full-covariance matrix
468
+ if vectorized:
469
+ mean_vector = np.mean(best_solutions, axis=1)
470
+ cov_matrix = np.cov(best_solutions)
471
+ else:
472
+ mean_vector = np.mean(best_solutions, axis=0)
473
+ cov_matrix = np.cov(best_solutions, rowvar=False)
474
+
475
+ # add epsilon to the diagonal to prevent singular matrix issues
476
+ cov_matrix += np.eye(dimensions) * epsilon
477
+
478
+ # identify solutions to be reset
479
+ reset_population = 0.33
480
+ num_to_replace = int(popsize * reset_population)
481
+ worst_indices = sorted_indices[-num_to_replace:]
482
+
483
+ # new solutions sampled from multivariate normal distribution
484
+ if vectorized:
485
+ new_solutions_sampled = np.random.multivariate_normal(mean=mean_vector, cov=cov_matrix, size=num_to_replace).T
486
+ else:
487
+ new_solutions_sampled = np.random.multivariate_normal(mean=mean_vector, cov=cov_matrix, size=num_to_replace)
488
+
489
+ # add noise for exploration
490
+ noise_scale = (bounds[:, 1] - bounds[:, 0]) / 20.0
491
+ if vectorized:
492
+ noise = np.random.normal(0, noise_scale[:, np.newaxis], size=new_solutions_sampled.shape)
493
+ new_solutions = new_solutions_sampled + noise
494
+ else:
495
+ noise = np.random.normal(0, noise_scale, size=new_solutions_sampled.shape)
496
+ new_solutions = new_solutions_sampled + noise
497
+
498
+ # clip new solutions to bounds
499
+ if vectorized:
500
+ population[:, worst_indices] = np.clip(new_solutions, bounds[:, np.newaxis, 0], bounds[:, np.newaxis, 1])
501
+ else:
502
+ population[worst_indices] = np.clip(new_solutions, bounds[:, 0], bounds[:, 1])
503
+
504
+ return population
505
+
506
+ def optimize(func, bounds, args=(),
507
+ init='sobol', popsize=None, maxiter=100,
508
+ entangle_rate=0.33, polish=True,
509
+ patience=np.inf, vectorized=False,
510
+ verbose=True, num_to_plot=10,
511
+ hds_weights=None,
512
+ seed=None):
513
+ '''
514
+ Objective:
515
+ - Searches for the optimal solution to minimize the objective function.
516
+ - Conceptualizes quantum stellar particles searching for the most stable energy state.
517
+ - Mutation factors can be visualized with plot_mutations().
518
+ Inputs:
519
+ - func: Objective function to minimize.
520
+ - bounds: Parameter bounds.
521
+ - *args: Input arguments for objective function.
522
+ - init: Initial population sampling method.
523
+ - Options are 'sobol', 'random', 'hds', 'lhs', or a custom sample sequence.
524
+ - Defaults to 'sobol'.
525
+ - 'hds' to accelerate convergence with slower initialization.
526
+ - popsize: Number of solution vectors to evolve.
527
+ - Defaults to 10 * n_dimensions.
528
+ - maxiter: Number of generations to evolve.
529
+ - entangle_rate: Probability of solutions using the local Spooky-Best mutation strategy.
530
+ - Defaults to 0.33. This causes to the three mutation strategies to be applied equally.
531
+ - Decreasing leads to higher exploration.
532
+ - patience: Number of generations without improvement before early convergence.
533
+ - polish: Final polishing step using 'L-BFGS-B' minimization.
534
+ - vectorized: Boolean to accept vectorized objective functions.
535
+ - verbose: Displays prints and plots.
536
+ - num_to_plot: Number of solution trajectories to display in the verbose plot.
537
+ Outputs:
538
+ - (best_solution, best_fitness) tuple:
539
+ - best_solution: Best solution found.
540
+ - best_fitness: Fitness of the optimal solution.
541
+ '''
542
+
543
+ # set random seed
544
+ if seed == None:
545
+ np.random.seed()
546
+ else:
547
+ np.random.seed(seed)
548
+
549
+ # handle case where time is not imported
550
+ if verbose:
551
+ try:
552
+ import time
553
+ start_time = time.time()
554
+ except:
555
+ pass
556
+
557
+ # lowercase initialization
558
+ if type(init) == str:
559
+ init = init.lower()
560
+
561
+ # raise errors for invalid inputs:
562
+ # entangle rate error
563
+ if not 0.0 <= entangle_rate <= 1.0:
564
+ raise ValueError('Entanglement rate must be between [0,1].')
565
+
566
+ # initialization error
567
+ if (type(init) == str) and init not in ['sobol','hds','random','lhs']:
568
+ raise ValueError("Initial sampler must be one of ['sobol','random','ahs','lhs'], or a custom population.")
569
+
570
+ # patience error
571
+ if patience <= 1:
572
+ raise ValueError('Patience must be > 1 generation.')
573
+
574
+ # initialize histories
575
+ pop_history = []
576
+ best_history = []
577
+
578
+ # ensure bounds is array; shape (n_dimensions,2)
579
+ bounds = np.array(bounds)
580
+ n_dimensions = bounds.shape[0]
581
+
582
+ # if init is not a string, assume it is a custom population
583
+ if not isinstance(init, str):
584
+ popsize = init.shape[0]
585
+
586
+ # default popsize to 10*n_dimensions
587
+ if popsize == None:
588
+ popsize = 10*n_dimensions
589
+
590
+ # ensure integers
591
+ popsize, maxiter = int(popsize), int(maxiter)
592
+
593
+ # generate initial population
594
+ initial_population = initialize_population(popsize, bounds, init, hds_weights, seed, verbose)
595
+ if verbose:
596
+ print('\nEvolving population:')
597
+
598
+ # match differential evolution conventions
599
+ if vectorized:
600
+ initial_population = initial_population.T
601
+ initial_fitnesses = func(initial_population.T, *args)
602
+ else:
603
+ initial_fitnesses = np.array([func(sol, *args) for sol in initial_population])
604
+
605
+ # calculate initial best fitness
606
+ min_fitness_idx = np.argmin(initial_fitnesses)
607
+ initial_best_fitness = initial_fitnesses[min_fitness_idx]
608
+
609
+ # identify initial best solution
610
+ if vectorized:
611
+ initial_best_solution = initial_population[:, min_fitness_idx].copy()
612
+ else:
613
+ initial_best_solution = initial_population[min_fitness_idx].copy()
614
+
615
+ # initialze population and fitnesses
616
+ population = initial_population
617
+ current_fitnesses = initial_fitnesses
618
+
619
+ # add initial population to population history
620
+ if verbose:
621
+ if vectorized:
622
+ pop_history.append(population.T.copy())
623
+ else:
624
+ pop_history.append(population.copy())
625
+
626
+ # add initial best solution to best solution history
627
+ best_history.append(initial_best_solution.copy())
628
+
629
+ # initialize best solution and fitness
630
+ best_solution = initial_best_solution
631
+ best_fitness = initial_best_fitness
632
+
633
+ # iterate through generations
634
+ last_improvement_gen = 0
635
+ for generation in range(maxiter):
636
+
637
+ # evolve population
638
+ if vectorized:
639
+ population, current_fitnesses = evolve_generation(func, population, current_fitnesses, best_solution, bounds,
640
+ entangle_rate, generation, maxiter, vectorized, *args)
641
+ else:
642
+ population, current_fitnesses = evolve_generation(func, population, current_fitnesses, best_solution, bounds,
643
+ entangle_rate, generation, maxiter, vectorized, *args)
644
+
645
+ # update best solution found
646
+ min_fitness_idx = np.argmin(current_fitnesses)
647
+ current_best_fitness = current_fitnesses[min_fitness_idx]
648
+
649
+ # identify current best solution
650
+ if vectorized:
651
+ current_best_solution = population[:, min_fitness_idx].copy()
652
+ else:
653
+ current_best_solution = population[min_fitness_idx].copy()
654
+
655
+ # update best known solution
656
+ if current_best_fitness < best_fitness:
657
+ best_fitness = current_best_fitness
658
+ best_solution = current_best_solution
659
+ last_improvement_gen = 0
660
+ else:
661
+ last_improvement_gen += 1
662
+
663
+ # apply asymptotic covariance reinitialization to population
664
+ final_proba = 0.33
665
+ decay_generation = 0.33
666
+ reinit_proba = np.e**((np.log(final_proba)/(decay_generation*maxiter))*generation)
667
+ if np.random.rand() < reinit_proba:
668
+ population = covariance_reinit(population, current_fitnesses, bounds, vectorized=vectorized)
669
+
670
+ # clip population to bounds
671
+ if vectorized:
672
+ population = np.clip(population, bounds[:, np.newaxis, 0], bounds[:, np.newaxis, 1])
673
+ else:
674
+ population = np.clip(population, bounds[:, 0], bounds[:, 1])
675
+
676
+ # add to population history
677
+ if verbose:
678
+ if vectorized:
679
+ pop_history.append(population.T.copy())
680
+ else:
681
+ pop_history.append(population.copy())
682
+ best_history.append(best_solution.copy())
683
+
684
+ # print generation status
685
+ if verbose:
686
+ stdev = np.std(current_fitnesses)
687
+ print(f' Gen. {generation+1}/{maxiter} | f(x)={best_fitness:.2e} | stdev={stdev:.2e} | reinit={reinit_proba:.2f}')
688
+
689
+ # patience for early convergence
690
+ if (generation - last_improvement_gen) > patience:
691
+ if verbose:
692
+ print(f'\nEarly convergence: generations without improvement exceeds patience ({patience}).')
693
+ break
694
+
695
+ # polish final solution step via L-BFGS-B
696
+ if polish:
697
+ if verbose:
698
+ print('Polishing solution.')
699
+ try:
700
+ from scipy.optimize import minimize
701
+ if vectorized:
702
+ res = minimize(func, best_solution, args=args, bounds=bounds, method='L-BFGS-B', tol=0)
703
+ else:
704
+ res = minimize(func, best_solution, args=args, bounds=bounds, method='L-BFGS-B', tol=0)
705
+ if res.success:
706
+ best_solution = res.x
707
+ best_fitness = res.fun
708
+ except Exception as e:
709
+ print(f'Polishing failed: {e}')
710
+
711
+ # final solution prints
712
+ if verbose:
713
+ print('\nResults:')
714
+
715
+ # print best fitness
716
+ print(f'- f(x): {best_fitness:.2e}')
717
+
718
+ #print best solution
719
+ if len(best_solution)>3:
720
+ formatted_display = ', '.join([f'{val:.2e}' for val in best_solution[:3]])
721
+ print(f'- Solution: [{formatted_display}, ...]')
722
+ else:
723
+ formatted_display = ', '.join([f'{val:.2e}' for val in best_solution])
724
+ print(f'- Solution: [{formatted_display}]')
725
+
726
+ # print optimization time
727
+ try:
728
+ print(f'- Elapsed: {(time.time() - start_time):.3f}s')
729
+ except Exception as e:
730
+ print(f'- Elapsed: null') # case where time isn't imported
731
+
732
+ # plotting
733
+ print()
734
+ try:
735
+ plot_trajectories(func, pop_history, best_history, bounds, num_to_plot)
736
+
737
+ except Exception as e:
738
+ print(f'Failed to generate plots: {e}')
739
+
740
+ return (best_solution, best_fitness)
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: hdim_opt
3
+ Version: 1.0.0
4
+ Summary: High-Dimensional Optimization Suite: contains QUASAR optimizer and HDS sampling algorithms.
5
+ Author-email: Julian Soltes <jsoltes@regis.edu>
6
+ License: MIT
7
+ Keywords: optimization,high-dimensional,sampling,QUASAR,HDS
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: numpy
19
+ Requires-Dist: scipy
20
+ Provides-Extra: hds
21
+ Requires-Dist: pandas; extra == "hds"
22
+ Requires-Dist: scikit-learn; extra == "hds"
23
+ Requires-Dist: joblib; extra == "hds"
@@ -0,0 +1,7 @@
1
+ hdim_opt/__init__.py,sha256=pMI8O7BXPx6ldK98DCsrjGUEZIjlvTDayhO0ceoa--I,263
2
+ hdim_opt/hyperellipsoid_sampling.py,sha256=B8A238hL9bQxVS7R4OjMGRnM6_UyuT70QmE1IR957Lw,24387
3
+ hdim_opt/quasar_optimization.py,sha256=6UwLwyG5H-Q_lgeSHVPsHJe2mW4adhniOinEPx6_sb0,29075
4
+ hdim_opt-1.0.0.dist-info/METADATA,sha256=Wa7wnKwCHwhgsZXe1IS_bjkHhrmseYQfdOCRveN9Fa4,944
5
+ hdim_opt-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ hdim_opt-1.0.0.dist-info/top_level.txt,sha256=1KtWo9tEfEK3GC8D43cwVsC8yVG2Kc-9pl0hhcDjw4o,9
7
+ hdim_opt-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ hdim_opt