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]
class AdaptiveHistogramSampler:
  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
def fit(self, X):
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()
class DiffusionModel(sklearn.base.BaseEstimator):
  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)
def fit( self, X: numpy.ndarray, y: Optional[numpy.ndarray] = None, n_steps: int = 1000) -> DiffusionModel:
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

def predict(self, n_samples: int = 1, **kwargs) -> numpy.ndarray:
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)

def score(self, X: numpy.ndarray, metric: str = 'mmd') -> float:
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)

class DistroSimulator:
  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        }
def fit(self, Y, n_train=None, metric='energy', n_trials=50, **kwargs):
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

class EmpiricalCopula:
 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.

def fit(self, X: numpy.ndarray) -> EmpiricalCopula:
 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.

class StratifiedClusteringSubsampling:
 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
def fit(self, data):
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.
def predict(self, data):
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.

class SubSampler:
 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

def subsample(self):
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:

class SmartHealthSimulator:
 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()
class DistanceMetrics:
 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
class MaximumEntropyBootstrap:
 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.

def fit( self, x: Union[numpy.ndarray, List, pandas.Series]) -> MaximumEntropyBootstrap:
 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.

class TsDistroSimulator:
 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        }
def fit(self, Y, metric='energy', n_trials=50, **kwargs):
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
class DiversityGenerator:
  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.

def fit(self, X):
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

class SyntheticTabularSampler:
 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.

class PCARVFLSimulator(synthe.pcarvflsimulator._RVFLBase):
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)
def fit(self, Y, n_train=None, metric='mmd', n_trials=50):
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)

def adequacy_report( X_real: numpy.ndarray, X_syn: numpy.ndarray, n_proj: int = 50, alpha: float = 0.05, cap: int = 500, random_state: int = 0, verbose: bool = True) -> dict:
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 '_')