hdim-opt 1.0.0__tar.gz

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,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,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