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.
- hdim_opt-1.0.0/PKG-INFO +23 -0
- hdim_opt-1.0.0/hdim_opt/__init__.py +11 -0
- hdim_opt-1.0.0/hdim_opt/hyperellipsoid_sampling.py +579 -0
- hdim_opt-1.0.0/hdim_opt/quasar_optimization.py +740 -0
- hdim_opt-1.0.0/hdim_opt.egg-info/PKG-INFO +23 -0
- hdim_opt-1.0.0/hdim_opt.egg-info/SOURCES.txt +9 -0
- hdim_opt-1.0.0/hdim_opt.egg-info/dependency_links.txt +1 -0
- hdim_opt-1.0.0/hdim_opt.egg-info/requires.txt +7 -0
- hdim_opt-1.0.0/hdim_opt.egg-info/top_level.txt +1 -0
- hdim_opt-1.0.0/pyproject.toml +40 -0
- hdim_opt-1.0.0/setup.cfg +4 -0
hdim_opt-1.0.0/PKG-INFO
ADDED
|
@@ -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
|