synthe
Top-level package for synthe.
1"""Top-level package for synthe.""" 2 3__author__ = """T. Moudiki""" 4__email__ = "thierry.moudiki@gmail.com" 5 6from .adaptivehistsampler import AdaptiveHistogramSampler # noqa: F401 7from .diffusion import DiffusionModel 8from .distro_simulator import DistroSimulator # noqa: F401 9from .empirical_copula import EmpiricalCopula # noqa: F401 10from .stratified_sampling import StratifiedClusteringSubsampling 11from .row_subsampling import SubSampler 12from .healthsims import SmartHealthSimulator # noqa: F401 13from .metrics import DistanceMetrics # noqa: F401 14from .meboot import MaximumEntropyBootstrap 15from .ts_distro_simulator import TsDistroSimulator # noqa: F401 16from .diversity_generator import DiversityGenerator # noqa: F401 17from .synthetictabular import SyntheticTabularSampler 18from .pcarvflsimulator import PCARVFLSimulator, adequacy_report 19 20__all__ = [ 21 "AdaptiveHistogramSampler", 22 "DiffusionModel", 23 "DistroSimulator", 24 "EmpiricalCopula", 25 "StratifiedClusteringSubsampling", 26 "SubSampler", 27 "SmartHealthSimulator", 28 "DistanceMetrics", 29 "MaximumEntropyBootstrap", 30 "TsDistroSimulator", 31 "DiversityGenerator", 32 "SyntheticTabularSampler", 33 "PCARVFLSimulator", 34 "adequacy_report", 35]
7class AdaptiveHistogramSampler: 8 def __init__(self, n_bins=10, method="quantile", seed=123): 9 self.n_bins = n_bins 10 self.method = method 11 self.rng = np.random.default_rng(seed) 12 self.bin_edges = None 13 self.bin_indices = None 14 self.unique_bins = None 15 self.bin_probs = None 16 self.X = None 17 self.n = None 18 self.d = None 19 20 def fit(self, X): 21 self.X = np.asarray(X) 22 self.n, self.d = self.X.shape 23 24 self.bin_edges = [] 25 for j in range(self.d): 26 xj = self.X[:, j] 27 if self.method == "quantile": 28 edges_j = np.quantile(xj, np.linspace(0, 1, self.n_bins + 1)) 29 else: 30 edges_j = np.linspace(xj.min(), xj.max(), self.n_bins + 1) 31 self.bin_edges.append(edges_j) 32 33 # Assign points to bins 34 bin_idx = np.zeros((self.n, self.d), dtype=int) 35 for j in range(self.d): 36 bin_idx[:, j] = np.digitize(self.X[:, j], self.bin_edges[j]) - 1 37 bin_idx[:, j] = np.clip(bin_idx[:, j], 0, self.n_bins - 1) 38 self.bin_indices = bin_idx 39 40 bin_ids = np.ravel_multi_index( 41 self.bin_indices.T, (self.n_bins,) * self.d 42 ) 43 unique_bins, counts = np.unique(bin_ids, return_counts=True) 44 self.unique_bins = unique_bins 45 self.bin_probs = counts / counts.sum() 46 47 def sample( 48 self, 49 n_samples, 50 oversample=False, 51 oversample_method="bootstrap", 52 jitter_scale=0.05, 53 ): 54 if self.bin_probs is None: 55 raise RuntimeError("You must call `fit` before `sample`.") 56 57 chosen_bins = self.rng.choice( 58 self.unique_bins, size=n_samples, p=self.bin_probs 59 ) 60 61 if not oversample: 62 return self._subsample_existing(chosen_bins) 63 64 if oversample_method == "uniform": 65 return self._oversample_uniform(chosen_bins) 66 elif oversample_method == "bootstrap": 67 return self._oversample_bootstrap(chosen_bins) 68 elif oversample_method == "jitter": 69 return self._oversample_jitter(chosen_bins, jitter_scale) 70 else: 71 raise ValueError(f"Unknown oversample_method: {oversample_method}") 72 73 # --- Internal helpers ------------------------------------------------ 74 def _subsample_existing(self, chosen_bins): 75 bin_ids = np.ravel_multi_index( 76 self.bin_indices.T, (self.n_bins,) * self.d 77 ) 78 X_sampled = [] 79 for b in chosen_bins: 80 idx_in_bin = np.where(bin_ids == b)[0] 81 i = self.rng.choice(idx_in_bin) 82 X_sampled.append(self.X[i]) 83 return np.array(X_sampled) 84 85 def _oversample_uniform(self, chosen_bins): 86 X_sampled = [] 87 for b in chosen_bins: 88 multi_idx = np.unravel_index(b, (self.n_bins,) * self.d) 89 coords = [] 90 for j, bi in enumerate(multi_idx): 91 left = self.bin_edges[j][bi] 92 right = self.bin_edges[j][bi + 1] 93 coords.append(self.rng.uniform(left, right)) 94 X_sampled.append(coords) 95 return np.array(X_sampled) 96 97 def _oversample_bootstrap(self, chosen_bins): 98 return self._subsample_existing(chosen_bins) 99 100 def _oversample_jitter(self, chosen_bins, jitter_scale): 101 base_points = self._subsample_existing(chosen_bins) 102 noise = self.rng.normal(scale=jitter_scale, size=base_points.shape) 103 return base_points + noise 104 105 # --- Visualization ---------------------------------------------------- 106 def plot_comparison(self, X_sampled, bins=30): 107 """Plot joint 2D histogram and marginal distributions (for d=2).""" 108 if self.d != 2: 109 raise ValueError("plot_comparison only supports 2D currently.") 110 111 fig = plt.figure(figsize=(10, 10)) 112 grid = plt.GridSpec(4, 4, hspace=0.3, wspace=0.3) 113 114 main_ax = fig.add_subplot(grid[1:, :-1]) 115 y_hist = fig.add_subplot(grid[0, :-1], sharex=main_ax) 116 x_hist = fig.add_subplot(grid[1:, -1], sharey=main_ax) 117 118 # 2D histogram 119 main_ax.hist2d( 120 self.X[:, 0], self.X[:, 1], bins=bins, alpha=0.5, cmap="Blues" 121 ) 122 main_ax.hist2d( 123 X_sampled[:, 0], X_sampled[:, 1], bins=bins, alpha=0.5, cmap="Reds" 124 ) 125 main_ax.set_xlabel("X1") 126 main_ax.set_ylabel("X2") 127 main_ax.set_title("Joint distribution") 128 129 # Marginals 130 y_hist.hist( 131 self.X[:, 0], bins=bins, color="blue", alpha=0.5, density=True 132 ) 133 y_hist.hist( 134 X_sampled[:, 0], bins=bins, color="red", alpha=0.5, density=True 135 ) 136 x_hist.hist( 137 self.X[:, 1], 138 bins=bins, 139 orientation="horizontal", 140 color="blue", 141 alpha=0.5, 142 density=True, 143 ) 144 x_hist.hist( 145 X_sampled[:, 1], 146 bins=bins, 147 orientation="horizontal", 148 color="red", 149 alpha=0.5, 150 density=True, 151 ) 152 153 y_hist.axis("off") 154 x_hist.axis("off") 155 156 plt.show() 157 158 # --- Goodness of fit tests ------------------------------------------- 159 def goodness_of_fit(self, X_sampled): 160 """ 161 Compare marginals with Kolmogorov–Smirnov and Anderson–Darling tests. 162 Returns dict of test results for each dimension. 163 """ 164 results = {} 165 for j in range(self.d): 166 x_orig = self.X[:, j] 167 x_samp = X_sampled[:, j] 168 169 # KS test 170 ks_stat, ks_p = stats.ks_2samp(x_orig, x_samp) 171 172 # Anderson-Darling test 173 ad_result = stats.anderson_ksamp([x_orig, x_samp]) 174 175 results[f"dim_{j}"] = { 176 "ks_statistic": ks_stat, 177 "ks_pvalue": ks_p, 178 "ad_statistic": ad_result.statistic, 179 "ad_significance_level": ad_result.significance_level, 180 } 181 return results
20 def fit(self, X): 21 self.X = np.asarray(X) 22 self.n, self.d = self.X.shape 23 24 self.bin_edges = [] 25 for j in range(self.d): 26 xj = self.X[:, j] 27 if self.method == "quantile": 28 edges_j = np.quantile(xj, np.linspace(0, 1, self.n_bins + 1)) 29 else: 30 edges_j = np.linspace(xj.min(), xj.max(), self.n_bins + 1) 31 self.bin_edges.append(edges_j) 32 33 # Assign points to bins 34 bin_idx = np.zeros((self.n, self.d), dtype=int) 35 for j in range(self.d): 36 bin_idx[:, j] = np.digitize(self.X[:, j], self.bin_edges[j]) - 1 37 bin_idx[:, j] = np.clip(bin_idx[:, j], 0, self.n_bins - 1) 38 self.bin_indices = bin_idx 39 40 bin_ids = np.ravel_multi_index( 41 self.bin_indices.T, (self.n_bins,) * self.d 42 ) 43 unique_bins, counts = np.unique(bin_ids, return_counts=True) 44 self.unique_bins = unique_bins 45 self.bin_probs = counts / counts.sum()
11class DiffusionModel(BaseEstimator): 12 """ 13 Sklearn-compatible diffusion model with MMD-based and noise-prediction training. 14 15 Implements both traditional DDPM (noise prediction with MSE) and novel MMD-based 16 training that directly minimizes distribution mismatch between true posterior 17 and learned transitions using Maximum Mean Discrepancy. 18 19 Parameters 20 ---------- 21 timesteps : int, default=1000 22 Number of diffusion timesteps 23 beta_start : float, default=0.0001 24 Initial noise variance 25 beta_end : float, default=0.02 26 Final noise variance 27 model : sklearn estimator, optional 28 Base model for reverse process (default: Ridge with alpha=1.0) 29 schedule : {'linear', 'cosine'}, default='linear' 30 Noise schedule type 31 use_pca : bool, default=False 32 Apply PCA for dimensionality reduction (recommended for >100 dims) 33 pca_components : int, default=50 34 Number of PCA components if use_pca=True 35 variance_type : {'fixed_small', 'fixed_large', 'learned'}, default='fixed_small' 36 Variance schedule for reverse process 37 random_state : int, optional 38 Random seed for reproducibility 39 batch_size : int, default=32 40 Batch size for training data generation 41 training_objective : {'noise', 'mmd', 'hybrid'}, default='noise' 42 Training objective: 43 - 'noise': Traditional DDPM noise prediction with MSE loss 44 - 'mmd': Direct MMD minimization between true and learned posteriors 45 - 'hybrid': Combine both objectives 46 mmd_samples_per_step : int, default=10 47 Number of samples to draw per timestep for MMD estimation 48 mmd_kernel : {'rbf', 'imq', 'linear'}, default='rbf' 49 Kernel for MMD computation 50 mmd_bandwidth : float or 'auto', default='auto' 51 Kernel bandwidth (gamma for RBF) 52 53 Examples 54 -------- 55 Traditional noise-prediction training: 56 >>> model = DiffusionModel(timesteps=100, training_objective='noise') 57 >>> model.fit(X, n_steps=1000) 58 59 MMD-based training (distribution matching): 60 >>> model = DiffusionModel(timesteps=100, training_objective='mmd', 61 ... mmd_samples_per_step=20) 62 >>> model.fit(X, n_steps=1000) 63 64 Hybrid approach: 65 >>> model = DiffusionModel(timesteps=100, training_objective='hybrid') 66 >>> model.fit(X, n_steps=1000) 67 """ 68 69 def __init__( 70 self, 71 timesteps: int = 1000, 72 beta_start: float = 0.0001, 73 beta_end: float = 0.02, 74 schedule: Literal["linear", "cosine"] = "linear", 75 model: Optional[BaseEstimator] = None, 76 use_pca: bool = False, 77 pca_components: int = 50, 78 variance_type: Literal[ 79 "fixed_small", "fixed_large", "learned" 80 ] = "fixed_small", 81 random_state: Optional[int] = None, 82 batch_size: int = 32, 83 training_objective: Literal["noise", "mmd", "hybrid"] = "noise", 84 mmd_samples_per_step: int = 10, 85 mmd_kernel: Literal["rbf", "imq", "linear"] = "rbf", 86 mmd_bandwidth: Union[float, str] = "auto", 87 ): 88 self.timesteps = timesteps 89 self.beta_start = beta_start 90 self.beta_end = beta_end 91 self.schedule = schedule 92 self.model = model 93 self.use_pca = use_pca 94 self.pca_components = pca_components 95 self.variance_type = variance_type 96 self.random_state = random_state 97 self.batch_size = batch_size 98 self.training_objective = training_objective 99 self.mmd_samples_per_step = mmd_samples_per_step 100 self.mmd_kernel = mmd_kernel 101 self.mmd_bandwidth = mmd_bandwidth 102 103 # Initialize random state generator 104 self._rng = np.random.RandomState(random_state) 105 106 # Input validation 107 self._validate_parameters() 108 self._init_noise_schedule() 109 110 def _validate_parameters(self) -> None: 111 """Validate input parameters with comprehensive checks""" 112 if self.beta_start >= self.beta_end: 113 raise ValueError("beta_start must be less than beta_end") 114 if self.timesteps <= 0: 115 raise ValueError("timesteps must be positive") 116 if self.batch_size <= 0: 117 raise ValueError("batch_size must be positive") 118 if self.schedule not in ["linear", "cosine"]: 119 raise ValueError("schedule must be 'linear' or 'cosine'") 120 if self.variance_type not in ["fixed_small", "fixed_large", "learned"]: 121 raise ValueError( 122 "variance_type must be 'fixed_small', 'fixed_large', or 'learned'" 123 ) 124 if self.training_objective not in ["noise", "mmd", "hybrid"]: 125 raise ValueError( 126 "training_objective must be 'noise', 'mmd', or 'hybrid'" 127 ) 128 if self.mmd_kernel not in ["rbf", "imq", "linear"]: 129 raise ValueError("mmd_kernel must be 'rbf', 'imq', or 'linear'") 130 if self.mmd_samples_per_step <= 0: 131 raise ValueError("mmd_samples_per_step must be positive") 132 133 def _init_noise_schedule(self) -> None: 134 """Initialize forward diffusion noise schedule with numerical stability""" 135 if self.schedule == "linear": 136 self.betas = np.linspace( 137 self.beta_start, self.beta_end, self.timesteps 138 ) 139 elif self.schedule == "cosine": 140 s = 0.008 141 steps = np.arange(self.timesteps + 1, dtype=np.float64) 142 alphas_bar = ( 143 np.cos(((steps / self.timesteps) + s) / (1 + s) * np.pi * 0.5) 144 ** 2 145 ) 146 alphas_bar = alphas_bar / alphas_bar[0] 147 self.betas = np.clip( 148 1 - (alphas_bar[1:] / alphas_bar[:-1]), 0, 0.999 149 ) 150 151 self.alphas = 1.0 - self.betas 152 # Numerical stability: clamp away from 0 153 self.alphas_cumprod = np.clip(np.cumprod(self.alphas), 1e-8, 1.0) 154 self.sqrt_alphas_cumprod = np.sqrt(self.alphas_cumprod) 155 self.sqrt_one_minus_alphas_cumprod = np.sqrt(1.0 - self.alphas_cumprod) 156 157 # Compute posterior variance (beta_tilde) for true posterior 158 alphas_cumprod_prev = np.concatenate([[1.0], self.alphas_cumprod[:-1]]) 159 self.posterior_variance = ( 160 self.betas 161 * (1.0 - alphas_cumprod_prev) 162 / (1.0 - self.alphas_cumprod) 163 ) 164 165 # Compute posterior mean coefficients 166 self.posterior_mean_coef1 = ( 167 self.betas 168 * np.sqrt(alphas_cumprod_prev) 169 / (1.0 - self.alphas_cumprod) 170 ) 171 self.posterior_mean_coef2 = ( 172 (1.0 - alphas_cumprod_prev) 173 * np.sqrt(self.alphas) 174 / (1.0 - self.alphas_cumprod) 175 ) 176 177 def _validate_data(self, X: np.ndarray) -> np.ndarray: 178 """Validate input data with comprehensive checks""" 179 X = np.asarray(X) 180 if X.ndim != 2: 181 raise ValueError(f"Expected 2D array, got {X.ndim}D array instead") 182 if np.any(np.isnan(X)) or np.any(np.isinf(X)): 183 raise ValueError("Input contains NaN or infinite values") 184 if X.std(axis=0).min() == 0: 185 warnings.warn("Some features have zero variance") 186 return X 187 188 def _validate_timestep(self, t: Union[int, np.ndarray]) -> None: 189 """Validate timestep bounds""" 190 t_array = np.atleast_1d(t) 191 if np.any(t_array < 0) or np.any(t_array >= self.timesteps): 192 raise ValueError( 193 f"Timestep values out of range [0, {self.timesteps})" 194 ) 195 196 def forward_diffusion( 197 self, x0: np.ndarray, t: np.ndarray, noise: Optional[np.ndarray] = None 198 ) -> Tuple[np.ndarray, np.ndarray]: 199 """ 200 Forward diffusion: q(x_t | x_0) = N(sqrt(alpha_bar_t) * x_0, (1 - alpha_bar_t) * I) 201 202 Parameters 203 ---------- 204 x0 : ndarray of shape (n_samples, n_features) 205 Original data samples 206 t : ndarray of shape (n_samples,) 207 Timesteps (0 to timesteps-1) 208 noise : ndarray, optional 209 Noise to add (generated if None) 210 211 Returns 212 ------- 213 xt : ndarray 214 Noised data 215 noise : ndarray 216 Noise that was added 217 """ 218 x0 = self._validate_data(x0) 219 self._validate_timestep(t) 220 221 if noise is None: 222 noise = self._rng.randn(*x0.shape) 223 224 # Vectorized computation for batch processing 225 sqrt_alpha = self.sqrt_alphas_cumprod[t][:, np.newaxis] 226 sqrt_one_minus_alpha = self.sqrt_one_minus_alphas_cumprod[t][ 227 :, np.newaxis 228 ] 229 230 xt = sqrt_alpha * x0 + sqrt_one_minus_alpha * noise 231 return xt, noise 232 233 def _sample_true_posterior( 234 self, x0: np.ndarray, xt: np.ndarray, t: int, n_samples: int = 1 235 ) -> np.ndarray: 236 """ 237 Sample from true posterior q(x_{t-1} | x_t, x_0) 238 239 This is the ground truth denoising distribution that we want to match. 240 241 Parameters 242 ---------- 243 x0 : ndarray of shape (batch_size, n_features) 244 Clean data 245 xt : ndarray of shape (batch_size, n_features) 246 Noisy data at timestep t 247 t : int 248 Current timestep 249 n_samples : int 250 Number of samples to draw per data point 251 252 Returns 253 ------- 254 samples : ndarray of shape (batch_size * n_samples, n_features) 255 Samples from true posterior 256 """ 257 if t == 0: 258 # At t=0, posterior is deterministic: x_{-1} doesn't exist, return x_0 259 return np.repeat(x0, n_samples, axis=0) 260 261 batch_size = x0.shape[0] 262 263 # Posterior mean: μ̃_t = coef1 * x_0 + coef2 * x_t 264 coef1 = self.posterior_mean_coef1[t] 265 coef2 = self.posterior_mean_coef2[t] 266 posterior_mean = coef1 * x0 + coef2 * xt 267 268 # Posterior variance: σ̃_t² 269 posterior_var = self.posterior_variance[t] 270 271 # Sample from N(μ̃_t, σ̃_t² I) 272 samples = [] 273 for _ in range(n_samples): 274 noise = self._rng.randn(*x0.shape) * np.sqrt(posterior_var) 275 sample = posterior_mean + noise 276 samples.append(sample) 277 278 return np.vstack(samples) 279 280 def _sample_learned_transition( 281 self, xt: np.ndarray, t: int, n_samples: int = 1 282 ) -> np.ndarray: 283 """ 284 Sample from learned transition p_θ(x_{t-1} | x_t) 285 286 Parameters 287 ---------- 288 xt : ndarray of shape (batch_size, n_features) 289 Noisy data at timestep t 290 t : int 291 Current timestep 292 n_samples : int 293 Number of samples to draw per data point 294 295 Returns 296 ------- 297 samples : ndarray of shape (batch_size * n_samples, n_features) 298 Samples from learned transition 299 """ 300 batch_size = xt.shape[0] 301 t_array = np.full(batch_size, t) 302 303 # Predict noise using learned model 304 features = self._create_features(xt, t_array) 305 pred_noise = self.model_.predict(features).reshape(xt.shape) 306 307 # Compute predicted mean 308 alpha = self.alphas[t] 309 alpha_bar = self.alphas_cumprod[t] 310 beta = self.betas[t] 311 312 coef1 = 1.0 / np.sqrt(alpha) 313 coef2 = beta / np.sqrt(1.0 - alpha_bar) 314 predicted_mean = coef1 * (xt - coef2 * pred_noise) 315 316 # Get variance 317 if self.variance_type == "fixed_small": 318 variance = self.posterior_variance[t] 319 elif self.variance_type == "fixed_large": 320 variance = beta 321 else: # learned - for now use fixed_small 322 variance = self.posterior_variance[t] 323 324 # Sample from N(μ_θ, σ² I) 325 if t == 0: 326 # Deterministic at final step 327 return np.repeat(predicted_mean, n_samples, axis=0) 328 329 samples = [] 330 for _ in range(n_samples): 331 noise = self._rng.randn(*xt.shape) * np.sqrt(variance) 332 sample = predicted_mean + noise 333 samples.append(sample) 334 335 return np.vstack(samples) 336 337 def _compute_kernel( 338 self, X: np.ndarray, Y: np.ndarray, gamma: Optional[float] = None 339 ) -> np.ndarray: 340 """ 341 Compute kernel matrix K(X, Y) using specified kernel 342 343 Parameters 344 ---------- 345 X : ndarray of shape (n, d) 346 Y : ndarray of shape (m, d) 347 gamma : float, optional 348 Kernel bandwidth 349 350 Returns 351 ------- 352 K : ndarray of shape (n, m) 353 Kernel matrix 354 """ 355 if self.mmd_kernel == "linear": 356 return X @ Y.T 357 358 # Compute squared distances 359 try: 360 from scipy.spatial.distance import cdist 361 362 sq_dist = cdist(X, Y, metric="sqeuclidean") 363 except ImportError: 364 sq_dist = np.sum((X[:, None, :] - Y[None, :, :]) ** 2, axis=2) 365 366 if gamma is None: 367 # Use median heuristic 368 if len(X) > 100: 369 sample_idx = self._rng.choice(len(X), 100, replace=False) 370 sq_dist_sample = sq_dist[sample_idx][:, :100] 371 else: 372 sq_dist_sample = sq_dist 373 gamma = 1.0 / np.median(sq_dist_sample[sq_dist_sample > 0]) 374 375 if self.mmd_kernel == "rbf": 376 return np.exp(-gamma * sq_dist) 377 elif self.mmd_kernel == "imq": 378 # Inverse multiquadric: (||x-y||² + c²)^(-β) 379 c = 1.0 380 beta = 0.5 381 return (sq_dist + c**2) ** (-beta) 382 else: 383 raise ValueError(f"Unknown kernel: {self.mmd_kernel}") 384 385 def _compute_mmd_unbiased( 386 self, X: np.ndarray, Y: np.ndarray, gamma: Optional[float] = None 387 ) -> float: 388 """ 389 Compute unbiased MMD² estimator 390 391 MMD²(P, Q) = E[k(u,u')] + E[k(v,v')] - 2E[k(u,v)] 392 393 Uses unbiased estimator that excludes diagonal terms. 394 """ 395 n, m = len(X), len(Y) 396 397 # Compute kernel matrices 398 K_XX = self._compute_kernel(X, X, gamma) 399 K_YY = self._compute_kernel(Y, Y, gamma) 400 K_XY = self._compute_kernel(X, Y, gamma) 401 402 # Unbiased estimator: exclude diagonal 403 if n > 1: 404 K_XX_unbiased = (K_XX.sum() - np.trace(K_XX)) / (n * (n - 1)) 405 else: 406 K_XX_unbiased = 0 407 408 if m > 1: 409 K_YY_unbiased = (K_YY.sum() - np.trace(K_YY)) / (m * (m - 1)) 410 else: 411 K_YY_unbiased = 0 412 413 K_XY_mean = K_XY.mean() 414 415 mmd_sq = K_XX_unbiased + K_YY_unbiased - 2 * K_XY_mean 416 417 return max(0.0, mmd_sq) # Ensure non-negative 418 419 def _create_features(self, x: np.ndarray, t: np.ndarray) -> np.ndarray: 420 """ 421 Create features for the regressor with multi-frequency temporal encoding 422 """ 423 # Normalize timestep 424 t_norm = t / self.timesteps 425 426 # Multi-frequency positional encoding 427 frequencies = [1.0, 2.0, 4.0] 428 t_encoded_parts = [] 429 430 for freq in frequencies: 431 t_encoded_parts.extend( 432 [ 433 np.sin(2 * np.pi * freq * t_norm), 434 np.cos(2 * np.pi * freq * t_norm), 435 ] 436 ) 437 438 t_encoded_parts.append(t_norm) 439 t_encoded = np.column_stack(t_encoded_parts) 440 441 # Concatenate spatial and temporal features 442 features = np.hstack([x, t_encoded]) 443 return features 444 445 def fit( 446 self, 447 X: np.ndarray, 448 y: Optional[np.ndarray] = None, 449 n_steps: int = 1000, 450 ) -> "DiffusionModel": 451 """ 452 Train the reverse diffusion model using specified objective 453 454 Parameters 455 ---------- 456 X : ndarray of shape (n_samples, n_features) 457 Training data (will be normalized internally) 458 y : ignored 459 Not used, present for API consistency 460 n_steps : int, default=1000 461 Number of training iterations 462 463 Returns 464 ------- 465 self : DiffusionModel 466 Fitted estimator 467 """ 468 X = self._validate_data(X) 469 n_samples, n_features = X.shape 470 471 # Store normalization params 472 self.X_mean_ = X.mean(axis=0) 473 self.X_std_ = X.std(axis=0) + 1e-8 474 X_norm = (X - self.X_mean_) / self.X_std_ 475 476 # Optional PCA 477 if self.use_pca and n_features > 100: 478 self.pca_ = PCA( 479 n_components=min(self.pca_components, n_features), 480 random_state=self.random_state, 481 ) 482 X_norm = self.pca_.fit_transform(X_norm) 483 n_features = X_norm.shape[1] 484 else: 485 self.pca_ = None 486 487 self.n_features_ = n_features 488 489 # Initialize model 490 if self.model is None: 491 self.model_ = Ridge(alpha=1.0, random_state=self.random_state) 492 else: 493 self.model_ = clone(self.model) 494 495 # Train based on objective 496 if self.training_objective == "noise": 497 self._fit_noise_prediction(X_norm, n_samples, n_steps) 498 elif self.training_objective == "mmd": 499 self._fit_mmd_matching(X_norm, n_samples, n_steps) 500 elif self.training_objective == "hybrid": 501 self._fit_hybrid(X_norm, n_samples, n_steps) 502 503 self.is_fitted_ = True 504 return self 505 506 def _fit_noise_prediction( 507 self, X_norm: np.ndarray, n_samples: int, n_steps: int 508 ) -> None: 509 """Traditional DDPM training: predict noise with MSE loss""" 510 n_batches = max(1, n_steps // self.batch_size) 511 512 X_train_batches: List[np.ndarray] = [] 513 y_train_batches: List[np.ndarray] = [] 514 515 for batch_idx in range(n_batches): 516 indices = self._rng.randint(n_samples, size=self.batch_size) 517 t_batch = self._rng.randint(self.timesteps, size=self.batch_size) 518 519 x0_batch = X_norm[indices] 520 noise_batch = self._rng.randn(self.batch_size, self.n_features_) 521 522 xt_batch, _ = self.forward_diffusion(x0_batch, t_batch, noise_batch) 523 features = self._create_features(xt_batch, t_batch) 524 525 X_train_batches.append(features) 526 y_train_batches.append(noise_batch) 527 528 X_train = np.vstack(X_train_batches) 529 y_train = np.vstack(y_train_batches) 530 531 self.model_.fit(X_train, y_train) 532 533 def _fit_mmd_matching( 534 self, X_norm: np.ndarray, n_samples: int, n_steps: int 535 ) -> None: 536 """ 537 MMD-based training: directly minimize distribution mismatch 538 539 This implements the algorithm from the theoretical document: 540 1. Sample (x_0, t, x_t) 541 2. Draw samples from true posterior q(x_{t-1}|x_t,x_0) 542 3. Draw samples from learned transition p_θ(x_{t-1}|x_t) 543 4. Compute MMD² between these sample sets 544 5. Update θ to minimize MMD 545 """ 546 print( 547 f"Training with MMD objective (kernel={self.mmd_kernel}, samples={self.mmd_samples_per_step})" 548 ) 549 550 # Determine gamma for kernel 551 if self.mmd_bandwidth == "auto": 552 gamma = None # Will be computed adaptively 553 else: 554 gamma = float(self.mmd_bandwidth) 555 556 # For sklearn models without iterative updates, we need to: 557 # 1. Generate training data with MMD-weighted importance 558 # 2. Fit the model on this data 559 560 # We'll use an iterative refinement approach: 561 # Start with noise prediction, then refine with MMD 562 print("Phase 1: Initial noise prediction training...") 563 self._fit_noise_prediction(X_norm, n_samples, n_steps // 2) 564 565 print("Phase 2: MMD refinement...") 566 # Generate MMD-weighted training data 567 X_train_batches: List[np.ndarray] = [] 568 y_train_batches: List[np.ndarray] = [] 569 weights_batches: List[np.ndarray] = [] 570 571 n_batches = max(1, (n_steps // 2) // self.batch_size) 572 573 for batch_idx in range(n_batches): 574 if batch_idx % 10 == 0: 575 print(f" Batch {batch_idx}/{n_batches}") 576 577 indices = self._rng.randint(n_samples, size=self.batch_size) 578 t_batch = self._rng.randint(1, self.timesteps, size=self.batch_size) 579 580 x0_batch = X_norm[indices] 581 noise_batch = self._rng.randn(self.batch_size, self.n_features_) 582 xt_batch, _ = self.forward_diffusion(x0_batch, t_batch, noise_batch) 583 584 # For each timestep, compute MMD between true and learned transitions 585 batch_weights = [] 586 for i in range(self.batch_size): 587 t = t_batch[i] 588 x0_i = x0_batch[i: i + 1] 589 xt_i = xt_batch[i: i + 1] 590 591 # Sample from both distributions 592 true_samples = self._sample_true_posterior( 593 x0_i, xt_i, t, n_samples=self.mmd_samples_per_step 594 ) 595 learned_samples = self._sample_learned_transition( 596 xt_i, t, n_samples=self.mmd_samples_per_step 597 ) 598 599 # Compute MMD 600 mmd = self._compute_mmd_unbiased( 601 true_samples, learned_samples, gamma 602 ) 603 604 # Weight by MMD (higher mismatch = higher weight) 605 weight = 1.0 + mmd 606 batch_weights.append(weight) 607 608 features = self._create_features(xt_batch, t_batch) 609 X_train_batches.append(features) 610 y_train_batches.append(noise_batch) 611 weights_batches.append(np.array(batch_weights)) 612 613 # Fit with weighted samples 614 X_train = np.vstack(X_train_batches) 615 y_train = np.vstack(y_train_batches) 616 weights = np.concatenate(weights_batches) 617 618 # Normalize weights 619 weights = weights / weights.sum() * len(weights) 620 621 print(" Final weighted fit...") 622 if ( 623 hasattr(self.model_, "fit") 624 and "sample_weight" in self.model_.fit.__code__.co_varnames 625 ): 626 self.model_.fit(X_train, y_train, sample_weight=weights) 627 else: 628 # Fallback: repeat samples according to weights 629 weights_int = np.round(weights).astype(int) 630 indices_weighted = np.repeat(np.arange(len(X_train)), weights_int) 631 self.model_.fit( 632 X_train[indices_weighted], y_train[indices_weighted] 633 ) 634 635 print("MMD training complete!") 636 637 def _fit_hybrid( 638 self, X_norm: np.ndarray, n_samples: int, n_steps: int 639 ) -> None: 640 """Hybrid training: combine noise prediction and MMD objectives""" 641 # Split training: 60% noise prediction, 40% MMD refinement 642 n_noise_steps = int(0.6 * n_steps) 643 n_mmd_steps = n_steps - n_noise_steps 644 645 print( 646 f"Hybrid training: {n_noise_steps} noise steps + {n_mmd_steps} MMD steps" 647 ) 648 self._fit_noise_prediction(X_norm, n_samples, n_noise_steps) 649 650 # Continue with MMD refinement 651 self.training_objective = "mmd" # Temporarily switch 652 self._fit_mmd_matching(X_norm, n_samples, n_mmd_steps) 653 self.training_objective = "hybrid" # Restore 654 655 def sample( 656 self, 657 n_samples: int = 1, 658 return_trajectory: bool = False, 659 ddim: bool = False, 660 ddim_steps: int = 50, 661 ) -> np.ndarray: 662 """ 663 Generate samples via reverse diffusion 664 665 Parameters 666 ---------- 667 n_samples : int, default=1 668 Number of samples to generate 669 return_trajectory : bool, default=False 670 If True, return all intermediate denoising steps 671 ddim : bool, default=False 672 Use DDIM for faster deterministic sampling 673 ddim_steps : int, default=50 674 Number of DDIM steps (only used if ddim=True) 675 676 Returns 677 ------- 678 samples : ndarray 679 Generated samples (denormalized to original scale) 680 """ 681 check_is_fitted(self, ["model_", "X_mean_", "X_std_", "n_features_"]) 682 683 if ddim: 684 return self._sample_ddim(n_samples, ddim_steps, return_trajectory) 685 686 # Standard DDPM sampling 687 x = self._rng.randn(n_samples, self.n_features_) 688 trajectory = [x.copy()] if return_trajectory else None 689 690 for t in reversed(range(self.timesteps)): 691 t_array = np.full(n_samples, t) 692 features = self._create_features(x, t_array) 693 694 pred_noise = self.model_.predict(features).reshape(x.shape) 695 696 alpha = self.alphas[t] 697 alpha_bar = self.alphas_cumprod[t] 698 beta = self.betas[t] 699 700 coef1 = 1.0 / np.sqrt(alpha) 701 coef2 = beta / np.sqrt(1.0 - alpha_bar) 702 mean = coef1 * (x - coef2 * pred_noise) 703 704 if t > 0: 705 if self.variance_type == "fixed_small": 706 variance = self.posterior_variance[t] 707 else: 708 variance = beta 709 noise = np.sqrt(variance) * self._rng.randn(*x.shape) 710 x = mean + noise 711 else: 712 x = mean 713 714 if return_trajectory: 715 trajectory.append(x.copy()) 716 717 return self._postprocess_samples(x, trajectory, return_trajectory) 718 719 def _sample_ddim( 720 self, n_samples: int, steps: int, return_trajectory: bool 721 ) -> np.ndarray: 722 """DDIM sampling for faster generation""" 723 skip = max(1, self.timesteps // steps) 724 timesteps = np.arange(0, self.timesteps, skip)[::-1] 725 726 x = self._rng.randn(n_samples, self.n_features_) 727 trajectory = [x.copy()] if return_trajectory else None 728 729 for i, t in enumerate(timesteps): 730 t_array = np.full(n_samples, t) 731 features = self._create_features(x, t_array) 732 pred_noise = self.model_.predict(features).reshape(x.shape) 733 734 alpha_bar = self.alphas_cumprod[t] 735 alpha_bar_prev = ( 736 self.alphas_cumprod[timesteps[i + 1]] 737 if i < len(timesteps) - 1 738 else 1.0 739 ) 740 741 pred_x0 = (x - np.sqrt(1 - alpha_bar) * pred_noise) / np.sqrt( 742 alpha_bar 743 ) 744 dir_xt = np.sqrt(1 - alpha_bar_prev) * pred_noise 745 x = np.sqrt(alpha_bar_prev) * pred_x0 + dir_xt 746 747 if return_trajectory: 748 trajectory.append(x.copy()) 749 750 return self._postprocess_samples(x, trajectory, return_trajectory) 751 752 def _postprocess_samples( 753 self, 754 x: np.ndarray, 755 trajectory: Optional[List[np.ndarray]], 756 return_trajectory: bool, 757 ) -> np.ndarray: 758 """Apply inverse transforms to generated samples""" 759 if return_trajectory and trajectory is not None: 760 processed_trajectory = [] 761 for step in trajectory: 762 if self.pca_ is not None: 763 step = self.pca_.inverse_transform(step) 764 step = step * self.X_std_ + self.X_mean_ 765 processed_trajectory.append(step) 766 return np.array(processed_trajectory) 767 768 if self.pca_ is not None: 769 x = self.pca_.inverse_transform(x) 770 771 x = x * self.X_std_ + self.X_mean_ 772 return x 773 774 def predict(self, n_samples: int = 1, **kwargs) -> np.ndarray: 775 """Sklearn-style predict method (alias for sample)""" 776 return self.sample(n_samples=n_samples, **kwargs) 777 778 def score(self, X: np.ndarray, metric: str = "mmd") -> float: 779 """Compute negative reconstruction error (higher is better)""" 780 return -self.reconstruction_error(X, metric=metric) 781 782 def reconstruction_error( 783 self, 784 X: np.ndarray, 785 metric: str = "mmd", 786 n_samples: Optional[int] = None, 787 gamma: Union[str, float] = "auto", 788 ) -> float: 789 """ 790 Compute distributional reconstruction error 791 792 Parameters 793 ---------- 794 X : ndarray 795 Original data distribution 796 metric : str, default='mmd' 797 Error metric: 'mmd' or 'energy' 798 n_samples : int, optional 799 Number of samples to generate 800 gamma : str or float, default='auto' 801 RBF bandwidth for MMD 802 803 Returns 804 ------- 805 error : float 806 Reconstruction error (lower is better) 807 """ 808 check_is_fitted(self, ["model_", "X_mean_", "X_std_"]) 809 810 if n_samples is None: 811 n_samples = min(1000, len(X)) 812 813 X_reconstructed = self.sample(n_samples) 814 815 if metric == "mmd": 816 return self._compute_mmd(X, X_reconstructed, gamma) 817 elif metric == "energy": 818 return self._compute_energy_distance(X, X_reconstructed) 819 else: 820 raise ValueError(f"Metric must be 'mmd' or 'energy', got: {metric}") 821 822 def _compute_mmd( 823 self, X: np.ndarray, Y: np.ndarray, gamma: Union[str, float] = "auto" 824 ) -> float: 825 """Compute MMD with adaptive bandwidth (for evaluation)""" 826 max_samples = 1000 827 if len(X) > max_samples: 828 indices = self._rng.choice(len(X), max_samples, replace=False) 829 X = X[indices] 830 if len(Y) > max_samples: 831 indices = self._rng.choice(len(Y), max_samples, replace=False) 832 Y = Y[indices] 833 834 if gamma == "auto": 835 n_bandwidth = min(100, len(X), len(Y)) 836 X_bw = X[:n_bandwidth] 837 Y_bw = Y[:n_bandwidth] 838 combined = np.vstack([X_bw, Y_bw]) 839 840 from scipy.spatial.distance import pdist 841 842 squared_dists = pdist(combined, metric="sqeuclidean") 843 gamma = 1.0 / np.median(squared_dists) 844 845 try: 846 from scipy.spatial.distance import cdist 847 848 XX = cdist(X, X, metric="sqeuclidean") 849 YY = cdist(Y, Y, metric="sqeuclidean") 850 XY = cdist(X, Y, metric="sqeuclidean") 851 852 K_XX = np.exp(-gamma * XX).mean() 853 K_YY = np.exp(-gamma * YY).mean() 854 K_XY = np.exp(-gamma * XY).mean() 855 except ImportError: 856 chunk_size = 100 857 858 def chunked_kernel_mean(A, B): 859 total = 0.0 860 count = 0 861 for i in range(0, len(A), chunk_size): 862 A_chunk = A[i: i + chunk_size] 863 for j in range(0, len(B), chunk_size): 864 B_chunk = B[j: j + chunk_size] 865 sq_dist = np.sum( 866 (A_chunk[:, None, :] - B_chunk[None, :, :]) ** 2, 867 axis=2, 868 ) 869 total += np.exp(-gamma * sq_dist).sum() 870 count += len(A_chunk) * len(B_chunk) 871 return total / count 872 873 K_XX = chunked_kernel_mean(X, X) 874 K_YY = chunked_kernel_mean(Y, Y) 875 K_XY = chunked_kernel_mean(X, Y) 876 877 mmd = K_XX + K_YY - 2 * K_XY 878 return max(0, mmd) 879 880 def _compute_energy_distance(self, X: np.ndarray, Y: np.ndarray) -> float: 881 """Compute energy distance""" 882 try: 883 from scipy.spatial.distance import pdist, cdist 884 885 max_samples = 500 886 if len(X) > max_samples: 887 indices = self._rng.choice(len(X), max_samples, replace=False) 888 X = X[indices] 889 if len(Y) > max_samples: 890 indices = self._rng.choice(len(Y), max_samples, replace=False) 891 Y = Y[indices] 892 893 xy_dist = cdist(X, Y, metric="euclidean").mean() 894 xx_dist = pdist(X, metric="euclidean").mean() if len(X) > 1 else 0 895 yy_dist = pdist(Y, metric="euclidean").mean() if len(Y) > 1 else 0 896 897 energy = 2 * xy_dist - xx_dist - yy_dist 898 return max(0, energy) 899 except ImportError: 900 max_samples = 500 901 if len(X) > max_samples: 902 indices = self._rng.choice(len(X), max_samples, replace=False) 903 X = X[indices] 904 if len(Y) > max_samples: 905 indices = self._rng.choice(len(Y), max_samples, replace=False) 906 Y = Y[indices] 907 908 def chunked_pairwise_dist(A, B, chunk_size=100): 909 total = 0.0 910 count = 0 911 for i in range(0, len(A), chunk_size): 912 A_chunk = A[i: i + chunk_size] 913 for j in range(0, len(B), chunk_size): 914 B_chunk = B[j: j + chunk_size] 915 dist = np.sqrt( 916 np.sum( 917 (A_chunk[:, None, :] - B_chunk[None, :, :]) 918 ** 2, 919 axis=2, 920 ) 921 ) 922 total += dist.sum() 923 count += len(A_chunk) * len(B_chunk) 924 return total / count 925 926 xy_dist = chunked_pairwise_dist(X, Y) 927 928 n, m = len(X), len(Y) 929 if n > 1: 930 xx_dist = chunked_pairwise_dist(X, X) 931 xx_dist = (xx_dist * n * n) / (n * (n - 1)) 932 else: 933 xx_dist = 0 934 935 if m > 1: 936 yy_dist = chunked_pairwise_dist(Y, Y) 937 yy_dist = (yy_dist * m * m) / (m * (m - 1)) 938 else: 939 yy_dist = 0 940 941 energy = 2 * xy_dist - xx_dist - yy_dist 942 return max(0, energy) 943 944 def optimize_hyperparameters( 945 self, 946 X: np.ndarray, 947 n_calls: int = 20, 948 metric: str = "mmd", 949 cv_splits: int = 3, 950 ) -> dict: 951 """Bayesian optimization of hyperparameters with cross-validation""" 952 try: 953 from skopt import gp_minimize 954 from skopt.space import Real, Integer, Categorical 955 except ImportError: 956 raise ImportError( 957 "scikit-optimize not installed. Install with: pip install scikit-optimize" 958 ) 959 960 space = [ 961 Integer(100, 2000, name="timesteps"), 962 Real(1e-5, 1e-3, name="beta_start", prior="log-uniform"), 963 Real(0.01, 0.05, name="beta_end"), 964 Categorical(["linear", "cosine"], name="schedule"), 965 Real(0.1, 10.0, name="ridge_alpha", prior="log-uniform"), 966 ] 967 968 def objective(params): 969 try: 970 model = DiffusionModel( 971 timesteps=params[0], 972 beta_start=params[1], 973 beta_end=params[2], 974 schedule=params[3], 975 model=Ridge( 976 alpha=params[4], random_state=self.random_state 977 ), 978 random_state=self.random_state, 979 batch_size=self.batch_size, 980 use_pca=self.use_pca, 981 pca_components=self.pca_components, 982 training_objective=self.training_objective, 983 ) 984 985 errors = [] 986 for _ in range(cv_splits): 987 model.fit(X, n_steps=500) 988 X_eval = X[: min(100, len(X))] 989 error = model.reconstruction_error( 990 X_eval, metric=metric, n_samples=100 991 ) 992 errors.append(error) 993 994 return np.mean(errors) 995 except Exception as e: 996 warnings.warn(f"Optimization iteration failed: {e}") 997 return 1e6 998 999 result = gp_minimize( 1000 objective, 1001 space, 1002 n_calls=n_calls, 1003 random_state=self.random_state, 1004 verbose=False, 1005 ) 1006 1007 best_params = { 1008 "timesteps": result.x[0], 1009 "beta_start": result.x[1], 1010 "beta_end": result.x[2], 1011 "schedule": result.x[3], 1012 "model": Ridge(alpha=result.x[4], random_state=self.random_state), 1013 } 1014 1015 self.set_params(**best_params) 1016 self._init_noise_schedule() 1017 self.fit(X, n_steps=2000) 1018 1019 print(f"\n✅ Optimization complete!") 1020 print(f"Best {metric.upper()} error: {result.fun:.6f}") 1021 print(f"Best parameters:") 1022 for key, value in best_params.items(): 1023 if key != "model": 1024 print(f" {key}: {value}") 1025 else: 1026 print(f" model: Ridge(alpha={result.x[4]:.4f})") 1027 1028 return best_params
Sklearn-compatible diffusion model with MMD-based and noise-prediction training.
Implements both traditional DDPM (noise prediction with MSE) and novel MMD-based training that directly minimizes distribution mismatch between true posterior and learned transitions using Maximum Mean Discrepancy.
Parameters
timesteps : int, default=1000 Number of diffusion timesteps beta_start : float, default=0.0001 Initial noise variance beta_end : float, default=0.02 Final noise variance model : sklearn estimator, optional Base model for reverse process (default: Ridge with alpha=1.0) schedule : {'linear', 'cosine'}, default='linear' Noise schedule type use_pca : bool, default=False Apply PCA for dimensionality reduction (recommended for >100 dims) pca_components : int, default=50 Number of PCA components if use_pca=True variance_type : {'fixed_small', 'fixed_large', 'learned'}, default='fixed_small' Variance schedule for reverse process random_state : int, optional Random seed for reproducibility batch_size : int, default=32 Batch size for training data generation training_objective : {'noise', 'mmd', 'hybrid'}, default='noise' Training objective: - 'noise': Traditional DDPM noise prediction with MSE loss - 'mmd': Direct MMD minimization between true and learned posteriors - 'hybrid': Combine both objectives mmd_samples_per_step : int, default=10 Number of samples to draw per timestep for MMD estimation mmd_kernel : {'rbf', 'imq', 'linear'}, default='rbf' Kernel for MMD computation mmd_bandwidth : float or 'auto', default='auto' Kernel bandwidth (gamma for RBF)
Examples
Traditional noise-prediction training:
>>> model = DiffusionModel(timesteps=100, training_objective='noise')
>>> model.fit(X, n_steps=1000)
MMD-based training (distribution matching):
>>> model = DiffusionModel(timesteps=100, training_objective='mmd',
... mmd_samples_per_step=20)
>>> model.fit(X, n_steps=1000)
Hybrid approach:
>>> model = DiffusionModel(timesteps=100, training_objective='hybrid')
>>> model.fit(X, n_steps=1000)
445 def fit( 446 self, 447 X: np.ndarray, 448 y: Optional[np.ndarray] = None, 449 n_steps: int = 1000, 450 ) -> "DiffusionModel": 451 """ 452 Train the reverse diffusion model using specified objective 453 454 Parameters 455 ---------- 456 X : ndarray of shape (n_samples, n_features) 457 Training data (will be normalized internally) 458 y : ignored 459 Not used, present for API consistency 460 n_steps : int, default=1000 461 Number of training iterations 462 463 Returns 464 ------- 465 self : DiffusionModel 466 Fitted estimator 467 """ 468 X = self._validate_data(X) 469 n_samples, n_features = X.shape 470 471 # Store normalization params 472 self.X_mean_ = X.mean(axis=0) 473 self.X_std_ = X.std(axis=0) + 1e-8 474 X_norm = (X - self.X_mean_) / self.X_std_ 475 476 # Optional PCA 477 if self.use_pca and n_features > 100: 478 self.pca_ = PCA( 479 n_components=min(self.pca_components, n_features), 480 random_state=self.random_state, 481 ) 482 X_norm = self.pca_.fit_transform(X_norm) 483 n_features = X_norm.shape[1] 484 else: 485 self.pca_ = None 486 487 self.n_features_ = n_features 488 489 # Initialize model 490 if self.model is None: 491 self.model_ = Ridge(alpha=1.0, random_state=self.random_state) 492 else: 493 self.model_ = clone(self.model) 494 495 # Train based on objective 496 if self.training_objective == "noise": 497 self._fit_noise_prediction(X_norm, n_samples, n_steps) 498 elif self.training_objective == "mmd": 499 self._fit_mmd_matching(X_norm, n_samples, n_steps) 500 elif self.training_objective == "hybrid": 501 self._fit_hybrid(X_norm, n_samples, n_steps) 502 503 self.is_fitted_ = True 504 return self
Train the reverse diffusion model using specified objective
Parameters
X : ndarray of shape (n_samples, n_features) Training data (will be normalized internally) y : ignored Not used, present for API consistency n_steps : int, default=1000 Number of training iterations
Returns
self : DiffusionModel Fitted estimator
774 def predict(self, n_samples: int = 1, **kwargs) -> np.ndarray: 775 """Sklearn-style predict method (alias for sample)""" 776 return self.sample(n_samples=n_samples, **kwargs)
Sklearn-style predict method (alias for sample)
778 def score(self, X: np.ndarray, metric: str = "mmd") -> float: 779 """Compute negative reconstruction error (higher is better)""" 780 return -self.reconstruction_error(X, metric=metric)
Compute negative reconstruction error (higher is better)
34class DistroSimulator: 35 def __init__( 36 self, 37 kernel="rbf", 38 backend="numpy", 39 n_clusters=5, 40 clustering_method="kmeans", 41 kde_kernel="gaussian", 42 random_state=None, 43 conformalize=False, 44 residual_sampling="bootstrap", 45 block_size=None, 46 gmm_components=3, 47 category_encoder=None, 48 use_rff="auto", 49 rff_components="auto", 50 rff_gamma=None, 51 kernel_approximation="rff", 52 force_rff_threshold=1000, 53 ): 54 """ 55 Initialize the multivariate data generator. 56 57 Parameters: 58 ----------- 59 kernel : str, default='rbf' 60 Kernel type for KernelRidge regression 61 backend : str, default='numpy' 62 Backend for distance calculations ('numpy', 'gpu', 'tpu') 63 n_clusters : int, default=5 64 Number of clusters for stratified splitting 65 clustering_method : str, default='kmeans' 66 Clustering method for stratification ('kmeans' or 'gmm') 67 random_state : int, default=None 68 Random seed for reproducibility 69 conformalize : bool 70 Use split conformal prediction or not 71 residual_sampling : str, default='bootstrap' 72 Method for sampling residuals ('bootstrap', 'kde', 'gmm', 'block-bootstrap', 'me-bootstrap'). 73 Where 'me-bootstrap' refers to Maximum Entropy Bootstrap. 74 block_size : int, default=None 75 Block size for block bootstrap (if applicable) 76 gmm_components : int, default=3 77 Number of components for GMM sampling 78 category_encoder: object, default=None 79 Category encoder 80 use_rff : bool or 'auto', default='auto' 81 Whether to use kernel approximation. 'auto' enables for large datasets 82 rff_components : int or 'auto', default='auto' 83 Number of approximation components. 'auto' chooses based on data size 84 rff_gamma : float, default=None 85 Gamma parameter for approximation. If None, will be tuned. 86 kernel_approximation : str, default='rff' 87 Approximation method ('rff' or 'nystroem') 88 force_rff_threshold : int, default=1000 89 Auto-enable RFF when n_samples exceeds this threshold 90 """ 91 self.kernel = kernel 92 self.backend = backend 93 self.n_clusters = n_clusters 94 self.clustering_method = clustering_method 95 self.random_state = random_state 96 self.conformalize = conformalize 97 self.residual_sampling = residual_sampling 98 self.block_size = block_size 99 self.gmm_components = gmm_components 100 self.category_encoder = category_encoder 101 self.use_rff = use_rff 102 self.rff_components = rff_components 103 self.rff_gamma = rff_gamma 104 self.kernel_approximation = kernel_approximation 105 self.force_rff_threshold = force_rff_threshold 106 self.kde_kernel = kde_kernel 107 108 # Initialize random number generator with the seed 109 self.rng = np.random.RandomState(random_state) 110 111 # Set global numpy seed for sklearn consistency 112 if random_state is not None: 113 np.random.seed(random_state) 114 115 # Initialize JAX random key if JAX is available 116 self.jax_key = None 117 if JAX_AVAILABLE and random_state is not None: 118 self.jax_key = jax.random.PRNGKey(random_state) 119 120 # Validate sampling method 121 valid_sampling_methods = [ 122 "bootstrap", 123 "kde", 124 "gmm", 125 "block-bootstrap", 126 "me-bootstrap", 127 ] 128 if residual_sampling not in valid_sampling_methods: 129 raise ValueError( 130 f"residual_sampling must be one of {valid_sampling_methods}" 131 ) 132 133 # Validate approximation method 134 valid_approximations = ["rff", "nystroem"] 135 if kernel_approximation not in valid_approximations: 136 raise ValueError( 137 f"kernel_approximation must be one of {valid_approximations}" 138 ) 139 140 # Initialize JAX if using GPU/TPU backend 141 if backend in ["gpu", "tpu"] and JAX_AVAILABLE: 142 self._setup_jax_backend() 143 elif backend in ["gpu", "tpu"] and not JAX_AVAILABLE: 144 print("JAX not available. Falling back to NumPy backend.") 145 self.backend = "numpy" 146 147 # Initialize attributes that will be set during fitting 148 self.model = None 149 self.residuals_ = None 150 self.X_dist = None 151 self.is_fitted = False 152 self.best_params_ = None 153 self.best_score_ = None 154 self.cluster_labels_ = None 155 self.cluster_model_ = None 156 self.kde_model_ = None 157 self.gmm_model_ = None 158 self.scaler_ = None 159 self.actual_rff_components_ = None 160 self.actual_use_rff_ = None 161 162 def _setup_jax_backend(self): 163 """Setup JAX backend for GPU/TPU acceleration.""" 164 if not JAX_AVAILABLE: 165 raise ImportError("JAX is required for GPU/TPU backend") 166 167 # Initialize JAX key if not already done 168 if self.jax_key is None: 169 seed = self.random_state if self.random_state is not None else 0 170 self.jax_key = jax.random.PRNGKey(seed) 171 172 # JIT compiled distance functions 173 @jit 174 def pairwise_sq_dists_jax(X1, X2): 175 X1_sq = jnp.sum(X1**2, axis=1)[:, jnp.newaxis] 176 X2_sq = jnp.sum(X2**2, axis=1)[jnp.newaxis, :] 177 return X1_sq + X2_sq - 2 * X1 @ X2.T 178 179 @jit 180 def cdist_jax(X1, X2): 181 return vmap( 182 lambda x: vmap(lambda y: jnp.sqrt(jnp.sum((x - y) ** 2)))(X2) 183 )(X1) 184 185 self._pairwise_sq_dists_jax = pairwise_sq_dists_jax 186 self._cdist_jax = cdist_jax 187 188 def _determine_components(self, n_samples): 189 """Automatically determine optimal number of components.""" 190 if self.rff_components == "auto": 191 # Optimized heuristic based on performance results 192 if n_samples < 500: 193 return min(50, n_samples) 194 elif n_samples < 2000: 195 return min(100, n_samples // 2) 196 elif n_samples < 5000: 197 return min(150, n_samples // 3) 198 elif n_samples < 10000: 199 return min(200, n_samples // 4) 200 else: 201 return min(300, n_samples // 5) 202 else: 203 return self.rff_components 204 205 def _create_model(self, gamma, alpha, use_rff=None): 206 """Create the appropriate model based on RFF setting.""" 207 if use_rff is None: 208 use_rff = self.actual_use_rff_ 209 210 if use_rff: 211 # Use kernel approximation with Ridge regression 212 if self.rff_gamma is not None: 213 effective_gamma = self.rff_gamma 214 else: 215 effective_gamma = gamma 216 # Determine number of components 217 n_components = self.actual_rff_components_ 218 219 if self.kernel_approximation == "rff": 220 approximator = RBFSampler( 221 gamma=effective_gamma, 222 n_components=n_components, 223 random_state=self.random_state, 224 ) 225 else: # nystroem 226 approximator = Nystroem( 227 kernel="rbf", 228 gamma=effective_gamma, 229 n_components=n_components, 230 random_state=self.random_state, 231 ) 232 # Create pipeline with scaling, approximation, and Ridge 233 return Pipeline( 234 [ 235 ("scaler", StandardScaler()), 236 ("approx", approximator), 237 ( 238 "ridge", 239 Ridge(alpha=alpha, random_state=self.random_state), 240 ), 241 ] 242 ) 243 # Standard KernelRidge 244 return KernelRidge(kernel=self.kernel, gamma=gamma, alpha=alpha) 245 246 def _fit_residual_sampler(self, **kwargs): 247 """Fit the chosen residual sampling model.""" 248 if self.residuals_ is None or len(self.residuals_) == 0: 249 raise ValueError("No residuals available for fitting sampler") 250 251 if self.residual_sampling == "kde": 252 kernel_bandwidths = {"bandwidth": np.logspace(-6, 6, 150)} 253 grid = GridSearchCV( 254 KernelDensity(kernel=self.kde_kernel, **kwargs), 255 param_grid=kernel_bandwidths, 256 cv=3, 257 # random_state=self.random_state, 258 ) 259 grid.fit(self.residuals_) 260 self.kde_model_ = grid.best_estimator_ 261 262 elif self.residual_sampling == "gmm": 263 self.gmm_model_ = GaussianMixture( 264 n_components=min(self.gmm_components, len(self.residuals_)), 265 random_state=self.random_state, 266 covariance_type="full", 267 ) 268 self.gmm_model_.fit(self.residuals_) 269 270 def _sample_residuals(self, num_samples): 271 """Sample residuals using the chosen method.""" 272 if self.residuals_ is None: 273 raise ValueError("No residuals available for sampling") 274 275 if self.residual_sampling == "bootstrap": 276 # Original bootstrap method 277 n = self.residuals_.shape[0] 278 idx = self.rng.choice(n, num_samples, replace=True) 279 return self.residuals_[idx] 280 281 elif self.residual_sampling == "kde": 282 # Kernel Density Estimation sampling 283 if self.kde_model_ is None: 284 raise ValueError( 285 "KDE model not fitted. Call _fit_residual_sampler first." 286 ) 287 # Sample from KDE with random_state 288 samples = self.kde_model_.sample(num_samples, random_state=self.rng) 289 return samples 290 291 elif self.residual_sampling == "gmm": 292 # Gaussian Mixture Model sampling 293 if self.gmm_model_ is None: 294 raise ValueError( 295 "GMM model not fitted. Call _fit_residual_sampler first." 296 ) 297 # Sample from GMM 298 return self.gmm_model_.sample(num_samples)[0] 299 300 elif self.residual_sampling == "me-bootstrap": 301 # Note: MaximumEntropyBootstrap needs to be imported 302 # from .meboot import MaximumEntropyBootstrap 303 # meb = MaximumEntropyBootstrap(random_state=self.random_state) 304 residuals = self.residuals_.flatten() 305 if residuals.shape[0] < num_samples: 306 repeats = int(np.ceil(num_samples / residuals.shape[0])) 307 residuals = np.tile(residuals, repeats)[:num_samples] 308 else: 309 residuals = residuals[:num_samples] 310 # meb.fit(residuals) 311 # return meb.sample(1)[:, 0].reshape(-1, 1) 312 # Placeholder for ME-bootstrap 313 idx = self.rng.choice(len(residuals), num_samples, replace=True) 314 return residuals[idx].reshape(-1, 1) 315 316 elif self.residual_sampling == "block-bootstrap": 317 # Note: bootstrap function needs to be imported 318 # from .utils import bootstrap 319 # return bootstrap( 320 # self.residuals_, 321 # num_samples, 322 # block_size=self.block_size, 323 # seed=self.random_state, 324 # ) 325 # Placeholder for block bootstrap 326 idx = self.rng.choice( 327 len(self.residuals_), num_samples, replace=True 328 ) 329 return self.residuals_[idx] 330 331 else: 332 raise ValueError( 333 f"Unknown sampling method: {self.residual_sampling}" 334 ) 335 336 def _pairwise_sq_dists(self, X1, X2): 337 """Compute pairwise squared Euclidean distances.""" 338 if self.backend in ["gpu", "tpu"] and JAX_AVAILABLE: 339 X1_jax = jnp.array(X1) 340 X2_jax = jnp.array(X2) 341 result = self._pairwise_sq_dists_jax(X1_jax, X2_jax) 342 return np.array(result) 343 else: 344 X1 = np.atleast_2d(X1) 345 X2 = np.atleast_2d(X2) 346 return ( 347 np.sum(X1**2, axis=1)[:, np.newaxis] 348 + np.sum(X2**2, axis=1)[np.newaxis, :] 349 - 2 * X1 @ X2.T 350 ) 351 352 def _compute_clusters(self, Y): 353 """Compute cluster labels for stratified splitting.""" 354 n_samples = len(Y) 355 356 # Adjust number of clusters based on dataset size to avoid tiny clusters 357 # Rule: ensure at least 10 samples per cluster on average 358 effective_n_clusters = min(self.n_clusters, max(2, n_samples // 10)) 359 360 if effective_n_clusters < self.n_clusters: 361 warnings.warn( 362 f"Reducing n_clusters from {self.n_clusters} to {effective_n_clusters} " 363 f"due to small dataset size (n={n_samples}).", 364 UserWarning, 365 ) 366 367 if self.clustering_method == "kmeans": 368 self.cluster_model_ = KMeans( 369 n_clusters=effective_n_clusters, 370 random_state=self.random_state, 371 n_init=10, 372 ) 373 elif self.clustering_method == "gmm": 374 self.cluster_model_ = GaussianMixture( 375 n_components=effective_n_clusters, 376 random_state=self.random_state, 377 ) 378 else: 379 raise ValueError("clustering_method must be 'kmeans' or 'gmm'") 380 381 self.cluster_model_.fit(Y) 382 return self.cluster_model_.predict(Y) 383 384 def _train_test_split(self, Y, n_train, sequential: bool = False): 385 """Create train-test split. Stratified by clusters or sequential if specified.""" 386 try: 387 n_samples = len(Y) 388 except Exception: 389 n_samples = Y.shape[0] 390 391 if sequential: 392 # Sequential split (no shuffling, preserves temporal order) 393 train_idx = np.arange(n_train) 394 test_idx = np.arange(n_train, n_samples) 395 return train_idx, test_idx 396 397 # Stratified split (default) 398 self.cluster_labels_ = self._compute_clusters(Y) 399 400 # Check if stratification is possible 401 unique_labels, counts = np.unique( 402 self.cluster_labels_, return_counts=True 403 ) 404 min_cluster_size = counts.min() 405 406 # If any cluster has too few samples for stratification, fall back to random split 407 if min_cluster_size < 2: 408 warnings.warn( 409 f"Cluster sizes too small for stratification (min={min_cluster_size}). " 410 "Using random split instead.", 411 UserWarning, 412 ) 413 indices = np.arange(n_samples) 414 self.rng.shuffle(indices) 415 return indices[:n_train], indices[n_train:] 416 417 try: 418 return train_test_split( 419 np.arange(n_samples), 420 train_size=n_train, 421 stratify=self.cluster_labels_, 422 random_state=self.random_state, 423 ) 424 except ValueError as e: 425 # Fall back to random split if stratification fails 426 warnings.warn( 427 f"Stratification failed: {e}. Using random split instead.", 428 UserWarning, 429 ) 430 indices = np.arange(n_samples) 431 self.rng.shuffle(indices) 432 return indices[:n_train], indices[n_train:] 433 434 def _mmd(self, u, v, kernel_sigma=1): 435 """Maximum Mean Discrepancy between two distributions.""" 436 if u.ndim == 1: 437 u = u.reshape(-1, 1) 438 if v.ndim == 1: 439 v = v.reshape(-1, 1) 440 441 def kmat(A, B): 442 return np.exp( 443 -self._pairwise_sq_dists(A, B) / (2 * kernel_sigma**2) 444 ) 445 446 return ( 447 np.mean(kmat(u, u)) + np.mean(kmat(v, v)) - 2 * np.mean(kmat(u, v)) 448 ) 449 450 def _custom_energy_distance(self, u, v): 451 """Energy distance between two distributions.""" 452 if u.ndim == 1: 453 u = u.reshape(-1, 1) 454 if v.ndim == 1: 455 v = v.reshape(-1, 1) 456 457 n, d = u.shape 458 m = v.shape[0] 459 460 if self.backend in ["gpu", "tpu"] and JAX_AVAILABLE: 461 # JAX implementation 462 u_jax = jnp.array(u) 463 v_jax = jnp.array(v) 464 dist_xx = self._cdist_jax(u_jax, u_jax) 465 dist_yy = self._cdist_jax(v_jax, v_jax) 466 dist_xy = self._cdist_jax(u_jax, v_jax) 467 term1 = 2 * jnp.sum(dist_xy) / (n * m) 468 term2 = jnp.sum(dist_xx) / (n * n) 469 term3 = jnp.sum(dist_yy) / (m * m) 470 return float(term1 - term2 - term3) 471 else: 472 # NumPy implementation 473 dist_xx = cdist(u, u, metric="euclidean") 474 dist_yy = cdist(v, v, metric="euclidean") 475 dist_xy = cdist(u, v, metric="euclidean") 476 term1 = 2 * np.sum(dist_xy) / (n * m) 477 term2 = np.sum(dist_xx) / (n * n) 478 term3 = np.sum(dist_yy) / (m * m) 479 return term1 - term2 - term3 480 481 def _kl_divergence(self, p_samples, q_samples, epsilon=1e-10): 482 """ 483 Compute KL divergence KL(P||Q) between two distributions. 484 485 P is the observed distribution, Q is the synthetic distribution. 486 KL(P||Q) = ∫ p(x) * log(p(x)/q(x)) dx 487 488 Parameters: 489 ----------- 490 p_samples : array-like 491 Samples from the observed distribution P 492 q_samples : array-like 493 Samples from the synthetic distribution Q 494 epsilon : float, default=1e-10 495 Small value added to avoid log(0) 496 497 Returns: 498 -------- 499 kl_div : float 500 KL divergence value 501 """ 502 if p_samples.ndim == 1: 503 p_samples = p_samples.reshape(-1, 1) 504 if q_samples.ndim == 1: 505 q_samples = q_samples.reshape(-1, 1) 506 507 # Use KDE to estimate the densities 508 # Use Scott's rule for bandwidth selection 509 from sklearn.neighbors import KernelDensity 510 511 # Fit KDE on P (observed) and Q (synthetic) 512 p_kde = KernelDensity(kernel="gaussian", bandwidth="scott") 513 q_kde = KernelDensity(kernel="gaussian", bandwidth="scott") 514 515 p_kde.fit(p_samples) 516 q_kde.fit(q_samples) 517 518 # Evaluate log densities at P samples 519 log_p = p_kde.score_samples(p_samples) # log p(x) 520 log_q = q_kde.score_samples(p_samples) # log q(x) 521 522 # KL(P||Q) = E_P[log(p(x)/q(x))] = E_P[log p(x) - log q(x)] 523 # Monte Carlo estimate using samples from P 524 kl_div = np.mean(log_p - log_q) 525 526 return max(0.0, kl_div) # KL divergence is always non-negative 527 528 def _generate_pseudo(self, num_samples): 529 """Generate synthetic data using the fitted model and residuals.""" 530 if not self.is_fitted: 531 raise ValueError("Model not fitted. Call fit() first.") 532 X_new = self.X_dist[:num_samples] 533 # Handle prediction based on model type 534 preds = self.model.predict(X_new) 535 if preds.ndim == 1: 536 preds = preds.reshape(-1, 1) 537 # Sample residuals using the chosen method 538 return preds + self._sample_residuals(preds.shape[0]) 539 540 def fit(self, Y, n_train=None, metric="energy", n_trials=50, **kwargs): 541 """ 542 Fit the data generator to match the distribution of Y. 543 544 Parameters: 545 ----------- 546 Y : array-like, shape (n_samples, n_features) 547 Target multivariate data to emulate 548 n_train : int, default=None 549 Number of training samples (default: n_samples // 2) 550 metric : str, default='energy' 551 Distance metric for optimization ('energy', 'mmd', 'kl', or 'wasserstein') 552 n_trials : int, default=50 553 Number of Optuna optimization trials 554 **kwargs : dict 555 Additional arguments for Optuna optimization 556 557 Returns: 558 -------- 559 self : object 560 Returns self 561 """ 562 if self.category_encoder is not None: 563 Y = self.category_encoder.fit_transform(Y) 564 try: 565 Y = Y.values 566 except Exception: 567 pass 568 569 if Y.ndim == 1: 570 Y = Y.reshape(-1, 1) 571 572 n, d = Y.shape 573 self.n_features_ = d 574 575 # Determine whether to use RFF 576 if self.use_rff == "auto": 577 self.actual_use_rff_ = n >= self.force_rff_threshold 578 else: 579 self.actual_use_rff_ = self.use_rff 580 581 # Auto-enable RFF for large datasets with component determination 582 if self.actual_use_rff_: 583 self.actual_rff_components_ = self._determine_components(n) 584 if self.use_rff == "auto": 585 print( 586 f"Large dataset detected (n={n}). Auto-enabling {self.kernel_approximation.upper()} for scalability." 587 ) 588 589 if n_train is None: 590 n_train = n // 2 591 592 # Store the input distribution function 593 self.X_dist = self.rng.normal(0, 1, (n, d)) 594 595 # Create stratified train-test split 596 if self.residual_sampling in ("block-bootstrap", "me-bootstrap"): 597 train_idx, test_idx = self._train_test_split( 598 Y, n_train, sequential=True 599 ) 600 else: 601 train_idx, test_idx = self._train_test_split( 602 Y, n_train, sequential=False 603 ) 604 605 Y_train = Y[train_idx] 606 Y_test = Y[test_idx] 607 X_train = self.X_dist[:n_train] 608 609 def objective(trial): 610 sigma = trial.suggest_float("sigma", 0.01, 10, log=True) 611 lambd = trial.suggest_float("lambd", 1e-5, 1, log=True) 612 gamma = 1 / (2 * sigma**2) 613 614 # Create model with current parameters 615 model = self._create_model(gamma, lambd) 616 model.fit(X_train, Y_train) 617 preds_train = model.predict(X_train) 618 619 if preds_train.ndim == 1: 620 preds_train = preds_train.reshape(-1, 1) 621 622 res = Y_train - preds_train 623 Y_sim = self._generate_pseudo_with_model(model, res, len(Y_test)) 624 625 if metric == "energy": 626 dist_val = self._custom_energy_distance(Y_test, Y_sim) 627 elif metric == "mmd": 628 dist_val = self._mmd(Y_test, Y_sim) 629 elif metric == "kl": 630 dist_val = self._kl_divergence(Y_test, Y_sim) 631 elif metric == "wasserstein" and d == 1: 632 dist_val = stats.wasserstein_distance( 633 Y_test.flatten(), Y_sim.flatten() 634 ) 635 else: 636 raise ValueError(f"Invalid metric '{metric}' for dimension {d}") 637 638 return dist_val 639 640 # Optimize hyperparameters with seeded sampler 641 sampler = optuna.samplers.TPESampler(seed=self.random_state) 642 study = optuna.create_study(direction="minimize", sampler=sampler) 643 study.optimize( 644 objective, n_trials=n_trials, show_progress_bar=False, **kwargs 645 ) 646 647 # Store best parameters and fit final model 648 self.best_params_ = study.best_params 649 self.best_score_ = study.best_value 650 sigma = self.best_params_["sigma"] 651 lambd = self.best_params_["lambd"] 652 gamma = 1 / (2 * sigma**2) 653 654 # Fit final model with best parameters 655 self.model = self._create_model(gamma, lambd) 656 self.model.fit(X_train, Y_train) 657 658 # Compute residuals 659 preds_train = self.model.predict(X_train) 660 if preds_train.ndim == 1: 661 preds_train = preds_train.reshape(-1, 1) 662 self.residuals_ = Y_train - preds_train 663 664 # Fit the residual sampler 665 self._fit_residual_sampler() 666 self.is_fitted = True 667 668 # Print final configuration 669 if self.actual_use_rff_: 670 print( 671 f"Using {self.kernel_approximation.upper()} with {self.actual_rff_components_} components" 672 ) 673 else: 674 print(f"Using standard kernel method") 675 676 return self 677 678 def _generate_pseudo_with_model(self, model, residuals, num_samples): 679 """Helper method to generate data with a specific model.""" 680 X_new = self.X_dist[:num_samples] 681 682 # Handle prediction based on model type 683 preds = model.predict(X_new) 684 685 if preds.ndim == 1: 686 preds = preds.reshape(-1, 1) 687 688 # Temporarily store original state 689 original_residuals = self.residuals_ 690 original_kde = self.kde_model_ 691 original_gmm = self.gmm_model_ 692 693 # Set residuals for this model 694 self.residuals_ = residuals 695 696 # Fit sampler with the new residuals 697 self._fit_residual_sampler() 698 699 # Sample residuals 700 sampled_residuals = self._sample_residuals(num_samples) 701 702 # Restore original state 703 self.residuals_ = original_residuals 704 self.kde_model_ = original_kde 705 self.gmm_model_ = original_gmm 706 707 return preds + sampled_residuals 708 709 def sample(self, n_samples=1): 710 """ 711 Generate synthetic samples. 712 713 Parameters: 714 ----------- 715 n_samples : int, default=1 716 Number of samples to generate 717 718 Returns: 719 -------- 720 Y_sim : array, shape (n_samples, n_features) 721 Generated synthetic data 722 """ 723 if not self.is_fitted: 724 raise ValueError("Model not fitted. Call fit() first.") 725 return self._generate_pseudo(n_samples) 726 727 def compare_approximation_methods(self, Y, n_train=None, n_trials=20): 728 """ 729 Compare different kernel approximation methods. 730 731 Parameters: 732 ----------- 733 Y : array-like 734 Target data 735 n_train : int, default=None 736 Number of training samples 737 n_trials : int, default=20 738 Number of optimization trials 739 740 Returns: 741 -------- 742 comparison_results : dict 743 Comparison results 744 """ 745 if Y.ndim == 1: 746 Y = Y.reshape(-1, 1) 747 748 print("Comparing Kernel Approximation Methods...") 749 750 # Store original settings 751 original_use_rff = self.use_rff 752 original_approximation = self.kernel_approximation 753 original_is_fitted = self.is_fitted 754 755 methods = ["rff", "nystroem"] 756 results = {} 757 758 for method in methods: 759 print(f"\nTesting {method.upper()}...") 760 self.use_rff = True 761 self.kernel_approximation = method 762 763 start_time = time() 764 self.fit(Y, n_train=n_train, n_trials=n_trials) 765 method_time = time() - start_time 766 method_score = self.best_score_ 767 method_params = self.best_params_ 768 769 results[method] = { 770 "time": method_time, 771 "score": method_score, 772 "params": method_params, 773 "components": self.actual_rff_components_, 774 } 775 776 # Test standard method for comparison 777 print(f"\nTesting Standard Kernel...") 778 self.use_rff = False 779 start_time = time() 780 self.fit(Y, n_train=n_train, n_trials=n_trials) 781 standard_time = time() - start_time 782 standard_score = self.best_score_ 783 standard_params = self.best_params_ 784 785 results["standard"] = { 786 "time": standard_time, 787 "score": standard_score, 788 "params": standard_params, 789 "components": "N/A", 790 } 791 792 # Restore original settings 793 self.use_rff = original_use_rff 794 self.kernel_approximation = original_approximation 795 self.is_fitted = original_is_fitted 796 797 # Print comparison 798 print("\n" + "=" * 60) 799 print("KERNEL APPROXIMATION COMPARISON RESULTS") 800 print("=" * 60) 801 802 for method in ["standard"] + methods: 803 data = results[method] 804 print(f"\n{method.upper()}:") 805 print(f" Time: {data['time']:.2f}s") 806 print(f" Score: {data['score']:.6f}") 807 print(f" Components: {data['components']}") 808 if method != "standard": 809 speedup = standard_time / data["time"] 810 score_ratio = data["score"] / standard_score 811 print(f" Speedup: {speedup:.2f}x") 812 print(f" Score Ratio: {score_ratio:.4f}") 813 814 return results 815 816 def compare_residual_sampling(self, n_samples=1000): 817 """ 818 Compare different residual sampling methods visually. 819 820 Parameters: 821 ----------- 822 n_samples : int, default=1000 823 Number of samples to generate for comparison 824 """ 825 if not self.is_fitted: 826 raise ValueError("Model not fitted. Call fit() first.") 827 # Store original sampling method 828 original_sampling = self.residual_sampling 829 # Generate samples with different methods 830 sampling_methods = ["bootstrap", "kde", "gmm"] 831 samples = {} 832 833 for method in sampling_methods: 834 self.residual_sampling = method 835 if method == "kde": 836 self._fit_residual_sampler() 837 elif method == "gmm": 838 self._fit_residual_sampler() 839 samples[method] = self._sample_residuals(n_samples) 840 # Restore original method 841 self.residual_sampling = original_sampling 842 self._fit_residual_sampler() 843 # Plot comparison 844 n_dims = self.residuals_.shape[1] 845 fig, axes = plt.subplots( 846 n_dims, 847 len(sampling_methods) + 1, 848 figsize=(5 * (len(sampling_methods) + 1), 4 * n_dims), 849 ) 850 851 if n_dims == 1: 852 axes = axes.reshape(1, -1) 853 854 for dim in range(n_dims): 855 # Original residuals 856 axes[dim, 0].hist( 857 self.residuals_[:, dim], bins=30, alpha=0.7, density=True 858 ) 859 axes[dim, 0].set_title(f"Original Residuals\nDim {dim+1}") 860 axes[dim, 0].set_xlabel("Residual Value") 861 axes[dim, 0].set_ylabel("Density") 862 863 # Sampled residuals 864 for j, method in enumerate(sampling_methods): 865 col = j + 1 866 axes[dim, col].hist( 867 samples[method][:, dim], bins=30, alpha=0.7, density=True 868 ) 869 axes[dim, col].set_title( 870 f"{method.upper()} Sampling\nDim {dim+1}" 871 ) 872 axes[dim, col].set_xlabel("Residual Value") 873 axes[dim, col].set_ylabel("Density") 874 875 plt.tight_layout() 876 plt.show() 877 878 return samples 879 880 def _perm_test(self, Y_orig, Y_sim, stat_func, n_perm=1000): 881 """Permutation test for distribution comparison.""" 882 if Y_orig.ndim == 1: 883 Y_orig = Y_orig.reshape(-1, 1) 884 if Y_sim.ndim == 1: 885 Y_sim = Y_sim.reshape(-1, 1) 886 887 obs = stat_func(Y_orig, Y_sim) 888 combined = np.vstack((Y_orig, Y_sim)) 889 n1 = Y_orig.shape[0] 890 perms = np.zeros(n_perm) 891 892 for i in range(n_perm): 893 idx = self.rng.permutation(combined.shape[0]) 894 p1 = combined[idx[:n1]] 895 p2 = combined[idx[n1:]] 896 perms[i] = stat_func(p1, p2) 897 898 pval = (np.sum(perms >= obs) + 1) / (n_perm + 1) 899 return obs, pval 900 901 def _fisher_z_test(self, r1, r2, n1, n2): 902 """Fisher z-test for comparing correlation coefficients.""" 903 z1 = np.arctanh(r1) 904 z2 = np.arctanh(r2) 905 z = (z1 - z2) / np.sqrt(1 / (n1 - 3) + 1 / (n2 - 3)) 906 p = 2 * (1 - stats.norm.cdf(np.abs(z))) 907 return z, p 908 909 def test_similarity(self, Y_orig, Y_sim, n_perm=1000): 910 """ 911 Test statistical similarity between original and synthetic data. 912 913 Parameters: 914 ----------- 915 Y_orig : array-like 916 Original data 917 Y_sim : array-like 918 Synthetic data 919 n_perm : int, default=1000 920 Number of permutations for permutation tests 921 922 Returns: 923 -------- 924 results : dict 925 Dictionary containing test results 926 """ 927 if Y_orig.ndim == 1: 928 Y_orig = Y_orig.reshape(-1, 1) 929 if Y_sim.ndim == 1: 930 Y_sim = Y_sim.reshape(-1, 1) 931 932 d = Y_orig.shape[1] 933 results = {} 934 # Test 1: Perm with energy 935 results["energy_perm"] = self._perm_test( 936 Y_orig, Y_sim, self._custom_energy_distance, n_perm 937 ) 938 # Test 2: Perm with MMD 939 results["mmd_perm"] = self._perm_test( 940 Y_orig, Y_sim, lambda u, v: self._mmd(u, v), n_perm 941 ) 942 943 # Test 3: Perm with avg Wasserstein on margins 944 def avg_wass(u, v): 945 return np.mean( 946 [stats.wasserstein_distance(u[:, i], v[:, i]) for i in range(d)] 947 ) 948 949 results["avg_wass_perm"] = self._perm_test( 950 Y_orig, Y_sim, avg_wass, n_perm 951 ) 952 # Test 4: Min p-value from marginal KS tests 953 ps_ks = [ 954 stats.ks_2samp(Y_orig[:, i], Y_sim[:, i]).pvalue for i in range(d) 955 ] 956 results["min_marginal_ks_p"] = min(ps_ks) 957 # Test 5: Min p-value from marginal Anderson-Darling tests 958 ps_ad = [ 959 stats.anderson_ksamp([Y_orig[:, i], Y_sim[:, i]]).significance_level 960 for i in range(d) 961 ] 962 results["min_marginal_ad_p"] = min(ps_ad) 963 # Test 6: Min p-value from marginal Cramer-von Mises tests 964 ps_cvm = [ 965 stats.cramervonmises_2samp(Y_orig[:, i], Y_sim[:, i]).pvalue 966 for i in range(d) 967 ] 968 results["min_marginal_cvm_p"] = min(ps_cvm) 969 # Correlation test: Compare all pairwise correlations 970 corr_results = {} 971 pairs = [(i, j) for i in range(d) for j in range(i + 1, d)] 972 for i, j in pairs: 973 r_orig = stats.pearsonr(Y_orig[:, i], Y_orig[:, j])[0] 974 r_sim = stats.pearsonr(Y_sim[:, i], Y_sim[:, j])[0] 975 z, p = self._fisher_z_test(r_orig, r_sim, len(Y_orig), len(Y_sim)) 976 corr_results[f"corr_dim{i+1}_dim{j+1}"] = (r_orig, r_sim, z, p) 977 results["corr_tests"] = corr_results 978 return results 979 980 def compare_distributions(self, Y_orig, Y_sim, save_prefix=""): 981 """ 982 Visual comparison of original and synthetic distributions. 983 984 Parameters: 985 ----------- 986 Y_orig : array-like 987 Original data 988 Y_sim : array-like 989 Synthetic data 990 save_prefix : str, default='' 991 Prefix for saving plots 992 """ 993 if Y_orig.ndim == 1: 994 Y_orig = Y_orig.reshape(-1, 1) 995 if Y_sim.ndim == 1: 996 Y_sim = Y_sim.reshape(-1, 1) 997 998 n, d = Y_orig.shape 999 1000 # Create a figure with subplots for statistical tests 1001 fig, axes = plt.subplots(2, d, figsize=(6 * d, 10)) 1002 if d == 1: 1003 axes = axes.reshape(2, 1) 1004 1005 # Statistical test results storage 1006 ks_results = [] 1007 ad_results = [] 1008 1009 for i in range(d): 1010 # Top row: Histograms with statistical test annotations 1011 ax_hist = axes[0, i] 1012 1013 # Plot histograms 1014 ax_hist.hist( 1015 Y_orig[:, i], 1016 alpha=0.5, 1017 label="Original", 1018 density=True, 1019 bins=20, 1020 color="blue", 1021 ) 1022 ax_hist.hist( 1023 Y_sim[:, i], 1024 alpha=0.5, 1025 label="Simulated", 1026 density=True, 1027 bins=20, 1028 color="red", 1029 ) 1030 1031 # Perform statistical tests 1032 # Kolmogorov-Smirnov test 1033 ks_stat, ks_pvalue = stats.ks_2samp(Y_orig[:, i], Y_sim[:, i]) 1034 ks_results.append((ks_stat, ks_pvalue)) 1035 1036 # Anderson-Darling test 1037 ad_result = stats.anderson_ksamp([Y_orig[:, i], Y_sim[:, i]]) 1038 ad_stat = ad_result.statistic 1039 ad_critical = ad_result.critical_values 1040 ad_significance = ad_result.significance_level 1041 ad_results.append((ad_stat, ad_significance)) 1042 1043 # Add test results to histogram plot 1044 textstr = "\n".join( 1045 ( 1046 f"KS test: p = {ks_pvalue:.4f}", 1047 f"AD test: p < {ad_significance:.3f}", 1048 f"AD stat: {ad_stat:.4f}", 1049 ) 1050 ) 1051 props = dict(boxstyle="round", facecolor="wheat", alpha=0.8) 1052 ax_hist.text( 1053 0.05, 1054 0.95, 1055 textstr, 1056 transform=ax_hist.transAxes, 1057 fontsize=10, 1058 verticalalignment="top", 1059 bbox=props, 1060 ) 1061 1062 ax_hist.legend() 1063 ax_hist.set_title( 1064 f"Dimension {i+1} - Histograms with Statistical Tests" 1065 ) 1066 ax_hist.set_xlabel("Value") 1067 ax_hist.set_ylabel("Density") 1068 1069 # Bottom row: ECDFs with KS test visualization 1070 ax_ecdf = axes[1, i] 1071 1072 # Compute ECDFs 1073 sorted_orig = np.sort(Y_orig[:, i]) 1074 ecdf_orig = np.arange(1, len(sorted_orig) + 1) / len(sorted_orig) 1075 sorted_sim = np.sort(Y_sim[:, i]) 1076 ecdf_sim = np.arange(1, len(sorted_sim) + 1) / len(sorted_sim) 1077 1078 # Plot ECDFs 1079 ax_ecdf.step( 1080 sorted_orig, 1081 ecdf_orig, 1082 label="Original", 1083 color="blue", 1084 linewidth=2, 1085 ) 1086 ax_ecdf.step( 1087 sorted_sim, 1088 ecdf_sim, 1089 label="Simulated", 1090 color="red", 1091 linewidth=2, 1092 ) 1093 1094 # Find the point of maximum difference for KS test 1095 # Combine and sort all values 1096 all_values = np.sort(np.concatenate([sorted_orig, sorted_sim])) 1097 # Compute ECDFs at all points 1098 ecdf_orig_all = np.searchsorted( 1099 sorted_orig, all_values, side="right" 1100 ) / len(sorted_orig) 1101 ecdf_sim_all = np.searchsorted( 1102 sorted_sim, all_values, side="right" 1103 ) / len(sorted_sim) 1104 # Find maximum difference 1105 diff = np.abs(ecdf_orig_all - ecdf_sim_all) 1106 max_idx = np.argmax(diff) 1107 max_x = all_values[max_idx] 1108 max_y1 = ecdf_orig_all[max_idx] 1109 max_y2 = ecdf_sim_all[max_idx] 1110 1111 # Mark the maximum difference point 1112 ax_ecdf.plot( 1113 [max_x, max_x], 1114 [max_y1, max_y2], 1115 "k-", 1116 linewidth=3, 1117 label=f"KS stat: {ks_stat:.4f}", 1118 ) 1119 ax_ecdf.plot(max_x, max_y1, "ko", markersize=8) 1120 ax_ecdf.plot(max_x, max_y2, "ko", markersize=8) 1121 1122 ax_ecdf.legend() 1123 ax_ecdf.set_title(f"Dimension {i+1} - ECDFs with KS Statistic") 1124 ax_ecdf.set_xlabel("Value") 1125 ax_ecdf.set_ylabel("ECDF") 1126 1127 plt.tight_layout() 1128 if save_prefix: 1129 plt.savefig( 1130 f"{save_prefix}_statistical_comparison.png", 1131 dpi=300, 1132 bbox_inches="tight", 1133 ) 1134 plt.show() 1135 1136 # Print comprehensive test results 1137 print("\n" + "=" * 60) 1138 print("COMPREHENSIVE STATISTICAL TEST RESULTS") 1139 print("=" * 60) 1140 1141 for i in range(d): 1142 ks_stat, ks_pvalue = ks_results[i] 1143 ad_stat, ad_significance = ad_results[i] 1144 1145 print(f"\nDimension {i+1}:") 1146 print(f" Kolmogorov-Smirnov Test:") 1147 print(f" Statistic: {ks_stat:.6f}") 1148 print(f" p-value: {ks_pvalue:.6f}") 1149 print( 1150 f" Significance: {'Not Significant' if ks_pvalue > 0.05 else 'SIGNIFICANT'}" 1151 ) 1152 1153 print(f" Anderson-Darling Test:") 1154 print(f" Statistic: {ad_stat:.6f}") 1155 print(f" Significance level: {ad_significance:.3f}") 1156 print( 1157 f" Interpretation: {'Distributions differ' if ad_stat > ad_result.critical_values[2] else 'Distributions similar'}" 1158 ) 1159 1160 # Create summary plot for all dimensions 1161 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) 1162 1163 # KS test p-values across dimensions 1164 ks_pvalues = [result[1] for result in ks_results] 1165 dimensions = list(range(1, d + 1)) 1166 1167 bars = ax1.bar( 1168 dimensions, 1169 ks_pvalues, 1170 color=["red" if p < 0.05 else "green" for p in ks_pvalues], 1171 ) 1172 ax1.axhline( 1173 y=0.05, color="black", linestyle="--", alpha=0.7, label="α = 0.05" 1174 ) 1175 ax1.set_xlabel("Dimension") 1176 ax1.set_ylabel("KS Test p-value") 1177 ax1.set_title("Kolmogorov-Smirnov Test Results\nby Dimension") 1178 ax1.set_xticks(dimensions) 1179 ax1.legend() 1180 1181 # Add value labels on bars 1182 for bar, pvalue in zip(bars, ks_pvalues): 1183 height = bar.get_height() 1184 ax1.text( 1185 bar.get_x() + bar.get_width() / 2.0, 1186 height, 1187 f"{pvalue:.3f}", 1188 ha="center", 1189 va="bottom", 1190 ) 1191 1192 # AD test statistics across dimensions 1193 ad_stats = [result[0] for result in ad_results] 1194 1195 bars = ax2.bar(dimensions, ad_stats, color="skyblue") 1196 ax2.set_xlabel("Dimension") 1197 ax2.set_ylabel("AD Test Statistic") 1198 ax2.set_title("Anderson-Darling Test Statistics\nby Dimension") 1199 ax2.set_xticks(dimensions) 1200 1201 # Add value labels on bars 1202 for bar, stat in zip(bars, ad_stats): 1203 height = bar.get_height() 1204 ax2.text( 1205 bar.get_x() + bar.get_width() / 2.0, 1206 height, 1207 f"{stat:.3f}", 1208 ha="center", 1209 va="bottom", 1210 ) 1211 1212 plt.tight_layout() 1213 if save_prefix: 1214 plt.savefig( 1215 f"{save_prefix}_test_summary.png", dpi=300, bbox_inches="tight" 1216 ) 1217 plt.show() 1218 1219 # Additional: Q-Q plots for each dimension 1220 fig, axes = plt.subplots(1, d, figsize=(5 * d, 5)) 1221 if d == 1: 1222 axes = [axes] 1223 1224 for i in range(d): 1225 # Sort data for Q-Q plot 1226 orig_sorted = np.sort(Y_orig[:, i]) 1227 sim_sorted = np.sort(Y_sim[:, i]) 1228 1229 # Generate theoretical quantiles 1230 n_orig = len(orig_sorted) 1231 n_sim = len(sim_sorted) 1232 1233 # Use smaller set for quantiles to avoid interpolation issues 1234 n_points = min(n_orig, n_sim, 1000) 1235 quantiles = np.linspace(0, 1, n_points) 1236 1237 orig_quantiles = np.quantile(orig_sorted, quantiles) 1238 sim_quantiles = np.quantile(sim_sorted, quantiles) 1239 1240 axes[i].plot( 1241 orig_quantiles, sim_quantiles, "o", alpha=0.6, markersize=3 1242 ) 1243 min_val = min(orig_quantiles.min(), sim_quantiles.min()) 1244 max_val = max(orig_quantiles.max(), sim_quantiles.max()) 1245 axes[i].plot( 1246 [min_val, max_val], 1247 [min_val, max_val], 1248 "r--", 1249 alpha=0.8, 1250 linewidth=2, 1251 ) 1252 axes[i].set_xlabel("Original Data Quantiles") 1253 axes[i].set_ylabel("Simulated Data Quantiles") 1254 axes[i].set_title(f"Dimension {i+1} - Q-Q Plot") 1255 1256 # Add correlation coefficient 1257 corr = np.corrcoef(orig_quantiles, sim_quantiles)[0, 1] 1258 axes[i].text( 1259 0.05, 1260 0.95, 1261 f"Corr: {corr:.4f}", 1262 transform=axes[i].transAxes, 1263 bbox=dict( 1264 boxstyle="round,pad=0.3", facecolor="white", alpha=0.8 1265 ), 1266 verticalalignment="top", 1267 ) 1268 1269 plt.tight_layout() 1270 if save_prefix: 1271 plt.savefig( 1272 f"{save_prefix}_qq_plots.png", dpi=300, bbox_inches="tight" 1273 ) 1274 plt.show() 1275 1276 return { 1277 "ks_results": ks_results, 1278 "ad_results": ad_results, 1279 "dimensions": d, 1280 }
540 def fit(self, Y, n_train=None, metric="energy", n_trials=50, **kwargs): 541 """ 542 Fit the data generator to match the distribution of Y. 543 544 Parameters: 545 ----------- 546 Y : array-like, shape (n_samples, n_features) 547 Target multivariate data to emulate 548 n_train : int, default=None 549 Number of training samples (default: n_samples // 2) 550 metric : str, default='energy' 551 Distance metric for optimization ('energy', 'mmd', 'kl', or 'wasserstein') 552 n_trials : int, default=50 553 Number of Optuna optimization trials 554 **kwargs : dict 555 Additional arguments for Optuna optimization 556 557 Returns: 558 -------- 559 self : object 560 Returns self 561 """ 562 if self.category_encoder is not None: 563 Y = self.category_encoder.fit_transform(Y) 564 try: 565 Y = Y.values 566 except Exception: 567 pass 568 569 if Y.ndim == 1: 570 Y = Y.reshape(-1, 1) 571 572 n, d = Y.shape 573 self.n_features_ = d 574 575 # Determine whether to use RFF 576 if self.use_rff == "auto": 577 self.actual_use_rff_ = n >= self.force_rff_threshold 578 else: 579 self.actual_use_rff_ = self.use_rff 580 581 # Auto-enable RFF for large datasets with component determination 582 if self.actual_use_rff_: 583 self.actual_rff_components_ = self._determine_components(n) 584 if self.use_rff == "auto": 585 print( 586 f"Large dataset detected (n={n}). Auto-enabling {self.kernel_approximation.upper()} for scalability." 587 ) 588 589 if n_train is None: 590 n_train = n // 2 591 592 # Store the input distribution function 593 self.X_dist = self.rng.normal(0, 1, (n, d)) 594 595 # Create stratified train-test split 596 if self.residual_sampling in ("block-bootstrap", "me-bootstrap"): 597 train_idx, test_idx = self._train_test_split( 598 Y, n_train, sequential=True 599 ) 600 else: 601 train_idx, test_idx = self._train_test_split( 602 Y, n_train, sequential=False 603 ) 604 605 Y_train = Y[train_idx] 606 Y_test = Y[test_idx] 607 X_train = self.X_dist[:n_train] 608 609 def objective(trial): 610 sigma = trial.suggest_float("sigma", 0.01, 10, log=True) 611 lambd = trial.suggest_float("lambd", 1e-5, 1, log=True) 612 gamma = 1 / (2 * sigma**2) 613 614 # Create model with current parameters 615 model = self._create_model(gamma, lambd) 616 model.fit(X_train, Y_train) 617 preds_train = model.predict(X_train) 618 619 if preds_train.ndim == 1: 620 preds_train = preds_train.reshape(-1, 1) 621 622 res = Y_train - preds_train 623 Y_sim = self._generate_pseudo_with_model(model, res, len(Y_test)) 624 625 if metric == "energy": 626 dist_val = self._custom_energy_distance(Y_test, Y_sim) 627 elif metric == "mmd": 628 dist_val = self._mmd(Y_test, Y_sim) 629 elif metric == "kl": 630 dist_val = self._kl_divergence(Y_test, Y_sim) 631 elif metric == "wasserstein" and d == 1: 632 dist_val = stats.wasserstein_distance( 633 Y_test.flatten(), Y_sim.flatten() 634 ) 635 else: 636 raise ValueError(f"Invalid metric '{metric}' for dimension {d}") 637 638 return dist_val 639 640 # Optimize hyperparameters with seeded sampler 641 sampler = optuna.samplers.TPESampler(seed=self.random_state) 642 study = optuna.create_study(direction="minimize", sampler=sampler) 643 study.optimize( 644 objective, n_trials=n_trials, show_progress_bar=False, **kwargs 645 ) 646 647 # Store best parameters and fit final model 648 self.best_params_ = study.best_params 649 self.best_score_ = study.best_value 650 sigma = self.best_params_["sigma"] 651 lambd = self.best_params_["lambd"] 652 gamma = 1 / (2 * sigma**2) 653 654 # Fit final model with best parameters 655 self.model = self._create_model(gamma, lambd) 656 self.model.fit(X_train, Y_train) 657 658 # Compute residuals 659 preds_train = self.model.predict(X_train) 660 if preds_train.ndim == 1: 661 preds_train = preds_train.reshape(-1, 1) 662 self.residuals_ = Y_train - preds_train 663 664 # Fit the residual sampler 665 self._fit_residual_sampler() 666 self.is_fitted = True 667 668 # Print final configuration 669 if self.actual_use_rff_: 670 print( 671 f"Using {self.kernel_approximation.upper()} with {self.actual_rff_components_} components" 672 ) 673 else: 674 print(f"Using standard kernel method") 675 676 return self
Fit the data generator to match the distribution of Y.
Parameters:
Y : array-like, shape (n_samples, n_features) Target multivariate data to emulate n_train : int, default=None Number of training samples (default: n_samples // 2) metric : str, default='energy' Distance metric for optimization ('energy', 'mmd', 'kl', or 'wasserstein') n_trials : int, default=50 Number of Optuna optimization trials **kwargs : dict Additional arguments for Optuna optimization
Returns:
self : object Returns self
14class EmpiricalCopula: 15 """ 16 Empirical Copula implementation for multivariate dependence modeling. 17 18 This class implements a non-parametric copula based on the empirical distribution 19 of the data. It can fit to multivariate data and generate samples that preserve 20 the original dependence structure. 21 22 The empirical copula is defined as: 23 C_n(u1, ..., ud) = (1/n) * sum(I(U1i <= u1, ..., Udi <= ud)) 24 25 where U_ji are the pseudo-observations (ranks) of the original data. 26 """ 27 28 def __init__( 29 self, 30 smoothing_method: str = "none", 31 jitter_scale: float = 0.01, 32 boundary_correction: bool = True, 33 ): 34 """ 35 Initialize the Empirical Copula. 36 37 Parameters: 38 ----------- 39 smoothing_method : str, default "none" 40 Smoothing method for the empirical copula: 41 - "none": Pure empirical copula (no smoothing) 42 - "jitter": Add small random noise to avoid ties 43 jitter_scale : float, default 0.0 44 Scale of uniform jitter to add to pseudo-observations (0 = no jitter). 45 boundary_correction : bool, default True 46 Whether to apply boundary correction for kernel methods. 47 """ 48 self.smoothing_method = smoothing_method 49 self.jitter_scale = jitter_scale 50 self.boundary_correction = boundary_correction 51 # Fitted attributes 52 self.is_fitted_ = False 53 self.n_samples_ = None 54 self.n_vars_ = None 55 self.pseudo_observations_ = None 56 self.original_data_ = None 57 self.marginal_cdfs_ = [] 58 self.marginal_quantiles_ = [] 59 # For kernel-based methods 60 self.kde_model_ = None 61 # For Gaussian mixture model 62 self.gmm_model_ = None 63 64 def fit(self, X: np.ndarray) -> "EmpiricalCopula": 65 """ 66 Fit the empirical copula to the data. 67 68 Parameters: 69 ----------- 70 X : np.ndarray 71 Input data of shape (n_samples, n_features) on original scale. 72 73 Returns: 74 -------- 75 self : EmpiricalCopula 76 Returns self for method chaining. 77 78 Raises: 79 ------- 80 ValueError 81 If X has inappropriate dimensions. 82 """ 83 X = np.asarray(X) 84 85 if X.ndim != 2: 86 raise ValueError("X must be a 2D array") 87 if X.shape[1] < 2: 88 raise ValueError("X must have at least 2 variables") 89 if X.shape[0] < 2: 90 raise ValueError("X must have at least 2 observations") 91 92 self.n_samples_, self.n_vars_ = X.shape 93 self.original_data_ = X.copy() 94 # Step 1: Convert to pseudo-observations (ranks) 95 self.pseudo_observations_ = self._to_pseudo_observations(X) 96 # Step 2: Apply smoothing if requested 97 if self.smoothing_method != "none": 98 self.pseudo_observations_ = self._apply_smoothing( 99 self.pseudo_observations_ 100 ) 101 # Step 3: Store marginal information for inverse transformation 102 self._fit_marginal_transforms(X) 103 self.is_fitted_ = True 104 # print(f"Empirical copula fitted successfully using '{self.smoothing_method}' method") 105 return self 106 107 def sample( 108 self, 109 n_samples: int = 50, 110 method: str = "bootstrap", 111 kernel: str = "gaussian", 112 n_components: int = 5, 113 covariance_type: str = "full", 114 return_pseudo: bool = False, 115 random_state: Optional[int] = 123, 116 **kwargs, 117 ) -> np.ndarray: 118 """ 119 Generate samples from the fitted empirical copula. 120 121 Parameters: 122 ----------- 123 n_samples : int, default 50 124 Number of samples to generate. 125 method : str, default "bootstrap" 126 Sampling method: 127 - "bootstrap": Bootstrap resampling from fitted pseudo-observations 128 - "kde": Kernel density estimation sampling (if smoothing was used) 129 - "gmm": Gaussian mixture model sampling 130 kernel : str, default "gaussian" 131 Kernel to use if method is "kde" (default is 'gaussian'). 132 Can also be 'tophat'. 133 n_components : int, default 5 134 Number of Gaussian components for GMM method. 135 covariance_type : str, default "full" 136 Type of covariance parameters for GMM method. 137 Options: 'full', 'tied', 'diag', 'spherical'. 138 return_pseudo : bool, default False 139 If True, return samples on [0,1] copula scale. 140 If False, return samples transformed to original scale. 141 random_state : int, optional 142 Random state for reproducible sampling. 143 kwargs : additional arguments for specific sampling methods. 144 145 Returns: 146 -------- 147 samples : np.ndarray 148 Generated samples of shape (n_samples, n_features). 149 """ 150 if not self.is_fitted_: 151 raise ValueError( 152 "Copula must be fitted before sampling. Call fit() first." 153 ) 154 155 if random_state is not None: 156 np.random.seed(random_state) 157 # Generate pseudo-observations 158 if method == "bootstrap": 159 pseudo_samples = self._bootstrap_sample(n_samples) 160 elif method == "kde": 161 pseudo_samples = self._kde_sample( 162 n_samples, kernel=kernel, **kwargs 163 ) 164 elif method == "gmm": 165 pseudo_samples = self._gmm_sample( 166 n_samples, 167 n_components=n_components, 168 covariance_type=covariance_type, 169 **kwargs, 170 ) 171 else: 172 raise ValueError( 173 f"Unknown sampling method: {method}. " 174 f"Supported methods: 'bootstrap', 'kde', 'gmm'" 175 ) 176 if return_pseudo: 177 return pseudo_samples 178 # Transform back to original scale 179 return self._inverse_transform(pseudo_samples) 180 181 def plot_pairwise_pseudo(self): 182 if not self.is_fitted_: 183 raise ValueError("Copula must be fitted before plotting.") 184 plt.figure(figsize=(15, 15)) 185 for i in range(self.n_vars_): 186 for j in range(i + 1, self.n_vars_): 187 plt.subplot( 188 self.n_vars_ - 1, 189 self.n_vars_ - 1, 190 i * (self.n_vars_ - 1) + j - i, 191 ) 192 plt.scatter( 193 self.pseudo_observations_[:, i], 194 self.pseudo_observations_[:, j], 195 s=5, 196 alpha=0.5, 197 ) 198 plt.xlabel(f"Variable {i+1}") 199 plt.ylabel(f"Variable {j+1}") 200 plt.title( 201 f"Var{i+1}-Var{j+1} (ρ={self._calculate_spearman_matrix(self.original_data_)[i,j]:.2f})" 202 ) 203 plt.tight_layout() 204 plt.show() 205 206 def estimate_tail_dependence(self, threshold=0.05): 207 tail_dep = {} 208 for i in range(self.n_vars_): 209 for j in range(i + 1, self.n_vars_): 210 u = self.pseudo_observations_[:, i] 211 v = self.pseudo_observations_[:, j] 212 lower_tail = ( 213 np.mean((u < threshold) & (v < threshold)) / threshold 214 ) 215 upper_tail = ( 216 np.mean((u > 1 - threshold) & (v > 1 - threshold)) 217 / threshold 218 ) 219 tail_dep[f"var{i+1}-var{j+1}"] = { 220 "lower": lower_tail, 221 "upper": upper_tail, 222 } 223 return tail_dep 224 225 def plot_marginals(self, simulated_samples): 226 import matplotlib.pyplot as plt 227 228 plt.figure(figsize=(15, 5)) 229 for j in range(self.n_vars_): 230 plt.subplot(2, self.n_vars_ // 2, j + 1) 231 plt.hist( 232 self.original_data_[:, j], 233 bins=30, 234 alpha=0.5, 235 label="Original", 236 density=True, 237 ) 238 plt.hist( 239 simulated_samples[:, j], 240 bins=30, 241 alpha=0.5, 242 label="Simulated", 243 density=True, 244 ) 245 plt.title(f"Variable {j+1}") 246 plt.legend() 247 plt.tight_layout() 248 plt.show() 249 250 def validate_fit( 251 self, 252 X_test: Optional[np.ndarray] = None, 253 n_bootstrap: int = 250, 254 alpha: float = 0.05, 255 verbose: bool = True, 256 ) -> Dict: 257 """ 258 Validate the fitted empirical copula using comprehensive hypothesis tests. 259 260 This method performs: 261 1. Kolmogorov-Smirnov tests on marginal distributions 262 2. Anderson-Darling tests for marginal goodness-of-fit 263 3. Tests for dependence measures (Spearman rho, Kendall tau, Pearson correlation) 264 4. Cramér-von Mises test for copula goodness-of-fit 265 5. Tests for uniform distribution of pseudo-observations 266 267 Parameters: 268 ----------- 269 X_test : np.ndarray, optional 270 Test data for validation. If None, uses training data. 271 n_bootstrap : int, default 1000 272 Number of bootstrap samples for validation. 273 alpha : float, default 0.05 274 Significance level for statistical tests. 275 verbose : bool, default True 276 Whether to print detailed validation results. 277 278 Returns: 279 -------- 280 validation_results : dict 281 Dictionary containing all validation test results. 282 """ 283 if not self.is_fitted_: 284 raise ValueError("Copula must be fitted before validation.") 285 286 # Use training data if no test data provided 287 if X_test is None: 288 X_test = self.original_data_.copy() 289 if verbose: 290 print("Note: Using training data for validation") 291 292 # Generate bootstrap samples for comparison 293 bootstrap_samples = self.sample(n_samples=n_bootstrap, random_state=42) 294 295 results = { 296 "marginal_tests": {}, 297 "dependence_tests": {}, 298 "copula_tests": {}, 299 "uniformity_tests": {}, 300 "summary": {}, 301 } 302 303 if verbose: 304 print("\n=== EMPIRICAL COPULA VALIDATION TESTS ===\n") 305 306 # 1. MARGINAL DISTRIBUTION TESTS 307 if verbose: 308 print("1. Marginal Distribution Tests:") 309 print("-" * 35) 310 311 for j in range(self.n_vars_): 312 original_margin = X_test[:, j] 313 simulated_margin = bootstrap_samples[:, j] 314 # Kolmogorov-Smirnov test 315 ks_stat, ks_pvalue = stats.ks_2samp( 316 original_margin, simulated_margin 317 ) 318 # Anderson-Darling test (if samples are from same distribution) 319 try: 320 # Combine samples and test if they're from the same distribution 321 combined_data = np.concatenate( 322 [original_margin, simulated_margin] 323 ) 324 combined_mean = np.mean(combined_data) 325 combined_std = np.std(combined_data) 326 # Test both against normal distribution with combined parameters 327 ad_orig = stats.anderson(original_margin, dist="norm") 328 ad_sim = stats.anderson(simulated_margin, dist="norm") 329 # Use the test statistic difference as a measure 330 ad_diff = abs(ad_orig.statistic - ad_sim.statistic) 331 ad_critical = ad_orig.critical_values[ 332 2 333 ] # 5% significance level 334 ad_pass = ad_diff < ad_critical * 0.5 # Heuristic threshold 335 except: 336 ad_diff = np.nan 337 ad_pass = None 338 # Two-sample t-test for means 339 ttest_stat, ttest_pvalue = stats.ttest_ind( 340 original_margin, simulated_margin 341 ) 342 # Levene's test for equal variances 343 levene_stat, levene_pvalue = stats.levene( 344 original_margin, simulated_margin 345 ) 346 347 results["marginal_tests"][f"variable_{j+1}"] = { 348 "ks_statistic": ks_stat, 349 "ks_p_value": ks_pvalue, 350 "ks_reject_null": ks_pvalue < alpha, 351 "ad_difference": ad_diff, 352 "ad_pass": ad_pass, 353 "ttest_statistic": ttest_stat, 354 "ttest_p_value": ttest_pvalue, 355 "mean_difference_significant": ttest_pvalue < alpha, 356 "levene_statistic": levene_stat, 357 "levene_p_value": levene_pvalue, 358 "variance_difference_significant": levene_pvalue < alpha, 359 } 360 361 if verbose: 362 status = "FAIL" if ks_pvalue < alpha else "PASS" 363 mean_status = "FAIL" if ttest_pvalue < alpha else "PASS" 364 var_status = "FAIL" if levene_pvalue < alpha else "PASS" 365 print(f"Variable {j+1}:") 366 print( 367 f" KS test: statistic={ks_stat:.4f}, p-value={ks_pvalue:.4f} [{status}]" 368 ) 369 print( 370 f" Mean test: p-value={ttest_pvalue:.4f} [{mean_status}]" 371 ) 372 print( 373 f" Variance test: p-value={levene_pvalue:.4f} [{var_status}]" 374 ) 375 376 # 2. DEPENDENCE STRUCTURE TESTS 377 if verbose: 378 print(f"\n2. Dependence Structure Tests:") 379 print("-" * 32) 380 381 # Calculate dependence measures 382 orig_corr = np.corrcoef(X_test.T) 383 orig_spearman = self._calculate_spearman_matrix(X_test) 384 orig_kendall = self._calculate_kendall_matrix(X_test) 385 386 sim_corr = np.corrcoef(bootstrap_samples.T) 387 sim_spearman = self._calculate_spearman_matrix(bootstrap_samples) 388 sim_kendall = self._calculate_kendall_matrix(bootstrap_samples) 389 390 # Statistical tests for dependence measures 391 dependence_results = {} 392 393 for i in range(self.n_vars_): 394 for j in range(i + 1, self.n_vars_): 395 pair_name = f"var{i+1}_var{j+1}" 396 # Test correlations using Fisher's z-transform 397 r1, r2 = orig_corr[i, j], sim_corr[i, j] 398 n1, n2 = len(X_test), len(bootstrap_samples) 399 # Fisher's z-transform 400 z1 = ( 401 0.5 * np.log((1 + r1) / (1 - r1)) 402 if abs(r1) < 0.999 403 else np.sign(r1) * 3 404 ) 405 z2 = ( 406 0.5 * np.log((1 + r2) / (1 - r2)) 407 if abs(r2) < 0.999 408 else np.sign(r2) * 3 409 ) 410 # Test statistic 411 se = np.sqrt(1 / (n1 - 3) + 1 / (n2 - 3)) 412 z_stat = (z1 - z2) / se if se > 0 else 0 413 corr_pvalue = 2 * (1 - stats.norm.cdf(abs(z_stat))) 414 # Spearman and Kendall differences 415 spear_diff = abs(orig_spearman[i, j] - sim_spearman[i, j]) 416 kendall_diff = abs(orig_kendall[i, j] - sim_kendall[i, j]) 417 418 dependence_results[pair_name] = { 419 "pearson_original": r1, 420 "pearson_simulated": r2, 421 "pearson_z_statistic": z_stat, 422 "pearson_p_value": corr_pvalue, 423 "pearson_significant_diff": corr_pvalue < alpha, 424 "spearman_difference": spear_diff, 425 "kendall_difference": kendall_diff, 426 "spearman_large_diff": spear_diff > 0.1, 427 "kendall_large_diff": kendall_diff > 0.1, 428 } 429 430 results["dependence_tests"] = dependence_results 431 432 if verbose: 433 for pair, tests in dependence_results.items(): 434 corr_status = ( 435 "FAIL" if tests["pearson_significant_diff"] else "PASS" 436 ) 437 spear_status = ( 438 "WARN" if tests["spearman_large_diff"] else "PASS" 439 ) 440 kendall_status = ( 441 "WARN" if tests["kendall_large_diff"] else "PASS" 442 ) 443 print(f"{pair.replace('_', '-')}:") 444 print( 445 f" Pearson: {tests['pearson_original']:.4f} vs {tests['pearson_simulated']:.4f}, " 446 f"p-val={tests['pearson_p_value']:.4f} [{corr_status}]" 447 ) 448 print( 449 f" Spearman diff: {tests['spearman_difference']:.4f} [{spear_status}]" 450 ) 451 print( 452 f" Kendall diff: {tests['kendall_difference']:.4f} [{kendall_status}]" 453 ) 454 # 3. UNIFORMITY TESTS FOR PSEUDO-OBSERVATIONS 455 if verbose: 456 print(f"\n3. Uniformity Tests (Pseudo-Observations):") 457 print("-" * 42) 458 # Test if pseudo-observations are uniform on [0,1] 459 pseudo_test = self._to_pseudo_observations(X_test) 460 461 uniformity_results = {} 462 463 for j in range(self.n_vars_): 464 pseudo_margin = pseudo_test[:, j] 465 # Kolmogorov-Smirnov test against uniform distribution 466 ks_uniform_stat, ks_uniform_pvalue = stats.kstest( 467 pseudo_margin, "uniform" 468 ) 469 # Anderson-Darling test for uniformity 470 # Transform to standard normal and test 471 normal_transformed = stats.norm.ppf( 472 np.clip(pseudo_margin, 1e-10, 1 - 1e-10) 473 ) 474 ad_result = stats.anderson(normal_transformed, dist="norm") 475 476 # Cramer-von Mises test for uniformity 477 def cvm_uniform(data): 478 """Cramér-von Mises test for uniform distribution.""" 479 n = len(data) 480 sorted_data = np.sort(data) 481 i = np.arange(1, n + 1) 482 T = (1.0 / (12 * n)) + np.sum( 483 ((2 * i - 1) / (2 * n) - sorted_data) ** 2 484 ) 485 return T 486 487 cvm_stat = cvm_uniform(pseudo_margin) 488 # Critical value at 5% significance level 489 cvm_critical = 0.461 / ( 490 np.sqrt(len(pseudo_margin)) 491 + 0.25 492 + 0.75 / np.sqrt(len(pseudo_margin)) 493 ) 494 495 uniformity_results[f"variable_{j+1}"] = { 496 "ks_uniform_statistic": ks_uniform_stat, 497 "ks_uniform_p_value": ks_uniform_pvalue, 498 "ks_uniform_reject": ks_uniform_pvalue < alpha, 499 "ad_statistic": ad_result.statistic, 500 "ad_critical_5pct": ad_result.critical_values[2], 501 "ad_reject": ad_result.statistic > ad_result.critical_values[2], 502 "cvm_statistic": cvm_stat, 503 "cvm_critical": cvm_critical, 504 "cvm_reject": cvm_stat > cvm_critical, 505 } 506 507 if verbose: 508 ks_status = "FAIL" if ks_uniform_pvalue < alpha else "PASS" 509 ad_status = ( 510 "FAIL" 511 if ad_result.statistic > ad_result.critical_values[2] 512 else "PASS" 513 ) 514 cvm_status = "FAIL" if cvm_stat > cvm_critical else "PASS" 515 print(f"Variable {j+1}:") 516 print( 517 f" KS uniform: stat={ks_uniform_stat:.4f}, p-val={ks_uniform_pvalue:.4f} [{ks_status}]" 518 ) 519 print( 520 f" AD normal: stat={ad_result.statistic:.4f}, crit={ad_result.critical_values[2]:.4f} [{ad_status}]" 521 ) 522 print( 523 f" CvM uniform: stat={cvm_stat:.4f}, crit={cvm_critical:.4f} [{cvm_status}]" 524 ) 525 526 results["uniformity_tests"] = uniformity_results 527 528 pseudo_orig = self._to_pseudo_observations(X_test) 529 pseudo_sim = self._to_pseudo_observations(bootstrap_samples) 530 # 5. SUMMARY ASSESSMENT 531 if verbose: 532 print(f"\n5. Overall Assessment:") 533 print("-" * 22) 534 # Count various test failures 535 ks_failures = sum( 536 1 537 for j in range(self.n_vars_) 538 if results["marginal_tests"][f"variable_{j+1}"]["ks_reject_null"] 539 ) 540 541 mean_failures = sum( 542 1 543 for j in range(self.n_vars_) 544 if results["marginal_tests"][f"variable_{j+1}"][ 545 "mean_difference_significant" 546 ] 547 ) 548 549 var_failures = sum( 550 1 551 for j in range(self.n_vars_) 552 if results["marginal_tests"][f"variable_{j+1}"][ 553 "variance_difference_significant" 554 ] 555 ) 556 557 corr_failures = sum( 558 1 559 for tests in dependence_results.values() 560 if tests["pearson_significant_diff"] 561 ) 562 563 uniform_failures = sum( 564 1 565 for j in range(self.n_vars_) 566 if results["uniformity_tests"][f"variable_{j+1}"][ 567 "ks_uniform_reject" 568 ] 569 ) 570 # Calculate average differences 571 avg_spear_diff = np.mean( 572 [ 573 tests["spearman_difference"] 574 for tests in dependence_results.values() 575 ] 576 ) 577 avg_kendall_diff = np.mean( 578 [ 579 tests["kendall_difference"] 580 for tests in dependence_results.values() 581 ] 582 ) 583 # Overall quality assessment 584 total_tests = ( 585 self.n_vars_ * 3 + len(dependence_results) + self.n_vars_ + 1 586 ) 587 total_failures = ( 588 ks_failures 589 + mean_failures 590 + var_failures 591 + corr_failures 592 + uniform_failures 593 ) 594 595 pass_rate = (total_tests - total_failures) / total_tests * 100 596 597 if pass_rate >= 85 and avg_spear_diff <= 0.05: 598 quality = "Excellent" 599 elif pass_rate >= 70 and avg_spear_diff <= 0.10: 600 quality = "Good" 601 elif pass_rate >= 50 and avg_spear_diff <= 0.15: 602 quality = "Fair" 603 else: 604 quality = "Poor" 605 606 results["summary"] = { 607 "ks_failures": ks_failures, 608 "mean_failures": mean_failures, 609 "variance_failures": var_failures, 610 "correlation_failures": corr_failures, 611 "uniformity_failures": uniform_failures, 612 "total_failures": total_failures, 613 "total_tests": total_tests, 614 "pass_rate": pass_rate, 615 "avg_spearman_difference": avg_spear_diff, 616 "avg_kendall_difference": avg_kendall_diff, 617 "overall_quality": quality, 618 } 619 620 if verbose: 621 print(f"Test Summary ({total_tests} total tests):") 622 print(f" Marginal KS failures: {ks_failures}/{self.n_vars_}") 623 print(f" Mean difference failures: {mean_failures}/{self.n_vars_}") 624 print( 625 f" Variance difference failures: {var_failures}/{self.n_vars_}" 626 ) 627 print( 628 f" Correlation failures: {corr_failures}/{len(dependence_results)}" 629 ) 630 print(f" Uniformity failures: {uniform_failures}/{self.n_vars_}") 631 print(f" Overall pass rate: {pass_rate:.1f}%") 632 print(f" Average Spearman difference: {avg_spear_diff:.4f}") 633 print(f" Average Kendall difference: {avg_kendall_diff:.4f}") 634 print(f" Overall model quality: {quality}") 635 636 return results 637 638 def _to_pseudo_observations(self, X: np.ndarray) -> np.ndarray: 639 """Convert data to pseudo-observations using empirical CDF.""" 640 n_samples, n_vars = X.shape 641 pseudo_obs = np.zeros_like(X) 642 643 for j in range(n_vars): 644 # Rank-based transformation 645 ranks = stats.rankdata(X[:, j], method="average") 646 # Use (rank - 0.5) / n to avoid boundary values 647 pseudo_obs[:, j] = (ranks - 0.5) / n_samples 648 649 return pseudo_obs 650 651 def _apply_smoothing(self, pseudo_obs: np.ndarray) -> np.ndarray: 652 """Apply smoothing to pseudo-observations.""" 653 if self.smoothing_method == "jitter": 654 # Add uniform jitter 655 jitter = np.random.uniform( 656 -self.jitter_scale / 2, self.jitter_scale / 2, pseudo_obs.shape 657 ) 658 smoothed = pseudo_obs + jitter 659 # Ensure values stay in [0,1] 660 smoothed = np.clip(smoothed, 1e-10, 1 - 1e-10) 661 return smoothed 662 else: 663 return pseudo_obs 664 665 def _fit_marginal_transforms(self, X: np.ndarray) -> None: 666 """Fit marginal transformations for inverse sampling.""" 667 self.marginal_cdfs_ = [] 668 self.marginal_quantiles_ = [] 669 670 for j in range(self.n_vars_): 671 data_col = X[:, j] 672 sorted_data = np.sort(data_col) 673 # Create empirical CDF 674 n = len(sorted_data) 675 cdf_values = np.arange(1, n + 1) / n 676 # Store quantile function (inverse CDF) 677 # Add boundary extrapolation 678 extended_probs = np.concatenate([[0], cdf_values, [1]]) 679 extended_data = np.concatenate( 680 [ 681 [sorted_data[0] - (sorted_data[1] - sorted_data[0])], 682 sorted_data, 683 [sorted_data[-1] + (sorted_data[-1] - sorted_data[-2])], 684 ] 685 ) 686 quantile_func = interp1d( 687 extended_probs, 688 extended_data, 689 kind="linear", 690 bounds_error=False, 691 fill_value=(extended_data[0], extended_data[-1]), 692 ) 693 self.marginal_quantiles_.append(quantile_func) 694 695 def _bootstrap_sample(self, n_samples: int) -> np.ndarray: 696 """Generate samples using bootstrap resampling.""" 697 # Randomly sample indices with replacement 698 indices = np.random.choice( 699 self.n_samples_, size=n_samples, replace=True 700 ) 701 return self.pseudo_observations_[indices] 702 703 def _kde_sample( 704 self, n_samples: int, kernel="gaussian", **kwargs 705 ) -> np.ndarray: 706 """Generate samples using kernel density estimation.""" 707 kernel_bandwidths = {"bandwidth": np.logspace(-6, 6, 150)} 708 grid = GridSearchCV( 709 KernelDensity(kernel=kernel, **kwargs), param_grid=kernel_bandwidths 710 ) 711 grid.fit(self.pseudo_observations_) 712 self.kde_model_ = grid.best_estimator_ 713 return self.kde_model_.sample(n_samples) 714 715 def _gmm_sample( 716 self, 717 n_samples: int, 718 n_components: int = 5, 719 covariance_type: str = "full", 720 **kwargs, 721 ) -> np.ndarray: 722 """ 723 Generate samples using Gaussian mixture model. 724 725 Parameters: 726 ----------- 727 n_samples : int 728 Number of samples to generate. 729 n_components : int, default 5 730 Number of Gaussian components in the mixture. 731 covariance_type : str, default "full" 732 Type of covariance parameters. Options: 'full', 'tied', 'diag', 'spherical'. 733 **kwargs : additional arguments for GaussianMixture. 734 735 Returns: 736 -------- 737 samples : np.ndarray 738 Generated samples on [0,1] copula scale. 739 """ 740 # Fit Gaussian mixture model to pseudo-observations 741 gmm = GaussianMixture( 742 n_components=n_components, 743 covariance_type=covariance_type, 744 random_state=kwargs.get("random_state", None), 745 **{k: v for k, v in kwargs.items() if k != "random_state"}, 746 ) 747 748 # Fit the model 749 gmm.fit(self.pseudo_observations_) 750 751 # Store the fitted model 752 self.gmm_model_ = gmm 753 754 # Generate samples 755 samples, _ = gmm.sample(n_samples) 756 757 # Ensure samples are in [0,1] range (clip if necessary) 758 samples = np.clip(samples, 1e-10, 1 - 1e-10) 759 760 return samples 761 762 def _inverse_transform(self, pseudo_samples: np.ndarray) -> np.ndarray: 763 """Transform pseudo-observations back to original scale.""" 764 n_samples, n_vars = pseudo_samples.shape 765 original_samples = np.zeros_like(pseudo_samples) 766 767 for j in range(n_vars): 768 u = pseudo_samples[:, j] 769 # Ensure values are in valid range 770 u = np.clip(u, 1e-10, 1 - 1e-10) 771 original_samples[:, j] = self.marginal_quantiles_[j](u) 772 773 return original_samples 774 775 def _calculate_spearman_matrix(self, X: np.ndarray) -> np.ndarray: 776 """Calculate Spearman rank correlation matrix.""" 777 n_vars = X.shape[1] 778 spearman_matrix = np.zeros((n_vars, n_vars)) 779 780 for i in range(n_vars): 781 for j in range(n_vars): 782 if i == j: 783 spearman_matrix[i, j] = 1.0 784 else: 785 spearman_matrix[i, j], _ = stats.spearmanr(X[:, i], X[:, j]) 786 787 return spearman_matrix 788 789 def _calculate_kendall_matrix(self, X: np.ndarray) -> np.ndarray: 790 """Calculate Kendall's tau correlation matrix.""" 791 n_vars = X.shape[1] 792 kendall_matrix = np.zeros((n_vars, n_vars)) 793 794 for i in range(n_vars): 795 for j in range(n_vars): 796 if i == j: 797 kendall_matrix[i, j] = 1.0 798 else: 799 kendall_matrix[i, j], _ = stats.kendalltau(X[:, i], X[:, j]) 800 801 return kendall_matrix 802 803 def get_info(self) -> Dict: 804 """Get information about the fitted empirical copula.""" 805 if not self.is_fitted_: 806 raise ValueError("Copula must be fitted first.") 807 808 return { 809 "n_samples": self.n_samples_, 810 "n_vars": self.n_vars_, 811 "smoothing_method": self.smoothing_method, 812 "jitter_scale": self.jitter_scale, 813 "boundary_correction": self.boundary_correction, 814 "has_kde_model": self.kde_model_ is not None, 815 "has_gmm_model": self.gmm_model_ is not None, 816 } 817 818 def __repr__(self) -> str: 819 if self.is_fitted_: 820 return ( 821 f"EmpiricalCopula(n_samples={self.n_samples_}, n_vars={self.n_vars_}, " 822 f"smoothing='{self.smoothing_method}', fitted=True)" 823 ) 824 else: 825 return f"EmpiricalCopula(smoothing='{self.smoothing_method}', fitted=False)"
Empirical Copula implementation for multivariate dependence modeling.
This class implements a non-parametric copula based on the empirical distribution of the data. It can fit to multivariate data and generate samples that preserve the original dependence structure.
The empirical copula is defined as: C_n(u1, ..., ud) = (1/n) * sum(I(U1i <= u1, ..., Udi <= ud))
where U_ji are the pseudo-observations (ranks) of the original data.
64 def fit(self, X: np.ndarray) -> "EmpiricalCopula": 65 """ 66 Fit the empirical copula to the data. 67 68 Parameters: 69 ----------- 70 X : np.ndarray 71 Input data of shape (n_samples, n_features) on original scale. 72 73 Returns: 74 -------- 75 self : EmpiricalCopula 76 Returns self for method chaining. 77 78 Raises: 79 ------- 80 ValueError 81 If X has inappropriate dimensions. 82 """ 83 X = np.asarray(X) 84 85 if X.ndim != 2: 86 raise ValueError("X must be a 2D array") 87 if X.shape[1] < 2: 88 raise ValueError("X must have at least 2 variables") 89 if X.shape[0] < 2: 90 raise ValueError("X must have at least 2 observations") 91 92 self.n_samples_, self.n_vars_ = X.shape 93 self.original_data_ = X.copy() 94 # Step 1: Convert to pseudo-observations (ranks) 95 self.pseudo_observations_ = self._to_pseudo_observations(X) 96 # Step 2: Apply smoothing if requested 97 if self.smoothing_method != "none": 98 self.pseudo_observations_ = self._apply_smoothing( 99 self.pseudo_observations_ 100 ) 101 # Step 3: Store marginal information for inverse transformation 102 self._fit_marginal_transforms(X) 103 self.is_fitted_ = True 104 # print(f"Empirical copula fitted successfully using '{self.smoothing_method}' method") 105 return self
Fit the empirical copula to the data.
Parameters:
X : np.ndarray Input data of shape (n_samples, n_features) on original scale.
Returns:
self : EmpiricalCopula Returns self for method chaining.
Raises:
ValueError If X has inappropriate dimensions.
14class StratifiedClusteringSubsampling: 15 def __init__( 16 self, 17 n_components=3, 18 method=ClusterMethod.GMM, 19 random_state=None, 20 **kwargs, 21 ): 22 """ 23 Initializes the StratifiedClusteringSubsampling class. 24 25 :param n_components: Number of clusters for clustering algorithm. Default is 3. 26 :param method: Cluster method - 'gmm' or 'kmeans'. Default is GMM. 27 :param random_state: Seed for random number generator. 28 :param kwargs: Additional parameters for the clustering algorithms. 29 """ 30 self.n_components = n_components 31 self.method = ( 32 method 33 if isinstance(method, ClusterMethod) 34 else ClusterMethod(method.lower()) 35 ) 36 self.random_state = random_state 37 self.kwargs = kwargs 38 39 # Initialize the clustering model based on the chosen method 40 if self.method == ClusterMethod.GMM: 41 self.cluster_model = GaussianMixture( 42 n_components=self.n_components, 43 random_state=self.random_state, 44 **kwargs, 45 ) 46 elif self.method == ClusterMethod.KMEANS: 47 self.cluster_model = KMeans( 48 n_clusters=self.n_components, 49 random_state=self.random_state, 50 **kwargs, 51 ) 52 else: 53 raise ValueError( 54 f"Unsupported method: {method}. Choose 'gmm' or 'kmeans'." 55 ) 56 57 def fit(self, data): 58 """ 59 Fit the clustering model to the given 2D data. 60 61 :param data: 2D numpy array where each row is a data point and each column is a feature. 62 """ 63 # Input validation 64 if not isinstance(data, np.ndarray): 65 raise TypeError("Data must be a numpy array") 66 if data.ndim != 2: 67 raise ValueError("Data must be 2-dimensional") 68 if len(data) < self.n_components: 69 raise ValueError( 70 f"Number of samples ({len(data)}) must be >= n_components ({self.n_components})" 71 ) 72 73 self.cluster_model.fit(data) 74 75 # Get cluster labels based on the method 76 if self.method == ClusterMethod.GMM: 77 self.cluster_labels = self.cluster_model.predict(data) 78 else: # KMEANS 79 self.cluster_labels = self.cluster_model.labels_ 80 81 return self 82 83 def stratified_sample(self, data, test_size=0.3): 84 """ 85 Perform stratified sampling based on cluster labels. 86 87 :param data: 2D numpy array to sample from. 88 :param test_size: Proportion of data to be used for testing (default is 30%). 89 :return: Tuple of (train_data, test_data) where each is a stratified sample. 90 """ 91 if not hasattr(self, "cluster_labels"): 92 raise ValueError("Must call fit() before stratified_sample()") 93 if len(data) != len(self.cluster_labels): 94 raise ValueError( 95 "Data length must match fitted cluster labels length" 96 ) 97 if not 0 < test_size < 1: 98 raise ValueError("test_size must be between 0 and 1") 99 100 sss = StratifiedShuffleSplit( 101 n_splits=1, test_size=test_size, random_state=self.random_state 102 ) 103 104 for train_index, test_index in sss.split(data, self.cluster_labels): 105 train_data = data[train_index] 106 test_data = data[test_index] 107 108 return train_data, test_data 109 110 def get_cluster_labels(self): 111 """ 112 Get the cluster labels assigned by the clustering model. 113 114 :return: 1D numpy array of cluster labels for each data point. 115 """ 116 if not hasattr(self, "cluster_labels"): 117 raise ValueError("Must call fit() first") 118 return self.cluster_labels 119 120 def predict(self, data): 121 """ 122 Predict the cluster labels for new data using the fitted model. 123 124 :param data: 2D numpy array of new data points. 125 :return: Cluster labels for the new data points. 126 """ 127 if self.method == ClusterMethod.GMM: 128 return self.cluster_model.predict(data) 129 else: # KMEANS 130 return self.cluster_model.predict(data) 131 132 def get_cluster_centers(self): 133 """Get the cluster centers.""" 134 if self.method == ClusterMethod.GMM: 135 if not hasattr(self.cluster_model, "means_"): 136 raise ValueError("GMM not fitted yet") 137 return self.cluster_model.means_ 138 else: # KMEANS 139 if not hasattr(self.cluster_model, "cluster_centers_"): 140 raise ValueError("KMeans not fitted yet") 141 return self.cluster_model.cluster_centers_ 142 143 def get_cluster_proportions(self): 144 """Get the proportion of data points in each cluster.""" 145 if not hasattr(self, "cluster_labels"): 146 raise ValueError("Must call fit() first") 147 unique, counts = np.unique(self.cluster_labels, return_counts=True) 148 return counts / len(self.cluster_labels) 149 150 def score_samples(self, data): 151 """ 152 Get the model scores for samples. 153 For GMM: log-likelihood of samples 154 For KMeans: negative of inertia (distance to closest cluster center) 155 """ 156 if self.method == ClusterMethod.GMM: 157 return self.cluster_model.score_samples(data) 158 else: 159 # For KMeans, return negative distances to cluster centers 160 return -self.cluster_model.transform(data).min(axis=1) 161 162 def get_model_params(self): 163 """Get the parameters of the fitted model.""" 164 return self.cluster_model.get_params() 165 166 def set_method(self, method): 167 """ 168 Change the clustering method after initialization (will require re-fitting). 169 170 :param method: New cluster method ('gmm' or 'kmeans') 171 """ 172 old_method = self.method 173 self.method = ( 174 method 175 if isinstance(method, ClusterMethod) 176 else ClusterMethod(method.lower()) 177 ) 178 179 if self.method != old_method: 180 # Reinitialize the model with the new method 181 if self.method == ClusterMethod.GMM: 182 self.cluster_model = GaussianMixture( 183 n_components=self.n_components, 184 random_state=self.random_state, 185 **self.kwargs, 186 ) 187 else: # KMEANS 188 self.cluster_model = KMeans( 189 n_clusters=self.n_components, 190 random_state=self.random_state, 191 **self.kwargs, 192 ) 193 194 # Remove fitted attributes to force re-fitting 195 if hasattr(self, "cluster_labels"): 196 del self.cluster_labels
57 def fit(self, data): 58 """ 59 Fit the clustering model to the given 2D data. 60 61 :param data: 2D numpy array where each row is a data point and each column is a feature. 62 """ 63 # Input validation 64 if not isinstance(data, np.ndarray): 65 raise TypeError("Data must be a numpy array") 66 if data.ndim != 2: 67 raise ValueError("Data must be 2-dimensional") 68 if len(data) < self.n_components: 69 raise ValueError( 70 f"Number of samples ({len(data)}) must be >= n_components ({self.n_components})" 71 ) 72 73 self.cluster_model.fit(data) 74 75 # Get cluster labels based on the method 76 if self.method == ClusterMethod.GMM: 77 self.cluster_labels = self.cluster_model.predict(data) 78 else: # KMEANS 79 self.cluster_labels = self.cluster_model.labels_ 80 81 return self
Fit the clustering model to the given 2D data.
Parameters
- data: 2D numpy array where each row is a data point and each column is a feature.
120 def predict(self, data): 121 """ 122 Predict the cluster labels for new data using the fitted model. 123 124 :param data: 2D numpy array of new data points. 125 :return: Cluster labels for the new data points. 126 """ 127 if self.method == ClusterMethod.GMM: 128 return self.cluster_model.predict(data) 129 else: # KMEANS 130 return self.cluster_model.predict(data)
Predict the cluster labels for new data using the fitted model.
Parameters
- data: 2D numpy array of new data points.
Returns
Cluster labels for the new data points.
6class SubSampler: 7 """Subsampling class. 8 9 Attributes: 10 11 y: array-like, shape = [n_samples] 12 Target values. 13 14 row_sample: double 15 subsampling fraction 16 17 n_samples: int 18 subsampling by using the number of rows (supersedes row_sample) 19 20 seed: int 21 reproductibility seed 22 23 n_jobs: int 24 number of jobs to run in parallel 25 26 verbose: bool 27 print progress messages and bars 28 """ 29 30 def __init__( 31 self, 32 y, 33 row_sample=0.8, 34 n_samples=None, 35 seed=123, 36 n_jobs=None, 37 verbose=False, 38 ): 39 self.y = y 40 self.n_samples = n_samples 41 if self.n_samples is None: 42 assert ( 43 row_sample < 1 and row_sample >= 0 44 ), "'row_sample' must be provided, plus < 1 and >= 0" 45 self.row_sample = row_sample 46 else: 47 assert self.n_samples < len(y), "'n_samples' must be < len(y)" 48 self.row_sample = self.n_samples / len(y) 49 self.seed = seed 50 self.indices = None 51 self.n_jobs = n_jobs 52 self.verbose = verbose 53 54 def subsample(self): 55 """Returns indices of subsampled input data. 56 57 Examples: 58 59 <ul> 60 <li> <a href="https://github.com/Techtonique/nnetsauce/blob/master/nnetsauce/demo/thierrymoudiki_20240105_subsampling.ipynb">20240105_subsampling.ipynb</a> </li> 61 <li> <a href="https://github.com/Techtonique/nnetsauce/blob/master/nnetsauce/demo/thierrymoudiki_20240131_subsampling_nsamples.ipynb">20240131_subsampling_nsamples.ipynb</a> </li> 62 </ul> 63 64 """ 65 self.indices = dosubsample( 66 y=self.y, 67 row_sample=self.row_sample, 68 seed=self.seed, 69 n_jobs=self.n_jobs, 70 verbose=self.verbose, 71 ) 72 return self.indices
Subsampling class.
Attributes:
y: array-like, shape = [n_samples] Target values.
row_sample: double subsampling fraction
n_samples: int subsampling by using the number of rows (supersedes row_sample)
seed: int reproductibility seed
n_jobs: int number of jobs to run in parallel
verbose: bool print progress messages and bars
54 def subsample(self): 55 """Returns indices of subsampled input data. 56 57 Examples: 58 59 <ul> 60 <li> <a href="https://github.com/Techtonique/nnetsauce/blob/master/nnetsauce/demo/thierrymoudiki_20240105_subsampling.ipynb">20240105_subsampling.ipynb</a> </li> 61 <li> <a href="https://github.com/Techtonique/nnetsauce/blob/master/nnetsauce/demo/thierrymoudiki_20240131_subsampling_nsamples.ipynb">20240131_subsampling_nsamples.ipynb</a> </li> 62 </ul> 63 64 """ 65 self.indices = dosubsample( 66 y=self.y, 67 row_sample=self.row_sample, 68 seed=self.seed, 69 n_jobs=self.n_jobs, 70 verbose=self.verbose, 71 ) 72 return self.indices
Returns indices of subsampled input data.
Examples:
12class SmartHealthSimulator: 13 """ 14 Simulates a synthetic, multimodal time series dataset resembling wearable, environmental, 15 behavioral, and self-reported health data over time. Includes numeric, categorical, and text data. 16 17 The simulator generates realistic daily records including: 18 - Heart rate 19 - Steps 20 - Skin and ambient temperature 21 - Activity label (rest, walk, exercise) 22 - Mood score (1-5) 23 - Mood notes (short texts) 24 - Air quality index 25 - Sleep quality (dependent variable) 26 27 Includes methods for plotting time series, distributions, relationships, and text-based visualizations. 28 29 Examples 30 -------- 31 >>> sim = SmartHealthSimulator(days=180, seed=42) 32 >>> print(sim.data.head()) 33 >>> sim.plot_time_series() 34 >>> sim.plot_mood_sleep() 35 >>> sim.plot_activity_distribution() 36 >>> sim.plot_mood_wordcloud() 37 """ 38 39 def __init__(self, days: int = 180, seed: int = 123): 40 """ 41 Create a new simulator instance and generate synthetic data 42 43 Parameters 44 ---------- 45 days : int, optional 46 Number of days to simulate (default: 180) 47 seed : int, optional 48 Random seed for reproducibility (default: 123) 49 """ 50 self.seed = seed 51 self._n_days = days 52 self.data = None 53 self._generate_data() 54 55 def _generate_data(self) -> None: 56 """Internal data generation function""" 57 np.random.seed(self.seed) 58 n = self._n_days 59 60 # Generate timestamps 61 start_date = datetime(2025, 1, 1) 62 timestamps = [start_date + timedelta(days=i) for i in range(n)] 63 64 # Generate numeric variables 65 hr_mean = np.round(np.random.normal(70, 10, n), 1) 66 steps = np.round(np.random.normal(7000, 3000, n)).astype(int) 67 skin_temp = np.round(np.random.normal(36.5, 0.4, n), 1) 68 ambient_temp = np.round(np.random.normal(23, 3, n), 1) 69 air_quality_index = np.round(np.random.uniform(20, 120, n)).astype(int) 70 71 # Generate activity labels based on steps 72 activity_label = pd.cut( 73 steps, 74 bins=[-np.inf, 3000, 7000, np.inf], 75 labels=["rest", "walk", "exercise"], 76 ) 77 78 # Generate mood score with dependencies 79 mood_score = ( 80 3 81 + 0.001 * (steps - 7000) 82 - 0.01 * (air_quality_index - 50) 83 + np.random.normal(0, 0.5, n) 84 ) 85 mood_score = np.round(np.clip(mood_score, 1, 5)).astype(int) 86 87 # Generate mood notes 88 mood_phrases = [ 89 "Felt great today.", 90 "Very tired.", 91 "Worked out hard.", 92 "Anxious and stressed.", 93 "Calm and productive day.", 94 "Slept poorly.", 95 "Long day at work.", 96 ] 97 98 mood_note = [] 99 for ms in mood_score: 100 if ms >= 4: 101 mood_note.append( 102 np.random.choice( 103 [mood_phrases[0], mood_phrases[2], mood_phrases[4]] 104 ) 105 ) 106 elif ms <= 2: 107 mood_note.append( 108 np.random.choice( 109 [mood_phrases[1], mood_phrases[3], mood_phrases[5]] 110 ) 111 ) 112 else: 113 mood_note.append(np.random.choice(mood_phrases)) 114 115 # Generate sleep quality (dependent variable) 116 activity_penalty = np.where(activity_label == "exercise", -10, 0) 117 118 sleep_quality = ( 119 100 120 - 0.2 * hr_mean 121 + 0.01 * steps 122 + 5 * (mood_score - 3) 123 - 0.3 * air_quality_index 124 + activity_penalty 125 + np.random.normal(0, 5, n) 126 ) 127 sleep_quality = np.round(np.clip(sleep_quality, 0, 100), 1) 128 129 # Create DataFrame 130 self.data = pd.DataFrame( 131 { 132 "timestamp": timestamps, 133 "hr_mean": hr_mean, 134 "steps": steps, 135 "skin_temp": skin_temp, 136 "ambient_temp": ambient_temp, 137 "activity_label": activity_label, 138 "mood_score": mood_score, 139 "mood_note": mood_note, 140 "air_quality_index": air_quality_index, 141 "sleep_quality": sleep_quality, 142 } 143 ) 144 145 def plot_time_series(self, vars: Optional[List[str]] = None) -> plt.Figure: 146 """ 147 Plot time series of selected numeric variables 148 149 Parameters 150 ---------- 151 vars : list of str, optional 152 Column names to plot (default: ["hr_mean", "steps", "sleep_quality"]) 153 154 Returns 155 ------- 156 matplotlib.figure.Figure 157 The time series plot 158 """ 159 if vars is None: 160 vars = ["hr_mean", "steps", "sleep_quality"] 161 162 fig, axes = plt.subplots(len(vars), 1, figsize=(12, 3 * len(vars))) 163 if len(vars) == 1: 164 axes = [axes] 165 166 for i, var in enumerate(vars): 167 axes[i].plot(self.data["timestamp"], self.data[var], linewidth=2) 168 axes[i].set_title(f"Time Series of {var}") 169 axes[i].set_xlabel("Date") 170 axes[i].set_ylabel(var) 171 axes[i].tick_params(axis="x", rotation=45) 172 173 plt.tight_layout() 174 return fig 175 176 def plot_mood_sleep(self) -> plt.Figure: 177 """ 178 Plot the relationship between mood score and sleep quality 179 180 Returns 181 ------- 182 matplotlib.figure.Figure 183 Scatter plot with regression line 184 """ 185 fig, ax = plt.subplots(figsize=(10, 6)) 186 187 # Create jitter for discrete mood scores 188 mood_jitter = self.data["mood_score"] + np.random.normal( 189 0, 0.1, len(self.data) 190 ) 191 192 sns.regplot( 193 x=mood_jitter, 194 y=self.data["sleep_quality"], 195 scatter_kws={"alpha": 0.6, "color": "steelblue"}, 196 line_kws={"color": "darkred"}, 197 ax=ax, 198 ) 199 200 ax.set_xlabel("Mood Score") 201 ax.set_ylabel("Sleep Quality") 202 ax.set_title("Sleep Quality vs. Mood Score") 203 ax.set_xticks(range(1, 6)) 204 ax.grid(True, alpha=0.3) 205 206 plt.tight_layout() 207 return fig 208 209 def plot_activity_distribution(self) -> plt.Figure: 210 """ 211 Plot the distribution of activity labels 212 213 Returns 214 ------- 215 matplotlib.figure.Figure 216 Bar chart of activity distribution 217 """ 218 fig, ax = plt.subplots(figsize=(10, 6)) 219 220 activity_counts = self.data["activity_label"].value_counts() 221 colors = plt.cm.Set2(np.linspace(0, 1, len(activity_counts))) 222 223 bars = ax.bar( 224 activity_counts.index, activity_counts.values, color=colors 225 ) 226 ax.set_xlabel("Activity") 227 ax.set_ylabel("Count") 228 ax.set_title("Activity Label Distribution") 229 230 # Add value labels on bars 231 for bar in bars: 232 height = bar.get_height() 233 ax.text( 234 bar.get_x() + bar.get_width() / 2.0, 235 height, 236 f"{int(height)}", 237 ha="center", 238 va="bottom", 239 ) 240 241 plt.tight_layout() 242 return fig 243 244 def plot_mood_wordcloud(self) -> plt.Figure: 245 """ 246 Create a word cloud from the self-reported mood notes 247 248 Returns 249 ------- 250 matplotlib.figure.Figure 251 Word cloud visualization 252 """ 253 try: 254 # Combine all mood notes 255 text = " ".join(self.data["mood_note"].tolist()) 256 257 # Generate word cloud 258 wordcloud = WordCloud( 259 width=800, 260 height=400, 261 background_color="white", 262 colormap="viridis", 263 max_words=100, 264 ).generate(text) 265 266 fig, ax = plt.subplots(figsize=(12, 6)) 267 ax.imshow(wordcloud, interpolation="bilinear") 268 ax.set_title("Mood Notes Word Cloud", fontsize=16) 269 ax.axis("off") 270 271 plt.tight_layout() 272 return fig 273 274 except ImportError: 275 warnings.warn( 276 "wordcloud package not installed. Install with: pip install wordcloud" 277 ) 278 return None 279 280 def __repr__(self) -> str: 281 return f"SmartHealthSimulator(days={self._n_days}, seed={self.seed})" 282 283 def __str__(self) -> str: 284 return f"SmartHealthSimulator with {len(self.data)} days of synthetic health data"
Simulates a synthetic, multimodal time series dataset resembling wearable, environmental, behavioral, and self-reported health data over time. Includes numeric, categorical, and text data.
The simulator generates realistic daily records including:
- Heart rate
- Steps
- Skin and ambient temperature
- Activity label (rest, walk, exercise)
- Mood score (1-5)
- Mood notes (short texts)
- Air quality index
- Sleep quality (dependent variable)
Includes methods for plotting time series, distributions, relationships, and text-based visualizations.
Examples
>>> sim = SmartHealthSimulator(days=180, seed=42)
>>> print(sim.data.head())
>>> sim.plot_time_series()
>>> sim.plot_mood_sleep()
>>> sim.plot_activity_distribution()
>>> sim.plot_mood_wordcloud()
13class DistanceMetrics: 14 def __init__(self, vector, matrix): 15 self.vector = np.array(vector) 16 self.matrix = np.array(matrix) 17 18 def euclidean_distance(self): 19 """Euclidean (L2) Distance between vector and each row of the matrix.""" 20 return np.linalg.norm(self.matrix - self.vector, axis=1) 21 22 def manhattan_distance(self): 23 """Manhattan (L1) Distance between vector and each row of the matrix.""" 24 return np.sum(np.abs(self.matrix - self.vector), axis=1) 25 26 def cosine_distance(self): 27 """Cosine Distance between vector and each row of the matrix.""" 28 similarities = cosine_similarity( 29 self.vector.reshape(1, -1), self.matrix 30 ) 31 return 1 - similarities.flatten() 32 33 def mahalanobis_distance(self): 34 """Mahalanobis Distance between vector and each row of the matrix.""" 35 cov_matrix = np.cov(self.matrix.T) 36 inv_cov_matrix = np.linalg.inv(cov_matrix) 37 return [ 38 distance.mahalanobis(self.vector, m, inv_cov_matrix) 39 for m in self.matrix 40 ] 41 42 def chebyshev_distance(self): 43 """Chebyshev Distance (Maximum absolute difference).""" 44 return np.max(np.abs(self.matrix - self.vector), axis=1) 45 46 def hamming_distance(self): 47 """Hamming Distance between vector and each row of the matrix (for binary data).""" 48 return np.sum(self.matrix != self.vector, axis=1) 49 50 def jaccard_distance(self): 51 """Jaccard Distance between vector and each row of the matrix (for binary data).""" 52 return [ 53 1 - jaccard_score(self.vector, m, average="binary") 54 for m in self.matrix 55 ] 56 57 def weighted_euclidean_distance(self, weights): 58 """Weighted Euclidean Distance between vector and each row of the matrix.""" 59 weights = np.array(weights) 60 return np.sqrt( 61 np.sum(weights * (self.matrix - self.vector) ** 2, axis=1) 62 ) 63 64 def kullback_leibler_divergence(self, P, Q): 65 """Kullback-Leibler Divergence between two distributions P and Q.""" 66 return entropy(P, Q) 67 68 def wasserstein_distance(self, distribution_1, distribution_2): 69 """Wasserstein Distance (Earth Mover's Distance) between two distributions.""" 70 return wasserstein_distance(distribution_1, distribution_2) 71 72 def pearson_correlation(self): 73 """Pearson Correlation between the vector and each row of the matrix.""" 74 return [pearsonr(self.vector, m)[0] for m in self.matrix] 75 76 def jensen_shannon_divergence(self, P, Q): 77 """Jensen-Shannon Divergence between two distributions.""" 78 M = 0.5 * (P + Q) 79 return 0.5 * (kl_div(P, M).sum() + kl_div(Q, M).sum()) 80 81 def total_variation_distance(self, P, Q): 82 """Total Variation Distance between two distributions.""" 83 return 0.5 * np.sum(np.abs(P - Q)) 84 85 def qqplot_with_summary( 86 self, data1, data2, label1="Sample 1", label2="Sample 2" 87 ): 88 data1 = np.asarray(data1) 89 data2 = np.asarray(data2) 90 91 # Remove NaN values 92 data1 = data1[~np.isnan(data1)] 93 data2 = data2[~np.isnan(data2)] 94 95 # Q–Q plot 96 n_quantiles = min(len(data1), len(data2)) 97 quantiles1 = np.percentile(data1, np.linspace(0, 100, n_quantiles)) 98 quantiles2 = np.percentile(data2, np.linspace(0, 100, n_quantiles)) 99 100 plt.figure(figsize=(6, 6)) 101 plt.scatter(quantiles1, quantiles2, alpha=0.7) 102 min_val = min(quantiles1.min(), quantiles2.min()) 103 max_val = max(quantiles1.max(), quantiles2.max()) 104 plt.plot([min_val, max_val], [min_val, max_val], "r--", label="y = x") 105 plt.xlabel(label1) 106 plt.ylabel(label2) 107 plt.title("Q–Q Plot") 108 plt.legend() 109 plt.grid(True) 110 plt.show() 111 112 # Descriptive stats 113 mean1, mean2 = np.mean(data1), np.mean(data2) 114 std1, std2 = np.std(data1, ddof=1), np.std(data2, ddof=1) 115 n1, n2 = len(data1), len(data2) 116 117 # Kolmogorov–Smirnov test 118 ks_stat, ks_p = stats.ks_2samp(data1, data2) 119 120 # Anderson–Darling test (two-sample) 121 ad_result = stats.anderson_ksamp([data1, data2]) 122 ad_stat = ad_result.statistic 123 ad_p = ad_result.significance_level / 100 # convert % to proportion 124 125 # Quantile correlation 126 corr = np.corrcoef(quantiles1, quantiles2)[0, 1] 127 128 # Summary table 129 summary = pd.DataFrame( 130 { 131 "Statistic": [ 132 "Sample size", 133 "Mean", 134 "Std. deviation", 135 "KS statistic", 136 "KS p-value", 137 "AD statistic", 138 "AD p-value", 139 "Quantile correlation", 140 ], 141 label1: [n1, mean1, std1, ks_stat, ks_p, ad_stat, ad_p, corr], 142 label2: [n2, mean2, std2, "", "", "", "", ""], 143 } 144 ) 145 146 return summary
26class MaximumEntropyBootstrap: 27 """ 28 Maximum Entropy Bootstrap for time series inference with plotting and hypothesis testing. 29 """ 30 31 def __init__(self, trim: float = 0.10, random_state: Optional[int] = None): 32 self.trim = trim 33 self.random_state = random_state 34 if random_state is not None: 35 np.random.seed(random_state) 36 37 # Storage for intermediate results 38 self.order_stats_ = None 39 self.ordering_index_ = None 40 self.intermediate_points_ = None 41 self.interval_means_ = None 42 self.limits_ = None 43 self.original_series_ = None 44 45 def _calculate_trimmed_mean_diff(self, x: np.ndarray) -> float: 46 """Calculate trimmed mean of consecutive differences.""" 47 diffs = np.diff(x) 48 if len(diffs) == 0: 49 return 0.0 50 51 lower_bound = np.percentile(diffs, self.trim * 100) 52 upper_bound = np.percentile(diffs, (1 - self.trim) * 100) 53 trimmed_diffs = diffs[(diffs >= lower_bound) & (diffs <= upper_bound)] 54 55 return ( 56 np.mean(trimmed_diffs) if len(trimmed_diffs) > 0 else np.mean(diffs) 57 ) 58 59 def _calculate_interval_means(self, order_stats: np.ndarray) -> np.ndarray: 60 """Calculate means for each interval using mean-preserving constraint.""" 61 T = len(order_stats) 62 means = np.zeros(T) 63 64 means[0] = 0.75 * order_stats[0] + 0.25 * order_stats[1] 65 66 for k in range(1, T - 1): 67 means[k] = ( 68 0.25 * order_stats[k - 1] 69 + 0.50 * order_stats[k] 70 + 0.25 * order_stats[k + 1] 71 ) 72 73 means[T - 1] = 0.25 * order_stats[T - 2] + 0.75 * order_stats[T - 1] 74 75 return means 76 77 def fit( 78 self, x: Union[np.ndarray, List, pd.Series] 79 ) -> "MaximumEntropyBootstrap": 80 """Fit the ME bootstrap to the time series.""" 81 x = np.asarray(x) 82 if len(x) < 3: 83 raise ValueError("Time series must have at least 3 observations") 84 85 self.original_series_ = x.copy() 86 T = len(x) 87 88 # Step 1: Sort data and store ordering index 89 self.ordering_index_ = np.argsort(x) 90 self.order_stats_ = x[self.ordering_index_] 91 92 # Step 2: Compute intermediate points 93 self.intermediate_points_ = ( 94 self.order_stats_[:-1] + self.order_stats_[1:] 95 ) / 2 96 97 # Step 3: Compute limits for tails 98 m_trim = self._calculate_trimmed_mean_diff(x) 99 z0 = self.order_stats_[0] - m_trim 100 zT = self.order_stats_[-1] + m_trim 101 102 self.limits_ = (z0, zT) 103 104 # Step 4: Compute interval means 105 self.interval_means_ = self._calculate_interval_means(self.order_stats_) 106 107 return self 108 109 def _generate_me_quantiles(self, size: int) -> np.ndarray: 110 """Generate quantiles from maximum entropy density.""" 111 z0, zT = self.limits_ 112 all_z_points = np.concatenate([[z0], self.intermediate_points_, [zT]]) 113 114 u = np.random.uniform(0, 1, size) 115 quantiles = np.zeros(size) 116 n_intervals = len(all_z_points) - 1 117 118 for i in range(size): 119 interval_idx = int(u[i] * n_intervals) 120 interval_idx = min(interval_idx, n_intervals - 1) 121 122 interval_start = all_z_points[interval_idx] 123 interval_end = all_z_points[interval_idx + 1] 124 interval_frac = (u[i] * n_intervals) - interval_idx 125 126 quantiles[i] = interval_start + interval_frac * ( 127 interval_end - interval_start 128 ) 129 130 return quantiles 131 132 def sample(self, reps: int = 999) -> np.ndarray: 133 """Generate bootstrap replicates.""" 134 if self.order_stats_ is None: 135 raise ValueError("Must call fit() before sample()") 136 137 T = len(self.order_stats_) 138 ensemble = np.zeros((T, reps)) 139 140 for j in range(reps): 141 me_quantiles = self._generate_me_quantiles(T) 142 sorted_quantiles = np.sort(me_quantiles) 143 144 original_order_quantiles = np.zeros(T) 145 for i, idx in enumerate(self.ordering_index_): 146 original_order_quantiles[idx] = sorted_quantiles[i] 147 148 ensemble[:, j] = original_order_quantiles 149 150 return ensemble 151 152 # ==================== PLOTTING METHODS ==================== 153 154 def plot_me_density(self, figsize: Tuple[int, int] = (12, 8)) -> plt.Figure: 155 """Plot the maximum entropy density with intervals.""" 156 if self.order_stats_ is None: 157 raise ValueError("Must call fit() first") 158 159 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=figsize) 160 161 # Plot 1: ME Density Intervals 162 z0, zT = self.limits_ 163 all_z_points = np.concatenate([[z0], self.intermediate_points_, [zT]]) 164 165 for i in range(len(all_z_points) - 1): 166 ax1.axvspan( 167 all_z_points[i], 168 all_z_points[i + 1], 169 alpha=0.3, 170 label=f"Interval {i+1}" if i == 0 else "", 171 ) 172 ax1.axvline(all_z_points[i], color="red", linestyle="--", alpha=0.7) 173 174 ax1.axvline(all_z_points[-1], color="red", linestyle="--", alpha=0.7) 175 ax1.set_title("Maximum Entropy Density Intervals") 176 ax1.set_xlabel("Value") 177 ax1.set_ylabel("Intervals") 178 ax1.legend() 179 180 # Plot 2: Original vs Order Statistics 181 ax2.plot( 182 self.original_series_, "o-", label="Original Series", alpha=0.7 183 ) 184 ax2.plot(self.order_stats_, "s-", label="Order Statistics", alpha=0.7) 185 ax2.set_title("Original Series vs Order Statistics") 186 ax2.set_xlabel("Index") 187 ax2.set_ylabel("Value") 188 ax2.legend() 189 ax2.grid(True, alpha=0.3) 190 191 plt.tight_layout() 192 return fig 193 194 def plot_bootstrap_ensemble( 195 self, reps: int = 50, figsize: Tuple[int, int] = (15, 10) 196 ) -> plt.Figure: 197 """Plot multiple bootstrap replicates with original series.""" 198 ensemble = self.sample(reps) 199 200 fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=figsize) 201 202 # Plot 1: All replicates 203 time_index = np.arange(len(self.original_series_)) 204 for j in range(min(reps, 50)): # Limit to 50 for clarity 205 ax1.plot(time_index, ensemble[:, j], alpha=0.1, color="blue") 206 207 ax1.plot( 208 time_index, 209 self.original_series_, 210 "r-", 211 linewidth=2, 212 label="Original", 213 ) 214 ax1.set_title(f"ME Bootstrap Ensemble ({reps} replicates)") 215 ax1.set_xlabel("Time") 216 ax1.set_ylabel("Value") 217 ax1.legend() 218 ax1.grid(True, alpha=0.3) 219 220 # Plot 2: Mean and confidence intervals 221 mean_ensemble = np.mean(ensemble, axis=1) 222 ci_lower = np.percentile(ensemble, 2.5, axis=1) 223 ci_upper = np.percentile(ensemble, 97.5, axis=1) 224 225 ax2.fill_between( 226 time_index, ci_lower, ci_upper, alpha=0.3, label="95% CI" 227 ) 228 ax2.plot(time_index, mean_ensemble, "b-", label="Ensemble Mean") 229 ax2.plot(time_index, self.original_series_, "r-", label="Original") 230 ax2.set_title("Ensemble Mean and 95% Confidence Intervals") 231 ax2.set_xlabel("Time") 232 ax2.set_ylabel("Value") 233 ax2.legend() 234 ax2.grid(True, alpha=0.3) 235 236 # Plot 3: Distribution at selected time points 237 if len(time_index) >= 5: 238 selected_times = np.linspace(0, len(time_index) - 1, 5, dtype=int) 239 for i, t in enumerate(selected_times): 240 ax3.hist( 241 ensemble[t, :], 242 bins=30, 243 alpha=0.5, 244 label=f"Time {t}", 245 density=True, 246 ) 247 ax3.set_title("Distribution at Selected Time Points") 248 ax3.set_xlabel("Value") 249 ax3.set_ylabel("Density") 250 ax3.legend() 251 252 plt.tight_layout() 253 return fig 254 255 def plot_sampling_distribution( 256 self, 257 statistic: Callable, 258 reps: int = 999, 259 figsize: Tuple[int, int] = (12, 10), 260 ) -> plt.Figure: 261 """Plot sampling distribution of a statistic.""" 262 ensemble = self.sample(reps) 263 264 # Calculate statistic for each bootstrap sample 265 stats_boot = np.zeros(reps) 266 for j in range(reps): 267 stats_boot[j] = statistic(ensemble[:, j]) 268 269 original_stat = statistic(self.original_series_) 270 271 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) 272 273 # Histogram with KDE 274 ax1.hist(stats_boot, bins=30, density=True, alpha=0.7, color="skyblue") 275 ax1.axvline( 276 original_stat, 277 color="red", 278 linestyle="--", 279 linewidth=2, 280 label=f"Original: {original_stat:.3f}", 281 ) 282 ax1.axvline( 283 np.mean(stats_boot), 284 color="green", 285 linestyle="--", 286 linewidth=2, 287 label=f"Bootstrap Mean: {np.mean(stats_boot):.3f}", 288 ) 289 ax1.set_title("Bootstrap Sampling Distribution") 290 ax1.set_xlabel("Statistic Value") 291 ax1.set_ylabel("Density") 292 ax1.legend() 293 ax1.grid(True, alpha=0.3) 294 295 # Q-Q plot for normality check 296 stats.probplot(stats_boot, dist="norm", plot=ax2) 297 ax2.set_title("Q-Q Plot for Normality Check") 298 299 plt.tight_layout() 300 return fig, stats_boot 301 302 # ==================== HYPOTHESIS TESTING METHODS ==================== 303 304 def hypothesis_test( 305 self, 306 statistic: Callable, 307 null_value: float = 0, 308 alternative: str = "two-sided", 309 reps: int = 999, 310 confidence: float = 0.95, 311 ) -> HypothesisTestResult: 312 """ 313 Perform hypothesis test using ME bootstrap. 314 315 Parameters 316 ---------- 317 statistic : callable 318 Function that computes the test statistic 319 null_value : float, default=0 320 Value under the null hypothesis 321 alternative : str, default='two-sided' 322 Alternative hypothesis: 'two-sided', 'less', or 'greater' 323 reps : int, default=999 324 Number of bootstrap replicates 325 confidence : float, default=0.95 326 Confidence level for interval 327 328 Returns 329 ------- 330 HypothesisTestResult 331 """ 332 if alternative not in ["two-sided", "less", "greater"]: 333 raise ValueError( 334 "Alternative must be 'two-sided', 'less', or 'greater'" 335 ) 336 337 ensemble = self.sample(reps) 338 339 # Calculate statistic for each bootstrap sample 340 stats_boot = np.zeros(reps) 341 for j in range(reps): 342 stats_boot[j] = statistic(ensemble[:, j]) 343 344 original_stat = statistic(self.original_series_) 345 346 # Calculate p-value based on alternative hypothesis 347 if alternative == "two-sided": 348 p_value = 2 * min( 349 np.mean(stats_boot <= null_value), 350 np.mean(stats_boot >= null_value), 351 ) 352 ci_lower = np.percentile(stats_boot, (1 - confidence) / 2 * 100) 353 ci_upper = np.percentile( 354 stats_boot, (1 - (1 - confidence) / 2) * 100 355 ) 356 elif alternative == "less": 357 p_value = np.mean(stats_boot <= null_value) 358 ci_lower = np.percentile(stats_boot, (1 - confidence) * 100) 359 ci_upper = np.inf 360 else: # 'greater' 361 p_value = np.mean(stats_boot >= null_value) 362 ci_lower = -np.inf 363 ci_upper = np.percentile(stats_boot, confidence * 100) 364 365 reject_null = p_value < (1 - confidence) 366 367 return HypothesisTestResult( 368 statistic=original_stat, 369 p_value=p_value, 370 ci_lower=ci_lower, 371 ci_upper=ci_upper, 372 null_value=null_value, 373 alternative=alternative, 374 reject_null=reject_null, 375 test_type="bootstrap", 376 ) 377 378 def test_mean( 379 self, 380 null_value: float = 0, 381 alternative: str = "two-sided", 382 reps: int = 999, 383 confidence: float = 0.95, 384 ) -> HypothesisTestResult: 385 """Test hypothesis about the mean.""" 386 return self.hypothesis_test( 387 statistic=np.mean, 388 null_value=null_value, 389 alternative=alternative, 390 reps=reps, 391 confidence=confidence, 392 ) 393 394 def test_median( 395 self, 396 null_value: float = 0, 397 alternative: str = "two-sided", 398 reps: int = 999, 399 confidence: float = 0.95, 400 ) -> HypothesisTestResult: 401 """Test hypothesis about the median.""" 402 return self.hypothesis_test( 403 statistic=np.median, 404 null_value=null_value, 405 alternative=alternative, 406 reps=reps, 407 confidence=confidence, 408 ) 409 410 def test_variance( 411 self, 412 null_value: float = 1, 413 alternative: str = "two-sided", 414 reps: int = 999, 415 confidence: float = 0.95, 416 ) -> HypothesisTestResult: 417 """Test hypothesis about the variance.""" 418 return self.hypothesis_test( 419 statistic=np.var, 420 null_value=null_value, 421 alternative=alternative, 422 reps=reps, 423 confidence=confidence, 424 ) 425 426 def test_correlation( 427 self, 428 y: np.ndarray, 429 null_value: float = 0, 430 alternative: str = "two-sided", 431 reps: int = 999, 432 confidence: float = 0.95, 433 ) -> HypothesisTestResult: 434 """Test hypothesis about correlation with another series.""" 435 if len(y) != len(self.original_series_): 436 raise ValueError("y must have same length as original series") 437 438 def corr_statistic(x): 439 return np.corrcoef(x, y)[0, 1] 440 441 return self.hypothesis_test( 442 statistic=corr_statistic, 443 null_value=null_value, 444 alternative=alternative, 445 reps=reps, 446 confidence=confidence, 447 ) 448 449 def compare_means( 450 self, 451 y: np.ndarray, 452 null_value: float = 0, 453 alternative: str = "two-sided", 454 reps: int = 999, 455 confidence: float = 0.95, 456 ) -> HypothesisTestResult: 457 """Test for difference in means between two series.""" 458 459 def mean_diff_statistic(x): 460 return np.mean(x) - np.mean(y) 461 462 return self.hypothesis_test( 463 statistic=mean_diff_statistic, 464 null_value=null_value, 465 alternative=alternative, 466 reps=reps, 467 confidence=confidence, 468 ) 469 470 # ==================== UTILITY METHODS ==================== 471 472 def get_params(self) -> dict: 473 """Get parameters of the fitted ME bootstrap.""" 474 return { 475 "order_stats": self.order_stats_, 476 "ordering_index": self.ordering_index_, 477 "intermediate_points": self.intermediate_points_, 478 "interval_means": self.interval_means_, 479 "limits": self.limits_, 480 "trim": self.trim, 481 } 482 483 def summary(self) -> pd.DataFrame: 484 """Generate summary statistics of the original series.""" 485 if self.original_series_ is None: 486 raise ValueError("Must call fit() first") 487 488 x = self.original_series_ 489 stats_dict = { 490 "n_observations": len(x), 491 "mean": np.mean(x), 492 "median": np.median(x), 493 "std_dev": np.std(x), 494 "variance": np.var(x), 495 "min": np.min(x), 496 "max": np.max(x), 497 "skewness": stats.skew(x), 498 "kurtosis": stats.kurtosis(x), 499 } 500 501 return pd.DataFrame([stats_dict])
Maximum Entropy Bootstrap for time series inference with plotting and hypothesis testing.
77 def fit( 78 self, x: Union[np.ndarray, List, pd.Series] 79 ) -> "MaximumEntropyBootstrap": 80 """Fit the ME bootstrap to the time series.""" 81 x = np.asarray(x) 82 if len(x) < 3: 83 raise ValueError("Time series must have at least 3 observations") 84 85 self.original_series_ = x.copy() 86 T = len(x) 87 88 # Step 1: Sort data and store ordering index 89 self.ordering_index_ = np.argsort(x) 90 self.order_stats_ = x[self.ordering_index_] 91 92 # Step 2: Compute intermediate points 93 self.intermediate_points_ = ( 94 self.order_stats_[:-1] + self.order_stats_[1:] 95 ) / 2 96 97 # Step 3: Compute limits for tails 98 m_trim = self._calculate_trimmed_mean_diff(x) 99 z0 = self.order_stats_[0] - m_trim 100 zT = self.order_stats_[-1] + m_trim 101 102 self.limits_ = (z0, zT) 103 104 # Step 4: Compute interval means 105 self.interval_means_ = self._calculate_interval_means(self.order_stats_) 106 107 return self
Fit the ME bootstrap to the time series.
28class TsDistroSimulator: 29 def __init__( 30 self, 31 kernel="rbf", 32 backend="numpy", 33 kde_kernel="gaussian", 34 random_state=None, 35 residual_sampling="bootstrap", 36 block_size=None, 37 gmm_components=3, 38 ): 39 self.kernel = kernel 40 self.backend = backend 41 self.random_state = random_state 42 self.residual_sampling = residual_sampling 43 self.block_size = block_size 44 self.gmm_components = gmm_components 45 self.kde_kernel = kde_kernel 46 self.Y_ = None 47 self.n_samples_ = None 48 49 if random_state is not None: 50 np.random.seed(random_state) 51 if JAX_AVAILABLE: 52 key = jax.random.PRNGKey(random_state) 53 54 valid_sampling_methods = [ 55 "bootstrap", 56 "kde", 57 "gmm", 58 "block-bootstrap", 59 "me-bootstrap", 60 ] 61 if residual_sampling not in valid_sampling_methods: 62 raise ValueError( 63 f"residual_sampling must be one of {valid_sampling_methods}" 64 ) 65 66 if backend in ["gpu", "tpu"] and JAX_AVAILABLE: 67 self._setup_jax_backend() 68 elif backend in ["gpu", "tpu"] and not JAX_AVAILABLE: 69 print("JAX not available. Falling back to NumPy backend.") 70 self.backend = "numpy" 71 72 self.model = None 73 self.residuals_ = None 74 self.X_dist = None 75 self.is_fitted = False 76 self.best_params_ = None 77 self.best_score_ = None 78 self.kde_model_ = None 79 self.gmm_model_ = None 80 81 def _setup_jax_backend(self): 82 if not JAX_AVAILABLE: 83 raise ImportError("JAX is required for GPU/TPU backend") 84 85 @jit 86 def pairwise_sq_dists_jax(X1, X2): 87 X1_sq = jnp.sum(X1**2, axis=1)[:, jnp.newaxis] 88 X2_sq = jnp.sum(X2**2, axis=1)[jnp.newaxis, :] 89 return X1_sq + X2_sq - 2 * X1 @ X2.T 90 91 @jit 92 def cdist_jax(X1, X2): 93 return vmap( 94 lambda x: vmap(lambda y: jnp.sqrt(jnp.sum((x - y) ** 2)))(X2) 95 )(X1) 96 97 self._pairwise_sq_dists_jax = pairwise_sq_dists_jax 98 self._cdist_jax = cdist_jax 99 100 def _create_model(self, gamma, alpha, lags=20, n_hidden_features=5): 101 return ns.MTS( 102 obj=KernelRidge(kernel=self.kernel, gamma=gamma, alpha=alpha), 103 lags=lags, 104 n_hidden_features=n_hidden_features, 105 ) 106 107 def _fit_residual_sampler(self, **kwargs): 108 if self.residuals_ is None or len(self.residuals_) == 0: 109 raise ValueError("No residuals available for fitting sampler") 110 111 if self.residual_sampling == "kde": 112 kernel_bandwidths = {"bandwidth": np.logspace(-6, 6, 150)} 113 grid = GridSearchCV( 114 KernelDensity(kernel=self.kde_kernel, **kwargs), 115 param_grid=kernel_bandwidths, 116 ) 117 grid.fit(self.residuals_) 118 self.kde_model_ = grid.best_estimator_ 119 self.kde_model_.fit(self.residuals_) 120 121 elif self.residual_sampling == "gmm": 122 self.gmm_model_ = GaussianMixture( 123 n_components=min(self.gmm_components, len(self.residuals_)), 124 random_state=self.random_state, 125 covariance_type="full", 126 ) 127 self.gmm_model_.fit(self.residuals_) 128 129 def _sample_residuals(self, num_samples, random_state=123): 130 if self.residuals_ is None: 131 raise ValueError("No residuals available for sampling") 132 133 n = len(self.residuals_) 134 135 if self.residual_sampling == "bootstrap": 136 np.random.seed(random_state) 137 if num_samples <= n: 138 idx = np.random.choice(n, num_samples, replace=True) 139 return self.residuals_[idx] 140 else: 141 n_repeats = (num_samples // n) + 1 142 tiled = np.tile(self.residuals_, (n_repeats, 1)) 143 idx = np.random.choice(len(tiled), num_samples, replace=False) 144 return tiled[idx] 145 146 elif self.residual_sampling == "kde": 147 if self.kde_model_ is None: 148 raise ValueError( 149 "KDE model not fitted. Call _fit_residual_sampler first." 150 ) 151 152 samples = self.kde_model_.sample( 153 num_samples, random_state=random_state 154 ) 155 156 if samples.ndim == 1: 157 samples = samples.reshape(-1, 1) 158 return samples 159 160 elif self.residual_sampling == "gmm": 161 if self.gmm_model_ is None: 162 raise ValueError( 163 "GMM model not fitted. Call _fit_residual_sampler first." 164 ) 165 166 # Set random state before sampling 167 np.random.seed(random_state) 168 samples = self.gmm_model_.sample(num_samples)[0] 169 170 if samples.ndim == 1: 171 samples = samples.reshape(-1, 1) 172 return samples 173 174 elif self.residual_sampling == "me-bootstrap": 175 meb = MaximumEntropyBootstrap(random_state=random_state) 176 residuals = self.residuals_.flatten() 177 if residuals.shape[0] < num_samples: 178 repeats = int(np.ceil(num_samples / residuals.shape[0])) 179 residuals = np.tile(residuals, repeats)[:num_samples] 180 else: 181 residuals = residuals[:num_samples] 182 meb.fit(residuals) 183 samples = meb.sample(1)[:, 0].reshape(-1, 1) 184 # Ensure we have exactly num_samples 185 if len(samples) < num_samples: 186 n_repeats = (num_samples // len(samples)) + 1 187 samples = np.tile(samples, (n_repeats, 1))[:num_samples] 188 return samples 189 190 elif self.residual_sampling == "block-bootstrap": 191 samples = bootstrap( 192 self.residuals_, 193 num_samples, 194 block_size=self.block_size, 195 seed=random_state, 196 ) 197 # Ensure correct shape 198 if samples.ndim == 1: 199 samples = samples.reshape(-1, 1) 200 return samples 201 202 else: 203 raise ValueError( 204 f"Unknown sampling method: {self.residual_sampling}" 205 ) 206 207 def _pairwise_sq_dists(self, X1, X2): 208 if self.backend in ["gpu", "tpu"] and JAX_AVAILABLE: 209 X1_jax = jnp.array(X1) 210 X2_jax = jnp.array(X2) 211 result = self._pairwise_sq_dists_jax(X1_jax, X2_jax) 212 return np.array(result) 213 else: 214 X1 = np.atleast_2d(X1) 215 X2 = np.atleast_2d(X2) 216 return ( 217 np.sum(X1**2, axis=1)[:, np.newaxis] 218 + np.sum(X2**2, axis=1)[np.newaxis, :] 219 - 2 * X1 @ X2.T 220 ) 221 222 def _mmd(self, u, v, kernel_sigma=1): 223 if u.ndim == 1: 224 u = u.reshape(-1, 1) 225 if v.ndim == 1: 226 v = v.reshape(-1, 1) 227 228 def kmat(A, B): 229 return np.exp( 230 -self._pairwise_sq_dists(A, B) / (2 * kernel_sigma**2) 231 ) 232 233 return ( 234 np.mean(kmat(u, u)) + np.mean(kmat(v, v)) - 2 * np.mean(kmat(u, v)) 235 ) 236 237 def _custom_energy_distance(self, u, v): 238 if u.ndim == 1: 239 u = u.reshape(-1, 1) 240 if v.ndim == 1: 241 v = v.reshape(-1, 1) 242 243 n, d = u.shape 244 m = v.shape[0] 245 246 if self.backend in ["gpu", "tpu"] and JAX_AVAILABLE: 247 u_jax = jnp.array(u) 248 v_jax = jnp.array(v) 249 dist_xx = self._cdist_jax(u_jax, u_jax) 250 dist_yy = self._cdist_jax(v_jax, v_jax) 251 dist_xy = self._cdist_jax(u_jax, v_jax) 252 term1 = 2 * jnp.sum(dist_xy) / (n * m) 253 term2 = jnp.sum(dist_xx) / (n * n) 254 term3 = jnp.sum(dist_yy) / (m * m) 255 return float(term1 - term2 - term3) 256 else: 257 dist_xx = cdist(u, u, metric="euclidean") 258 dist_yy = cdist(v, v, metric="euclidean") 259 dist_xy = cdist(u, v, metric="euclidean") 260 term1 = 2 * np.sum(dist_xy) / (n * m) 261 term2 = np.sum(dist_xx) / (n * n) 262 term3 = np.sum(dist_yy) / (m * m) 263 return term1 - term2 - term3 264 265 def _generate_pseudo_single(self, random_state=123): 266 """ 267 Generate a single synthetic realization. 268 269 Returns original data (structure) + resampled residuals (noise) 270 Each call produces a different realization due to different residual samples. 271 """ 272 if not self.is_fitted: 273 raise ValueError("Model not fitted. Call fit() first.") 274 275 n_rows = self.n_samples_ 276 277 # Base: original time series structure 278 base = self.Y_.copy() 279 if base.ndim == 1: 280 base = base.reshape(-1, 1) 281 282 # Noise: sample new residuals from learned distribution 283 residuals = self._sample_residuals(n_rows, random_state) 284 285 # Ensure residuals match the size of base 286 # This is needed because model residuals may be shorter due to lags 287 if residuals.shape[0] < n_rows: 288 n_repeats = (n_rows // residuals.shape[0]) + 1 289 residuals = np.tile(residuals, (n_repeats, 1))[:n_rows] 290 elif residuals.shape[0] > n_rows: 291 residuals = residuals[:n_rows] 292 293 # Return: structure + new noise realization 294 return base + residuals 295 296 def fit(self, Y, metric="energy", n_trials=50, **kwargs): 297 if Y.ndim == 1: 298 Y = Y.reshape(-1, 1) 299 300 n, d = Y.shape 301 self.n_features_ = d 302 self.n_samples_ = n 303 self.Y_ = Y # Store once before optimization 304 305 self.X_dist = np.random.normal(0, 1, (n, d)) 306 307 def objective(trial): 308 sigma = trial.suggest_float("sigma", 0.01, 10, log=True) 309 lambd = trial.suggest_float("lambd", 1e-5, 1, log=True) 310 lags = trial.suggest_int("lags", 1, 50) 311 n_hidden_features = trial.suggest_int("n_hidden_features", 1, 20) 312 gamma = 1 / (2 * sigma**2) 313 314 model = self._create_model(gamma, lambd, lags, n_hidden_features) 315 model.fit(Y) 316 317 # Generate synthetic sample using this model's residuals 318 Y_sim = self._generate_pseudo_with_model( 319 model, model.residuals_, n, random_state=trial.number 320 ) 321 322 if metric == "energy": 323 dist_val = self._custom_energy_distance(Y, Y_sim) 324 elif metric == "mmd": 325 dist_val = self._mmd(Y, Y_sim) 326 elif metric == "wasserstein" and d == 1: 327 dist_val = stats.wasserstein_distance( 328 Y.flatten(), Y_sim.flatten() 329 ) 330 else: 331 raise ValueError("Invalid metric for dimension") 332 333 return dist_val 334 335 study = optuna.create_study(direction="minimize") 336 study.optimize(objective, n_trials=n_trials, **kwargs) 337 338 self.best_params_ = study.best_params 339 self.best_score_ = study.best_value 340 sigma = self.best_params_["sigma"] 341 lambd = self.best_params_["lambd"] 342 lags = self.best_params_["lags"] 343 n_hidden_features = self.best_params_["n_hidden_features"] 344 gamma = 1 / (2 * sigma**2) 345 346 self.model = self._create_model(gamma, lambd, lags, n_hidden_features) 347 self.model.fit(Y) 348 349 self.residuals_ = self.model.residuals_ 350 351 self._fit_residual_sampler() 352 self.is_fitted = True 353 354 print(f" Best energy distance: {self.best_score_:.6f}") 355 print(f" Best lags: {lags}, n_hidden_features: {n_hidden_features}") 356 357 return self 358 359 def _generate_pseudo_with_model( 360 self, model, residuals, num_samples, random_state=None 361 ): 362 """Helper function for optimization - temporarily uses different residuals""" 363 # Temporarily store and swap residual models 364 original_residuals = self.residuals_ 365 original_kde = self.kde_model_ 366 original_gmm = self.gmm_model_ 367 368 self.residuals_ = residuals 369 370 # Only fit if using kde or gmm 371 if self.residual_sampling in ["kde", "gmm"]: 372 self._fit_residual_sampler() 373 374 # Length of actual residuals from the model 375 residual_len = len(residuals) 376 377 # Use provided random state or generate one 378 if random_state is None: 379 random_state = np.random.randint(0, 10000) 380 381 # Sample residuals matching the residual length 382 sampled_residuals = self._sample_residuals( 383 residual_len, random_state=random_state 384 ) 385 386 # Restore original state 387 self.residuals_ = original_residuals 388 self.kde_model_ = original_kde 389 self.gmm_model_ = original_gmm 390 391 # The model with lags produces residuals shorter than original data 392 # We need to align: use only the portion of Y that corresponds to residuals 393 # Typically, if lags=L, residuals start from index L 394 y_slice = self.Y_[-residual_len:] # Take the last residual_len points 395 396 # Return: aligned data + resampled residuals 397 return y_slice + sampled_residuals 398 399 def sample(self, n_samples=1): 400 """ 401 Generate synthetic samples via distribution matching. 402 403 Each sample is: original structure + resampled residuals 404 405 Parameters: 406 ----------- 407 n_samples : int, default=1 408 Number of synthetic realizations to generate 409 410 Returns: 411 -------- 412 samples : ndarray 413 - If Y was univariate (n_rows, 1): returns shape (n_rows, n_samples) 414 - If Y was multivariate (n_rows, n_features): returns shape (n_features, n_rows, n_samples) 415 """ 416 if not self.is_fitted: 417 raise ValueError("Model not fitted. Call fit() first.") 418 419 # Generate n_samples realizations, each with shape (n_rows, n_features) 420 samples_list = [] 421 for i, _ in enumerate(range(n_samples)): 422 sample = self._generate_pseudo_single( 423 random_state=1000 + i 424 ) # Shape: (n_rows, n_features) 425 samples_list.append(sample) 426 427 # Stack to get shape (n_samples, n_rows, n_features) 428 stacked = np.stack(samples_list, axis=0) 429 430 # If univariate (n_features == 1), return (n_rows, n_samples) 431 if self.n_features_ == 1: 432 result = stacked.squeeze( 433 axis=2 434 ).T # (n_samples, n_rows) -> (n_rows, n_samples) 435 else: 436 # If multivariate, return (n_features, n_rows, n_samples) 437 result = stacked.transpose( 438 2, 1, 0 439 ) # (n_samples, n_rows, n_features) -> (n_features, n_rows, n_samples) 440 441 return result 442 443 def compare_distributions(self, Y_orig, Y_sim, save_prefix=""): 444 """ 445 Visual comparison of original and synthetic distributions. 446 447 Parameters: 448 ----------- 449 Y_orig : array-like 450 Original data 451 Y_sim : array-like 452 Synthetic data 453 save_prefix : str, default='' 454 Prefix for saving plots 455 """ 456 if Y_orig.ndim == 1: 457 Y_orig = Y_orig.reshape(-1, 1) 458 if Y_sim.ndim == 1: 459 Y_sim = Y_sim.reshape(-1, 1) 460 461 n, d = Y_orig.shape 462 463 # Create a figure with subplots for statistical tests 464 fig, axes = plt.subplots(2, d, figsize=(6 * d, 10)) 465 if d == 1: 466 axes = axes.reshape(2, 1) 467 468 # Statistical test results storage 469 ks_results = [] 470 ad_results = [] 471 472 for i in range(d): 473 # Top row: Histograms with statistical test annotations 474 ax_hist = axes[0, i] 475 476 # Plot histograms 477 ax_hist.hist( 478 Y_orig[:, i], 479 alpha=0.5, 480 label="Original", 481 density=True, 482 bins=20, 483 color="blue", 484 ) 485 ax_hist.hist( 486 Y_sim[:, i], 487 alpha=0.5, 488 label="Simulated", 489 density=True, 490 bins=20, 491 color="red", 492 ) 493 494 # Perform statistical tests 495 ks_stat, ks_pvalue = stats.ks_2samp(Y_orig[:, i], Y_sim[:, i]) 496 ks_results.append((ks_stat, ks_pvalue)) 497 498 ad_result = stats.anderson_ksamp([Y_orig[:, i], Y_sim[:, i]]) 499 ad_stat = ad_result.statistic 500 ad_critical = ad_result.critical_values 501 ad_significance = ad_result.significance_level 502 ad_results.append((ad_stat, ad_significance)) 503 504 # Add test results to histogram plot 505 textstr = "\n".join( 506 ( 507 f"KS test: p = {ks_pvalue:.4f}", 508 f"AD test: p < {ad_significance:.3f}", 509 f"AD stat: {ad_stat:.4f}", 510 ) 511 ) 512 props = dict(boxstyle="round", facecolor="wheat", alpha=0.8) 513 ax_hist.text( 514 0.05, 515 0.95, 516 textstr, 517 transform=ax_hist.transAxes, 518 fontsize=10, 519 verticalalignment="top", 520 bbox=props, 521 ) 522 523 ax_hist.legend() 524 ax_hist.set_title( 525 f"Dimension {i+1} - Histograms with Statistical Tests" 526 ) 527 ax_hist.set_xlabel("Value") 528 ax_hist.set_ylabel("Density") 529 530 # Bottom row: ECDFs with KS test visualization 531 ax_ecdf = axes[1, i] 532 533 # Compute ECDFs 534 sorted_orig = np.sort(Y_orig[:, i]) 535 ecdf_orig = np.arange(1, len(sorted_orig) + 1) / len(sorted_orig) 536 sorted_sim = np.sort(Y_sim[:, i]) 537 ecdf_sim = np.arange(1, len(sorted_sim) + 1) / len(sorted_sim) 538 539 # Plot ECDFs 540 ax_ecdf.step( 541 sorted_orig, 542 ecdf_orig, 543 label="Original", 544 color="blue", 545 linewidth=2, 546 ) 547 ax_ecdf.step( 548 sorted_sim, 549 ecdf_sim, 550 label="Simulated", 551 color="red", 552 linewidth=2, 553 ) 554 555 # Find the point of maximum difference for KS test 556 all_values = np.sort(np.concatenate([sorted_orig, sorted_sim])) 557 ecdf_orig_all = np.searchsorted( 558 sorted_orig, all_values, side="right" 559 ) / len(sorted_orig) 560 ecdf_sim_all = np.searchsorted( 561 sorted_sim, all_values, side="right" 562 ) / len(sorted_sim) 563 diff = np.abs(ecdf_orig_all - ecdf_sim_all) 564 max_idx = np.argmax(diff) 565 max_x = all_values[max_idx] 566 max_y1 = ecdf_orig_all[max_idx] 567 max_y2 = ecdf_sim_all[max_idx] 568 569 # Mark the maximum difference point 570 ax_ecdf.plot( 571 [max_x, max_x], 572 [max_y1, max_y2], 573 "k-", 574 linewidth=3, 575 label=f"KS stat: {ks_stat:.4f}", 576 ) 577 ax_ecdf.plot(max_x, max_y1, "ko", markersize=8) 578 ax_ecdf.plot(max_x, max_y2, "ko", markersize=8) 579 580 ax_ecdf.legend() 581 ax_ecdf.set_title(f"Dimension {i+1} - ECDFs with KS Statistic") 582 ax_ecdf.set_xlabel("Value") 583 ax_ecdf.set_ylabel("ECDF") 584 585 plt.tight_layout() 586 if save_prefix: 587 plt.savefig( 588 f"{save_prefix}_statistical_comparison.png", 589 dpi=300, 590 bbox_inches="tight", 591 ) 592 plt.show() 593 594 # Print comprehensive test results 595 print("\n" + "=" * 60) 596 print("COMPREHENSIVE STATISTICAL TEST RESULTS") 597 print("=" * 60) 598 599 for i in range(d): 600 ks_stat, ks_pvalue = ks_results[i] 601 ad_stat, ad_significance = ad_results[i] 602 603 print(f"\nDimension {i+1}:") 604 print(f" Kolmogorov-Smirnov Test:") 605 print(f" Statistic: {ks_stat:.6f}") 606 print(f" p-value: {ks_pvalue:.6f}") 607 print( 608 f" Significance: {'Not Significant' if ks_pvalue > 0.05 else 'SIGNIFICANT'}" 609 ) 610 611 print(f" Anderson-Darling Test:") 612 print(f" Statistic: {ad_stat:.6f}") 613 print(f" Significance level: {ad_significance:.3f}") 614 print( 615 f" Interpretation: {'Distributions differ' if ad_stat > ad_result.critical_values[2] else 'Distributions similar'}" 616 ) 617 618 # Q-Q plots for each dimension 619 fig, axes = plt.subplots(1, d, figsize=(5 * d, 5)) 620 if d == 1: 621 axes = [axes] 622 623 for i in range(d): 624 orig_sorted = np.sort(Y_orig[:, i]) 625 sim_sorted = np.sort(Y_sim[:, i]) 626 627 n_orig = len(orig_sorted) 628 n_sim = len(sim_sorted) 629 630 n_points = min(n_orig, n_sim, 1000) 631 quantiles = np.linspace(0, 1, n_points) 632 633 orig_quantiles = np.quantile(orig_sorted, quantiles) 634 sim_quantiles = np.quantile(sim_sorted, quantiles) 635 636 axes[i].plot( 637 orig_quantiles, sim_quantiles, "o", alpha=0.6, markersize=3 638 ) 639 min_val = min(orig_quantiles.min(), sim_quantiles.min()) 640 max_val = max(orig_quantiles.max(), sim_quantiles.max()) 641 axes[i].plot( 642 [min_val, max_val], 643 [min_val, max_val], 644 "r--", 645 alpha=0.8, 646 linewidth=2, 647 ) 648 axes[i].set_xlabel("Original Data Quantiles") 649 axes[i].set_ylabel("Simulated Data Quantiles") 650 axes[i].set_title(f"Dimension {i+1} - Q-Q Plot") 651 652 corr = np.corrcoef(orig_quantiles, sim_quantiles)[0, 1] 653 axes[i].text( 654 0.05, 655 0.95, 656 f"Corr: {corr:.4f}", 657 transform=axes[i].transAxes, 658 bbox=dict( 659 boxstyle="round,pad=0.3", facecolor="white", alpha=0.8 660 ), 661 verticalalignment="top", 662 ) 663 664 plt.tight_layout() 665 if save_prefix: 666 plt.savefig( 667 f"{save_prefix}_qq_plots.png", dpi=300, bbox_inches="tight" 668 ) 669 plt.show() 670 671 return { 672 "ks_results": ks_results, 673 "ad_results": ad_results, 674 "dimensions": d, 675 }
296 def fit(self, Y, metric="energy", n_trials=50, **kwargs): 297 if Y.ndim == 1: 298 Y = Y.reshape(-1, 1) 299 300 n, d = Y.shape 301 self.n_features_ = d 302 self.n_samples_ = n 303 self.Y_ = Y # Store once before optimization 304 305 self.X_dist = np.random.normal(0, 1, (n, d)) 306 307 def objective(trial): 308 sigma = trial.suggest_float("sigma", 0.01, 10, log=True) 309 lambd = trial.suggest_float("lambd", 1e-5, 1, log=True) 310 lags = trial.suggest_int("lags", 1, 50) 311 n_hidden_features = trial.suggest_int("n_hidden_features", 1, 20) 312 gamma = 1 / (2 * sigma**2) 313 314 model = self._create_model(gamma, lambd, lags, n_hidden_features) 315 model.fit(Y) 316 317 # Generate synthetic sample using this model's residuals 318 Y_sim = self._generate_pseudo_with_model( 319 model, model.residuals_, n, random_state=trial.number 320 ) 321 322 if metric == "energy": 323 dist_val = self._custom_energy_distance(Y, Y_sim) 324 elif metric == "mmd": 325 dist_val = self._mmd(Y, Y_sim) 326 elif metric == "wasserstein" and d == 1: 327 dist_val = stats.wasserstein_distance( 328 Y.flatten(), Y_sim.flatten() 329 ) 330 else: 331 raise ValueError("Invalid metric for dimension") 332 333 return dist_val 334 335 study = optuna.create_study(direction="minimize") 336 study.optimize(objective, n_trials=n_trials, **kwargs) 337 338 self.best_params_ = study.best_params 339 self.best_score_ = study.best_value 340 sigma = self.best_params_["sigma"] 341 lambd = self.best_params_["lambd"] 342 lags = self.best_params_["lags"] 343 n_hidden_features = self.best_params_["n_hidden_features"] 344 gamma = 1 / (2 * sigma**2) 345 346 self.model = self._create_model(gamma, lambd, lags, n_hidden_features) 347 self.model.fit(Y) 348 349 self.residuals_ = self.model.residuals_ 350 351 self._fit_residual_sampler() 352 self.is_fitted = True 353 354 print(f" Best energy distance: {self.best_score_:.6f}") 355 print(f" Best lags: {lags}, n_hidden_features: {n_hidden_features}") 356 357 return self
7class DiversityGenerator: 8 """ 9 Three-step Gaussian Copula transformation for controlled diversity generation 10 while preserving marginal distributions. 11 """ 12 13 def __init__( 14 self, target_correlation=0.1, preserve_moments=True, random_state=None 15 ): 16 self.target_correlation = target_correlation 17 self.preserve_moments = preserve_moments 18 self.random_state = random_state 19 if random_state is not None: 20 np.random.seed(random_state) 21 self.fitted_ = False # Initialize fitted_ attribute 22 23 def fit(self, X): 24 """ 25 STEP 1: Learn ECDFs and create target correlation matrix 26 """ 27 X = np.asarray(X) 28 self.n_samples_, self.n_features_ = X.shape 29 self.original_dtype_ = X.dtype 30 31 # Store original statistics for moment preservation 32 self.original_means_ = np.mean(X, axis=0) 33 self.original_stds_ = np.std(X, axis=0) 34 35 # Store ECDF information for inverse transformation 36 self.sorted_columns_ = [ 37 np.sort(X[:, j]) for j in range(self.n_features_) 38 ] 39 self.quantile_positions_ = (np.arange(1, self.n_samples_ + 1)) / ( 40 self.n_samples_ + 1 41 ) 42 43 # Create target correlation matrix 44 self.target_corr_matrix_ = self._create_target_correlation_matrix() 45 46 # Precompute Cholesky decomposition for correlation application 47 try: 48 self.cholesky_factor_ = np.linalg.cholesky(self.target_corr_matrix_) 49 except np.linalg.LinAlgError: 50 self.target_corr_matrix_ = self._nearest_positive_definite( 51 self.target_corr_matrix_ 52 ) 53 self.cholesky_factor_ = np.linalg.cholesky(self.target_corr_matrix_) 54 55 self.fitted_ = True 56 return self 57 58 def _create_target_correlation_matrix(self): 59 """Create valid target correlation matrix""" 60 if isinstance(self.target_correlation, (int, float)): 61 corr_val = float(self.target_correlation) 62 corr_val = np.clip(corr_val, -1.0 / (self.n_features_ - 1), 1.0) 63 64 corr_matrix = np.full( 65 (self.n_features_, self.n_features_), corr_val 66 ) 67 np.fill_diagonal(corr_matrix, 1.0) 68 69 elif isinstance(self.target_correlation, np.ndarray): 70 corr_matrix = self.target_correlation.copy() 71 np.fill_diagonal(corr_matrix, 1.0) 72 else: 73 raise ValueError("target_correlation must be scalar or matrix") 74 75 return corr_matrix 76 77 def _nearest_positive_definite(self, matrix): 78 """Ensure matrix is positive definite""" 79 n = matrix.shape[0] 80 matrix = (matrix + matrix.T) / 2 81 82 min_eigval = np.min(np.linalg.eigvals(matrix)) 83 if min_eigval > 0: 84 return matrix 85 86 identity = np.eye(n) 87 for k in range(1, 1000): 88 candidate = matrix + k * 1e-8 * identity 89 if np.min(np.linalg.eigvals(candidate)) > 0: 90 return candidate 91 92 return np.eye(n) 93 94 def transform_to_gaussian(self, X): 95 """ 96 STEP 2: Transform X → ranks → uniform → Gaussian 97 """ 98 X = np.asarray(X) 99 n_new = X.shape[0] 100 Y = np.zeros((n_new, self.n_features_), dtype=float) 101 102 for j in range(self.n_features_): 103 sorted_vals = self.sorted_columns_[j] 104 105 # X → ranks → uniform 106 empirical_cdf = np.searchsorted( 107 sorted_vals, X[:, j], side="right" 108 ) / (self.n_samples_ + 1) 109 empirical_cdf = np.clip(empirical_cdf, 0.001, 0.999) 110 111 # uniform → Gaussian (probit transform) 112 Y[:, j] = norm.ppf(empirical_cdf) 113 114 return Y 115 116 def apply_target_correlation(self, Y): 117 """ 118 STEP 2 (continued): Apply target correlation to Gaussian data 119 """ 120 return Y @ self.cholesky_factor_.T 121 122 def transform_from_gaussian(self, Y_transformed): 123 """ 124 STEP 3: Transform Gaussian → uniform → inverse ECDF → X_diverse 125 """ 126 n_new = Y_transformed.shape[0] 127 Z = np.zeros((n_new, self.n_features_), dtype=float) 128 129 for j in range(self.n_features_): 130 sorted_vals = self.sorted_columns_[j] 131 132 # Gaussian → uniform 133 U_transformed = norm.cdf(Y_transformed[:, j]) 134 135 # uniform → inverse ECDF → X_diverse 136 Z[:, j] = np.interp( 137 U_transformed, self.quantile_positions_, sorted_vals 138 ) 139 140 # Optional moment preservation 141 if self.preserve_moments: 142 Z[:, j] = self._preserve_moments(Z[:, j], j) 143 144 return Z.astype(self.original_dtype_) 145 146 def _preserve_moments(self, values, feature_idx): 147 """Preserve mean and standard deviation if needed""" 148 current_mean = np.mean(values) 149 current_std = np.std(values) 150 151 target_mean = self.original_means_[feature_idx] 152 target_std = self.original_stds_[feature_idx] 153 154 mean_ratio = abs(current_mean - target_mean) / ( 155 abs(target_mean) + 1e-10 156 ) 157 std_ratio = abs(current_std - target_std) / (target_std + 1e-10) 158 159 if mean_ratio > 0.02 or std_ratio > 0.05: 160 values_centered = values - current_mean 161 if current_std > 1e-10: 162 values_scaled = values_centered * (target_std / current_std) 163 else: 164 values_scaled = values_centered 165 return values_scaled + target_mean 166 167 return values 168 169 def generate_diverse_samples(self, X, n_samples=5): 170 """Generate diverse samples using the three-step pipeline""" 171 if not self.fitted_: 172 self.fit(X) 173 174 diverse_samples = [] 175 176 for i in range(n_samples): 177 if i == 0: 178 # First sample: transform original data 179 Y_gaussian = self.transform_to_gaussian(X) 180 else: 181 # Additional samples: generate new Gaussian data 182 Y_gaussian = np.random.normal( 183 0, 1, (self.n_samples_, self.n_features_) 184 ) 185 186 # Apply target correlation 187 Y_diverse = self.apply_target_correlation(Y_gaussian) 188 189 # Transform back to original distributions 190 X_diverse = self.transform_from_gaussian(Y_diverse) 191 diverse_samples.append(X_diverse) 192 193 return np.array(diverse_samples) 194 195 def fit_transform(self, X, n_samples=5): 196 """Fit and generate diverse samples in one call""" 197 return self.generate_diverse_samples(X, n_samples)
Three-step Gaussian Copula transformation for controlled diversity generation while preserving marginal distributions.
23 def fit(self, X): 24 """ 25 STEP 1: Learn ECDFs and create target correlation matrix 26 """ 27 X = np.asarray(X) 28 self.n_samples_, self.n_features_ = X.shape 29 self.original_dtype_ = X.dtype 30 31 # Store original statistics for moment preservation 32 self.original_means_ = np.mean(X, axis=0) 33 self.original_stds_ = np.std(X, axis=0) 34 35 # Store ECDF information for inverse transformation 36 self.sorted_columns_ = [ 37 np.sort(X[:, j]) for j in range(self.n_features_) 38 ] 39 self.quantile_positions_ = (np.arange(1, self.n_samples_ + 1)) / ( 40 self.n_samples_ + 1 41 ) 42 43 # Create target correlation matrix 44 self.target_corr_matrix_ = self._create_target_correlation_matrix() 45 46 # Precompute Cholesky decomposition for correlation application 47 try: 48 self.cholesky_factor_ = np.linalg.cholesky(self.target_corr_matrix_) 49 except np.linalg.LinAlgError: 50 self.target_corr_matrix_ = self._nearest_positive_definite( 51 self.target_corr_matrix_ 52 ) 53 self.cholesky_factor_ = np.linalg.cholesky(self.target_corr_matrix_) 54 55 self.fitted_ = True 56 return self
STEP 1: Learn ECDFs and create target correlation matrix
12class SyntheticTabularSampler: 13 """ 14 A class to generate synthetic tabular datasets for various machine learning tasks. 15 16 This class provides methods to create synthetic datasets for classification, 17 regression, multi-output regression, and multi-label classification problems 18 using scikit-learn's data generation functions. 19 20 Parameters 21 ---------- 22 random_state : int, optional 23 Seed for the random number generator to ensure reproducibility. 24 Defaults to 42. 25 n_samples : int, optional 26 Number of samples to generate. Defaults to 500. 27 type : str, optional 28 The type of synthetic data to generate. 29 Must be one of "classification", "regression", "multioutput_regression", 30 or "multilabel_classification". Defaults to "classification". 31 32 Attributes 33 ---------- 34 random_state : int 35 The seed used for the random number generator. 36 rng : numpy.random.Generator 37 The NumPy random number generator instance. 38 type : str 39 The specified type of synthetic data to generate. 40 """ 41 42 def __init__( 43 self, 44 random_state: int = 42, 45 n_samples: int = 500, 46 type: str = "classification", 47 ): 48 self.random_state = random_state 49 self.n_samples = n_samples 50 self.rng = np.random.default_rng(random_state) 51 self.type = type 52 53 # ============================================================ 54 # INTERNAL COMPONENTS 55 # ============================================================ 56 57 # ---- Classification 58 def _gen_classification(self): 59 n_features = self.rng.integers(5, 120) 60 # Ensure n_informative + n_redundant + n_repeated < n_features 61 max_informative = min( 62 30, n_features - 2 63 ) # Leave room for at least 1 useless feature 64 n_informative = self.rng.integers(2, max_informative + 1) 65 max_redundant = min(10, n_features - n_informative - 1) 66 n_redundant = ( 67 self.rng.integers(0, max_redundant + 1) if max_redundant > 0 else 0 68 ) 69 70 cfg = dict( 71 n_samples=self.n_samples, 72 n_features=n_features, 73 n_informative=n_informative, 74 n_redundant=n_redundant, 75 n_repeated=0, 76 n_classes=int(self.rng.choice([2, 3, 4, 5, 6])), 77 n_clusters_per_class=self.rng.integers(1, 4), 78 class_sep=float(self.rng.uniform(0.5, 5.0)), 79 flip_y=float(self.rng.uniform(0.0, 0.1)), 80 random_state=self.rng.integers(0, 10_000), 81 ) 82 X, y = make_classification(**cfg) 83 return X, y, cfg, "classification" 84 85 # ---- Regression 86 def _gen_regression(self): 87 cfg = dict( 88 n_samples=self.n_samples, 89 n_features=self.rng.integers(5, 120), 90 n_informative=self.rng.integers(2, 40), 91 noise=float(self.rng.uniform(0.1, 25)), 92 bias=float(self.rng.uniform(-10, 10)), 93 effective_rank=( 94 None if self.rng.random() < 0.5 else self.rng.integers(2, 10) 95 ), 96 tail_strength=float(self.rng.uniform(0.0, 1.0)), 97 random_state=self.rng.integers(0, 10_000), 98 ) 99 X, y = make_regression(**cfg) 100 return X, y, cfg, "regression" 101 102 # ---- Multi-output regression 103 def _gen_multioutput_regression(self): 104 n_targets = self.rng.integers(2, 6) 105 cfg = dict( 106 n_samples=self.n_samples, 107 n_features=self.rng.integers(5, 60), 108 n_informative=self.rng.integers(2, 20), 109 noise=float(self.rng.uniform(0.1, 10)), 110 random_state=self.rng.integers(0, 10_000), 111 ) 112 X, y = make_regression(**cfg) 113 # reshape to multi-target 114 y_multi = np.stack( 115 [ 116 y + self.rng.normal(0, cfg["noise"], size=len(y)) 117 for _ in range(n_targets) 118 ], 119 axis=1, 120 ) 121 return X, y_multi, cfg, "multioutput_regression" 122 123 # ---- Multi-label classification 124 def _gen_multilabel_classification(self): 125 cfg = dict( 126 n_samples=self.n_samples, 127 n_features=self.rng.integers(5, 80), 128 n_classes=self.rng.integers(3, 10), 129 n_labels=self.rng.integers(1, 5), 130 length=self.rng.integers(20, 100), 131 allow_unlabeled=False, 132 random_state=self.rng.integers(0, 10_000), 133 ) 134 X, y = make_multilabel_classification(**cfg) 135 return X, y, cfg, "multilabel_classification" 136 137 # ---- Nonlinear regression (sinusoidal / polynomial) 138 def _gen_nonlinear_regression(self): 139 n_features = self.rng.integers(3, 10) 140 X = self.rng.normal(size=(self.n_samples, n_features)) 141 142 # Nonlinear target 143 y = ( 144 np.sin(X[:, 0] * self.rng.uniform(1, 5)) 145 + X[:, 1] ** 2 * self.rng.uniform(0.5, 2.0) 146 + np.tanh(X[:, 2] * self.rng.uniform(1, 3)) 147 + self.rng.normal(0, 0.3, size=self.n_samples) 148 ) 149 150 cfg = {"type": "nonlinear_regression", "n_features": n_features} 151 return X, y, cfg, "nonlinear_regression" 152 153 # ---- Polynomial interactions 154 def _gen_polynomial_features(self): 155 base_features = self.rng.integers(3, 8) 156 degree = self.rng.integers(2, 4) 157 158 X = self.rng.normal(size=(self.n_samples, base_features)) 159 poly = PolynomialFeatures(degree=degree) 160 X_poly = poly.fit_transform(X) 161 162 # regression target 163 coef = self.rng.normal(0, 1, size=X_poly.shape[1]) 164 y = X_poly @ coef + self.rng.normal(0, 0.3, size=self.n_samples) 165 166 cfg = { 167 "base_features": base_features, 168 "degree": degree, 169 "expanded_dim": X_poly.shape[1], 170 } 171 return X_poly, y, cfg, "polynomial_regression" 172 173 # ---- Sparse high-dimensional regression 174 def _gen_sparse_regression(self): 175 n_features = self.rng.integers(500, 2000) 176 X = self.rng.normal(size=(self.n_samples, n_features)) 177 178 # sparse coefficients 179 coef = np.zeros(n_features) 180 k = self.rng.integers(5, 20) 181 idx = self.rng.choice(n_features, size=k, replace=False) 182 coef[idx] = self.rng.normal(0, 5, size=k) 183 184 y = X @ coef + self.rng.normal(0, 0.2, size=self.n_samples) 185 186 cfg = {"n_features": n_features, "nonzero": k} 187 return X, y, cfg, "sparse_regression" 188 189 # ---- Time-series-like AR regression 190 def _gen_ar_regression(self): 191 n_features = self.rng.integers(3, 10) 192 X = self.rng.normal(size=(self.n_samples, n_features)) 193 194 # AR(3)-like structure for y 195 y = np.zeros(self.n_samples) 196 for t in range(3, self.n_samples): 197 y[t] = ( 198 0.6 * y[t - 1] 199 - 0.2 * y[t - 2] 200 + 0.1 * y[t - 3] 201 + X[t] @ self.rng.normal(0, 1, n_features) 202 + self.rng.normal(0, 0.5) 203 ) 204 205 cfg = {"n_features": n_features, "AR_order": 3} 206 return X, y, cfg, "autoregressive_regression" 207 208 # ---- Mixture categorical + numerical 209 def _gen_mixed_features(self): 210 n_num = self.rng.integers(3, 10) 211 n_cat = self.rng.integers(1, 5) 212 213 X_num = self.rng.normal(size=(self.n_samples, n_num)) 214 X_cat = self.rng.integers(0, 5, size=(self.n_samples, n_cat)) 215 216 X = np.concatenate([X_num, X_cat], axis=1) 217 218 coef = self.rng.normal(0, 1, n_num) 219 y = X_num @ coef + self.rng.normal(0, 1, self.n_samples) 220 221 cfg = {"numerical": n_num, "categorical": n_cat} 222 return X, y, cfg, "mixed_regression" 223 224 # ---- Simple causal DAG-like dataset 225 def _gen_causal_style(self): 226 X1 = self.rng.normal(size=self.n_samples) 227 X2 = 2 * X1 + self.rng.normal(0, 0.2, self.n_samples) 228 X3 = -0.7 * X1 + self.rng.normal(0, 0.2, self.n_samples) 229 X4 = 1.5 * X2 + X3 + self.rng.normal(0, 0.2, self.n_samples) 230 X = np.column_stack([X1, X2, X3, X4]) 231 232 y = 3 * X4 + self.rng.normal(0, 1, self.n_samples) 233 234 cfg = {"DAG": "X1→X2/X3→X4→y"} 235 return X, y, cfg, "causal_regression" 236 237 # ============================================================ 238 # PUBLIC METHOD 239 # ============================================================ 240 def sample(self, n_sets: int = 10): 241 """ 242 Generate n_sets diverse datasets with n_samples=500. 243 """ 244 if self.type == "classification": 245 generators = [self._gen_classification] 246 elif self.type == "regression": 247 generators = [ 248 self._gen_regression, 249 self._gen_nonlinear_regression, 250 self._gen_sparse_regression, 251 self._gen_ar_regression, 252 ] 253 else: 254 raise ValueError(f"Unknown type: {self.type}") 255 256 datasets = [] 257 for _ in range(n_sets): 258 gen = self.rng.choice(generators) 259 X, y, cfg, task = gen() 260 261 X_df = pd.DataFrame( 262 X, columns=[f"feature_{i}" for i in range(X.shape[1])] 263 ) 264 y_df = ( 265 pd.DataFrame(y) if y.ndim > 1 else pd.Series(y, name="target") 266 ) 267 268 datasets.append( 269 { 270 "task": task, 271 "config": cfg, 272 "X": X_df, 273 "y": y_df, 274 } 275 ) 276 277 return datasets
A class to generate synthetic tabular datasets for various machine learning tasks.
This class provides methods to create synthetic datasets for classification, regression, multi-output regression, and multi-label classification problems using scikit-learn's data generation functions.
Parameters
random_state : int, optional Seed for the random number generator to ensure reproducibility. Defaults to 42. n_samples : int, optional Number of samples to generate. Defaults to 500. type : str, optional The type of synthetic data to generate. Must be one of "classification", "regression", "multioutput_regression", or "multilabel_classification". Defaults to "classification".
Attributes
random_state : int The seed used for the random number generator. rng : numpy.random.Generator The NumPy random number generator instance. type : str The specified type of synthetic data to generate.
388class PCARVFLSimulator(_RVFLBase): 389 """ 390 GAN-like synthesiser: PCA scores → RVFL → Ŷ + bootstrap residuals. 391 392 Parameters 393 ---------- 394 n_pca_components : int or 'auto' (default). 'auto' picks the 395 minimum components to explain 396 ``pca_variance_threshold`` of variance. 397 pca_variance_threshold : float in (0, 1]. Default 0.95. 398 scale : bool. If True (default), internally fit a 399 StandardScaler on the training data so that PCA 400 and the RVFL operate on z-scored features. 401 Samples are inverse-transformed before return. 402 Set to False only if your data is already scaled. 403 activation : 'tanh' (default) | 'relu' | 'sigmoid' 404 direct_link : skip connection in RVFL. Default True. 405 random_state : int seed. 406 407 Attributes (after fit) 408 ---------------------- 409 scaler_ : fitted StandardScaler (or None if scale=False) 410 pca_ : fitted sklearn PCA (operates on scaled data) 411 model_ : fitted RVFLLayer 412 residuals_ : (n_train, d) training residuals (scaled space) 413 Z_train_ : (n_train, nc) PCA scores 414 best_params_ : {'n_nodes', 'alpha'} from Optuna 415 is_fitted_ : bool 416 417 Examples 418 -------- 419 >>> from sklearn.datasets import load_iris 420 >>> X, _ = load_iris(return_X_y=True) 421 >>> sim = PCARVFLSimulator(random_state=0) 422 >>> sim.fit(X, n_trials=20) 423 >>> X_syn = sim.sample(200) # returned in original scale 424 >>> report = adequacy_report(X, X_syn) 425 """ 426 427 def __init__( 428 self, 429 n_pca_components="auto", 430 pca_variance_threshold=0.95, 431 scale=True, 432 activation="tanh", 433 direct_link=True, 434 random_state=42, 435 ): 436 self.n_pca_components = n_pca_components 437 self.pca_variance_threshold = pca_variance_threshold 438 self.scale = scale 439 self.activation = activation 440 self.direct_link = direct_link 441 self.random_state = random_state 442 self.rng = np.random.RandomState(random_state) 443 self.scaler_ = None 444 self.pca_ = None 445 self.model_ = None 446 self.residuals_ = None 447 self.Z_train_ = None 448 self.best_params_ = None 449 self.is_fitted_ = False 450 451 # ── helpers ─────────────────────────────────────────────────────────────── 452 453 def _auto_nc(self, Y): 454 pca_f = PCA(random_state=self.random_state).fit(Y) 455 cumv = np.cumsum(pca_f.explained_variance_ratio_) 456 return max( 457 1, 458 min( 459 int(np.searchsorted(cumv, self.pca_variance_threshold) + 1), 460 Y.shape[1], 461 ), 462 ) 463 464 # ── fit ─────────────────────────────────────────────────────────────────── 465 466 def fit(self, Y, n_train=None, metric="mmd", n_trials=50): 467 """ 468 Fit the simulator on data matrix Y. 469 470 Parameters 471 ---------- 472 Y : (n, d) real samples (raw, unscaled OK) 473 n_train : training rows (default n // 2) 474 metric : 'mmd' (default) or 'energy' 475 n_trials : Optuna trials (default 50) 476 """ 477 Y = np.asarray(Y, dtype=float) 478 if Y.ndim == 1: 479 Y = Y.reshape(-1, 1) 480 n, d = Y.shape 481 482 # ── optional standardisation ────────────────────────────────────────── 483 if self.scale: 484 self.scaler_ = StandardScaler().fit(Y) 485 Ys = self.scaler_.transform(Y) 486 else: 487 self.scaler_ = None 488 Ys = Y 489 490 # ── PCA latent space (on scaled data) ──────────────────────────────── 491 nc = ( 492 self._auto_nc(Ys) 493 if self.n_pca_components == "auto" 494 else int(self.n_pca_components) 495 ) 496 self.pca_ = PCA(n_components=nc, random_state=self.random_state).fit(Ys) 497 Z_all = self.pca_.transform(Ys) 498 evr = self.pca_.explained_variance_ratio_.sum() 499 print(f" [PCARVFL] {nc} components ({100 * evr:.1f}% var)") 500 501 # ── train / test split ──────────────────────────────────────────────── 502 if n_train is None: 503 n_train = n // 2 504 idx = self.rng.permutation(n) 505 tr, te = idx[:n_train], idx[n_train:] 506 Z_train, Y_train = Z_all[tr], Ys[tr] # everything in scaled space 507 Y_test = Ys[te] 508 self.Z_train_ = Z_train 509 510 # ── Optuna: tune n_nodes and alpha ──────────────────────────────────── 511 def objective(trial): 512 nn = trial.suggest_int("n_nodes", 50, 1000, log=True) 513 a = trial.suggest_float("alpha", 1e-5, 10.0, log=True) 514 m = self._build(nn, a) 515 m.fit(Z_train, Y_train) 516 res = Y_train - m.predict(Z_train) 517 zi = self.rng.choice(len(Z_train), len(te), replace=True) 518 sim = m.predict(Z_train[zi]) 519 sim = sim + res[self.rng.choice(len(res), len(te), replace=True)] 520 # use the module-level biased MMD — no inline reimplementation 521 return ( 522 mmd_biased(Y_test, sim) 523 if metric == "mmd" 524 else energy_distance(Y_test, sim) 525 ) 526 527 self.best_params_, best_val = self._tune(objective, n_trials) 528 529 # ── refit with best params ──────────────────────────────────────────── 530 self.model_ = self._build(**self.best_params_) 531 self.model_.fit(Z_train, Y_train) 532 self.residuals_ = Y_train - self.model_.predict(Z_train) 533 self.is_fitted_ = True 534 print( 535 f" [PCARVFL] nodes={self.best_params_['n_nodes']} " 536 f"α={self.best_params_['alpha']:.2e} " 537 f"{metric}={best_val:.5f}" 538 ) 539 return self 540 541 # ── sample ──────────────────────────────────────────────────────────────── 542 543 def sample(self, n_samples=1): 544 """ 545 Draw n_samples synthetic rows. 546 547 Returns 548 ------- 549 (n_samples, d) array in the *original* (unscaled) feature space. 550 """ 551 if not self.is_fitted_: 552 raise RuntimeError("Call fit() before sample().") 553 zi = self.rng.choice(len(self.Z_train_), n_samples, replace=True) 554 preds = self.model_.predict(self.Z_train_[zi]) 555 eps = self.residuals_[ 556 self.rng.choice(len(self.residuals_), n_samples, replace=True) 557 ] 558 Ys_syn = preds + eps 559 # inverse-transform to original scale 560 if self.scaler_ is not None: 561 return self.scaler_.inverse_transform(Ys_syn) 562 return Ys_syn 563 564 def __repr__(self): 565 status = "fitted" if self.is_fitted_ else "not fitted" 566 return ( 567 f"PCARVFLSimulator(n_pca_components={self.n_pca_components!r}, " 568 f"pca_variance_threshold={self.pca_variance_threshold}, " 569 f"scale={self.scale}, activation={self.activation!r}, " 570 f"direct_link={self.direct_link}, " 571 f"random_state={self.random_state}) [{status}]" 572 )
GAN-like synthesiser: PCA scores → RVFL → Ŷ + bootstrap residuals.
Parameters
n_pca_components : int or 'auto' (default). 'auto' picks the
minimum components to explain
pca_variance_threshold of variance.
pca_variance_threshold : float in (0, 1]. Default 0.95.
scale : bool. If True (default), internally fit a
StandardScaler on the training data so that PCA
and the RVFL operate on z-scored features.
Samples are inverse-transformed before return.
Set to False only if your data is already scaled.
activation : 'tanh' (default) | 'relu' | 'sigmoid'
direct_link : skip connection in RVFL. Default True.
random_state : int seed.
Attributes (after fit)
scaler_ : fitted StandardScaler (or None if scale=False) pca_ : fitted sklearn PCA (operates on scaled data) model_ : fitted RVFLLayer residuals_ : (n_train, d) training residuals (scaled space) Z_train_ : (n_train, nc) PCA scores best_params_ : {'n_nodes', 'alpha'} from Optuna is_fitted_ : bool
Examples
>>> from sklearn.datasets import load_iris
>>> X, _ = load_iris(return_X_y=True)
>>> sim = PCARVFLSimulator(random_state=0)
>>> sim.fit(X, n_trials=20)
>>> X_syn = sim.sample(200) # returned in original scale
>>> report = adequacy_report(X, X_syn)
466 def fit(self, Y, n_train=None, metric="mmd", n_trials=50): 467 """ 468 Fit the simulator on data matrix Y. 469 470 Parameters 471 ---------- 472 Y : (n, d) real samples (raw, unscaled OK) 473 n_train : training rows (default n // 2) 474 metric : 'mmd' (default) or 'energy' 475 n_trials : Optuna trials (default 50) 476 """ 477 Y = np.asarray(Y, dtype=float) 478 if Y.ndim == 1: 479 Y = Y.reshape(-1, 1) 480 n, d = Y.shape 481 482 # ── optional standardisation ────────────────────────────────────────── 483 if self.scale: 484 self.scaler_ = StandardScaler().fit(Y) 485 Ys = self.scaler_.transform(Y) 486 else: 487 self.scaler_ = None 488 Ys = Y 489 490 # ── PCA latent space (on scaled data) ──────────────────────────────── 491 nc = ( 492 self._auto_nc(Ys) 493 if self.n_pca_components == "auto" 494 else int(self.n_pca_components) 495 ) 496 self.pca_ = PCA(n_components=nc, random_state=self.random_state).fit(Ys) 497 Z_all = self.pca_.transform(Ys) 498 evr = self.pca_.explained_variance_ratio_.sum() 499 print(f" [PCARVFL] {nc} components ({100 * evr:.1f}% var)") 500 501 # ── train / test split ──────────────────────────────────────────────── 502 if n_train is None: 503 n_train = n // 2 504 idx = self.rng.permutation(n) 505 tr, te = idx[:n_train], idx[n_train:] 506 Z_train, Y_train = Z_all[tr], Ys[tr] # everything in scaled space 507 Y_test = Ys[te] 508 self.Z_train_ = Z_train 509 510 # ── Optuna: tune n_nodes and alpha ──────────────────────────────────── 511 def objective(trial): 512 nn = trial.suggest_int("n_nodes", 50, 1000, log=True) 513 a = trial.suggest_float("alpha", 1e-5, 10.0, log=True) 514 m = self._build(nn, a) 515 m.fit(Z_train, Y_train) 516 res = Y_train - m.predict(Z_train) 517 zi = self.rng.choice(len(Z_train), len(te), replace=True) 518 sim = m.predict(Z_train[zi]) 519 sim = sim + res[self.rng.choice(len(res), len(te), replace=True)] 520 # use the module-level biased MMD — no inline reimplementation 521 return ( 522 mmd_biased(Y_test, sim) 523 if metric == "mmd" 524 else energy_distance(Y_test, sim) 525 ) 526 527 self.best_params_, best_val = self._tune(objective, n_trials) 528 529 # ── refit with best params ──────────────────────────────────────────── 530 self.model_ = self._build(**self.best_params_) 531 self.model_.fit(Z_train, Y_train) 532 self.residuals_ = Y_train - self.model_.predict(Z_train) 533 self.is_fitted_ = True 534 print( 535 f" [PCARVFL] nodes={self.best_params_['n_nodes']} " 536 f"α={self.best_params_['alpha']:.2e} " 537 f"{metric}={best_val:.5f}" 538 ) 539 return self
Fit the simulator on data matrix Y.
Parameters
Y : (n, d) real samples (raw, unscaled OK) n_train : training rows (default n // 2) metric : 'mmd' (default) or 'energy' n_trials : Optuna trials (default 50)
120def adequacy_report( 121 X_real: np.ndarray, 122 X_syn: np.ndarray, 123 n_proj: int = 50, 124 alpha: float = 0.05, 125 cap: int = 500, 126 random_state: int = 0, 127 verbose: bool = True, 128) -> dict: 129 """ 130 Comprehensive adequacy report comparing real and synthetic samples. 131 132 Parameters 133 ---------- 134 X_real : (n, d) real data — in *original* (unscaled) space 135 X_syn : (m, d) synthetic data — in *original* space 136 n_proj : random projections for the projection sweep (default 50) 137 alpha : significance level for hypothesis tests (default 0.05) 138 cap : max samples for O(n²) metrics MMD / energy (default 500) 139 random_state : RNG seed 140 verbose : print formatted summary 141 142 Returns 143 ------- 144 dict of scalar metrics (plus raw per-feature arrays prefixed with '_') 145 """ 146 X_real = np.asarray(X_real, dtype=float) 147 X_syn = np.asarray(X_syn, dtype=float) 148 if X_real.shape[1] != X_syn.shape[1]: 149 raise ValueError( 150 "X_real and X_syn must have the same number of features." 151 ) 152 d = X_real.shape[1] 153 154 rng = np.random.RandomState(random_state) 155 156 # Standardise both sets with real-data statistics before computing 157 # distance metrics — keeps MMD / Energy on a comparable scale regardless 158 # of raw feature magnitudes. 159 sc = StandardScaler().fit(X_real) 160 Xr_s = sc.transform(X_real) 161 Xs_s = sc.transform(X_syn) 162 163 # ── cap samples for O(n²) metrics ──────────────────────────────────────── 164 def _sub(A): 165 return A[rng.choice(len(A), cap, replace=False)] if len(A) > cap else A 166 167 Xr_cap = _sub(Xr_s) 168 Xs_cap = _sub(Xs_s) 169 170 # ── distributional distances (on standardised data) ─────────────────────── 171 mmd_val = mmd_biased(Xr_cap, Xs_cap) 172 energy_val = energy_distance(Xr_cap, Xs_cap) 173 174 # ── per-feature tests (on original scale — easier to interpret) ─────────── 175 ks_stats, ks_pvals, ad_stats, ad_pvals = [], [], [], [] 176 for j in range(d): 177 r = ks_2samp(X_real[:, j], X_syn[:, j]) 178 ks_stats.append(r.statistic) 179 ks_pvals.append(r.pvalue) 180 try: 181 r2 = anderson_ksamp([X_real[:, j], X_syn[:, j]]) 182 ad_stats.append(r2.statistic) 183 ad_pvals.append(r2.significance_level) 184 except Exception: 185 ad_stats.append(np.nan) 186 ad_pvals.append(np.nan) 187 188 ks_stats = np.array(ks_stats) 189 ks_pvals = np.array(ks_pvals) 190 ad_stats = np.array(ad_stats) 191 ad_pvals = np.array(ad_pvals) 192 193 ks_bonf = float(np.minimum(d * ks_pvals.min(), 1.0)) 194 ks_reject = float((ks_pvals < alpha).mean()) 195 196 valid_adp = ad_pvals[~np.isnan(ad_pvals)] 197 ad_bonf = ( 198 float(np.minimum(d * valid_adp.min(), 1.0)) 199 if len(valid_adp) 200 else np.nan 201 ) 202 ad_reject = float((valid_adp < alpha).mean()) if len(valid_adp) else np.nan 203 204 # ── moment matching (original scale) ───────────────────────────────────── 205 mean_real = X_real.mean(0) 206 mean_syn = X_syn.mean(0) 207 std_real = X_real.std(0) 208 std_syn = X_syn.std(0) 209 210 mean_mae = float(np.abs(mean_real - mean_syn).mean()) 211 std_mae = float(np.abs(std_real - std_syn).mean()) 212 213 nz = std_real > 1e-10 214 std_ratio = ( 215 float(std_syn[nz].mean() / std_real[nz].mean()) if nz.any() else np.nan 216 ) 217 218 # Pearson correlation Frobenius distance (standardised space for fairness) 219 nz2 = (Xr_s.std(0) > 1e-10) & (Xs_s.std(0) > 1e-10) 220 if nz2.sum() > 1: 221 Cr = np.corrcoef(Xr_s[:, nz2].T) 222 Cs = np.corrcoef(Xs_s[:, nz2].T) 223 corr_frob = float(np.linalg.norm(Cr - Cs, "fro")) 224 else: 225 corr_frob = np.nan 226 227 # ── random-projection sweep (standardised) ──────────────────────────────── 228 proj_ks = [] 229 for _ in range(n_proj): 230 v = rng.randn(d) 231 v /= np.linalg.norm(v) + 1e-12 232 proj_ks.append(ks_2samp(Xr_s @ v, Xs_s @ v).statistic) 233 ks_proj = float(np.mean(proj_ks)) 234 235 # ── assemble ────────────────────────────────────────────────────────────── 236 report = dict( 237 MMD=mmd_val, 238 Energy=energy_val, 239 KS_stat=float(ks_stats.mean()), 240 KS_p_bonf=ks_bonf, 241 KS_reject=ks_reject, 242 AD_stat=float(np.nanmean(ad_stats)), 243 AD_p_bonf=ad_bonf, 244 AD_reject=ad_reject, 245 mean_mae=mean_mae, 246 std_mae=std_mae, 247 std_ratio=std_ratio, 248 corr_frob=corr_frob, 249 KS_proj=ks_proj, 250 # raw arrays for downstream plotting 251 _ks_per_feature=ks_stats, 252 _ks_pvals=ks_pvals, 253 _ad_per_feature=ad_stats, 254 _ad_pvals=ad_pvals, 255 n_real=len(X_real), 256 n_syn=len(X_syn), 257 d=d, 258 ) 259 260 if verbose: 261 _print_report(report, alpha, n_proj) 262 263 return report
Comprehensive adequacy report comparing real and synthetic samples.
Parameters
X_real : (n, d) real data — in original (unscaled) space X_syn : (m, d) synthetic data — in original space n_proj : random projections for the projection sweep (default 50) alpha : significance level for hypothesis tests (default 0.05) cap : max samples for O(n²) metrics MMD / energy (default 500) random_state : RNG seed verbose : print formatted summary
Returns
dict of scalar metrics (plus raw per-feature arrays prefixed with '_')