Skip to content

API Reference

src.distributions

Distribution modules for probability distributions.

BayesianGMM

Bayesian Gaussian Mixture Model with automatic component selection.

Source code in src/distributions/mixtures.py
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
class BayesianGMM:
    """Bayesian Gaussian Mixture Model with automatic component selection."""

    def __init__(
        self,
        max_components: int = 10,
        weight_concentration_prior: float = 1.0,
        max_iter: int = 200,
        tol: float = 1e-4,
    ):
        """
        Initialize Bayesian GMM.

        Args:
            max_components: Maximum number of components
            weight_concentration_prior: Dirichlet concentration prior
            max_iter: Maximum EM iterations
            tol: Convergence tolerance
        """
        self.max_components = max_components
        self.bgmm = BayesianGaussianMixture(
            n_components=max_components,
            weight_concentration_prior=weight_concentration_prior,
            max_iter=max_iter,
            tol=tol,
            random_state=42,
        )
        self.fitted = False

    def fit(self, data: np.ndarray) -> "BayesianGMM":
        """
        Fit Bayesian GMM to data.

        Args:
            data: Training data

        Returns:
            Self
        """
        data = _as_2d(data)

        self.bgmm.fit(data)
        self.fitted = True

        return self

    def predict(self, data: np.ndarray) -> np.ndarray:
        """Predict component labels."""
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.bgmm.predict(data)

    def get_active_components(self) -> int:
        """
        Get number of active components (with non-negligible weight).

        Returns:
            Number of active components
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        # Components with weight > 0.01 are considered active
        return np.sum(self.bgmm.weights_ > 0.01)

__init__(max_components=10, weight_concentration_prior=1.0, max_iter=200, tol=0.0001)

Initialize Bayesian GMM.

Parameters:

Name Type Description Default
max_components int

Maximum number of components

10
weight_concentration_prior float

Dirichlet concentration prior

1.0
max_iter int

Maximum EM iterations

200
tol float

Convergence tolerance

0.0001
Source code in src/distributions/mixtures.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def __init__(
    self,
    max_components: int = 10,
    weight_concentration_prior: float = 1.0,
    max_iter: int = 200,
    tol: float = 1e-4,
):
    """
    Initialize Bayesian GMM.

    Args:
        max_components: Maximum number of components
        weight_concentration_prior: Dirichlet concentration prior
        max_iter: Maximum EM iterations
        tol: Convergence tolerance
    """
    self.max_components = max_components
    self.bgmm = BayesianGaussianMixture(
        n_components=max_components,
        weight_concentration_prior=weight_concentration_prior,
        max_iter=max_iter,
        tol=tol,
        random_state=42,
    )
    self.fitted = False

fit(data)

Fit Bayesian GMM to data.

Parameters:

Name Type Description Default
data ndarray

Training data

required

Returns:

Type Description
BayesianGMM

Self

Source code in src/distributions/mixtures.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
def fit(self, data: np.ndarray) -> "BayesianGMM":
    """
    Fit Bayesian GMM to data.

    Args:
        data: Training data

    Returns:
        Self
    """
    data = _as_2d(data)

    self.bgmm.fit(data)
    self.fitted = True

    return self

get_active_components()

Get number of active components (with non-negligible weight).

Returns:

Type Description
int

Number of active components

Source code in src/distributions/mixtures.py
441
442
443
444
445
446
447
448
449
450
451
452
def get_active_components(self) -> int:
    """
    Get number of active components (with non-negligible weight).

    Returns:
        Number of active components
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    # Components with weight > 0.01 are considered active
    return np.sum(self.bgmm.weights_ > 0.01)

predict(data)

Predict component labels.

Source code in src/distributions/mixtures.py
432
433
434
435
436
437
438
439
def predict(self, data: np.ndarray) -> np.ndarray:
    """Predict component labels."""
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.bgmm.predict(data)

BetaDistribution

Bases: Distribution

Beta distribution.

Source code in src/distributions/continuous.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
class BetaDistribution(Distribution):
    """Beta distribution."""

    def __init__(self, alpha: float = 2.0, beta: float = 2.0):
        """
        Initialize Beta distribution.

        Args:
            alpha: Shape parameter (must be > 0)
            beta: Shape parameter (must be > 0)
        """
        super().__init__("Beta", is_discrete=False)
        self.alpha = alpha
        self.beta_param = beta
        self.set_parameters(alpha=alpha, beta=beta)

    def _create_distribution(self, **params):
        """Create scipy beta distribution."""
        return stats.beta(a=params["alpha"], b=params["beta"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"alpha": self.alpha, "beta": self.beta_param}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.alpha = params.get("alpha", self.alpha)
        self.beta_param = params.get("beta", self.beta_param)

        if self.alpha <= 0 or self.beta_param <= 0:
            raise ValueError("alpha and beta must be positive")

        self._dist = self._create_distribution(alpha=self.alpha, beta=self.beta_param)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "alpha": (0.1, 10.0),
            "beta": (0.1, 10.0),
        }

__init__(alpha=2.0, beta=2.0)

Initialize Beta distribution.

Parameters:

Name Type Description Default
alpha float

Shape parameter (must be > 0)

2.0
beta float

Shape parameter (must be > 0)

2.0
Source code in src/distributions/continuous.py
134
135
136
137
138
139
140
141
142
143
144
145
def __init__(self, alpha: float = 2.0, beta: float = 2.0):
    """
    Initialize Beta distribution.

    Args:
        alpha: Shape parameter (must be > 0)
        beta: Shape parameter (must be > 0)
    """
    super().__init__("Beta", is_discrete=False)
    self.alpha = alpha
    self.beta_param = beta
    self.set_parameters(alpha=alpha, beta=beta)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
165
166
167
168
169
170
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "alpha": (0.1, 10.0),
        "beta": (0.1, 10.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
151
152
153
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"alpha": self.alpha, "beta": self.beta_param}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
155
156
157
158
159
160
161
162
163
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.alpha = params.get("alpha", self.alpha)
    self.beta_param = params.get("beta", self.beta_param)

    if self.alpha <= 0 or self.beta_param <= 0:
        raise ValueError("alpha and beta must be positive")

    self._dist = self._create_distribution(alpha=self.alpha, beta=self.beta_param)

BinomialDistribution

Bases: Distribution

Binomial distribution.

Source code in src/distributions/discrete.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class BinomialDistribution(Distribution):
    """Binomial distribution."""

    def __init__(self, n: int = 10, p: float = 0.5):
        """
        Initialize Binomial distribution.

        Args:
            n: Number of trials (must be positive integer)
            p: Probability of success (must be between 0 and 1)
        """
        super().__init__("Binomial", is_discrete=True)
        self.n = n
        self.p = p
        self.set_parameters(n=n, p=p)

    def _create_distribution(self, **params):
        """Create scipy binomial distribution."""
        return stats.binom(n=params["n"], p=params["p"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"n": self.n, "p": self.p}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.n = int(params.get("n", self.n))
        self.p = params.get("p", self.p)

        if self.n <= 0:
            raise ValueError("n must be positive")
        if not 0 <= self.p <= 1:
            raise ValueError("p must be between 0 and 1")

        self._dist = self._create_distribution(n=self.n, p=self.p)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "n": (1, 100),
            "p": (0.0, 1.0),
        }

__init__(n=10, p=0.5)

Initialize Binomial distribution.

Parameters:

Name Type Description Default
n int

Number of trials (must be positive integer)

10
p float

Probability of success (must be between 0 and 1)

0.5
Source code in src/distributions/discrete.py
13
14
15
16
17
18
19
20
21
22
23
24
def __init__(self, n: int = 10, p: float = 0.5):
    """
    Initialize Binomial distribution.

    Args:
        n: Number of trials (must be positive integer)
        p: Probability of success (must be between 0 and 1)
    """
    super().__init__("Binomial", is_discrete=True)
    self.n = n
    self.p = p
    self.set_parameters(n=n, p=p)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
46
47
48
49
50
51
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "n": (1, 100),
        "p": (0.0, 1.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
30
31
32
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"n": self.n, "p": self.p}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
34
35
36
37
38
39
40
41
42
43
44
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.n = int(params.get("n", self.n))
    self.p = params.get("p", self.p)

    if self.n <= 0:
        raise ValueError("n must be positive")
    if not 0 <= self.p <= 1:
        raise ValueError("p must be between 0 and 1")

    self._dist = self._create_distribution(n=self.n, p=self.p)

CauchyDistribution

Bases: Distribution

Cauchy distribution.

Source code in src/distributions/continuous.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
class CauchyDistribution(Distribution):
    """Cauchy distribution."""

    def __init__(self, x0: float = 0.0, gamma: float = 1.0):
        """
        Initialize Cauchy distribution.

        Args:
            x0: Location parameter
            gamma: Scale parameter (must be > 0)
        """
        super().__init__("Cauchy", is_discrete=False)
        self.x0 = x0
        self.gamma = gamma
        self.set_parameters(x0=x0, gamma=gamma)

    def _create_distribution(self, **params):
        """Create scipy cauchy distribution."""
        return stats.cauchy(loc=params["x0"], scale=params["gamma"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"x0": self.x0, "gamma": self.gamma}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.x0 = params.get("x0", self.x0)
        self.gamma = params.get("gamma", self.gamma)

        if self.gamma <= 0:
            raise ValueError("gamma must be positive")

        self._dist = self._create_distribution(x0=self.x0, gamma=self.gamma)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "x0": (-10.0, 10.0),
            "gamma": (0.1, 5.0),
        }

__init__(x0=0.0, gamma=1.0)

Initialize Cauchy distribution.

Parameters:

Name Type Description Default
x0 float

Location parameter

0.0
gamma float

Scale parameter (must be > 0)

1.0
Source code in src/distributions/continuous.py
375
376
377
378
379
380
381
382
383
384
385
386
def __init__(self, x0: float = 0.0, gamma: float = 1.0):
    """
    Initialize Cauchy distribution.

    Args:
        x0: Location parameter
        gamma: Scale parameter (must be > 0)
    """
    super().__init__("Cauchy", is_discrete=False)
    self.x0 = x0
    self.gamma = gamma
    self.set_parameters(x0=x0, gamma=gamma)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
406
407
408
409
410
411
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "x0": (-10.0, 10.0),
        "gamma": (0.1, 5.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
392
393
394
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"x0": self.x0, "gamma": self.gamma}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
396
397
398
399
400
401
402
403
404
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.x0 = params.get("x0", self.x0)
    self.gamma = params.get("gamma", self.gamma)

    if self.gamma <= 0:
        raise ValueError("gamma must be positive")

    self._dist = self._create_distribution(x0=self.x0, gamma=self.gamma)

ChiSquareDistribution

Bases: Distribution

Chi-square distribution.

Source code in src/distributions/continuous.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
class ChiSquareDistribution(Distribution):
    """Chi-square distribution."""

    def __init__(self, df: int = 3):
        """
        Initialize Chi-square distribution.

        Args:
            df: Degrees of freedom (must be > 0)
        """
        super().__init__("Chi-Square", is_discrete=False)
        self.df = df
        self.set_parameters(df=df)

    def _create_distribution(self, **params):
        """Create scipy chi-square distribution."""
        return stats.chi2(df=params["df"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"df": self.df}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.df = params.get("df", self.df)

        if self.df <= 0:
            raise ValueError("df must be positive")

        self._dist = self._create_distribution(df=self.df)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"df": (1, 30)}

__init__(df=3)

Initialize Chi-square distribution.

Parameters:

Name Type Description Default
df int

Degrees of freedom (must be > 0)

3
Source code in src/distributions/continuous.py
218
219
220
221
222
223
224
225
226
227
def __init__(self, df: int = 3):
    """
    Initialize Chi-square distribution.

    Args:
        df: Degrees of freedom (must be > 0)
    """
    super().__init__("Chi-Square", is_discrete=False)
    self.df = df
    self.set_parameters(df=df)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
246
247
248
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"df": (1, 30)}

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
233
234
235
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"df": self.df}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
237
238
239
240
241
242
243
244
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.df = params.get("df", self.df)

    if self.df <= 0:
        raise ValueError("df must be positive")

    self._dist = self._create_distribution(df=self.df)

ClaytonCopula

Bases: Copula

Clayton copula (Archimedean).

Source code in src/distributions/copulas.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
class ClaytonCopula(Copula):
    """Clayton copula (Archimedean)."""

    def __init__(self, theta: float, dimension: int = 2):
        """
        Initialize Clayton copula.

        Args:
            theta: Dependence parameter (theta >= -1/(d-1), theta != 0)
            dimension: Number of dimensions
        """
        if theta == 0:
            raise ValueError("theta must be non-zero")

        min_theta = -1.0 / (dimension - 1) if dimension > 1 else -np.inf
        if theta < min_theta:
            raise ValueError(f"theta must be >= {min_theta}")

        super().__init__("Clayton", dimension)
        self.theta = theta

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Clayton copula CDF."""
        u = np.atleast_2d(u)

        # C(u1, ..., ud) = (u1^(-θ) + ... + ud^(-θ) - d + 1)^(-1/θ)
        sum_terms = np.sum(u ** (-self.theta), axis=1)
        cdf_vals = (sum_terms - self.dimension + 1) ** (-1 / self.theta)

        return cdf_vals

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Clayton copula density (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
            )

        u = np.atleast_2d(u)
        u1, u2 = u[:, 0], u[:, 1]

        # c(u1, u2) = (1 + θ) * (u1*u2)^(-1-θ) * (u1^(-θ) + u2^(-θ) - 1)^(-2-1/θ)
        theta = self.theta

        term1 = 1 + theta
        term2 = (u1 * u2) ** (-1 - theta)
        term3 = (u1 ** (-theta) + u2 ** (-theta) - 1) ** (-2 - 1 / theta)

        pdf_vals = term1 * term2 * term3

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Clayton copula (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
            )

        rng = np.random.default_rng(random_state)

        # Algorithm: Use conditional distribution method
        u1 = rng.uniform(0, 1, size)
        v = rng.uniform(0, 1, size)

        # u2 = (u1^(-θ) * (v^(-θ/(1+θ)) - 1) + 1)^(-1/θ)
        theta = self.theta
        u2 = (u1 ** (-theta) * (v ** (-theta / (1 + theta)) - 1) + 1) ** (-1 / theta)

        return np.column_stack([u1, u2])

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau.

        Returns:
            Kendall's tau
        """
        return self.theta / (self.theta + 2)

    def __repr__(self) -> str:
        return f"ClaytonCopula(theta={self.theta}, dimension={self.dimension})"

__init__(theta, dimension=2)

Initialize Clayton copula.

Parameters:

Name Type Description Default
theta float

Dependence parameter (theta >= -1/(d-1), theta != 0)

required
dimension int

Number of dimensions

2
Source code in src/distributions/copulas.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def __init__(self, theta: float, dimension: int = 2):
    """
    Initialize Clayton copula.

    Args:
        theta: Dependence parameter (theta >= -1/(d-1), theta != 0)
        dimension: Number of dimensions
    """
    if theta == 0:
        raise ValueError("theta must be non-zero")

    min_theta = -1.0 / (dimension - 1) if dimension > 1 else -np.inf
    if theta < min_theta:
        raise ValueError(f"theta must be >= {min_theta}")

    super().__init__("Clayton", dimension)
    self.theta = theta

cdf(u)

Clayton copula CDF.

Source code in src/distributions/copulas.py
182
183
184
185
186
187
188
189
190
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Clayton copula CDF."""
    u = np.atleast_2d(u)

    # C(u1, ..., ud) = (u1^(-θ) + ... + ud^(-θ) - d + 1)^(-1/θ)
    sum_terms = np.sum(u ** (-self.theta), axis=1)
    cdf_vals = (sum_terms - self.dimension + 1) ** (-1 / self.theta)

    return cdf_vals

kendall_tau()

Calculate Kendall's tau.

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
232
233
234
235
236
237
238
239
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau.

    Returns:
        Kendall's tau
    """
    return self.theta / (self.theta + 2)

pdf(u)

Clayton copula density (bivariate only).

Source code in src/distributions/copulas.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Clayton copula density (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
        )

    u = np.atleast_2d(u)
    u1, u2 = u[:, 0], u[:, 1]

    # c(u1, u2) = (1 + θ) * (u1*u2)^(-1-θ) * (u1^(-θ) + u2^(-θ) - 1)^(-2-1/θ)
    theta = self.theta

    term1 = 1 + theta
    term2 = (u1 * u2) ** (-1 - theta)
    term3 = (u1 ** (-theta) + u2 ** (-theta) - 1) ** (-2 - 1 / theta)

    pdf_vals = term1 * term2 * term3

    return pdf_vals

rvs(size=1, random_state=None)

Generate samples from Clayton copula (bivariate only).

Source code in src/distributions/copulas.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Clayton copula (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
        )

    rng = np.random.default_rng(random_state)

    # Algorithm: Use conditional distribution method
    u1 = rng.uniform(0, 1, size)
    v = rng.uniform(0, 1, size)

    # u2 = (u1^(-θ) * (v^(-θ/(1+θ)) - 1) + 1)^(-1/θ)
    theta = self.theta
    u2 = (u1 ** (-theta) * (v ** (-theta / (1 + theta)) - 1) + 1) ** (-1 / theta)

    return np.column_stack([u1, u2])

Copula

Base class for copulas.

Source code in src/distributions/copulas.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Copula:
    """Base class for copulas."""

    def __init__(self, name: str, dimension: int = 2):
        """
        Initialize copula.

        Args:
            name: Copula name
            dimension: Number of dimensions
        """
        self.name = name
        self.dimension = dimension

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """
        Copula CDF.

        Args:
            u: Uniform(0,1) marginals (shape: n x d or d)

        Returns:
            Copula CDF values
        """
        raise NotImplementedError("Subclasses must implement cdf()")

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """
        Copula density.

        Args:
            u: Uniform(0,1) marginals

        Returns:
            Copula density values
        """
        raise NotImplementedError("Subclasses must implement pdf()")

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples from copula.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Uniform(0,1) samples (shape: size x d)
        """
        raise NotImplementedError("Subclasses must implement rvs()")

    def kendall_tau(self) -> float:
        """Calculate Kendall's tau."""
        raise NotImplementedError("Subclasses must implement kendall_tau()")

__init__(name, dimension=2)

Initialize copula.

Parameters:

Name Type Description Default
name str

Copula name

required
dimension int

Number of dimensions

2
Source code in src/distributions/copulas.py
11
12
13
14
15
16
17
18
19
20
def __init__(self, name: str, dimension: int = 2):
    """
    Initialize copula.

    Args:
        name: Copula name
        dimension: Number of dimensions
    """
    self.name = name
    self.dimension = dimension

cdf(u)

Copula CDF.

Parameters:

Name Type Description Default
u ndarray

Uniform(0,1) marginals (shape: n x d or d)

required

Returns:

Type Description
ndarray

Copula CDF values

Source code in src/distributions/copulas.py
22
23
24
25
26
27
28
29
30
31
32
def cdf(self, u: np.ndarray) -> np.ndarray:
    """
    Copula CDF.

    Args:
        u: Uniform(0,1) marginals (shape: n x d or d)

    Returns:
        Copula CDF values
    """
    raise NotImplementedError("Subclasses must implement cdf()")

kendall_tau()

Calculate Kendall's tau.

Source code in src/distributions/copulas.py
59
60
61
def kendall_tau(self) -> float:
    """Calculate Kendall's tau."""
    raise NotImplementedError("Subclasses must implement kendall_tau()")

pdf(u)

Copula density.

Parameters:

Name Type Description Default
u ndarray

Uniform(0,1) marginals

required

Returns:

Type Description
ndarray

Copula density values

Source code in src/distributions/copulas.py
34
35
36
37
38
39
40
41
42
43
44
def pdf(self, u: np.ndarray) -> np.ndarray:
    """
    Copula density.

    Args:
        u: Uniform(0,1) marginals

    Returns:
        Copula density values
    """
    raise NotImplementedError("Subclasses must implement pdf()")

rvs(size=1, random_state=None)

Generate random samples from copula.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Uniform(0,1) samples (shape: size x d)

Source code in src/distributions/copulas.py
46
47
48
49
50
51
52
53
54
55
56
57
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples from copula.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Uniform(0,1) samples (shape: size x d)
    """
    raise NotImplementedError("Subclasses must implement rvs()")

DirichletDistribution

Bases: MultivariateDistribution

Dirichlet distribution.

Source code in src/distributions/multivariate.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
class DirichletDistribution(MultivariateDistribution):
    """Dirichlet distribution."""

    def __init__(self, alpha: np.ndarray):
        """
        Initialize Dirichlet distribution.

        Args:
            alpha: Concentration parameters (must be positive)
        """
        alpha = np.asarray(alpha)

        if alpha.ndim != 1:
            raise ValueError("alpha must be 1-dimensional")

        if np.any(alpha <= 0):
            raise ValueError("alpha must be positive")

        super().__init__("Dirichlet", len(alpha))
        self.alpha = alpha
        self._dist = stats.dirichlet(alpha)

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate probability density function.

        Args:
            x: Points on simplex (shape: n x d or d), must sum to 1

        Returns:
            PDF values
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.pdf(x.T if x.ndim == 2 else x)

    def logpdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate log probability density function."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.logpdf(x.T if x.ndim == 2 else x)

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Samples on simplex (shape: size x d)
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.rvs(size=size, random_state=random_state)

    def mean(self) -> np.ndarray:
        """Calculate mean vector."""
        return self.alpha / np.sum(self.alpha)

    def var(self) -> np.ndarray:
        """Calculate variance for each component."""
        alpha0 = np.sum(self.alpha)
        return (self.alpha * (alpha0 - self.alpha)) / (alpha0**2 * (alpha0 + 1))

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.cov()

    def mode(self) -> np.ndarray:
        """
        Calculate mode.

        Returns:
            Mode vector (only valid if all alpha > 1)
        """
        if np.any(self.alpha <= 1):
            raise ValueError("Mode only defined when all alpha > 1")

        return (self.alpha - 1) / (np.sum(self.alpha) - self.dimension)

    def entropy(self) -> float:
        """Calculate differential entropy."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.entropy()

    def __repr__(self) -> str:
        return f"Dirichlet(dimension={self.dimension}, alpha={self.alpha})"

__init__(alpha)

Initialize Dirichlet distribution.

Parameters:

Name Type Description Default
alpha ndarray

Concentration parameters (must be positive)

required
Source code in src/distributions/multivariate.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def __init__(self, alpha: np.ndarray):
    """
    Initialize Dirichlet distribution.

    Args:
        alpha: Concentration parameters (must be positive)
    """
    alpha = np.asarray(alpha)

    if alpha.ndim != 1:
        raise ValueError("alpha must be 1-dimensional")

    if np.any(alpha <= 0):
        raise ValueError("alpha must be positive")

    super().__init__("Dirichlet", len(alpha))
    self.alpha = alpha
    self._dist = stats.dirichlet(alpha)

cov()

Calculate covariance matrix.

Source code in src/distributions/multivariate.py
253
254
255
256
257
def cov(self) -> np.ndarray:
    """Calculate covariance matrix."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.cov()

entropy()

Calculate differential entropy.

Source code in src/distributions/multivariate.py
271
272
273
274
275
def entropy(self) -> float:
    """Calculate differential entropy."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.entropy()

logpdf(x)

Calculate log probability density function.

Source code in src/distributions/multivariate.py
223
224
225
226
227
def logpdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate log probability density function."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.logpdf(x.T if x.ndim == 2 else x)

mean()

Calculate mean vector.

Source code in src/distributions/multivariate.py
244
245
246
def mean(self) -> np.ndarray:
    """Calculate mean vector."""
    return self.alpha / np.sum(self.alpha)

mode()

Calculate mode.

Returns:

Type Description
ndarray

Mode vector (only valid if all alpha > 1)

Source code in src/distributions/multivariate.py
259
260
261
262
263
264
265
266
267
268
269
def mode(self) -> np.ndarray:
    """
    Calculate mode.

    Returns:
        Mode vector (only valid if all alpha > 1)
    """
    if np.any(self.alpha <= 1):
        raise ValueError("Mode only defined when all alpha > 1")

    return (self.alpha - 1) / (np.sum(self.alpha) - self.dimension)

pdf(x)

Calculate probability density function.

Parameters:

Name Type Description Default
x ndarray

Points on simplex (shape: n x d or d), must sum to 1

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/multivariate.py
209
210
211
212
213
214
215
216
217
218
219
220
221
def pdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate probability density function.

    Args:
        x: Points on simplex (shape: n x d or d), must sum to 1

    Returns:
        PDF values
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.pdf(x.T if x.ndim == 2 else x)

rvs(size=1, random_state=None)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Samples on simplex (shape: size x d)

Source code in src/distributions/multivariate.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Samples on simplex (shape: size x d)
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.rvs(size=size, random_state=random_state)

var()

Calculate variance for each component.

Source code in src/distributions/multivariate.py
248
249
250
251
def var(self) -> np.ndarray:
    """Calculate variance for each component."""
    alpha0 = np.sum(self.alpha)
    return (self.alpha * (alpha0 - self.alpha)) / (alpha0**2 * (alpha0 + 1))

DiscreteUniformDistribution

Bases: Distribution

Discrete Uniform distribution.

Source code in src/distributions/discrete.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
class DiscreteUniformDistribution(Distribution):
    """Discrete Uniform distribution."""

    def __init__(self, low: int = 1, high: int = 6):
        """
        Initialize Discrete Uniform distribution.

        Args:
            low: Lower bound (inclusive)
            high: Upper bound (inclusive)
        """
        super().__init__("Discrete Uniform", is_discrete=True)
        self.low = low
        self.high = high
        self.set_parameters(low=low, high=high)

    def _create_distribution(self, **params):
        """Create scipy discrete uniform distribution."""
        return stats.randint(low=params["low"], high=params["high"] + 1)

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"low": self.low, "high": self.high}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.low = int(params.get("low", self.low))
        self.high = int(params.get("high", self.high))

        if self.low >= self.high:
            raise ValueError("low must be less than high")

        self._dist = self._create_distribution(low=self.low, high=self.high)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "low": (0, 50),
            "high": (1, 50),
        }

__init__(low=1, high=6)

Initialize Discrete Uniform distribution.

Parameters:

Name Type Description Default
low int

Lower bound (inclusive)

1
high int

Upper bound (inclusive)

6
Source code in src/distributions/discrete.py
223
224
225
226
227
228
229
230
231
232
233
234
def __init__(self, low: int = 1, high: int = 6):
    """
    Initialize Discrete Uniform distribution.

    Args:
        low: Lower bound (inclusive)
        high: Upper bound (inclusive)
    """
    super().__init__("Discrete Uniform", is_discrete=True)
    self.low = low
    self.high = high
    self.set_parameters(low=low, high=high)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
254
255
256
257
258
259
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "low": (0, 50),
        "high": (1, 50),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
240
241
242
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"low": self.low, "high": self.high}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
244
245
246
247
248
249
250
251
252
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.low = int(params.get("low", self.low))
    self.high = int(params.get("high", self.high))

    if self.low >= self.high:
        raise ValueError("low must be less than high")

    self._dist = self._create_distribution(low=self.low, high=self.high)

Distribution

Bases: ABC

Abstract base class for probability distributions.

Source code in src/distributions/base.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
class Distribution(ABC):
    """Abstract base class for probability distributions."""

    def __init__(self, name: str, is_discrete: bool = False):
        """
        Initialize the distribution.

        Args:
            name: Name of the distribution
            is_discrete: Whether the distribution is discrete
        """
        self.name = name
        self.is_discrete = is_discrete
        self._dist = None
        logger.debug("Distribution '%s' created (discrete=%s)", name, is_discrete)

    @abstractmethod
    def _create_distribution(self, **params):
        """
        Create the scipy distribution object.

        Args:
            **params: Distribution parameters

        Returns:
            scipy distribution object
        """
        pass

    @abstractmethod
    def get_parameters(self) -> dict[str, Any]:
        """
        Get current distribution parameters.

        Returns:
            Dictionary of parameter names and values
        """
        pass

    @abstractmethod
    def set_parameters(self, **params: Any):
        """
        Set distribution parameters.

        Args:
            **params: Parameter names and values
        """
        pass

    @abstractmethod
    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """
        Get valid parameter ranges.

        Returns:
            Dictionary of parameter names and (min, max) tuples
        """
        pass

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate probability density function (or PMF for discrete).

        Args:
            x: Input values

        Returns:
            PDF/PMF values
        """
        if self._dist is None:
            logger.error("Attempted to call pdf() on uninitialized distribution '%s'", self.name)
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        if self.is_discrete:
            return self._dist.pmf(x)
        return self._dist.pdf(x)

    def cdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate cumulative distribution function.

        Args:
            x: Input values

        Returns:
            CDF values
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.cdf(x)

    def ppf(self, q: np.ndarray) -> np.ndarray:
        """
        Calculate percent point function (inverse CDF).

        Args:
            q: Probabilities (between 0 and 1)

        Returns:
            Quantile values
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.ppf(q)

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples from the distribution.

        Args:
            size: Number of samples to generate
            random_state: Random seed for reproducibility

        Returns:
            Array of random samples
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.rvs(size=size, random_state=random_state)

    def mean(self) -> float:
        """
        Calculate the mean of the distribution.

        Returns:
            Mean value
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.mean()

    def var(self) -> float:
        """
        Calculate the variance of the distribution.

        Returns:
            Variance value
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.var()

    def std(self) -> float:
        """
        Calculate the standard deviation of the distribution.

        Returns:
            Standard deviation value
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.std()

    def median(self) -> float:
        """
        Calculate the median of the distribution.

        Returns:
            Median value
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.median()

    def mode(self) -> float:
        """
        Calculate the mode of the distribution.

        Returns:
            Mode value (may not be available for all distributions)
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        support = self.get_support()
        lower, upper = support[0], support[1]

        if np.isinf(lower) or np.isinf(upper):
            try:
                return float(self._dist.mode())
            except Exception:
                bounds = (-1e6, 1e6)
                if not np.isinf(lower):
                    bounds = (lower, min(upper, bounds[1]) if not np.isinf(upper) else bounds[1])
                elif not np.isinf(upper):
                    bounds = (max(lower, bounds[0]), upper)
                lower_b, upper_b = bounds

                if self.is_discrete:
                    x = np.arange(int(lower_b), int(upper_b) + 1)
                else:
                    x = np.linspace(lower_b, upper_b, 10000)

                pdf_values = self.pdf(x)
                return float(x[np.argmax(pdf_values)])

        if self.is_discrete:
            x = np.arange(lower, upper + 1)
        else:
            x = np.linspace(lower, upper, 10000)

        pdf_values = self.pdf(x)
        return float(x[np.argmax(pdf_values)])

    def skewness(self) -> float:
        """
        Calculate the skewness of the distribution.

        Returns:
            Skewness value
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        stats_result = self._dist.stats(moments="s")
        return float(stats_result)

    def kurtosis(self) -> float:
        """
        Calculate the excess kurtosis of the distribution.

        Returns:
            Excess kurtosis value
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        stats_result = self._dist.stats(moments="k")
        return float(stats_result)

    def entropy(self) -> float:
        """
        Calculate the differential entropy of the distribution.

        Returns:
            Entropy value
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.entropy()

    def get_support(self) -> tuple[float, float]:
        """
        Get the support (valid range) of the distribution.

        Returns:
            Tuple of (min, max) values
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.support()

    def interval(self, alpha: float = 0.95) -> tuple[float, float]:
        """
        Calculate confidence interval.

        Args:
            alpha: Confidence level (e.g., 0.95 for 95% confidence)

        Returns:
            Tuple of (lower, upper) bounds
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized. Call set_parameters first.")

        return self._dist.interval(alpha)

    def get_statistics(self) -> dict[str, float | None]:
        """
        Get comprehensive statistics for the distribution.

        Returns:
            Dictionary of statistic names and values
        """
        try:
            stats_dict: dict[str, float | None] = {
                "mean": self.mean(),
                "variance": self.var(),
                "std_dev": self.std(),
                "median": self.median(),
            }

            try:
                stats_dict["mode"] = self.mode()
            except Exception:
                stats_dict["mode"] = None

            try:
                stats_dict["skewness"] = self.skewness()
            except Exception:
                stats_dict["skewness"] = None

            try:
                stats_dict["kurtosis"] = self.kurtosis()
            except Exception:
                stats_dict["kurtosis"] = None

            try:
                stats_dict["entropy"] = self.entropy()
            except Exception:
                stats_dict["entropy"] = None

            return stats_dict
        except Exception as e:
            logger.error("Error calculating statistics for '%s': %s", self.name, e)
            raise ValueError(f"Error calculating statistics: {e}") from e

    def __repr__(self) -> str:
        """String representation of the distribution."""
        params = self.get_parameters()
        param_str = ", ".join(f"{k}={v}" for k, v in params.items())
        return f"{self.name}({param_str})"

__init__(name, is_discrete=False)

Initialize the distribution.

Parameters:

Name Type Description Default
name str

Name of the distribution

required
is_discrete bool

Whether the distribution is discrete

False
Source code in src/distributions/base.py
15
16
17
18
19
20
21
22
23
24
25
26
def __init__(self, name: str, is_discrete: bool = False):
    """
    Initialize the distribution.

    Args:
        name: Name of the distribution
        is_discrete: Whether the distribution is discrete
    """
    self.name = name
    self.is_discrete = is_discrete
    self._dist = None
    logger.debug("Distribution '%s' created (discrete=%s)", name, is_discrete)

__repr__()

String representation of the distribution.

Source code in src/distributions/base.py
328
329
330
331
332
def __repr__(self) -> str:
    """String representation of the distribution."""
    params = self.get_parameters()
    param_str = ", ".join(f"{k}={v}" for k, v in params.items())
    return f"{self.name}({param_str})"

cdf(x)

Calculate cumulative distribution function.

Parameters:

Name Type Description Default
x ndarray

Input values

required

Returns:

Type Description
ndarray

CDF values

Source code in src/distributions/base.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def cdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate cumulative distribution function.

    Args:
        x: Input values

    Returns:
        CDF values
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.cdf(x)

entropy()

Calculate the differential entropy of the distribution.

Returns:

Type Description
float

Entropy value

Source code in src/distributions/base.py
249
250
251
252
253
254
255
256
257
258
259
def entropy(self) -> float:
    """
    Calculate the differential entropy of the distribution.

    Returns:
        Entropy value
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.entropy()

get_parameter_bounds() abstractmethod

Get valid parameter ranges.

Returns:

Type Description
dict[str, tuple[float, float]]

Dictionary of parameter names and (min, max) tuples

Source code in src/distributions/base.py
61
62
63
64
65
66
67
68
69
@abstractmethod
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """
    Get valid parameter ranges.

    Returns:
        Dictionary of parameter names and (min, max) tuples
    """
    pass

get_parameters() abstractmethod

Get current distribution parameters.

Returns:

Type Description
dict[str, Any]

Dictionary of parameter names and values

Source code in src/distributions/base.py
41
42
43
44
45
46
47
48
49
@abstractmethod
def get_parameters(self) -> dict[str, Any]:
    """
    Get current distribution parameters.

    Returns:
        Dictionary of parameter names and values
    """
    pass

get_statistics()

Get comprehensive statistics for the distribution.

Returns:

Type Description
dict[str, float | None]

Dictionary of statistic names and values

Source code in src/distributions/base.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def get_statistics(self) -> dict[str, float | None]:
    """
    Get comprehensive statistics for the distribution.

    Returns:
        Dictionary of statistic names and values
    """
    try:
        stats_dict: dict[str, float | None] = {
            "mean": self.mean(),
            "variance": self.var(),
            "std_dev": self.std(),
            "median": self.median(),
        }

        try:
            stats_dict["mode"] = self.mode()
        except Exception:
            stats_dict["mode"] = None

        try:
            stats_dict["skewness"] = self.skewness()
        except Exception:
            stats_dict["skewness"] = None

        try:
            stats_dict["kurtosis"] = self.kurtosis()
        except Exception:
            stats_dict["kurtosis"] = None

        try:
            stats_dict["entropy"] = self.entropy()
        except Exception:
            stats_dict["entropy"] = None

        return stats_dict
    except Exception as e:
        logger.error("Error calculating statistics for '%s': %s", self.name, e)
        raise ValueError(f"Error calculating statistics: {e}") from e

get_support()

Get the support (valid range) of the distribution.

Returns:

Type Description
tuple[float, float]

Tuple of (min, max) values

Source code in src/distributions/base.py
261
262
263
264
265
266
267
268
269
270
271
def get_support(self) -> tuple[float, float]:
    """
    Get the support (valid range) of the distribution.

    Returns:
        Tuple of (min, max) values
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.support()

interval(alpha=0.95)

Calculate confidence interval.

Parameters:

Name Type Description Default
alpha float

Confidence level (e.g., 0.95 for 95% confidence)

0.95

Returns:

Type Description
tuple[float, float]

Tuple of (lower, upper) bounds

Source code in src/distributions/base.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def interval(self, alpha: float = 0.95) -> tuple[float, float]:
    """
    Calculate confidence interval.

    Args:
        alpha: Confidence level (e.g., 0.95 for 95% confidence)

    Returns:
        Tuple of (lower, upper) bounds
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.interval(alpha)

kurtosis()

Calculate the excess kurtosis of the distribution.

Returns:

Type Description
float

Excess kurtosis value

Source code in src/distributions/base.py
236
237
238
239
240
241
242
243
244
245
246
247
def kurtosis(self) -> float:
    """
    Calculate the excess kurtosis of the distribution.

    Returns:
        Excess kurtosis value
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    stats_result = self._dist.stats(moments="k")
    return float(stats_result)

mean()

Calculate the mean of the distribution.

Returns:

Type Description
float

Mean value

Source code in src/distributions/base.py
135
136
137
138
139
140
141
142
143
144
145
def mean(self) -> float:
    """
    Calculate the mean of the distribution.

    Returns:
        Mean value
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.mean()

median()

Calculate the median of the distribution.

Returns:

Type Description
float

Median value

Source code in src/distributions/base.py
171
172
173
174
175
176
177
178
179
180
181
def median(self) -> float:
    """
    Calculate the median of the distribution.

    Returns:
        Median value
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.median()

mode()

Calculate the mode of the distribution.

Returns:

Type Description
float

Mode value (may not be available for all distributions)

Source code in src/distributions/base.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def mode(self) -> float:
    """
    Calculate the mode of the distribution.

    Returns:
        Mode value (may not be available for all distributions)
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    support = self.get_support()
    lower, upper = support[0], support[1]

    if np.isinf(lower) or np.isinf(upper):
        try:
            return float(self._dist.mode())
        except Exception:
            bounds = (-1e6, 1e6)
            if not np.isinf(lower):
                bounds = (lower, min(upper, bounds[1]) if not np.isinf(upper) else bounds[1])
            elif not np.isinf(upper):
                bounds = (max(lower, bounds[0]), upper)
            lower_b, upper_b = bounds

            if self.is_discrete:
                x = np.arange(int(lower_b), int(upper_b) + 1)
            else:
                x = np.linspace(lower_b, upper_b, 10000)

            pdf_values = self.pdf(x)
            return float(x[np.argmax(pdf_values)])

    if self.is_discrete:
        x = np.arange(lower, upper + 1)
    else:
        x = np.linspace(lower, upper, 10000)

    pdf_values = self.pdf(x)
    return float(x[np.argmax(pdf_values)])

pdf(x)

Calculate probability density function (or PMF for discrete).

Parameters:

Name Type Description Default
x ndarray

Input values

required

Returns:

Type Description
ndarray

PDF/PMF values

Source code in src/distributions/base.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def pdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate probability density function (or PMF for discrete).

    Args:
        x: Input values

    Returns:
        PDF/PMF values
    """
    if self._dist is None:
        logger.error("Attempted to call pdf() on uninitialized distribution '%s'", self.name)
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    if self.is_discrete:
        return self._dist.pmf(x)
    return self._dist.pdf(x)

ppf(q)

Calculate percent point function (inverse CDF).

Parameters:

Name Type Description Default
q ndarray

Probabilities (between 0 and 1)

required

Returns:

Type Description
ndarray

Quantile values

Source code in src/distributions/base.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def ppf(self, q: np.ndarray) -> np.ndarray:
    """
    Calculate percent point function (inverse CDF).

    Args:
        q: Probabilities (between 0 and 1)

    Returns:
        Quantile values
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.ppf(q)

rvs(size=1, random_state=None)

Generate random samples from the distribution.

Parameters:

Name Type Description Default
size int

Number of samples to generate

1
random_state int | None

Random seed for reproducibility

None

Returns:

Type Description
ndarray

Array of random samples

Source code in src/distributions/base.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples from the distribution.

    Args:
        size: Number of samples to generate
        random_state: Random seed for reproducibility

    Returns:
        Array of random samples
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.rvs(size=size, random_state=random_state)

set_parameters(**params) abstractmethod

Set distribution parameters.

Parameters:

Name Type Description Default
**params Any

Parameter names and values

{}
Source code in src/distributions/base.py
51
52
53
54
55
56
57
58
59
@abstractmethod
def set_parameters(self, **params: Any):
    """
    Set distribution parameters.

    Args:
        **params: Parameter names and values
    """
    pass

skewness()

Calculate the skewness of the distribution.

Returns:

Type Description
float

Skewness value

Source code in src/distributions/base.py
223
224
225
226
227
228
229
230
231
232
233
234
def skewness(self) -> float:
    """
    Calculate the skewness of the distribution.

    Returns:
        Skewness value
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    stats_result = self._dist.stats(moments="s")
    return float(stats_result)

std()

Calculate the standard deviation of the distribution.

Returns:

Type Description
float

Standard deviation value

Source code in src/distributions/base.py
159
160
161
162
163
164
165
166
167
168
169
def std(self) -> float:
    """
    Calculate the standard deviation of the distribution.

    Returns:
        Standard deviation value
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.std()

var()

Calculate the variance of the distribution.

Returns:

Type Description
float

Variance value

Source code in src/distributions/base.py
147
148
149
150
151
152
153
154
155
156
157
def var(self) -> float:
    """
    Calculate the variance of the distribution.

    Returns:
        Variance value
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized. Call set_parameters first.")

    return self._dist.var()

ExponentialDistribution

Bases: Distribution

Exponential distribution.

Source code in src/distributions/continuous.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class ExponentialDistribution(Distribution):
    """Exponential distribution."""

    def __init__(self, lambda_param: float = 1.0):
        """
        Initialize Exponential distribution.

        Args:
            lambda_param: Rate parameter (must be > 0)
        """
        super().__init__("Exponential", is_discrete=False)
        self.lambda_param = lambda_param
        self.set_parameters(lambda_param=lambda_param)

    def _create_distribution(self, **params):
        """Create scipy exponential distribution."""
        return stats.expon(scale=1.0 / params["lambda_param"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"lambda": self.lambda_param}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

        if self.lambda_param <= 0:
            raise ValueError("lambda must be positive")

        self._dist = self._create_distribution(lambda_param=self.lambda_param)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"lambda": (0.1, 10.0)}

__init__(lambda_param=1.0)

Initialize Exponential distribution.

Parameters:

Name Type Description Default
lambda_param float

Rate parameter (must be > 0)

1.0
Source code in src/distributions/continuous.py
56
57
58
59
60
61
62
63
64
65
def __init__(self, lambda_param: float = 1.0):
    """
    Initialize Exponential distribution.

    Args:
        lambda_param: Rate parameter (must be > 0)
    """
    super().__init__("Exponential", is_discrete=False)
    self.lambda_param = lambda_param
    self.set_parameters(lambda_param=lambda_param)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
84
85
86
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"lambda": (0.1, 10.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
71
72
73
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"lambda": self.lambda_param}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
75
76
77
78
79
80
81
82
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

    if self.lambda_param <= 0:
        raise ValueError("lambda must be positive")

    self._dist = self._create_distribution(lambda_param=self.lambda_param)

GammaDistribution

Bases: Distribution

Gamma distribution.

Source code in src/distributions/continuous.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
class GammaDistribution(Distribution):
    """Gamma distribution."""

    def __init__(self, shape: float = 2.0, scale: float = 2.0):
        """
        Initialize Gamma distribution.

        Args:
            shape: Shape parameter (k, must be > 0)
            scale: Scale parameter (theta, must be > 0)
        """
        super().__init__("Gamma", is_discrete=False)
        self.shape = shape
        self.scale = scale
        self.set_parameters(shape=shape, scale=scale)

    def _create_distribution(self, **params):
        """Create scipy gamma distribution."""
        return stats.gamma(a=params["shape"], scale=params["scale"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"shape": self.shape, "scale": self.scale}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.shape = params.get("shape", self.shape)
        self.scale = params.get("scale", self.scale)

        if self.shape <= 0 or self.scale <= 0:
            raise ValueError("shape and scale must be positive")

        self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "shape": (0.1, 10.0),
            "scale": (0.1, 10.0),
        }

__init__(shape=2.0, scale=2.0)

Initialize Gamma distribution.

Parameters:

Name Type Description Default
shape float

Shape parameter (k, must be > 0)

2.0
scale float

Scale parameter (theta, must be > 0)

2.0
Source code in src/distributions/continuous.py
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(self, shape: float = 2.0, scale: float = 2.0):
    """
    Initialize Gamma distribution.

    Args:
        shape: Shape parameter (k, must be > 0)
        scale: Scale parameter (theta, must be > 0)
    """
    super().__init__("Gamma", is_discrete=False)
    self.shape = shape
    self.scale = scale
    self.set_parameters(shape=shape, scale=scale)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
207
208
209
210
211
212
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "shape": (0.1, 10.0),
        "scale": (0.1, 10.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
193
194
195
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"shape": self.shape, "scale": self.scale}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
197
198
199
200
201
202
203
204
205
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.shape = params.get("shape", self.shape)
    self.scale = params.get("scale", self.scale)

    if self.shape <= 0 or self.scale <= 0:
        raise ValueError("shape and scale must be positive")

    self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

GaussianCopula

Bases: Copula

Gaussian (Normal) copula.

Source code in src/distributions/copulas.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
class GaussianCopula(Copula):
    """Gaussian (Normal) copula."""

    def __init__(self, correlation: np.ndarray):
        """
        Initialize Gaussian copula.

        Args:
            correlation: Correlation matrix (must be positive definite)
        """
        corr = np.asarray(correlation)

        if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
            raise ValueError("correlation must be square matrix")

        if not np.allclose(np.diag(corr), 1.0):
            raise ValueError("diagonal of correlation matrix must be 1")

        try:
            np.linalg.cholesky(corr)
        except np.linalg.LinAlgError as err:
            raise ValueError("correlation must be positive definite") from err

        super().__init__("Gaussian", corr.shape[0])
        self.correlation = corr

        # Create multivariate normal for sampling
        self._mvn = stats.multivariate_normal(mean=np.zeros(self.dimension), cov=corr)

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Gaussian copula CDF."""
        u = np.atleast_2d(u)

        # Transform uniforms to standard normals
        z = stats.norm.ppf(u)

        # Evaluate multivariate normal CDF
        # Note: This is computationally expensive for high dimensions
        cdf_vals = np.array([self._mvn.cdf(zi) for zi in z])

        return cdf_vals

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Gaussian copula density."""
        u = np.atleast_2d(u)
        u = np.clip(u, 1e-12, 1.0 - 1e-12)

        # Transform to standard normals
        z = stats.norm.ppf(u)

        # Correlation matrix determinant and inverse
        corr_det = np.linalg.det(self.correlation)
        corr_inv = np.linalg.inv(self.correlation)

        # Copula density
        # c(u) = |Σ|^(-1/2) * exp(z^T (Σ^(-1) - I) z / 2)
        # where z = Φ^(-1)(u)

        identity = np.eye(self.dimension)
        diff = corr_inv - identity

        pdf_vals = np.zeros(len(z))
        for i, zi in enumerate(z):
            exponent = -0.5 * zi @ diff @ zi
            pdf_vals[i] = (1 / np.sqrt(corr_det)) * np.exp(exponent)

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Gaussian copula."""
        # Sample from multivariate normal
        z = self._mvn.rvs(size=size, random_state=random_state)
        if size == 1:
            z = z.reshape(1, -1)

        # Transform to uniform
        u = stats.norm.cdf(z)

        return u

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau (for bivariate case).

        Returns:
            Kendall's tau
        """
        if self.dimension != 2:
            raise ValueError("Kendall's tau only defined for bivariate copula")

        rho = self.correlation[0, 1]
        return (2 / np.pi) * np.arcsin(rho)

    def __repr__(self) -> str:
        return f"GaussianCopula(dimension={self.dimension})"

__init__(correlation)

Initialize Gaussian copula.

Parameters:

Name Type Description Default
correlation ndarray

Correlation matrix (must be positive definite)

required
Source code in src/distributions/copulas.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(self, correlation: np.ndarray):
    """
    Initialize Gaussian copula.

    Args:
        correlation: Correlation matrix (must be positive definite)
    """
    corr = np.asarray(correlation)

    if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
        raise ValueError("correlation must be square matrix")

    if not np.allclose(np.diag(corr), 1.0):
        raise ValueError("diagonal of correlation matrix must be 1")

    try:
        np.linalg.cholesky(corr)
    except np.linalg.LinAlgError as err:
        raise ValueError("correlation must be positive definite") from err

    super().__init__("Gaussian", corr.shape[0])
    self.correlation = corr

    # Create multivariate normal for sampling
    self._mvn = stats.multivariate_normal(mean=np.zeros(self.dimension), cov=corr)

cdf(u)

Gaussian copula CDF.

Source code in src/distributions/copulas.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Gaussian copula CDF."""
    u = np.atleast_2d(u)

    # Transform uniforms to standard normals
    z = stats.norm.ppf(u)

    # Evaluate multivariate normal CDF
    # Note: This is computationally expensive for high dimensions
    cdf_vals = np.array([self._mvn.cdf(zi) for zi in z])

    return cdf_vals

kendall_tau()

Calculate Kendall's tau (for bivariate case).

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
144
145
146
147
148
149
150
151
152
153
154
155
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau (for bivariate case).

    Returns:
        Kendall's tau
    """
    if self.dimension != 2:
        raise ValueError("Kendall's tau only defined for bivariate copula")

    rho = self.correlation[0, 1]
    return (2 / np.pi) * np.arcsin(rho)

pdf(u)

Gaussian copula density.

Source code in src/distributions/copulas.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Gaussian copula density."""
    u = np.atleast_2d(u)
    u = np.clip(u, 1e-12, 1.0 - 1e-12)

    # Transform to standard normals
    z = stats.norm.ppf(u)

    # Correlation matrix determinant and inverse
    corr_det = np.linalg.det(self.correlation)
    corr_inv = np.linalg.inv(self.correlation)

    # Copula density
    # c(u) = |Σ|^(-1/2) * exp(z^T (Σ^(-1) - I) z / 2)
    # where z = Φ^(-1)(u)

    identity = np.eye(self.dimension)
    diff = corr_inv - identity

    pdf_vals = np.zeros(len(z))
    for i, zi in enumerate(z):
        exponent = -0.5 * zi @ diff @ zi
        pdf_vals[i] = (1 / np.sqrt(corr_det)) * np.exp(exponent)

    return pdf_vals

rvs(size=1, random_state=None)

Generate samples from Gaussian copula.

Source code in src/distributions/copulas.py
132
133
134
135
136
137
138
139
140
141
142
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Gaussian copula."""
    # Sample from multivariate normal
    z = self._mvn.rvs(size=size, random_state=random_state)
    if size == 1:
        z = z.reshape(1, -1)

    # Transform to uniform
    u = stats.norm.cdf(z)

    return u

GaussianMixtureModel

Gaussian Mixture Model using sklearn.

Source code in src/distributions/mixtures.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
class GaussianMixtureModel:
    """Gaussian Mixture Model using sklearn."""

    def __init__(self, n_components: int = 2, covariance_type: str = "full", max_iter: int = 100):
        """
        Initialize Gaussian Mixture Model.

        Args:
            n_components: Number of mixture components
            covariance_type: Type of covariance ('full', 'tied', 'diag', 'spherical')
            max_iter: Maximum EM iterations
        """
        self.n_components = n_components
        self.gmm = GaussianMixture(
            n_components=n_components,
            covariance_type=covariance_type,
            max_iter=max_iter,
            random_state=42,
        )
        self.fitted = False

    def fit(self, data: np.ndarray) -> "GaussianMixtureModel":
        """
        Fit GMM to data.

        Args:
            data: Training data (n x d)

        Returns:
            Self
        """
        data = _as_2d(data)

        self.gmm.fit(data)
        self.fitted = True

        return self

    def predict(self, data: np.ndarray) -> np.ndarray:
        """
        Predict component labels.

        Args:
            data: Data to predict

        Returns:
            Component labels
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.predict(data)

    def score_samples(self, data: np.ndarray) -> np.ndarray:
        """
        Calculate log-likelihood of samples.

        Args:
            data: Data to score

        Returns:
            Log-likelihood values
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.score_samples(data)

    def pdf(self, data: np.ndarray) -> np.ndarray:
        """
        Calculate probability density.

        Args:
            data: Data points

        Returns:
            PDF values
        """
        log_pdf = self.score_samples(data)
        return np.exp(log_pdf)

    def rvs(self, size: int = 1) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples

        Returns:
            Samples
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        samples, _ = self.gmm.sample(size)
        return samples.squeeze()

    def bic(self, data: np.ndarray) -> float:
        """
        Calculate Bayesian Information Criterion.

        Args:
            data: Data

        Returns:
            BIC value
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.bic(data)

    def aic(self, data: np.ndarray) -> float:
        """
        Calculate Akaike Information Criterion.

        Args:
            data: Data

        Returns:
            AIC value
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.aic(data)

    def get_parameters(self) -> dict:
        """
        Get fitted parameters.

        Returns:
            Dictionary with means, covariances, and weights
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        return {
            "means": self.gmm.means_,
            "covariances": self.gmm.covariances_,
            "weights": self.gmm.weights_,
        }

__init__(n_components=2, covariance_type='full', max_iter=100)

Initialize Gaussian Mixture Model.

Parameters:

Name Type Description Default
n_components int

Number of mixture components

2
covariance_type str

Type of covariance ('full', 'tied', 'diag', 'spherical')

'full'
max_iter int

Maximum EM iterations

100
Source code in src/distributions/mixtures.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def __init__(self, n_components: int = 2, covariance_type: str = "full", max_iter: int = 100):
    """
    Initialize Gaussian Mixture Model.

    Args:
        n_components: Number of mixture components
        covariance_type: Type of covariance ('full', 'tied', 'diag', 'spherical')
        max_iter: Maximum EM iterations
    """
    self.n_components = n_components
    self.gmm = GaussianMixture(
        n_components=n_components,
        covariance_type=covariance_type,
        max_iter=max_iter,
        random_state=42,
    )
    self.fitted = False

aic(data)

Calculate Akaike Information Criterion.

Parameters:

Name Type Description Default
data ndarray

Data

required

Returns:

Type Description
float

AIC value

Source code in src/distributions/mixtures.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
def aic(self, data: np.ndarray) -> float:
    """
    Calculate Akaike Information Criterion.

    Args:
        data: Data

    Returns:
        AIC value
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.aic(data)

bic(data)

Calculate Bayesian Information Criterion.

Parameters:

Name Type Description Default
data ndarray

Data

required

Returns:

Type Description
float

BIC value

Source code in src/distributions/mixtures.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def bic(self, data: np.ndarray) -> float:
    """
    Calculate Bayesian Information Criterion.

    Args:
        data: Data

    Returns:
        BIC value
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.bic(data)

fit(data)

Fit GMM to data.

Parameters:

Name Type Description Default
data ndarray

Training data (n x d)

required

Returns:

Type Description
GaussianMixtureModel

Self

Source code in src/distributions/mixtures.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def fit(self, data: np.ndarray) -> "GaussianMixtureModel":
    """
    Fit GMM to data.

    Args:
        data: Training data (n x d)

    Returns:
        Self
    """
    data = _as_2d(data)

    self.gmm.fit(data)
    self.fitted = True

    return self

get_parameters()

Get fitted parameters.

Returns:

Type Description
dict

Dictionary with means, covariances, and weights

Source code in src/distributions/mixtures.py
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def get_parameters(self) -> dict:
    """
    Get fitted parameters.

    Returns:
        Dictionary with means, covariances, and weights
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    return {
        "means": self.gmm.means_,
        "covariances": self.gmm.covariances_,
        "weights": self.gmm.weights_,
    }

pdf(data)

Calculate probability density.

Parameters:

Name Type Description Default
data ndarray

Data points

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/mixtures.py
306
307
308
309
310
311
312
313
314
315
316
317
def pdf(self, data: np.ndarray) -> np.ndarray:
    """
    Calculate probability density.

    Args:
        data: Data points

    Returns:
        PDF values
    """
    log_pdf = self.score_samples(data)
    return np.exp(log_pdf)

predict(data)

Predict component labels.

Parameters:

Name Type Description Default
data ndarray

Data to predict

required

Returns:

Type Description
ndarray

Component labels

Source code in src/distributions/mixtures.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
def predict(self, data: np.ndarray) -> np.ndarray:
    """
    Predict component labels.

    Args:
        data: Data to predict

    Returns:
        Component labels
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.predict(data)

rvs(size=1)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1

Returns:

Type Description
ndarray

Samples

Source code in src/distributions/mixtures.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def rvs(self, size: int = 1) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples

    Returns:
        Samples
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    samples, _ = self.gmm.sample(size)
    return samples.squeeze()

score_samples(data)

Calculate log-likelihood of samples.

Parameters:

Name Type Description Default
data ndarray

Data to score

required

Returns:

Type Description
ndarray

Log-likelihood values

Source code in src/distributions/mixtures.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def score_samples(self, data: np.ndarray) -> np.ndarray:
    """
    Calculate log-likelihood of samples.

    Args:
        data: Data to score

    Returns:
        Log-likelihood values
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.score_samples(data)

GeometricDistribution

Bases: Distribution

Geometric distribution.

Source code in src/distributions/discrete.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class GeometricDistribution(Distribution):
    """Geometric distribution."""

    def __init__(self, p: float = 0.5):
        """
        Initialize Geometric distribution.

        Args:
            p: Probability of success (must be between 0 and 1)
        """
        super().__init__("Geometric", is_discrete=True)
        self.p = p
        self.set_parameters(p=p)

    def _create_distribution(self, **params):
        """Create scipy geometric distribution."""
        return stats.geom(p=params["p"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"p": self.p}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.p = params.get("p", self.p)

        if not 0 < self.p <= 1:
            raise ValueError("p must be between 0 and 1 (exclusive of 0)")

        self._dist = self._create_distribution(p=self.p)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"p": (0.01, 1.0)}

__init__(p=0.5)

Initialize Geometric distribution.

Parameters:

Name Type Description Default
p float

Probability of success (must be between 0 and 1)

0.5
Source code in src/distributions/discrete.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
def __init__(self, p: float = 0.5):
    """
    Initialize Geometric distribution.

    Args:
        p: Probability of success (must be between 0 and 1)
    """
    super().__init__("Geometric", is_discrete=True)
    self.p = p
    self.set_parameters(p=p)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
121
122
123
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"p": (0.01, 1.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
108
109
110
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"p": self.p}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
112
113
114
115
116
117
118
119
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.p = params.get("p", self.p)

    if not 0 < self.p <= 1:
        raise ValueError("p must be between 0 and 1 (exclusive of 0)")

    self._dist = self._create_distribution(p=self.p)

GumbelCopula

Bases: Copula

Gumbel copula (Archimedean).

Source code in src/distributions/copulas.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
class GumbelCopula(Copula):
    """Gumbel copula (Archimedean)."""

    def __init__(self, theta: float, dimension: int = 2):
        """
        Initialize Gumbel copula.

        Args:
            theta: Dependence parameter (theta >= 1)
            dimension: Number of dimensions
        """
        if theta < 1:
            raise ValueError("theta must be >= 1")

        super().__init__("Gumbel", dimension)
        self.theta = theta

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Gumbel copula CDF."""
        u = np.atleast_2d(u)

        # C(u1, ..., ud) = exp(-(sum_i (-log(ui))^θ)^(1/θ))
        log_terms = (-np.log(u)) ** self.theta
        sum_terms = np.sum(log_terms, axis=1)
        cdf_vals = np.exp(-(sum_terms ** (1 / self.theta)))

        return cdf_vals

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Gumbel copula density (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
            )

        u = np.atleast_2d(u)
        u1, u2 = u[:, 0], u[:, 1]

        theta = self.theta

        # Complex formula for Gumbel copula density
        log_u1 = -np.log(u1)
        log_u2 = -np.log(u2)

        A = (log_u1**theta + log_u2**theta) ** (1 / theta)
        B = (log_u1**theta + log_u2**theta) ** (-2 + 2 / theta)
        C = (log_u1 * log_u2) ** (theta - 1)
        D = 1 + (theta - 1) * (log_u1**theta + log_u2**theta) ** (-1 / theta)

        pdf_vals = np.exp(-A) * B * C * D / (u1 * u2)

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Gumbel copula (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
            )

        rng = np.random.default_rng(random_state)

        theta = self.theta
        u1 = rng.uniform(0, 1, size)
        p = rng.uniform(0, 1, size)
        u2 = np.zeros(size)

        def cond_cdf(v, u1_i, t, p_i):
            if v <= 1e-15:
                return 0.0 - p_i
            if v >= 1 - 1e-15:
                return 1.0 - p_i
            s = -np.log(v)
            a = (t**theta + s**theta) ** (1.0 / theta)
            return np.exp(-a) / u1_i * (t / a) ** (theta - 1) - p_i

        for i in range(size):
            u1_i = max(u1[i], 1e-15)
            t = -np.log(u1_i)
            p_i = p[i]

            try:
                u2[i] = brentq(cond_cdf, 1e-15, 1 - 1e-15, args=(u1_i, t, p_i))
            except ValueError:
                # Fall back to the median of the conditional distribution only
                # when the root bracket is degenerate; otherwise surface the error.
                bracket_lo = cond_cdf(1e-15, u1_i, t, p_i)
                bracket_hi = cond_cdf(1 - 1e-15, u1_i, t, p_i)
                if bracket_lo * bracket_hi > 0:
                    raise ValueError(
                        "Gumbel conditional CDF root-finding failed to bracket a root"
                    ) from None
                u2[i] = 0.5

        return np.column_stack([u1, u2])

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau.

        Returns:
            Kendall's tau
        """
        return 1 - 1 / self.theta

    def __repr__(self) -> str:
        return f"GumbelCopula(theta={self.theta}, dimension={self.dimension})"

__init__(theta, dimension=2)

Initialize Gumbel copula.

Parameters:

Name Type Description Default
theta float

Dependence parameter (theta >= 1)

required
dimension int

Number of dimensions

2
Source code in src/distributions/copulas.py
248
249
250
251
252
253
254
255
256
257
258
259
260
def __init__(self, theta: float, dimension: int = 2):
    """
    Initialize Gumbel copula.

    Args:
        theta: Dependence parameter (theta >= 1)
        dimension: Number of dimensions
    """
    if theta < 1:
        raise ValueError("theta must be >= 1")

    super().__init__("Gumbel", dimension)
    self.theta = theta

cdf(u)

Gumbel copula CDF.

Source code in src/distributions/copulas.py
262
263
264
265
266
267
268
269
270
271
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Gumbel copula CDF."""
    u = np.atleast_2d(u)

    # C(u1, ..., ud) = exp(-(sum_i (-log(ui))^θ)^(1/θ))
    log_terms = (-np.log(u)) ** self.theta
    sum_terms = np.sum(log_terms, axis=1)
    cdf_vals = np.exp(-(sum_terms ** (1 / self.theta)))

    return cdf_vals

kendall_tau()

Calculate Kendall's tau.

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
341
342
343
344
345
346
347
348
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau.

    Returns:
        Kendall's tau
    """
    return 1 - 1 / self.theta

pdf(u)

Gumbel copula density (bivariate only).

Source code in src/distributions/copulas.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Gumbel copula density (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
        )

    u = np.atleast_2d(u)
    u1, u2 = u[:, 0], u[:, 1]

    theta = self.theta

    # Complex formula for Gumbel copula density
    log_u1 = -np.log(u1)
    log_u2 = -np.log(u2)

    A = (log_u1**theta + log_u2**theta) ** (1 / theta)
    B = (log_u1**theta + log_u2**theta) ** (-2 + 2 / theta)
    C = (log_u1 * log_u2) ** (theta - 1)
    D = 1 + (theta - 1) * (log_u1**theta + log_u2**theta) ** (-1 / theta)

    pdf_vals = np.exp(-A) * B * C * D / (u1 * u2)

    return pdf_vals

rvs(size=1, random_state=None)

Generate samples from Gumbel copula (bivariate only).

Source code in src/distributions/copulas.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Gumbel copula (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
        )

    rng = np.random.default_rng(random_state)

    theta = self.theta
    u1 = rng.uniform(0, 1, size)
    p = rng.uniform(0, 1, size)
    u2 = np.zeros(size)

    def cond_cdf(v, u1_i, t, p_i):
        if v <= 1e-15:
            return 0.0 - p_i
        if v >= 1 - 1e-15:
            return 1.0 - p_i
        s = -np.log(v)
        a = (t**theta + s**theta) ** (1.0 / theta)
        return np.exp(-a) / u1_i * (t / a) ** (theta - 1) - p_i

    for i in range(size):
        u1_i = max(u1[i], 1e-15)
        t = -np.log(u1_i)
        p_i = p[i]

        try:
            u2[i] = brentq(cond_cdf, 1e-15, 1 - 1e-15, args=(u1_i, t, p_i))
        except ValueError:
            # Fall back to the median of the conditional distribution only
            # when the root bracket is degenerate; otherwise surface the error.
            bracket_lo = cond_cdf(1e-15, u1_i, t, p_i)
            bracket_hi = cond_cdf(1 - 1e-15, u1_i, t, p_i)
            if bracket_lo * bracket_hi > 0:
                raise ValueError(
                    "Gumbel conditional CDF root-finding failed to bracket a root"
                ) from None
            u2[i] = 0.5

    return np.column_stack([u1, u2])

HypergeometricDistribution

Bases: Distribution

Hypergeometric distribution.

Source code in src/distributions/discrete.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class HypergeometricDistribution(Distribution):
    """Hypergeometric distribution."""

    def __init__(self, M: int = 20, n: int = 7, N: int = 12):
        """
        Initialize Hypergeometric distribution.

        Args:
            M: Total population size
            n: Number of success states in population
            N: Number of draws
        """
        super().__init__("Hypergeometric", is_discrete=True)
        self.M = M
        self.n = n
        self.N = N
        self.set_parameters(M=M, n=n, N=N)

    def _create_distribution(self, **params):
        """Create scipy hypergeometric distribution."""
        return stats.hypergeom(M=params["M"], n=params["n"], N=params["N"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"M": self.M, "n": self.n, "N": self.N}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.M = int(params.get("M", self.M))
        self.n = int(params.get("n", self.n))
        self.N = int(params.get("N", self.N))

        if self.M <= 0 or self.n < 0 or self.N < 0:
            raise ValueError("M must be positive, n and N must be non-negative")
        if self.n > self.M:
            raise ValueError("n cannot be greater than M")
        if self.N > self.M:
            raise ValueError("N cannot be greater than M")

        self._dist = self._create_distribution(M=self.M, n=self.n, N=self.N)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "M": (1, 100),
            "n": (0, 100),
            "N": (1, 100),
        }

__init__(M=20, n=7, N=12)

Initialize Hypergeometric distribution.

Parameters:

Name Type Description Default
M int

Total population size

20
n int

Number of success states in population

7
N int

Number of draws

12
Source code in src/distributions/discrete.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def __init__(self, M: int = 20, n: int = 7, N: int = 12):
    """
    Initialize Hypergeometric distribution.

    Args:
        M: Total population size
        n: Number of success states in population
        N: Number of draws
    """
    super().__init__("Hypergeometric", is_discrete=True)
    self.M = M
    self.n = n
    self.N = N
    self.set_parameters(M=M, n=n, N=N)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
211
212
213
214
215
216
217
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "M": (1, 100),
        "n": (0, 100),
        "N": (1, 100),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
192
193
194
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"M": self.M, "n": self.n, "N": self.N}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.M = int(params.get("M", self.M))
    self.n = int(params.get("n", self.n))
    self.N = int(params.get("N", self.N))

    if self.M <= 0 or self.n < 0 or self.N < 0:
        raise ValueError("M must be positive, n and N must be non-negative")
    if self.n > self.M:
        raise ValueError("n cannot be greater than M")
    if self.N > self.M:
        raise ValueError("N cannot be greater than M")

    self._dist = self._create_distribution(M=self.M, n=self.n, N=self.N)

LognormalDistribution

Bases: Distribution

Lognormal distribution.

Source code in src/distributions/continuous.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
class LognormalDistribution(Distribution):
    """Lognormal distribution."""

    def __init__(self, mu: float = 0.0, sigma: float = 1.0):
        """
        Initialize Lognormal distribution.

        Args:
            mu: Mean of underlying normal distribution
            sigma: Standard deviation of underlying normal distribution (must be > 0)
        """
        super().__init__("Lognormal", is_discrete=False)
        self.mu = mu
        self.sigma = sigma
        self.set_parameters(mu=mu, sigma=sigma)

    def _create_distribution(self, **params):
        """Create scipy lognormal distribution."""
        return stats.lognorm(s=params["sigma"], scale=np.exp(params["mu"]))

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"mu": self.mu, "sigma": self.sigma}

    def set_parameters(self, **params):
        """Set distribution parameters."""

        self.mu = params.get("mu", self.mu)
        self.sigma = params.get("sigma", self.sigma)

        if self.sigma <= 0:
            raise ValueError("sigma must be positive")

        self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "mu": (-5.0, 5.0),
            "sigma": (0.1, 5.0),
        }

__init__(mu=0.0, sigma=1.0)

Initialize Lognormal distribution.

Parameters:

Name Type Description Default
mu float

Mean of underlying normal distribution

0.0
sigma float

Standard deviation of underlying normal distribution (must be > 0)

1.0
Source code in src/distributions/continuous.py
332
333
334
335
336
337
338
339
340
341
342
343
def __init__(self, mu: float = 0.0, sigma: float = 1.0):
    """
    Initialize Lognormal distribution.

    Args:
        mu: Mean of underlying normal distribution
        sigma: Standard deviation of underlying normal distribution (must be > 0)
    """
    super().__init__("Lognormal", is_discrete=False)
    self.mu = mu
    self.sigma = sigma
    self.set_parameters(mu=mu, sigma=sigma)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
364
365
366
367
368
369
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "mu": (-5.0, 5.0),
        "sigma": (0.1, 5.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
349
350
351
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"mu": self.mu, "sigma": self.sigma}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
353
354
355
356
357
358
359
360
361
362
def set_parameters(self, **params):
    """Set distribution parameters."""

    self.mu = params.get("mu", self.mu)
    self.sigma = params.get("sigma", self.sigma)

    if self.sigma <= 0:
        raise ValueError("sigma must be positive")

    self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

MixtureDistribution

General mixture distribution.

Source code in src/distributions/mixtures.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
class MixtureDistribution:
    """General mixture distribution."""

    def __init__(self, components: list, weights: list[float] | np.ndarray):
        """
        Initialize mixture distribution.

        Args:
            components: List of component distributions
            weights: Mixing weights (must sum to 1)
        """
        weights = np.asarray(weights, dtype=float)

        if len(components) != len(weights):
            raise ValueError("Number of components must match number of weights")

        if not np.isclose(np.sum(weights), 1.0):
            raise ValueError("Weights must sum to 1")

        if np.any(weights < 0):
            raise ValueError("Weights must be non-negative")

        self.components = components
        self.weights = weights
        self.n_components = len(components)

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate probability density function.

        Args:
            x: Input values

        Returns:
            PDF values
        """
        x = np.atleast_1d(x)
        pdf_vals = np.zeros_like(x, dtype=float)

        for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
            pdf_vals += weight * component.pdf(x)

        return pdf_vals

    def cdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate cumulative distribution function.

        Args:
            x: Input values

        Returns:
            CDF values
        """
        x = np.atleast_1d(x)
        cdf_vals = np.zeros_like(x, dtype=float)

        for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
            cdf_vals += weight * component.cdf(x)

        return cdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Random samples
        """
        rng = np.random.default_rng(random_state)

        # Sample component indices according to weights
        component_indices = rng.choice(self.n_components, size=size, p=self.weights)

        # Sample from each selected component
        samples = np.zeros(size)
        for i in range(self.n_components):
            mask = component_indices == i
            n_samples = np.sum(mask)

            if n_samples > 0:
                comp_samples = self.components[i].rvs(size=n_samples)
                samples[mask] = comp_samples

        return samples

    def mean(self) -> float:
        """
        Calculate mean of mixture.

        Returns:
            Mean value
        """
        mean = 0.0
        for component, weight in zip(self.components, self.weights, strict=True):
            mean += weight * component.mean()

        return mean

    def var(self) -> float:
        """
        Calculate variance of mixture.

        Returns:
            Variance value
        """
        # Var(X) = E[Var(X|Z)] + Var(E[X|Z])
        # where Z is the component indicator

        # E[Var(X|Z)]
        var_within = 0.0
        for component, weight in zip(self.components, self.weights, strict=True):
            var_within += weight * component.var()

        # Var(E[X|Z])
        mixture_mean = self.mean()
        var_between = 0.0
        for component, weight in zip(self.components, self.weights, strict=True):
            diff = component.mean() - mixture_mean
            var_between += weight * diff**2

        return var_within + var_between

    def fit_em(
        self,
        data: np.ndarray,
        n_components: int,
        max_iter: int = 100,
        tol: float = 1e-4,
        random_state: int | None = None,
    ) -> tuple[np.ndarray, list, list[float]]:
        """
        Fit mixture model using Expectation-Maximization.

        Args:
            data: Observed data
            n_components: Number of mixture components
            max_iter: Maximum EM iterations
            tol: Convergence tolerance
            random_state: Seed for the random component initialization.
                Pass an int for reproducible fits; ``None`` (default) uses
                non-deterministic entropy.

        Returns:
            Tuple of (responsibilities, components, weights)
        """
        data = np.asarray(data, dtype=float).ravel()
        n = len(data)
        if n == 0:
            raise ValueError("data must not be empty")
        if n_components < 1:
            raise ValueError("n_components must be >= 1")
        if n_components > n:
            raise ValueError("n_components must not exceed number of data points")

        # Initialize parameters randomly
        rng = np.random.default_rng(random_state)
        weights = np.ones(n_components) / n_components
        means = rng.choice(data, size=n_components, replace=False)
        std = float(np.std(data))
        stds = np.ones(n_components) * (std if std > 0 else 1.0)

        for _ in range(max_iter):
            prev_means = means.copy()
            prev_stds = stds.copy()
            # E-step: Calculate responsibilities
            responsibilities = np.zeros((n, n_components))

            for k in range(n_components):
                component = stats.norm(loc=means[k], scale=stds[k])
                responsibilities[:, k] = weights[k] * component.pdf(data)

            # Normalize responsibilities (guard against zero total likelihood)
            row_sums = responsibilities.sum(axis=1, keepdims=True)
            row_sums[row_sums == 0] = 1.0
            responsibilities /= row_sums

            # M-step: Update parameters
            nk = responsibilities.sum(axis=0)
            new_weights = nk / n

            new_means = np.zeros(n_components)
            new_stds = np.zeros(n_components)

            for k in range(n_components):
                if nk[k] == 0:
                    new_means[k] = means[k]
                    new_stds[k] = stds[k]
                    continue
                new_means[k] = np.sum(responsibilities[:, k] * data) / nk[k]
                diff_sq = (data - new_means[k]) ** 2
                new_stds[k] = np.sqrt(np.sum(responsibilities[:, k] * diff_sq) / nk[k])
                if new_stds[k] == 0:
                    new_stds[k] = 1e-6

            weights = new_weights
            means = new_means
            stds = new_stds

            # Check convergence (after updating so results are never stale)
            if np.max(np.abs(means - prev_means)) < tol and np.max(np.abs(stds - prev_stds)) < tol:
                break

        # Create component distributions
        components = [stats.norm(loc=m, scale=s) for m, s in zip(means, stds, strict=True)]

        return responsibilities, components, weights.tolist()

    def __repr__(self) -> str:
        return f"MixtureDistribution(n_components={self.n_components})"

__init__(components, weights)

Initialize mixture distribution.

Parameters:

Name Type Description Default
components list

List of component distributions

required
weights list[float] | ndarray

Mixing weights (must sum to 1)

required
Source code in src/distributions/mixtures.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def __init__(self, components: list, weights: list[float] | np.ndarray):
    """
    Initialize mixture distribution.

    Args:
        components: List of component distributions
        weights: Mixing weights (must sum to 1)
    """
    weights = np.asarray(weights, dtype=float)

    if len(components) != len(weights):
        raise ValueError("Number of components must match number of weights")

    if not np.isclose(np.sum(weights), 1.0):
        raise ValueError("Weights must sum to 1")

    if np.any(weights < 0):
        raise ValueError("Weights must be non-negative")

    self.components = components
    self.weights = weights
    self.n_components = len(components)

cdf(x)

Calculate cumulative distribution function.

Parameters:

Name Type Description Default
x ndarray

Input values

required

Returns:

Type Description
ndarray

CDF values

Source code in src/distributions/mixtures.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def cdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate cumulative distribution function.

    Args:
        x: Input values

    Returns:
        CDF values
    """
    x = np.atleast_1d(x)
    cdf_vals = np.zeros_like(x, dtype=float)

    for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
        cdf_vals += weight * component.cdf(x)

    return cdf_vals

fit_em(data, n_components, max_iter=100, tol=0.0001, random_state=None)

Fit mixture model using Expectation-Maximization.

Parameters:

Name Type Description Default
data ndarray

Observed data

required
n_components int

Number of mixture components

required
max_iter int

Maximum EM iterations

100
tol float

Convergence tolerance

0.0001
random_state int | None

Seed for the random component initialization. Pass an int for reproducible fits; None (default) uses non-deterministic entropy.

None

Returns:

Type Description
tuple[ndarray, list, list[float]]

Tuple of (responsibilities, components, weights)

Source code in src/distributions/mixtures.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def fit_em(
    self,
    data: np.ndarray,
    n_components: int,
    max_iter: int = 100,
    tol: float = 1e-4,
    random_state: int | None = None,
) -> tuple[np.ndarray, list, list[float]]:
    """
    Fit mixture model using Expectation-Maximization.

    Args:
        data: Observed data
        n_components: Number of mixture components
        max_iter: Maximum EM iterations
        tol: Convergence tolerance
        random_state: Seed for the random component initialization.
            Pass an int for reproducible fits; ``None`` (default) uses
            non-deterministic entropy.

    Returns:
        Tuple of (responsibilities, components, weights)
    """
    data = np.asarray(data, dtype=float).ravel()
    n = len(data)
    if n == 0:
        raise ValueError("data must not be empty")
    if n_components < 1:
        raise ValueError("n_components must be >= 1")
    if n_components > n:
        raise ValueError("n_components must not exceed number of data points")

    # Initialize parameters randomly
    rng = np.random.default_rng(random_state)
    weights = np.ones(n_components) / n_components
    means = rng.choice(data, size=n_components, replace=False)
    std = float(np.std(data))
    stds = np.ones(n_components) * (std if std > 0 else 1.0)

    for _ in range(max_iter):
        prev_means = means.copy()
        prev_stds = stds.copy()
        # E-step: Calculate responsibilities
        responsibilities = np.zeros((n, n_components))

        for k in range(n_components):
            component = stats.norm(loc=means[k], scale=stds[k])
            responsibilities[:, k] = weights[k] * component.pdf(data)

        # Normalize responsibilities (guard against zero total likelihood)
        row_sums = responsibilities.sum(axis=1, keepdims=True)
        row_sums[row_sums == 0] = 1.0
        responsibilities /= row_sums

        # M-step: Update parameters
        nk = responsibilities.sum(axis=0)
        new_weights = nk / n

        new_means = np.zeros(n_components)
        new_stds = np.zeros(n_components)

        for k in range(n_components):
            if nk[k] == 0:
                new_means[k] = means[k]
                new_stds[k] = stds[k]
                continue
            new_means[k] = np.sum(responsibilities[:, k] * data) / nk[k]
            diff_sq = (data - new_means[k]) ** 2
            new_stds[k] = np.sqrt(np.sum(responsibilities[:, k] * diff_sq) / nk[k])
            if new_stds[k] == 0:
                new_stds[k] = 1e-6

        weights = new_weights
        means = new_means
        stds = new_stds

        # Check convergence (after updating so results are never stale)
        if np.max(np.abs(means - prev_means)) < tol and np.max(np.abs(stds - prev_stds)) < tol:
            break

    # Create component distributions
    components = [stats.norm(loc=m, scale=s) for m, s in zip(means, stds, strict=True)]

    return responsibilities, components, weights.tolist()

mean()

Calculate mean of mixture.

Returns:

Type Description
float

Mean value

Source code in src/distributions/mixtures.py
108
109
110
111
112
113
114
115
116
117
118
119
def mean(self) -> float:
    """
    Calculate mean of mixture.

    Returns:
        Mean value
    """
    mean = 0.0
    for component, weight in zip(self.components, self.weights, strict=True):
        mean += weight * component.mean()

    return mean

pdf(x)

Calculate probability density function.

Parameters:

Name Type Description Default
x ndarray

Input values

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/mixtures.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def pdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate probability density function.

    Args:
        x: Input values

    Returns:
        PDF values
    """
    x = np.atleast_1d(x)
    pdf_vals = np.zeros_like(x, dtype=float)

    for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
        pdf_vals += weight * component.pdf(x)

    return pdf_vals

rvs(size=1, random_state=None)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Random samples

Source code in src/distributions/mixtures.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Random samples
    """
    rng = np.random.default_rng(random_state)

    # Sample component indices according to weights
    component_indices = rng.choice(self.n_components, size=size, p=self.weights)

    # Sample from each selected component
    samples = np.zeros(size)
    for i in range(self.n_components):
        mask = component_indices == i
        n_samples = np.sum(mask)

        if n_samples > 0:
            comp_samples = self.components[i].rvs(size=n_samples)
            samples[mask] = comp_samples

    return samples

var()

Calculate variance of mixture.

Returns:

Type Description
float

Variance value

Source code in src/distributions/mixtures.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def var(self) -> float:
    """
    Calculate variance of mixture.

    Returns:
        Variance value
    """
    # Var(X) = E[Var(X|Z)] + Var(E[X|Z])
    # where Z is the component indicator

    # E[Var(X|Z)]
    var_within = 0.0
    for component, weight in zip(self.components, self.weights, strict=True):
        var_within += weight * component.var()

    # Var(E[X|Z])
    mixture_mean = self.mean()
    var_between = 0.0
    for component, weight in zip(self.components, self.weights, strict=True):
        diff = component.mean() - mixture_mean
        var_between += weight * diff**2

    return var_within + var_between

MultivariateDistribution

Base class for multivariate distributions.

Source code in src/distributions/multivariate.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class MultivariateDistribution:
    """Base class for multivariate distributions."""

    def __init__(self, name: str, dimension: int):
        """
        Initialize multivariate distribution.

        Args:
            name: Name of the distribution
            dimension: Number of dimensions
        """
        self.name = name
        self.dimension = dimension
        self._dist = None

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate probability density function."""
        raise NotImplementedError

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate random samples."""
        raise NotImplementedError

    def mean(self) -> np.ndarray:
        """Calculate mean vector."""
        raise NotImplementedError

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix."""
        raise NotImplementedError

__init__(name, dimension)

Initialize multivariate distribution.

Parameters:

Name Type Description Default
name str

Name of the distribution

required
dimension int

Number of dimensions

required
Source code in src/distributions/multivariate.py
14
15
16
17
18
19
20
21
22
23
24
def __init__(self, name: str, dimension: int):
    """
    Initialize multivariate distribution.

    Args:
        name: Name of the distribution
        dimension: Number of dimensions
    """
    self.name = name
    self.dimension = dimension
    self._dist = None

cov()

Calculate covariance matrix.

Source code in src/distributions/multivariate.py
38
39
40
def cov(self) -> np.ndarray:
    """Calculate covariance matrix."""
    raise NotImplementedError

mean()

Calculate mean vector.

Source code in src/distributions/multivariate.py
34
35
36
def mean(self) -> np.ndarray:
    """Calculate mean vector."""
    raise NotImplementedError

pdf(x)

Calculate probability density function.

Source code in src/distributions/multivariate.py
26
27
28
def pdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate probability density function."""
    raise NotImplementedError

rvs(size=1, random_state=None)

Generate random samples.

Source code in src/distributions/multivariate.py
30
31
32
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate random samples."""
    raise NotImplementedError

MultivariateNormalDistribution

Bases: MultivariateDistribution

Multivariate Normal (Gaussian) distribution.

Source code in src/distributions/multivariate.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
class MultivariateNormalDistribution(MultivariateDistribution):
    """Multivariate Normal (Gaussian) distribution."""

    def __init__(self, mean: np.ndarray, cov: np.ndarray):
        """
        Initialize Multivariate Normal distribution.

        Args:
            mean: Mean vector (shape: d)
            cov: Covariance matrix (shape: d x d)
        """
        mean = np.asarray(mean)
        cov = np.asarray(cov)

        if mean.ndim != 1:
            raise ValueError("mean must be 1-dimensional")

        if cov.ndim != 2:
            raise ValueError("cov must be 2-dimensional")

        if cov.shape[0] != cov.shape[1]:
            raise ValueError("cov must be square")

        if len(mean) != cov.shape[0]:
            raise ValueError("mean and cov dimensions must match")

        # Check if covariance matrix is positive definite
        try:
            np.linalg.cholesky(cov)
        except np.linalg.LinAlgError as err:
            raise ValueError("cov must be positive definite") from err

        super().__init__("Multivariate Normal", len(mean))
        self.mean_vec = mean
        self.cov_mat = cov
        self._dist = stats.multivariate_normal(mean=mean, cov=cov)

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate probability density function.

        Args:
            x: Points to evaluate (shape: n x d or d)

        Returns:
            PDF values
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.pdf(x)

    def logpdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate log probability density function."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.logpdf(x)

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Samples (shape: size x d)
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.rvs(size=size, random_state=random_state)

    def mean(self) -> np.ndarray:
        """Calculate mean vector."""
        return self.mean_vec

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix."""
        return self.cov_mat

    def marginal(self, indices: list) -> "MultivariateNormalDistribution":
        """
        Get marginal distribution for selected variables.

        Args:
            indices: List of variable indices to keep

        Returns:
            Marginal distribution
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        indices = np.array(indices)
        marginal_mean = self.mean_vec[indices]
        marginal_cov = self.cov_mat[np.ix_(indices, indices)]
        return MultivariateNormalDistribution(marginal_mean, marginal_cov)

    def conditional(self, indices: list, values: np.ndarray) -> "MultivariateNormalDistribution":
        """
        Get conditional distribution.

        Args:
            indices: Indices of variables to condition on
            values: Values of conditioned variables

        Returns:
            Conditional distribution
        """
        indices_arr = np.array(indices)
        free_indices = np.array([i for i in range(self.dimension) if i not in indices_arr])

        # Partition mean and covariance
        mu1 = self.mean_vec[free_indices]
        mu2 = self.mean_vec[indices]
        sigma11 = self.cov_mat[np.ix_(free_indices, free_indices)]
        sigma12 = self.cov_mat[np.ix_(free_indices, indices)]
        sigma22 = self.cov_mat[np.ix_(indices, indices)]

        # Conditional parameters
        sigma22_inv = np.linalg.inv(sigma22)
        cond_mean = mu1 + sigma12 @ sigma22_inv @ (values - mu2)
        cond_cov = sigma11 - sigma12 @ sigma22_inv @ sigma12.T

        return MultivariateNormalDistribution(cond_mean, cond_cov)

    def mahalanobis(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate Mahalanobis distance.

        Args:
            x: Points (shape: n x d or d)

        Returns:
            Mahalanobis distances
        """
        x = np.atleast_2d(x)
        diff = x - self.mean_vec
        cov_inv = np.linalg.inv(self.cov_mat)
        return np.sqrt(np.sum(diff @ cov_inv * diff, axis=1))

    def __repr__(self) -> str:
        return f"MultivariateNormal(dimension={self.dimension})"

__init__(mean, cov)

Initialize Multivariate Normal distribution.

Parameters:

Name Type Description Default
mean ndarray

Mean vector (shape: d)

required
cov ndarray

Covariance matrix (shape: d x d)

required
Source code in src/distributions/multivariate.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def __init__(self, mean: np.ndarray, cov: np.ndarray):
    """
    Initialize Multivariate Normal distribution.

    Args:
        mean: Mean vector (shape: d)
        cov: Covariance matrix (shape: d x d)
    """
    mean = np.asarray(mean)
    cov = np.asarray(cov)

    if mean.ndim != 1:
        raise ValueError("mean must be 1-dimensional")

    if cov.ndim != 2:
        raise ValueError("cov must be 2-dimensional")

    if cov.shape[0] != cov.shape[1]:
        raise ValueError("cov must be square")

    if len(mean) != cov.shape[0]:
        raise ValueError("mean and cov dimensions must match")

    # Check if covariance matrix is positive definite
    try:
        np.linalg.cholesky(cov)
    except np.linalg.LinAlgError as err:
        raise ValueError("cov must be positive definite") from err

    super().__init__("Multivariate Normal", len(mean))
    self.mean_vec = mean
    self.cov_mat = cov
    self._dist = stats.multivariate_normal(mean=mean, cov=cov)

conditional(indices, values)

Get conditional distribution.

Parameters:

Name Type Description Default
indices list

Indices of variables to condition on

required
values ndarray

Values of conditioned variables

required

Returns:

Type Description
MultivariateNormalDistribution

Conditional distribution

Source code in src/distributions/multivariate.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def conditional(self, indices: list, values: np.ndarray) -> "MultivariateNormalDistribution":
    """
    Get conditional distribution.

    Args:
        indices: Indices of variables to condition on
        values: Values of conditioned variables

    Returns:
        Conditional distribution
    """
    indices_arr = np.array(indices)
    free_indices = np.array([i for i in range(self.dimension) if i not in indices_arr])

    # Partition mean and covariance
    mu1 = self.mean_vec[free_indices]
    mu2 = self.mean_vec[indices]
    sigma11 = self.cov_mat[np.ix_(free_indices, free_indices)]
    sigma12 = self.cov_mat[np.ix_(free_indices, indices)]
    sigma22 = self.cov_mat[np.ix_(indices, indices)]

    # Conditional parameters
    sigma22_inv = np.linalg.inv(sigma22)
    cond_mean = mu1 + sigma12 @ sigma22_inv @ (values - mu2)
    cond_cov = sigma11 - sigma12 @ sigma22_inv @ sigma12.T

    return MultivariateNormalDistribution(cond_mean, cond_cov)

cov()

Calculate covariance matrix.

Source code in src/distributions/multivariate.py
119
120
121
def cov(self) -> np.ndarray:
    """Calculate covariance matrix."""
    return self.cov_mat

logpdf(x)

Calculate log probability density function.

Source code in src/distributions/multivariate.py
94
95
96
97
98
def logpdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate log probability density function."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.logpdf(x)

mahalanobis(x)

Calculate Mahalanobis distance.

Parameters:

Name Type Description Default
x ndarray

Points (shape: n x d or d)

required

Returns:

Type Description
ndarray

Mahalanobis distances

Source code in src/distributions/multivariate.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def mahalanobis(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate Mahalanobis distance.

    Args:
        x: Points (shape: n x d or d)

    Returns:
        Mahalanobis distances
    """
    x = np.atleast_2d(x)
    diff = x - self.mean_vec
    cov_inv = np.linalg.inv(self.cov_mat)
    return np.sqrt(np.sum(diff @ cov_inv * diff, axis=1))

marginal(indices)

Get marginal distribution for selected variables.

Parameters:

Name Type Description Default
indices list

List of variable indices to keep

required

Returns:

Type Description
MultivariateNormalDistribution

Marginal distribution

Source code in src/distributions/multivariate.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def marginal(self, indices: list) -> "MultivariateNormalDistribution":
    """
    Get marginal distribution for selected variables.

    Args:
        indices: List of variable indices to keep

    Returns:
        Marginal distribution
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    indices = np.array(indices)
    marginal_mean = self.mean_vec[indices]
    marginal_cov = self.cov_mat[np.ix_(indices, indices)]
    return MultivariateNormalDistribution(marginal_mean, marginal_cov)

mean()

Calculate mean vector.

Source code in src/distributions/multivariate.py
115
116
117
def mean(self) -> np.ndarray:
    """Calculate mean vector."""
    return self.mean_vec

pdf(x)

Calculate probability density function.

Parameters:

Name Type Description Default
x ndarray

Points to evaluate (shape: n x d or d)

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/multivariate.py
80
81
82
83
84
85
86
87
88
89
90
91
92
def pdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate probability density function.

    Args:
        x: Points to evaluate (shape: n x d or d)

    Returns:
        PDF values
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.pdf(x)

rvs(size=1, random_state=None)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Samples (shape: size x d)

Source code in src/distributions/multivariate.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Samples (shape: size x d)
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.rvs(size=size, random_state=random_state)

MultivariateStudentT

Bases: MultivariateDistribution

Multivariate Student's t-distribution.

Source code in src/distributions/multivariate.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
class MultivariateStudentT(MultivariateDistribution):
    """Multivariate Student's t-distribution."""

    def __init__(self, df: float, loc: np.ndarray, shape: np.ndarray):
        """
        Initialize Multivariate Student-t distribution.

        Args:
            df: Degrees of freedom
            loc: Location vector
            shape: Shape matrix (similar to covariance)
        """
        loc = np.asarray(loc)
        shape = np.asarray(shape)

        if df <= 0:
            raise ValueError("df must be positive")

        if loc.ndim != 1:
            raise ValueError("loc must be 1-dimensional")

        if shape.ndim != 2 or shape.shape[0] != shape.shape[1]:
            raise ValueError("shape must be square matrix")

        super().__init__("Multivariate Student-t", len(loc))
        self.df = df
        self.loc_vec = loc
        self.shape_mat = shape

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate probability density function."""
        x = np.atleast_2d(x)
        d = self.dimension
        df = self.df

        diff = x - self.loc_vec
        shape_inv = np.linalg.inv(self.shape_mat)
        shape_det = np.linalg.det(self.shape_mat)

        mahalanobis_sq = np.sum(diff @ shape_inv * diff, axis=1)

        # Compute normalizing constant
        from scipy.special import gamma

        numer = gamma((df + d) / 2)
        denom = gamma(df / 2) * ((df * np.pi) ** (d / 2)) * np.sqrt(shape_det)
        normalizing = numer / denom

        # Compute PDF
        pdf_vals = normalizing * (1 + mahalanobis_sq / df) ** (-(df + d) / 2)

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate random samples."""
        rng = np.random.default_rng(random_state)

        # Generate using property: t_d = loc + sqrt(d/chi^2_d) * N(0, shape)
        chi2_samples = rng.chisquare(self.df, size=size)
        normal_samples = rng.multivariate_normal(
            np.zeros(self.dimension), self.shape_mat, size=size
        )

        samples = self.loc_vec + normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]
        return samples

    def mean(self) -> np.ndarray:
        """Calculate mean vector (only for df > 1)."""
        if self.df <= 1:
            raise ValueError("Mean only defined for df > 1")
        return self.loc_vec

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix (only for df > 2)."""
        if self.df <= 2:
            raise ValueError("Covariance only defined for df > 2")
        return self.shape_mat * (self.df / (self.df - 2))

    def __repr__(self) -> str:
        return f"MultivariateStudentT(dimension={self.dimension}, df={self.df})"

__init__(df, loc, shape)

Initialize Multivariate Student-t distribution.

Parameters:

Name Type Description Default
df float

Degrees of freedom

required
loc ndarray

Location vector

required
shape ndarray

Shape matrix (similar to covariance)

required
Source code in src/distributions/multivariate.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
def __init__(self, df: float, loc: np.ndarray, shape: np.ndarray):
    """
    Initialize Multivariate Student-t distribution.

    Args:
        df: Degrees of freedom
        loc: Location vector
        shape: Shape matrix (similar to covariance)
    """
    loc = np.asarray(loc)
    shape = np.asarray(shape)

    if df <= 0:
        raise ValueError("df must be positive")

    if loc.ndim != 1:
        raise ValueError("loc must be 1-dimensional")

    if shape.ndim != 2 or shape.shape[0] != shape.shape[1]:
        raise ValueError("shape must be square matrix")

    super().__init__("Multivariate Student-t", len(loc))
    self.df = df
    self.loc_vec = loc
    self.shape_mat = shape

cov()

Calculate covariance matrix (only for df > 2).

Source code in src/distributions/multivariate.py
353
354
355
356
357
def cov(self) -> np.ndarray:
    """Calculate covariance matrix (only for df > 2)."""
    if self.df <= 2:
        raise ValueError("Covariance only defined for df > 2")
    return self.shape_mat * (self.df / (self.df - 2))

mean()

Calculate mean vector (only for df > 1).

Source code in src/distributions/multivariate.py
347
348
349
350
351
def mean(self) -> np.ndarray:
    """Calculate mean vector (only for df > 1)."""
    if self.df <= 1:
        raise ValueError("Mean only defined for df > 1")
    return self.loc_vec

pdf(x)

Calculate probability density function.

Source code in src/distributions/multivariate.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def pdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate probability density function."""
    x = np.atleast_2d(x)
    d = self.dimension
    df = self.df

    diff = x - self.loc_vec
    shape_inv = np.linalg.inv(self.shape_mat)
    shape_det = np.linalg.det(self.shape_mat)

    mahalanobis_sq = np.sum(diff @ shape_inv * diff, axis=1)

    # Compute normalizing constant
    from scipy.special import gamma

    numer = gamma((df + d) / 2)
    denom = gamma(df / 2) * ((df * np.pi) ** (d / 2)) * np.sqrt(shape_det)
    normalizing = numer / denom

    # Compute PDF
    pdf_vals = normalizing * (1 + mahalanobis_sq / df) ** (-(df + d) / 2)

    return pdf_vals

rvs(size=1, random_state=None)

Generate random samples.

Source code in src/distributions/multivariate.py
334
335
336
337
338
339
340
341
342
343
344
345
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate random samples."""
    rng = np.random.default_rng(random_state)

    # Generate using property: t_d = loc + sqrt(d/chi^2_d) * N(0, shape)
    chi2_samples = rng.chisquare(self.df, size=size)
    normal_samples = rng.multivariate_normal(
        np.zeros(self.dimension), self.shape_mat, size=size
    )

    samples = self.loc_vec + normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]
    return samples

NegativeBinomialDistribution

Bases: Distribution

Negative Binomial distribution.

Source code in src/distributions/discrete.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
class NegativeBinomialDistribution(Distribution):
    """Negative Binomial distribution."""

    def __init__(self, r: int = 5, p: float = 0.5):
        """
        Initialize Negative Binomial distribution.

        Args:
            r: Number of successes (must be positive integer)
            p: Probability of success (must be between 0 and 1)
        """
        super().__init__("Negative Binomial", is_discrete=True)
        self.r = r
        self.p = p
        self.set_parameters(r=r, p=p)

    def _create_distribution(self, **params):
        """Create scipy negative binomial distribution."""
        return stats.nbinom(n=params["r"], p=params["p"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"r": self.r, "p": self.p}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.r = int(params.get("r", self.r))
        self.p = params.get("p", self.p)

        if self.r <= 0:
            raise ValueError("r must be positive")
        if not 0 < self.p <= 1:
            raise ValueError("p must be between 0 and 1")

        self._dist = self._create_distribution(r=self.r, p=self.p)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "r": (1, 50),
            "p": (0.01, 1.0),
        }

__init__(r=5, p=0.5)

Initialize Negative Binomial distribution.

Parameters:

Name Type Description Default
r int

Number of successes (must be positive integer)

5
p float

Probability of success (must be between 0 and 1)

0.5
Source code in src/distributions/discrete.py
129
130
131
132
133
134
135
136
137
138
139
140
def __init__(self, r: int = 5, p: float = 0.5):
    """
    Initialize Negative Binomial distribution.

    Args:
        r: Number of successes (must be positive integer)
        p: Probability of success (must be between 0 and 1)
    """
    super().__init__("Negative Binomial", is_discrete=True)
    self.r = r
    self.p = p
    self.set_parameters(r=r, p=p)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
162
163
164
165
166
167
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "r": (1, 50),
        "p": (0.01, 1.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
146
147
148
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"r": self.r, "p": self.p}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
150
151
152
153
154
155
156
157
158
159
160
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.r = int(params.get("r", self.r))
    self.p = params.get("p", self.p)

    if self.r <= 0:
        raise ValueError("r must be positive")
    if not 0 < self.p <= 1:
        raise ValueError("p must be between 0 and 1")

    self._dist = self._create_distribution(r=self.r, p=self.p)

NormalDistribution

Bases: Distribution

Normal (Gaussian) distribution.

Source code in src/distributions/continuous.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class NormalDistribution(Distribution):
    """Normal (Gaussian) distribution."""

    def __init__(self, mu: float = 0.0, sigma: float = 1.0):
        """
        Initialize Normal distribution.

        Args:
            mu: Mean parameter
            sigma: Standard deviation parameter (must be > 0)
        """
        super().__init__("Normal", is_discrete=False)
        self.mu = mu
        self.sigma = sigma
        self.set_parameters(mu=mu, sigma=sigma)

    def _create_distribution(self, **params):
        """Create scipy normal distribution."""
        return stats.norm(loc=params["mu"], scale=params["sigma"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"mu": self.mu, "sigma": self.sigma}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.mu = params.get("mu", self.mu)
        self.sigma = params.get("sigma", self.sigma)

        if self.sigma <= 0:
            raise ValueError("sigma must be positive")

        self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "mu": (-100.0, 100.0),
            "sigma": (0.1, 50.0),
        }

__init__(mu=0.0, sigma=1.0)

Initialize Normal distribution.

Parameters:

Name Type Description Default
mu float

Mean parameter

0.0
sigma float

Standard deviation parameter (must be > 0)

1.0
Source code in src/distributions/continuous.py
14
15
16
17
18
19
20
21
22
23
24
25
def __init__(self, mu: float = 0.0, sigma: float = 1.0):
    """
    Initialize Normal distribution.

    Args:
        mu: Mean parameter
        sigma: Standard deviation parameter (must be > 0)
    """
    super().__init__("Normal", is_discrete=False)
    self.mu = mu
    self.sigma = sigma
    self.set_parameters(mu=mu, sigma=sigma)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
45
46
47
48
49
50
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "mu": (-100.0, 100.0),
        "sigma": (0.1, 50.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
31
32
33
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"mu": self.mu, "sigma": self.sigma}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
35
36
37
38
39
40
41
42
43
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.mu = params.get("mu", self.mu)
    self.sigma = params.get("sigma", self.sigma)

    if self.sigma <= 0:
        raise ValueError("sigma must be positive")

    self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

PoissonDistribution

Bases: Distribution

Poisson distribution.

Source code in src/distributions/discrete.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class PoissonDistribution(Distribution):
    """Poisson distribution."""

    def __init__(self, lambda_param: float = 3.0):
        """
        Initialize Poisson distribution.

        Args:
            lambda_param: Rate parameter (must be > 0)
        """
        super().__init__("Poisson", is_discrete=True)
        self.lambda_param = lambda_param
        self.set_parameters(lambda_param=lambda_param)

    def _create_distribution(self, **params):
        """Create scipy poisson distribution."""
        return stats.poisson(mu=params["lambda_param"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"lambda": self.lambda_param}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

        if self.lambda_param <= 0:
            raise ValueError("lambda must be positive")

        self._dist = self._create_distribution(lambda_param=self.lambda_param)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"lambda": (0.1, 20.0)}

__init__(lambda_param=3.0)

Initialize Poisson distribution.

Parameters:

Name Type Description Default
lambda_param float

Rate parameter (must be > 0)

3.0
Source code in src/distributions/discrete.py
57
58
59
60
61
62
63
64
65
66
def __init__(self, lambda_param: float = 3.0):
    """
    Initialize Poisson distribution.

    Args:
        lambda_param: Rate parameter (must be > 0)
    """
    super().__init__("Poisson", is_discrete=True)
    self.lambda_param = lambda_param
    self.set_parameters(lambda_param=lambda_param)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
85
86
87
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"lambda": (0.1, 20.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
72
73
74
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"lambda": self.lambda_param}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
76
77
78
79
80
81
82
83
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

    if self.lambda_param <= 0:
        raise ValueError("lambda must be positive")

    self._dist = self._create_distribution(lambda_param=self.lambda_param)

StudentTCopula

Bases: Copula

Student-t copula.

Source code in src/distributions/copulas.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
class StudentTCopula(Copula):
    """Student-t copula."""

    def __init__(self, correlation: np.ndarray, df: float):
        """
        Initialize Student-t copula.

        Args:
            correlation: Correlation matrix
            df: Degrees of freedom
        """
        corr = np.asarray(correlation)

        if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
            raise ValueError("correlation must be square matrix")

        if df <= 0:
            raise ValueError("df must be positive")

        if not np.allclose(np.diag(corr), 1.0):
            raise ValueError("diagonal of correlation matrix must be 1")

        try:
            np.linalg.cholesky(corr)
        except np.linalg.LinAlgError as err:
            raise ValueError("correlation must be positive definite") from err

        super().__init__("Student-t", corr.shape[0])
        self.correlation = corr
        self.df = df

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Student-t copula CDF.

        Note: closed-form evaluation requires multivariate-t integration and is
        not implemented; use Monte Carlo estimation via :meth:`rvs` instead.
        """
        raise NotImplementedError(
            "StudentTCopula.cdf is not implemented (requires multivariate-t "
            "integration); estimate probabilities by Monte Carlo sampling with rvs()"
        )

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Student-t copula density.

        Note: the density is not implemented; use sampling-based inference via
        :meth:`rvs` instead.
        """
        raise NotImplementedError(
            "StudentTCopula.pdf is not implemented; use sampling-based inference with rvs()"
        )

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Student-t copula."""
        rng = np.random.default_rng(random_state)

        # Sample from multivariate t
        # Method: X = mu + Y * sqrt(df/S) where Y ~ N(0, Σ), S ~ chi2(df)
        normal_samples = rng.multivariate_normal(
            np.zeros(self.dimension), self.correlation, size=size
        )

        chi2_samples = rng.chisquare(self.df, size=size)

        t_samples = normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]

        # Transform to uniform using t CDF
        u = stats.t.cdf(t_samples, df=self.df)

        return u

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau (bivariate only).

        Returns:
            Kendall's tau
        """
        if self.dimension != 2:
            raise ValueError("Kendall's tau only defined for bivariate")

        rho = self.correlation[0, 1]
        return (2 / np.pi) * np.arcsin(rho)

    def __repr__(self) -> str:
        return f"StudentTCopula(dimension={self.dimension}, df={self.df})"

__init__(correlation, df)

Initialize Student-t copula.

Parameters:

Name Type Description Default
correlation ndarray

Correlation matrix

required
df float

Degrees of freedom

required
Source code in src/distributions/copulas.py
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def __init__(self, correlation: np.ndarray, df: float):
    """
    Initialize Student-t copula.

    Args:
        correlation: Correlation matrix
        df: Degrees of freedom
    """
    corr = np.asarray(correlation)

    if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
        raise ValueError("correlation must be square matrix")

    if df <= 0:
        raise ValueError("df must be positive")

    if not np.allclose(np.diag(corr), 1.0):
        raise ValueError("diagonal of correlation matrix must be 1")

    try:
        np.linalg.cholesky(corr)
    except np.linalg.LinAlgError as err:
        raise ValueError("correlation must be positive definite") from err

    super().__init__("Student-t", corr.shape[0])
    self.correlation = corr
    self.df = df

cdf(u)

Student-t copula CDF.

Note: closed-form evaluation requires multivariate-t integration and is not implemented; use Monte Carlo estimation via :meth:rvs instead.

Source code in src/distributions/copulas.py
385
386
387
388
389
390
391
392
393
394
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Student-t copula CDF.

    Note: closed-form evaluation requires multivariate-t integration and is
    not implemented; use Monte Carlo estimation via :meth:`rvs` instead.
    """
    raise NotImplementedError(
        "StudentTCopula.cdf is not implemented (requires multivariate-t "
        "integration); estimate probabilities by Monte Carlo sampling with rvs()"
    )

kendall_tau()

Calculate Kendall's tau (bivariate only).

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
425
426
427
428
429
430
431
432
433
434
435
436
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau (bivariate only).

    Returns:
        Kendall's tau
    """
    if self.dimension != 2:
        raise ValueError("Kendall's tau only defined for bivariate")

    rho = self.correlation[0, 1]
    return (2 / np.pi) * np.arcsin(rho)

pdf(u)

Student-t copula density.

Note: the density is not implemented; use sampling-based inference via :meth:rvs instead.

Source code in src/distributions/copulas.py
396
397
398
399
400
401
402
403
404
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Student-t copula density.

    Note: the density is not implemented; use sampling-based inference via
    :meth:`rvs` instead.
    """
    raise NotImplementedError(
        "StudentTCopula.pdf is not implemented; use sampling-based inference with rvs()"
    )

rvs(size=1, random_state=None)

Generate samples from Student-t copula.

Source code in src/distributions/copulas.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Student-t copula."""
    rng = np.random.default_rng(random_state)

    # Sample from multivariate t
    # Method: X = mu + Y * sqrt(df/S) where Y ~ N(0, Σ), S ~ chi2(df)
    normal_samples = rng.multivariate_normal(
        np.zeros(self.dimension), self.correlation, size=size
    )

    chi2_samples = rng.chisquare(self.df, size=size)

    t_samples = normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]

    # Transform to uniform using t CDF
    u = stats.t.cdf(t_samples, df=self.df)

    return u

StudentTDistribution

Bases: Distribution

Student's t-distribution.

Source code in src/distributions/continuous.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
class StudentTDistribution(Distribution):
    """Student's t-distribution."""

    def __init__(self, df: float = 10.0):
        """
        Initialize Student's t-distribution.

        Args:
            df: Degrees of freedom (must be > 0)
        """
        super().__init__("Student-t", is_discrete=False)
        self.df = df
        self.set_parameters(df=df)

    def _create_distribution(self, **params):
        """Create scipy t distribution."""
        return stats.t(df=params["df"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"df": self.df}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.df = params.get("df", self.df)

        if self.df <= 0:
            raise ValueError("df must be positive")

        self._dist = self._create_distribution(df=self.df)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"df": (1.0, 30.0)}

__init__(df=10.0)

Initialize Student's t-distribution.

Parameters:

Name Type Description Default
df float

Degrees of freedom (must be > 0)

10.0
Source code in src/distributions/continuous.py
254
255
256
257
258
259
260
261
262
263
def __init__(self, df: float = 10.0):
    """
    Initialize Student's t-distribution.

    Args:
        df: Degrees of freedom (must be > 0)
    """
    super().__init__("Student-t", is_discrete=False)
    self.df = df
    self.set_parameters(df=df)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
282
283
284
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"df": (1.0, 30.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
269
270
271
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"df": self.df}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
273
274
275
276
277
278
279
280
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.df = params.get("df", self.df)

    if self.df <= 0:
        raise ValueError("df must be positive")

    self._dist = self._create_distribution(df=self.df)

UniformDistribution

Bases: Distribution

Continuous Uniform distribution.

Source code in src/distributions/continuous.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class UniformDistribution(Distribution):
    """Continuous Uniform distribution."""

    def __init__(self, a: float = 0.0, b: float = 1.0):
        """
        Initialize Uniform distribution.

        Args:
            a: Lower bound
            b: Upper bound (must be > a)
        """
        super().__init__("Uniform", is_discrete=False)
        self.a = a
        self.b = b
        self.set_parameters(a=a, b=b)

    def _create_distribution(self, **params):
        """Create scipy uniform distribution."""
        return stats.uniform(loc=params["a"], scale=params["b"] - params["a"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"a": self.a, "b": self.b}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.a = params.get("a", self.a)
        self.b = params.get("b", self.b)

        if self.a >= self.b:
            raise ValueError("a must be less than b")

        self._dist = self._create_distribution(a=self.a, b=self.b)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "a": (-10.0, 10.0),
            "b": (-10.0, 10.0),
        }

__init__(a=0.0, b=1.0)

Initialize Uniform distribution.

Parameters:

Name Type Description Default
a float

Lower bound

0.0
b float

Upper bound (must be > a)

1.0
Source code in src/distributions/continuous.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def __init__(self, a: float = 0.0, b: float = 1.0):
    """
    Initialize Uniform distribution.

    Args:
        a: Lower bound
        b: Upper bound (must be > a)
    """
    super().__init__("Uniform", is_discrete=False)
    self.a = a
    self.b = b
    self.set_parameters(a=a, b=b)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
123
124
125
126
127
128
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "a": (-10.0, 10.0),
        "b": (-10.0, 10.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
109
110
111
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"a": self.a, "b": self.b}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
113
114
115
116
117
118
119
120
121
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.a = params.get("a", self.a)
    self.b = params.get("b", self.b)

    if self.a >= self.b:
        raise ValueError("a must be less than b")

    self._dist = self._create_distribution(a=self.a, b=self.b)

WeibullDistribution

Bases: Distribution

Weibull distribution.

Source code in src/distributions/continuous.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
class WeibullDistribution(Distribution):
    """Weibull distribution."""

    def __init__(self, shape: float = 1.5, scale: float = 1.0):
        """
        Initialize Weibull distribution.

        Args:
            shape: Shape parameter (k, must be > 0)
            scale: Scale parameter (lambda, must be > 0)
        """
        super().__init__("Weibull", is_discrete=False)
        self.shape = shape
        self.scale = scale
        self.set_parameters(shape=shape, scale=scale)

    def _create_distribution(self, **params):
        """Create scipy weibull distribution."""
        return stats.weibull_min(c=params["shape"], scale=params["scale"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"shape": self.shape, "scale": self.scale}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.shape = params.get("shape", self.shape)
        self.scale = params.get("scale", self.scale)

        if self.shape <= 0 or self.scale <= 0:
            raise ValueError("shape and scale must be positive")

        self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "shape": (0.1, 5.0),
            "scale": (0.1, 5.0),
        }

__init__(shape=1.5, scale=1.0)

Initialize Weibull distribution.

Parameters:

Name Type Description Default
shape float

Shape parameter (k, must be > 0)

1.5
scale float

Scale parameter (lambda, must be > 0)

1.0
Source code in src/distributions/continuous.py
290
291
292
293
294
295
296
297
298
299
300
301
def __init__(self, shape: float = 1.5, scale: float = 1.0):
    """
    Initialize Weibull distribution.

    Args:
        shape: Shape parameter (k, must be > 0)
        scale: Scale parameter (lambda, must be > 0)
    """
    super().__init__("Weibull", is_discrete=False)
    self.shape = shape
    self.scale = scale
    self.set_parameters(shape=shape, scale=scale)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
321
322
323
324
325
326
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "shape": (0.1, 5.0),
        "scale": (0.1, 5.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
307
308
309
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"shape": self.shape, "scale": self.scale}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
311
312
313
314
315
316
317
318
319
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.shape = params.get("shape", self.shape)
    self.scale = params.get("scale", self.scale)

    if self.shape <= 0 or self.scale <= 0:
        raise ValueError("shape and scale must be positive")

    self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

WishartDistribution

Wishart distribution (distribution over positive definite matrices).

Source code in src/distributions/multivariate.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
class WishartDistribution:
    """Wishart distribution (distribution over positive definite matrices)."""

    def __init__(self, df: int, scale: np.ndarray):
        """
        Initialize Wishart distribution.

        Args:
            df: Degrees of freedom (must be >= dimension)
            scale: Scale matrix (positive definite)
        """
        scale = np.asarray(scale)

        if scale.ndim != 2 or scale.shape[0] != scale.shape[1]:
            raise ValueError("scale must be square matrix")

        dimension = scale.shape[0]

        if df < dimension:
            raise ValueError("df must be >= dimension")

        self.name = "Wishart"
        self.dimension = dimension
        self.df = df
        self.scale_mat = scale
        self._dist = stats.wishart(df=df, scale=scale)

    def pdf(self, x: np.ndarray) -> float:
        """Calculate probability density function."""
        return self._dist.pdf(x)

    def logpdf(self, x: np.ndarray) -> float:
        """Calculate log probability density function."""
        return self._dist.logpdf(x)

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate random positive definite matrices."""
        return self._dist.rvs(size=size, random_state=random_state)

    def mean(self) -> np.ndarray:
        """Calculate mean matrix."""
        return self._dist.mean()

    def mode(self) -> np.ndarray:
        """Calculate mode matrix."""
        if self.df >= self.dimension + 1:
            return (self.df - self.dimension - 1) * self.scale_mat
        raise ValueError("Mode only defined for df >= dimension + 1")

    def __repr__(self) -> str:
        return f"Wishart(dimension={self.dimension}, df={self.df})"

__init__(df, scale)

Initialize Wishart distribution.

Parameters:

Name Type Description Default
df int

Degrees of freedom (must be >= dimension)

required
scale ndarray

Scale matrix (positive definite)

required
Source code in src/distributions/multivariate.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def __init__(self, df: int, scale: np.ndarray):
    """
    Initialize Wishart distribution.

    Args:
        df: Degrees of freedom (must be >= dimension)
        scale: Scale matrix (positive definite)
    """
    scale = np.asarray(scale)

    if scale.ndim != 2 or scale.shape[0] != scale.shape[1]:
        raise ValueError("scale must be square matrix")

    dimension = scale.shape[0]

    if df < dimension:
        raise ValueError("df must be >= dimension")

    self.name = "Wishart"
    self.dimension = dimension
    self.df = df
    self.scale_mat = scale
    self._dist = stats.wishart(df=df, scale=scale)

logpdf(x)

Calculate log probability density function.

Source code in src/distributions/multivariate.py
394
395
396
def logpdf(self, x: np.ndarray) -> float:
    """Calculate log probability density function."""
    return self._dist.logpdf(x)

mean()

Calculate mean matrix.

Source code in src/distributions/multivariate.py
402
403
404
def mean(self) -> np.ndarray:
    """Calculate mean matrix."""
    return self._dist.mean()

mode()

Calculate mode matrix.

Source code in src/distributions/multivariate.py
406
407
408
409
410
def mode(self) -> np.ndarray:
    """Calculate mode matrix."""
    if self.df >= self.dimension + 1:
        return (self.df - self.dimension - 1) * self.scale_mat
    raise ValueError("Mode only defined for df >= dimension + 1")

pdf(x)

Calculate probability density function.

Source code in src/distributions/multivariate.py
390
391
392
def pdf(self, x: np.ndarray) -> float:
    """Calculate probability density function."""
    return self._dist.pdf(x)

rvs(size=1, random_state=None)

Generate random positive definite matrices.

Source code in src/distributions/multivariate.py
398
399
400
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate random positive definite matrices."""
    return self._dist.rvs(size=size, random_state=random_state)

fit_copula_to_data(data, copula_type='gaussian', method='rank')

Fit copula to multivariate data.

Parameters:

Name Type Description Default
data ndarray

Multivariate data (n x d)

required
copula_type str

Type of copula ('gaussian', 'clayton', 'gumbel', 't')

'gaussian'
method str

Method for pseudo-observations ('rank' or 'empirical')

'rank'

Returns:

Type Description
Copula

Fitted copula object

Source code in src/distributions/copulas.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
def fit_copula_to_data(
    data: np.ndarray, copula_type: str = "gaussian", method: str = "rank"
) -> Copula:
    """
    Fit copula to multivariate data.

    Args:
        data: Multivariate data (n x d)
        copula_type: Type of copula ('gaussian', 'clayton', 'gumbel', 't')
        method: Method for pseudo-observations ('rank' or 'empirical')

    Returns:
        Fitted copula object
    """
    n, d = data.shape

    # Transform to pseudo-observations (uniform margins)
    if method == "rank":
        # Rank-based transformation
        u = np.zeros_like(data)
        for i in range(d):
            ranks = stats.rankdata(data[:, i])
            u[:, i] = ranks / (n + 1)
    else:
        # Empirical CDF
        u = np.zeros_like(data)
        for i in range(d):
            u[:, i] = stats.rankdata(data[:, i]) / n

    # Estimate copula parameters
    if copula_type == "gaussian":
        # Estimate correlation from Gaussian quantiles
        z = stats.norm.ppf(u)
        corr = np.corrcoef(z.T)
        return GaussianCopula(corr)

    elif copula_type == "clayton" and d == 2:
        # Estimate theta using Kendall's tau
        tau = float(stats.kendalltau(data[:, 0], data[:, 1])[0])
        if not np.isfinite(tau) or tau <= 0 or tau >= 1:
            raise ValueError(f"Clayton copula requires Kendall's tau in (0, 1); got {tau!r}")
        theta = 2 * tau / (1 - tau)
        return ClaytonCopula(theta, dimension=2)

    elif copula_type == "gumbel" and d == 2:
        # Estimate theta using Kendall's tau
        tau = float(stats.kendalltau(data[:, 0], data[:, 1])[0])
        if not np.isfinite(tau) or tau < 0 or tau >= 1:
            raise ValueError(f"Gumbel copula requires Kendall's tau in [0, 1); got {tau!r}")
        theta = 1 / (1 - tau)
        return GumbelCopula(theta, dimension=2)

    elif copula_type == "t":
        # Estimate correlation. Degrees of freedom are fixed at df=4 as a
        # documented simplification; full MLE over df is out of scope.
        # See https://github.com/sanskarpan/probviz/issues
        z = stats.t.ppf(u, df=4)
        corr = np.corrcoef(z.T)
        return StudentTCopula(corr, df=4)

    else:
        raise ValueError(f"Unsupported copula type: {copula_type}")

plot_bivariate_normal(dist, num_points=100, num_contours=10)

Plot bivariate normal distribution.

Parameters:

Name Type Description Default
dist MultivariateNormalDistribution

Multivariate normal distribution (dimension must be 2)

required
num_points int

Number of grid points

100
num_contours int

Number of contour levels

10

Returns:

Type Description
tuple[Figure, tuple[Axes, Axes]]

Figure and axes objects

Source code in src/distributions/multivariate.py
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
def plot_bivariate_normal(
    dist: MultivariateNormalDistribution, num_points: int = 100, num_contours: int = 10
) -> tuple[plt.Figure, tuple[plt.Axes, plt.Axes]]:
    """
    Plot bivariate normal distribution.

    Args:
        dist: Multivariate normal distribution (dimension must be 2)
        num_points: Number of grid points
        num_contours: Number of contour levels

    Returns:
        Figure and axes objects
    """
    if dist.dimension != 2:
        raise ValueError("Can only plot bivariate distributions")

    # Create grid
    mean = dist.mean()
    cov = dist.cov()

    # Determine plot limits based on covariance
    std1 = np.sqrt(cov[0, 0])
    std2 = np.sqrt(cov[1, 1])

    x1 = np.linspace(mean[0] - 3 * std1, mean[0] + 3 * std1, num_points)
    x2 = np.linspace(mean[1] - 3 * std2, mean[1] + 3 * std2, num_points)
    X1, X2 = np.meshgrid(x1, x2)

    # Evaluate PDF
    pos = np.dstack((X1, X2))
    Z = dist.pdf(pos)

    # Create figure with a 2-D panel and a 3-D panel side by side.
    fig = plt.figure(figsize=(14, 6))
    ax1 = fig.add_subplot(121)
    ax2 = cast(Axes3D, fig.add_subplot(122, projection="3d"))

    # Contour plot
    contour = ax1.contourf(X1, X2, Z, levels=num_contours, cmap="viridis")
    ax1.contour(X1, X2, Z, levels=num_contours, colors="white", alpha=0.3, linewidths=0.5)
    fig.colorbar(contour, ax=ax1, label="Probability Density")
    ax1.plot(mean[0], mean[1], "r*", markersize=15, label="Mean")
    ax1.set_xlabel("X₁")
    ax1.set_ylabel("X₂")
    ax1.set_title("Bivariate Normal Distribution - Contour Plot")
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    # 3D surface plot
    surf = ax2.plot_surface(X1, X2, Z, cmap="viridis", alpha=0.8, edgecolor="none")
    ax2.set_xlabel("X₁")
    ax2.set_ylabel("X₂")
    ax2.set_zlabel("Probability Density")
    ax2.set_title("Bivariate Normal Distribution - 3D Surface")
    fig.colorbar(surf, ax=ax2, shrink=0.5, aspect=5)

    plt.tight_layout()
    return fig, (ax1, ax2)

plot_dirichlet_simplex(dist, num_samples=1000)

Plot Dirichlet distribution samples on simplex (for dimension 3).

Parameters:

Name Type Description Default
dist DirichletDistribution

Dirichlet distribution (dimension must be 3)

required
num_samples int

Number of samples to generate

1000

Returns:

Type Description
tuple[Figure, Axes]

Figure and axes objects

Source code in src/distributions/multivariate.py
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
def plot_dirichlet_simplex(
    dist: DirichletDistribution, num_samples: int = 1000
) -> tuple[plt.Figure, plt.Axes]:
    """
    Plot Dirichlet distribution samples on simplex (for dimension 3).

    Args:
        dist: Dirichlet distribution (dimension must be 3)
        num_samples: Number of samples to generate

    Returns:
        Figure and axes objects
    """
    if dist.dimension != 3:
        raise ValueError("Can only plot 3-dimensional Dirichlet on simplex")

    # Generate samples
    samples = dist.rvs(size=num_samples, random_state=42)

    # Create figure
    fig = plt.figure(figsize=(12, 10))
    ax = cast(Axes3D, fig.add_subplot(111, projection="3d"))

    # Plot samples
    scatter = ax.scatter(
        samples[:, 0],
        samples[:, 1],
        samples[:, 2],
        c=samples[:, 0],
        cmap="viridis",
        alpha=0.6,
        s=20,
    )

    # Plot simplex edges
    vertices = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
    for i in range(3):
        for j in range(i + 1, 3):
            ax.plot(
                [vertices[i, 0], vertices[j, 0]],
                [vertices[i, 1], vertices[j, 1]],
                [vertices[i, 2], vertices[j, 2]],
                "k-",
                linewidth=2,
                alpha=0.5,
            )

    ax.set_xlabel("X₁")
    ax.set_ylabel("X₂")
    ax.set_zlabel("X₃")
    ax.set_title(f"Dirichlet Distribution Samples\nα = {dist.alpha}")
    fig.colorbar(scatter, ax=ax, label="X₁ value", shrink=0.5)

    return fig, ax

select_optimal_components(data, max_components=10)

Select optimal number of components using BIC.

Parameters:

Name Type Description Default
data ndarray

Training data

required
max_components int

Maximum components to try

10

Returns:

Type Description
tuple[int, dict]

Tuple of (optimal_n_components, results_dict)

Source code in src/distributions/mixtures.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
def select_optimal_components(data: np.ndarray, max_components: int = 10) -> tuple[int, dict]:
    """
    Select optimal number of components using BIC.

    Args:
        data: Training data
        max_components: Maximum components to try

    Returns:
        Tuple of (optimal_n_components, results_dict)
    """
    data = np.atleast_2d(data)
    if data.ndim == 1:
        data = data.reshape(-1, 1)

    bic_scores: list[float] = []
    aic_scores: list[float] = []

    for n in range(1, max_components + 1):
        gmm = GaussianMixtureModel(n_components=n)
        gmm.fit(data)

        bic_scores.append(gmm.bic(data))
        aic_scores.append(gmm.aic(data))

    # Lower BIC is better
    optimal_n: int = int(np.argmin(bic_scores) + 1)

    results = {
        "optimal_components": optimal_n,
        "bic_scores": bic_scores,
        "aic_scores": aic_scores,
        "components_range": list(range(1, max_components + 1)),
    }

    return optimal_n, results

src.distributions.continuous

Continuous probability distributions.

BetaDistribution

Bases: Distribution

Beta distribution.

Source code in src/distributions/continuous.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
class BetaDistribution(Distribution):
    """Beta distribution."""

    def __init__(self, alpha: float = 2.0, beta: float = 2.0):
        """
        Initialize Beta distribution.

        Args:
            alpha: Shape parameter (must be > 0)
            beta: Shape parameter (must be > 0)
        """
        super().__init__("Beta", is_discrete=False)
        self.alpha = alpha
        self.beta_param = beta
        self.set_parameters(alpha=alpha, beta=beta)

    def _create_distribution(self, **params):
        """Create scipy beta distribution."""
        return stats.beta(a=params["alpha"], b=params["beta"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"alpha": self.alpha, "beta": self.beta_param}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.alpha = params.get("alpha", self.alpha)
        self.beta_param = params.get("beta", self.beta_param)

        if self.alpha <= 0 or self.beta_param <= 0:
            raise ValueError("alpha and beta must be positive")

        self._dist = self._create_distribution(alpha=self.alpha, beta=self.beta_param)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "alpha": (0.1, 10.0),
            "beta": (0.1, 10.0),
        }

__init__(alpha=2.0, beta=2.0)

Initialize Beta distribution.

Parameters:

Name Type Description Default
alpha float

Shape parameter (must be > 0)

2.0
beta float

Shape parameter (must be > 0)

2.0
Source code in src/distributions/continuous.py
134
135
136
137
138
139
140
141
142
143
144
145
def __init__(self, alpha: float = 2.0, beta: float = 2.0):
    """
    Initialize Beta distribution.

    Args:
        alpha: Shape parameter (must be > 0)
        beta: Shape parameter (must be > 0)
    """
    super().__init__("Beta", is_discrete=False)
    self.alpha = alpha
    self.beta_param = beta
    self.set_parameters(alpha=alpha, beta=beta)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
165
166
167
168
169
170
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "alpha": (0.1, 10.0),
        "beta": (0.1, 10.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
151
152
153
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"alpha": self.alpha, "beta": self.beta_param}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
155
156
157
158
159
160
161
162
163
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.alpha = params.get("alpha", self.alpha)
    self.beta_param = params.get("beta", self.beta_param)

    if self.alpha <= 0 or self.beta_param <= 0:
        raise ValueError("alpha and beta must be positive")

    self._dist = self._create_distribution(alpha=self.alpha, beta=self.beta_param)

CauchyDistribution

Bases: Distribution

Cauchy distribution.

Source code in src/distributions/continuous.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
class CauchyDistribution(Distribution):
    """Cauchy distribution."""

    def __init__(self, x0: float = 0.0, gamma: float = 1.0):
        """
        Initialize Cauchy distribution.

        Args:
            x0: Location parameter
            gamma: Scale parameter (must be > 0)
        """
        super().__init__("Cauchy", is_discrete=False)
        self.x0 = x0
        self.gamma = gamma
        self.set_parameters(x0=x0, gamma=gamma)

    def _create_distribution(self, **params):
        """Create scipy cauchy distribution."""
        return stats.cauchy(loc=params["x0"], scale=params["gamma"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"x0": self.x0, "gamma": self.gamma}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.x0 = params.get("x0", self.x0)
        self.gamma = params.get("gamma", self.gamma)

        if self.gamma <= 0:
            raise ValueError("gamma must be positive")

        self._dist = self._create_distribution(x0=self.x0, gamma=self.gamma)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "x0": (-10.0, 10.0),
            "gamma": (0.1, 5.0),
        }

__init__(x0=0.0, gamma=1.0)

Initialize Cauchy distribution.

Parameters:

Name Type Description Default
x0 float

Location parameter

0.0
gamma float

Scale parameter (must be > 0)

1.0
Source code in src/distributions/continuous.py
375
376
377
378
379
380
381
382
383
384
385
386
def __init__(self, x0: float = 0.0, gamma: float = 1.0):
    """
    Initialize Cauchy distribution.

    Args:
        x0: Location parameter
        gamma: Scale parameter (must be > 0)
    """
    super().__init__("Cauchy", is_discrete=False)
    self.x0 = x0
    self.gamma = gamma
    self.set_parameters(x0=x0, gamma=gamma)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
406
407
408
409
410
411
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "x0": (-10.0, 10.0),
        "gamma": (0.1, 5.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
392
393
394
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"x0": self.x0, "gamma": self.gamma}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
396
397
398
399
400
401
402
403
404
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.x0 = params.get("x0", self.x0)
    self.gamma = params.get("gamma", self.gamma)

    if self.gamma <= 0:
        raise ValueError("gamma must be positive")

    self._dist = self._create_distribution(x0=self.x0, gamma=self.gamma)

ChiSquareDistribution

Bases: Distribution

Chi-square distribution.

Source code in src/distributions/continuous.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
class ChiSquareDistribution(Distribution):
    """Chi-square distribution."""

    def __init__(self, df: int = 3):
        """
        Initialize Chi-square distribution.

        Args:
            df: Degrees of freedom (must be > 0)
        """
        super().__init__("Chi-Square", is_discrete=False)
        self.df = df
        self.set_parameters(df=df)

    def _create_distribution(self, **params):
        """Create scipy chi-square distribution."""
        return stats.chi2(df=params["df"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"df": self.df}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.df = params.get("df", self.df)

        if self.df <= 0:
            raise ValueError("df must be positive")

        self._dist = self._create_distribution(df=self.df)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"df": (1, 30)}

__init__(df=3)

Initialize Chi-square distribution.

Parameters:

Name Type Description Default
df int

Degrees of freedom (must be > 0)

3
Source code in src/distributions/continuous.py
218
219
220
221
222
223
224
225
226
227
def __init__(self, df: int = 3):
    """
    Initialize Chi-square distribution.

    Args:
        df: Degrees of freedom (must be > 0)
    """
    super().__init__("Chi-Square", is_discrete=False)
    self.df = df
    self.set_parameters(df=df)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
246
247
248
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"df": (1, 30)}

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
233
234
235
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"df": self.df}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
237
238
239
240
241
242
243
244
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.df = params.get("df", self.df)

    if self.df <= 0:
        raise ValueError("df must be positive")

    self._dist = self._create_distribution(df=self.df)

ExponentialDistribution

Bases: Distribution

Exponential distribution.

Source code in src/distributions/continuous.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class ExponentialDistribution(Distribution):
    """Exponential distribution."""

    def __init__(self, lambda_param: float = 1.0):
        """
        Initialize Exponential distribution.

        Args:
            lambda_param: Rate parameter (must be > 0)
        """
        super().__init__("Exponential", is_discrete=False)
        self.lambda_param = lambda_param
        self.set_parameters(lambda_param=lambda_param)

    def _create_distribution(self, **params):
        """Create scipy exponential distribution."""
        return stats.expon(scale=1.0 / params["lambda_param"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"lambda": self.lambda_param}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

        if self.lambda_param <= 0:
            raise ValueError("lambda must be positive")

        self._dist = self._create_distribution(lambda_param=self.lambda_param)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"lambda": (0.1, 10.0)}

__init__(lambda_param=1.0)

Initialize Exponential distribution.

Parameters:

Name Type Description Default
lambda_param float

Rate parameter (must be > 0)

1.0
Source code in src/distributions/continuous.py
56
57
58
59
60
61
62
63
64
65
def __init__(self, lambda_param: float = 1.0):
    """
    Initialize Exponential distribution.

    Args:
        lambda_param: Rate parameter (must be > 0)
    """
    super().__init__("Exponential", is_discrete=False)
    self.lambda_param = lambda_param
    self.set_parameters(lambda_param=lambda_param)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
84
85
86
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"lambda": (0.1, 10.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
71
72
73
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"lambda": self.lambda_param}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
75
76
77
78
79
80
81
82
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

    if self.lambda_param <= 0:
        raise ValueError("lambda must be positive")

    self._dist = self._create_distribution(lambda_param=self.lambda_param)

GammaDistribution

Bases: Distribution

Gamma distribution.

Source code in src/distributions/continuous.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
class GammaDistribution(Distribution):
    """Gamma distribution."""

    def __init__(self, shape: float = 2.0, scale: float = 2.0):
        """
        Initialize Gamma distribution.

        Args:
            shape: Shape parameter (k, must be > 0)
            scale: Scale parameter (theta, must be > 0)
        """
        super().__init__("Gamma", is_discrete=False)
        self.shape = shape
        self.scale = scale
        self.set_parameters(shape=shape, scale=scale)

    def _create_distribution(self, **params):
        """Create scipy gamma distribution."""
        return stats.gamma(a=params["shape"], scale=params["scale"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"shape": self.shape, "scale": self.scale}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.shape = params.get("shape", self.shape)
        self.scale = params.get("scale", self.scale)

        if self.shape <= 0 or self.scale <= 0:
            raise ValueError("shape and scale must be positive")

        self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "shape": (0.1, 10.0),
            "scale": (0.1, 10.0),
        }

__init__(shape=2.0, scale=2.0)

Initialize Gamma distribution.

Parameters:

Name Type Description Default
shape float

Shape parameter (k, must be > 0)

2.0
scale float

Scale parameter (theta, must be > 0)

2.0
Source code in src/distributions/continuous.py
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(self, shape: float = 2.0, scale: float = 2.0):
    """
    Initialize Gamma distribution.

    Args:
        shape: Shape parameter (k, must be > 0)
        scale: Scale parameter (theta, must be > 0)
    """
    super().__init__("Gamma", is_discrete=False)
    self.shape = shape
    self.scale = scale
    self.set_parameters(shape=shape, scale=scale)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
207
208
209
210
211
212
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "shape": (0.1, 10.0),
        "scale": (0.1, 10.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
193
194
195
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"shape": self.shape, "scale": self.scale}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
197
198
199
200
201
202
203
204
205
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.shape = params.get("shape", self.shape)
    self.scale = params.get("scale", self.scale)

    if self.shape <= 0 or self.scale <= 0:
        raise ValueError("shape and scale must be positive")

    self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

LognormalDistribution

Bases: Distribution

Lognormal distribution.

Source code in src/distributions/continuous.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
class LognormalDistribution(Distribution):
    """Lognormal distribution."""

    def __init__(self, mu: float = 0.0, sigma: float = 1.0):
        """
        Initialize Lognormal distribution.

        Args:
            mu: Mean of underlying normal distribution
            sigma: Standard deviation of underlying normal distribution (must be > 0)
        """
        super().__init__("Lognormal", is_discrete=False)
        self.mu = mu
        self.sigma = sigma
        self.set_parameters(mu=mu, sigma=sigma)

    def _create_distribution(self, **params):
        """Create scipy lognormal distribution."""
        return stats.lognorm(s=params["sigma"], scale=np.exp(params["mu"]))

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"mu": self.mu, "sigma": self.sigma}

    def set_parameters(self, **params):
        """Set distribution parameters."""

        self.mu = params.get("mu", self.mu)
        self.sigma = params.get("sigma", self.sigma)

        if self.sigma <= 0:
            raise ValueError("sigma must be positive")

        self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "mu": (-5.0, 5.0),
            "sigma": (0.1, 5.0),
        }

__init__(mu=0.0, sigma=1.0)

Initialize Lognormal distribution.

Parameters:

Name Type Description Default
mu float

Mean of underlying normal distribution

0.0
sigma float

Standard deviation of underlying normal distribution (must be > 0)

1.0
Source code in src/distributions/continuous.py
332
333
334
335
336
337
338
339
340
341
342
343
def __init__(self, mu: float = 0.0, sigma: float = 1.0):
    """
    Initialize Lognormal distribution.

    Args:
        mu: Mean of underlying normal distribution
        sigma: Standard deviation of underlying normal distribution (must be > 0)
    """
    super().__init__("Lognormal", is_discrete=False)
    self.mu = mu
    self.sigma = sigma
    self.set_parameters(mu=mu, sigma=sigma)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
364
365
366
367
368
369
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "mu": (-5.0, 5.0),
        "sigma": (0.1, 5.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
349
350
351
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"mu": self.mu, "sigma": self.sigma}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
353
354
355
356
357
358
359
360
361
362
def set_parameters(self, **params):
    """Set distribution parameters."""

    self.mu = params.get("mu", self.mu)
    self.sigma = params.get("sigma", self.sigma)

    if self.sigma <= 0:
        raise ValueError("sigma must be positive")

    self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

NormalDistribution

Bases: Distribution

Normal (Gaussian) distribution.

Source code in src/distributions/continuous.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class NormalDistribution(Distribution):
    """Normal (Gaussian) distribution."""

    def __init__(self, mu: float = 0.0, sigma: float = 1.0):
        """
        Initialize Normal distribution.

        Args:
            mu: Mean parameter
            sigma: Standard deviation parameter (must be > 0)
        """
        super().__init__("Normal", is_discrete=False)
        self.mu = mu
        self.sigma = sigma
        self.set_parameters(mu=mu, sigma=sigma)

    def _create_distribution(self, **params):
        """Create scipy normal distribution."""
        return stats.norm(loc=params["mu"], scale=params["sigma"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"mu": self.mu, "sigma": self.sigma}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.mu = params.get("mu", self.mu)
        self.sigma = params.get("sigma", self.sigma)

        if self.sigma <= 0:
            raise ValueError("sigma must be positive")

        self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "mu": (-100.0, 100.0),
            "sigma": (0.1, 50.0),
        }

__init__(mu=0.0, sigma=1.0)

Initialize Normal distribution.

Parameters:

Name Type Description Default
mu float

Mean parameter

0.0
sigma float

Standard deviation parameter (must be > 0)

1.0
Source code in src/distributions/continuous.py
14
15
16
17
18
19
20
21
22
23
24
25
def __init__(self, mu: float = 0.0, sigma: float = 1.0):
    """
    Initialize Normal distribution.

    Args:
        mu: Mean parameter
        sigma: Standard deviation parameter (must be > 0)
    """
    super().__init__("Normal", is_discrete=False)
    self.mu = mu
    self.sigma = sigma
    self.set_parameters(mu=mu, sigma=sigma)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
45
46
47
48
49
50
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "mu": (-100.0, 100.0),
        "sigma": (0.1, 50.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
31
32
33
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"mu": self.mu, "sigma": self.sigma}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
35
36
37
38
39
40
41
42
43
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.mu = params.get("mu", self.mu)
    self.sigma = params.get("sigma", self.sigma)

    if self.sigma <= 0:
        raise ValueError("sigma must be positive")

    self._dist = self._create_distribution(mu=self.mu, sigma=self.sigma)

StudentTDistribution

Bases: Distribution

Student's t-distribution.

Source code in src/distributions/continuous.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
class StudentTDistribution(Distribution):
    """Student's t-distribution."""

    def __init__(self, df: float = 10.0):
        """
        Initialize Student's t-distribution.

        Args:
            df: Degrees of freedom (must be > 0)
        """
        super().__init__("Student-t", is_discrete=False)
        self.df = df
        self.set_parameters(df=df)

    def _create_distribution(self, **params):
        """Create scipy t distribution."""
        return stats.t(df=params["df"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"df": self.df}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.df = params.get("df", self.df)

        if self.df <= 0:
            raise ValueError("df must be positive")

        self._dist = self._create_distribution(df=self.df)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"df": (1.0, 30.0)}

__init__(df=10.0)

Initialize Student's t-distribution.

Parameters:

Name Type Description Default
df float

Degrees of freedom (must be > 0)

10.0
Source code in src/distributions/continuous.py
254
255
256
257
258
259
260
261
262
263
def __init__(self, df: float = 10.0):
    """
    Initialize Student's t-distribution.

    Args:
        df: Degrees of freedom (must be > 0)
    """
    super().__init__("Student-t", is_discrete=False)
    self.df = df
    self.set_parameters(df=df)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
282
283
284
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"df": (1.0, 30.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
269
270
271
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"df": self.df}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
273
274
275
276
277
278
279
280
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.df = params.get("df", self.df)

    if self.df <= 0:
        raise ValueError("df must be positive")

    self._dist = self._create_distribution(df=self.df)

UniformDistribution

Bases: Distribution

Continuous Uniform distribution.

Source code in src/distributions/continuous.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class UniformDistribution(Distribution):
    """Continuous Uniform distribution."""

    def __init__(self, a: float = 0.0, b: float = 1.0):
        """
        Initialize Uniform distribution.

        Args:
            a: Lower bound
            b: Upper bound (must be > a)
        """
        super().__init__("Uniform", is_discrete=False)
        self.a = a
        self.b = b
        self.set_parameters(a=a, b=b)

    def _create_distribution(self, **params):
        """Create scipy uniform distribution."""
        return stats.uniform(loc=params["a"], scale=params["b"] - params["a"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"a": self.a, "b": self.b}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.a = params.get("a", self.a)
        self.b = params.get("b", self.b)

        if self.a >= self.b:
            raise ValueError("a must be less than b")

        self._dist = self._create_distribution(a=self.a, b=self.b)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "a": (-10.0, 10.0),
            "b": (-10.0, 10.0),
        }

__init__(a=0.0, b=1.0)

Initialize Uniform distribution.

Parameters:

Name Type Description Default
a float

Lower bound

0.0
b float

Upper bound (must be > a)

1.0
Source code in src/distributions/continuous.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def __init__(self, a: float = 0.0, b: float = 1.0):
    """
    Initialize Uniform distribution.

    Args:
        a: Lower bound
        b: Upper bound (must be > a)
    """
    super().__init__("Uniform", is_discrete=False)
    self.a = a
    self.b = b
    self.set_parameters(a=a, b=b)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
123
124
125
126
127
128
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "a": (-10.0, 10.0),
        "b": (-10.0, 10.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
109
110
111
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"a": self.a, "b": self.b}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
113
114
115
116
117
118
119
120
121
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.a = params.get("a", self.a)
    self.b = params.get("b", self.b)

    if self.a >= self.b:
        raise ValueError("a must be less than b")

    self._dist = self._create_distribution(a=self.a, b=self.b)

WeibullDistribution

Bases: Distribution

Weibull distribution.

Source code in src/distributions/continuous.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
class WeibullDistribution(Distribution):
    """Weibull distribution."""

    def __init__(self, shape: float = 1.5, scale: float = 1.0):
        """
        Initialize Weibull distribution.

        Args:
            shape: Shape parameter (k, must be > 0)
            scale: Scale parameter (lambda, must be > 0)
        """
        super().__init__("Weibull", is_discrete=False)
        self.shape = shape
        self.scale = scale
        self.set_parameters(shape=shape, scale=scale)

    def _create_distribution(self, **params):
        """Create scipy weibull distribution."""
        return stats.weibull_min(c=params["shape"], scale=params["scale"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"shape": self.shape, "scale": self.scale}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.shape = params.get("shape", self.shape)
        self.scale = params.get("scale", self.scale)

        if self.shape <= 0 or self.scale <= 0:
            raise ValueError("shape and scale must be positive")

        self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "shape": (0.1, 5.0),
            "scale": (0.1, 5.0),
        }

__init__(shape=1.5, scale=1.0)

Initialize Weibull distribution.

Parameters:

Name Type Description Default
shape float

Shape parameter (k, must be > 0)

1.5
scale float

Scale parameter (lambda, must be > 0)

1.0
Source code in src/distributions/continuous.py
290
291
292
293
294
295
296
297
298
299
300
301
def __init__(self, shape: float = 1.5, scale: float = 1.0):
    """
    Initialize Weibull distribution.

    Args:
        shape: Shape parameter (k, must be > 0)
        scale: Scale parameter (lambda, must be > 0)
    """
    super().__init__("Weibull", is_discrete=False)
    self.shape = shape
    self.scale = scale
    self.set_parameters(shape=shape, scale=scale)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/continuous.py
321
322
323
324
325
326
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "shape": (0.1, 5.0),
        "scale": (0.1, 5.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/continuous.py
307
308
309
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"shape": self.shape, "scale": self.scale}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/continuous.py
311
312
313
314
315
316
317
318
319
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.shape = params.get("shape", self.shape)
    self.scale = params.get("scale", self.scale)

    if self.shape <= 0 or self.scale <= 0:
        raise ValueError("shape and scale must be positive")

    self._dist = self._create_distribution(shape=self.shape, scale=self.scale)

src.distributions.discrete

Discrete probability distributions.

BinomialDistribution

Bases: Distribution

Binomial distribution.

Source code in src/distributions/discrete.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class BinomialDistribution(Distribution):
    """Binomial distribution."""

    def __init__(self, n: int = 10, p: float = 0.5):
        """
        Initialize Binomial distribution.

        Args:
            n: Number of trials (must be positive integer)
            p: Probability of success (must be between 0 and 1)
        """
        super().__init__("Binomial", is_discrete=True)
        self.n = n
        self.p = p
        self.set_parameters(n=n, p=p)

    def _create_distribution(self, **params):
        """Create scipy binomial distribution."""
        return stats.binom(n=params["n"], p=params["p"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"n": self.n, "p": self.p}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.n = int(params.get("n", self.n))
        self.p = params.get("p", self.p)

        if self.n <= 0:
            raise ValueError("n must be positive")
        if not 0 <= self.p <= 1:
            raise ValueError("p must be between 0 and 1")

        self._dist = self._create_distribution(n=self.n, p=self.p)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "n": (1, 100),
            "p": (0.0, 1.0),
        }

__init__(n=10, p=0.5)

Initialize Binomial distribution.

Parameters:

Name Type Description Default
n int

Number of trials (must be positive integer)

10
p float

Probability of success (must be between 0 and 1)

0.5
Source code in src/distributions/discrete.py
13
14
15
16
17
18
19
20
21
22
23
24
def __init__(self, n: int = 10, p: float = 0.5):
    """
    Initialize Binomial distribution.

    Args:
        n: Number of trials (must be positive integer)
        p: Probability of success (must be between 0 and 1)
    """
    super().__init__("Binomial", is_discrete=True)
    self.n = n
    self.p = p
    self.set_parameters(n=n, p=p)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
46
47
48
49
50
51
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "n": (1, 100),
        "p": (0.0, 1.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
30
31
32
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"n": self.n, "p": self.p}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
34
35
36
37
38
39
40
41
42
43
44
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.n = int(params.get("n", self.n))
    self.p = params.get("p", self.p)

    if self.n <= 0:
        raise ValueError("n must be positive")
    if not 0 <= self.p <= 1:
        raise ValueError("p must be between 0 and 1")

    self._dist = self._create_distribution(n=self.n, p=self.p)

DiscreteUniformDistribution

Bases: Distribution

Discrete Uniform distribution.

Source code in src/distributions/discrete.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
class DiscreteUniformDistribution(Distribution):
    """Discrete Uniform distribution."""

    def __init__(self, low: int = 1, high: int = 6):
        """
        Initialize Discrete Uniform distribution.

        Args:
            low: Lower bound (inclusive)
            high: Upper bound (inclusive)
        """
        super().__init__("Discrete Uniform", is_discrete=True)
        self.low = low
        self.high = high
        self.set_parameters(low=low, high=high)

    def _create_distribution(self, **params):
        """Create scipy discrete uniform distribution."""
        return stats.randint(low=params["low"], high=params["high"] + 1)

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"low": self.low, "high": self.high}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.low = int(params.get("low", self.low))
        self.high = int(params.get("high", self.high))

        if self.low >= self.high:
            raise ValueError("low must be less than high")

        self._dist = self._create_distribution(low=self.low, high=self.high)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "low": (0, 50),
            "high": (1, 50),
        }

__init__(low=1, high=6)

Initialize Discrete Uniform distribution.

Parameters:

Name Type Description Default
low int

Lower bound (inclusive)

1
high int

Upper bound (inclusive)

6
Source code in src/distributions/discrete.py
223
224
225
226
227
228
229
230
231
232
233
234
def __init__(self, low: int = 1, high: int = 6):
    """
    Initialize Discrete Uniform distribution.

    Args:
        low: Lower bound (inclusive)
        high: Upper bound (inclusive)
    """
    super().__init__("Discrete Uniform", is_discrete=True)
    self.low = low
    self.high = high
    self.set_parameters(low=low, high=high)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
254
255
256
257
258
259
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "low": (0, 50),
        "high": (1, 50),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
240
241
242
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"low": self.low, "high": self.high}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
244
245
246
247
248
249
250
251
252
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.low = int(params.get("low", self.low))
    self.high = int(params.get("high", self.high))

    if self.low >= self.high:
        raise ValueError("low must be less than high")

    self._dist = self._create_distribution(low=self.low, high=self.high)

GeometricDistribution

Bases: Distribution

Geometric distribution.

Source code in src/distributions/discrete.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class GeometricDistribution(Distribution):
    """Geometric distribution."""

    def __init__(self, p: float = 0.5):
        """
        Initialize Geometric distribution.

        Args:
            p: Probability of success (must be between 0 and 1)
        """
        super().__init__("Geometric", is_discrete=True)
        self.p = p
        self.set_parameters(p=p)

    def _create_distribution(self, **params):
        """Create scipy geometric distribution."""
        return stats.geom(p=params["p"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"p": self.p}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.p = params.get("p", self.p)

        if not 0 < self.p <= 1:
            raise ValueError("p must be between 0 and 1 (exclusive of 0)")

        self._dist = self._create_distribution(p=self.p)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"p": (0.01, 1.0)}

__init__(p=0.5)

Initialize Geometric distribution.

Parameters:

Name Type Description Default
p float

Probability of success (must be between 0 and 1)

0.5
Source code in src/distributions/discrete.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
def __init__(self, p: float = 0.5):
    """
    Initialize Geometric distribution.

    Args:
        p: Probability of success (must be between 0 and 1)
    """
    super().__init__("Geometric", is_discrete=True)
    self.p = p
    self.set_parameters(p=p)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
121
122
123
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"p": (0.01, 1.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
108
109
110
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"p": self.p}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
112
113
114
115
116
117
118
119
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.p = params.get("p", self.p)

    if not 0 < self.p <= 1:
        raise ValueError("p must be between 0 and 1 (exclusive of 0)")

    self._dist = self._create_distribution(p=self.p)

HypergeometricDistribution

Bases: Distribution

Hypergeometric distribution.

Source code in src/distributions/discrete.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class HypergeometricDistribution(Distribution):
    """Hypergeometric distribution."""

    def __init__(self, M: int = 20, n: int = 7, N: int = 12):
        """
        Initialize Hypergeometric distribution.

        Args:
            M: Total population size
            n: Number of success states in population
            N: Number of draws
        """
        super().__init__("Hypergeometric", is_discrete=True)
        self.M = M
        self.n = n
        self.N = N
        self.set_parameters(M=M, n=n, N=N)

    def _create_distribution(self, **params):
        """Create scipy hypergeometric distribution."""
        return stats.hypergeom(M=params["M"], n=params["n"], N=params["N"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"M": self.M, "n": self.n, "N": self.N}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.M = int(params.get("M", self.M))
        self.n = int(params.get("n", self.n))
        self.N = int(params.get("N", self.N))

        if self.M <= 0 or self.n < 0 or self.N < 0:
            raise ValueError("M must be positive, n and N must be non-negative")
        if self.n > self.M:
            raise ValueError("n cannot be greater than M")
        if self.N > self.M:
            raise ValueError("N cannot be greater than M")

        self._dist = self._create_distribution(M=self.M, n=self.n, N=self.N)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "M": (1, 100),
            "n": (0, 100),
            "N": (1, 100),
        }

__init__(M=20, n=7, N=12)

Initialize Hypergeometric distribution.

Parameters:

Name Type Description Default
M int

Total population size

20
n int

Number of success states in population

7
N int

Number of draws

12
Source code in src/distributions/discrete.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def __init__(self, M: int = 20, n: int = 7, N: int = 12):
    """
    Initialize Hypergeometric distribution.

    Args:
        M: Total population size
        n: Number of success states in population
        N: Number of draws
    """
    super().__init__("Hypergeometric", is_discrete=True)
    self.M = M
    self.n = n
    self.N = N
    self.set_parameters(M=M, n=n, N=N)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
211
212
213
214
215
216
217
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "M": (1, 100),
        "n": (0, 100),
        "N": (1, 100),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
192
193
194
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"M": self.M, "n": self.n, "N": self.N}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.M = int(params.get("M", self.M))
    self.n = int(params.get("n", self.n))
    self.N = int(params.get("N", self.N))

    if self.M <= 0 or self.n < 0 or self.N < 0:
        raise ValueError("M must be positive, n and N must be non-negative")
    if self.n > self.M:
        raise ValueError("n cannot be greater than M")
    if self.N > self.M:
        raise ValueError("N cannot be greater than M")

    self._dist = self._create_distribution(M=self.M, n=self.n, N=self.N)

NegativeBinomialDistribution

Bases: Distribution

Negative Binomial distribution.

Source code in src/distributions/discrete.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
class NegativeBinomialDistribution(Distribution):
    """Negative Binomial distribution."""

    def __init__(self, r: int = 5, p: float = 0.5):
        """
        Initialize Negative Binomial distribution.

        Args:
            r: Number of successes (must be positive integer)
            p: Probability of success (must be between 0 and 1)
        """
        super().__init__("Negative Binomial", is_discrete=True)
        self.r = r
        self.p = p
        self.set_parameters(r=r, p=p)

    def _create_distribution(self, **params):
        """Create scipy negative binomial distribution."""
        return stats.nbinom(n=params["r"], p=params["p"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"r": self.r, "p": self.p}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.r = int(params.get("r", self.r))
        self.p = params.get("p", self.p)

        if self.r <= 0:
            raise ValueError("r must be positive")
        if not 0 < self.p <= 1:
            raise ValueError("p must be between 0 and 1")

        self._dist = self._create_distribution(r=self.r, p=self.p)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {
            "r": (1, 50),
            "p": (0.01, 1.0),
        }

__init__(r=5, p=0.5)

Initialize Negative Binomial distribution.

Parameters:

Name Type Description Default
r int

Number of successes (must be positive integer)

5
p float

Probability of success (must be between 0 and 1)

0.5
Source code in src/distributions/discrete.py
129
130
131
132
133
134
135
136
137
138
139
140
def __init__(self, r: int = 5, p: float = 0.5):
    """
    Initialize Negative Binomial distribution.

    Args:
        r: Number of successes (must be positive integer)
        p: Probability of success (must be between 0 and 1)
    """
    super().__init__("Negative Binomial", is_discrete=True)
    self.r = r
    self.p = p
    self.set_parameters(r=r, p=p)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
162
163
164
165
166
167
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {
        "r": (1, 50),
        "p": (0.01, 1.0),
    }

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
146
147
148
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"r": self.r, "p": self.p}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
150
151
152
153
154
155
156
157
158
159
160
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.r = int(params.get("r", self.r))
    self.p = params.get("p", self.p)

    if self.r <= 0:
        raise ValueError("r must be positive")
    if not 0 < self.p <= 1:
        raise ValueError("p must be between 0 and 1")

    self._dist = self._create_distribution(r=self.r, p=self.p)

PoissonDistribution

Bases: Distribution

Poisson distribution.

Source code in src/distributions/discrete.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class PoissonDistribution(Distribution):
    """Poisson distribution."""

    def __init__(self, lambda_param: float = 3.0):
        """
        Initialize Poisson distribution.

        Args:
            lambda_param: Rate parameter (must be > 0)
        """
        super().__init__("Poisson", is_discrete=True)
        self.lambda_param = lambda_param
        self.set_parameters(lambda_param=lambda_param)

    def _create_distribution(self, **params):
        """Create scipy poisson distribution."""
        return stats.poisson(mu=params["lambda_param"])

    def get_parameters(self) -> dict[str, Any]:
        """Get current parameters."""
        return {"lambda": self.lambda_param}

    def set_parameters(self, **params):
        """Set distribution parameters."""
        self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

        if self.lambda_param <= 0:
            raise ValueError("lambda must be positive")

        self._dist = self._create_distribution(lambda_param=self.lambda_param)

    def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
        """Get parameter bounds."""
        return {"lambda": (0.1, 20.0)}

__init__(lambda_param=3.0)

Initialize Poisson distribution.

Parameters:

Name Type Description Default
lambda_param float

Rate parameter (must be > 0)

3.0
Source code in src/distributions/discrete.py
57
58
59
60
61
62
63
64
65
66
def __init__(self, lambda_param: float = 3.0):
    """
    Initialize Poisson distribution.

    Args:
        lambda_param: Rate parameter (must be > 0)
    """
    super().__init__("Poisson", is_discrete=True)
    self.lambda_param = lambda_param
    self.set_parameters(lambda_param=lambda_param)

get_parameter_bounds()

Get parameter bounds.

Source code in src/distributions/discrete.py
85
86
87
def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
    """Get parameter bounds."""
    return {"lambda": (0.1, 20.0)}

get_parameters()

Get current parameters.

Source code in src/distributions/discrete.py
72
73
74
def get_parameters(self) -> dict[str, Any]:
    """Get current parameters."""
    return {"lambda": self.lambda_param}

set_parameters(**params)

Set distribution parameters.

Source code in src/distributions/discrete.py
76
77
78
79
80
81
82
83
def set_parameters(self, **params):
    """Set distribution parameters."""
    self.lambda_param = params.get("lambda_param", params.get("lambda", self.lambda_param))

    if self.lambda_param <= 0:
        raise ValueError("lambda must be positive")

    self._dist = self._create_distribution(lambda_param=self.lambda_param)

src.distributions.multivariate

Multivariate probability distributions.

DirichletDistribution

Bases: MultivariateDistribution

Dirichlet distribution.

Source code in src/distributions/multivariate.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
class DirichletDistribution(MultivariateDistribution):
    """Dirichlet distribution."""

    def __init__(self, alpha: np.ndarray):
        """
        Initialize Dirichlet distribution.

        Args:
            alpha: Concentration parameters (must be positive)
        """
        alpha = np.asarray(alpha)

        if alpha.ndim != 1:
            raise ValueError("alpha must be 1-dimensional")

        if np.any(alpha <= 0):
            raise ValueError("alpha must be positive")

        super().__init__("Dirichlet", len(alpha))
        self.alpha = alpha
        self._dist = stats.dirichlet(alpha)

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate probability density function.

        Args:
            x: Points on simplex (shape: n x d or d), must sum to 1

        Returns:
            PDF values
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.pdf(x.T if x.ndim == 2 else x)

    def logpdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate log probability density function."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.logpdf(x.T if x.ndim == 2 else x)

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Samples on simplex (shape: size x d)
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.rvs(size=size, random_state=random_state)

    def mean(self) -> np.ndarray:
        """Calculate mean vector."""
        return self.alpha / np.sum(self.alpha)

    def var(self) -> np.ndarray:
        """Calculate variance for each component."""
        alpha0 = np.sum(self.alpha)
        return (self.alpha * (alpha0 - self.alpha)) / (alpha0**2 * (alpha0 + 1))

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.cov()

    def mode(self) -> np.ndarray:
        """
        Calculate mode.

        Returns:
            Mode vector (only valid if all alpha > 1)
        """
        if np.any(self.alpha <= 1):
            raise ValueError("Mode only defined when all alpha > 1")

        return (self.alpha - 1) / (np.sum(self.alpha) - self.dimension)

    def entropy(self) -> float:
        """Calculate differential entropy."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.entropy()

    def __repr__(self) -> str:
        return f"Dirichlet(dimension={self.dimension}, alpha={self.alpha})"

__init__(alpha)

Initialize Dirichlet distribution.

Parameters:

Name Type Description Default
alpha ndarray

Concentration parameters (must be positive)

required
Source code in src/distributions/multivariate.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def __init__(self, alpha: np.ndarray):
    """
    Initialize Dirichlet distribution.

    Args:
        alpha: Concentration parameters (must be positive)
    """
    alpha = np.asarray(alpha)

    if alpha.ndim != 1:
        raise ValueError("alpha must be 1-dimensional")

    if np.any(alpha <= 0):
        raise ValueError("alpha must be positive")

    super().__init__("Dirichlet", len(alpha))
    self.alpha = alpha
    self._dist = stats.dirichlet(alpha)

cov()

Calculate covariance matrix.

Source code in src/distributions/multivariate.py
253
254
255
256
257
def cov(self) -> np.ndarray:
    """Calculate covariance matrix."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.cov()

entropy()

Calculate differential entropy.

Source code in src/distributions/multivariate.py
271
272
273
274
275
def entropy(self) -> float:
    """Calculate differential entropy."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.entropy()

logpdf(x)

Calculate log probability density function.

Source code in src/distributions/multivariate.py
223
224
225
226
227
def logpdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate log probability density function."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.logpdf(x.T if x.ndim == 2 else x)

mean()

Calculate mean vector.

Source code in src/distributions/multivariate.py
244
245
246
def mean(self) -> np.ndarray:
    """Calculate mean vector."""
    return self.alpha / np.sum(self.alpha)

mode()

Calculate mode.

Returns:

Type Description
ndarray

Mode vector (only valid if all alpha > 1)

Source code in src/distributions/multivariate.py
259
260
261
262
263
264
265
266
267
268
269
def mode(self) -> np.ndarray:
    """
    Calculate mode.

    Returns:
        Mode vector (only valid if all alpha > 1)
    """
    if np.any(self.alpha <= 1):
        raise ValueError("Mode only defined when all alpha > 1")

    return (self.alpha - 1) / (np.sum(self.alpha) - self.dimension)

pdf(x)

Calculate probability density function.

Parameters:

Name Type Description Default
x ndarray

Points on simplex (shape: n x d or d), must sum to 1

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/multivariate.py
209
210
211
212
213
214
215
216
217
218
219
220
221
def pdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate probability density function.

    Args:
        x: Points on simplex (shape: n x d or d), must sum to 1

    Returns:
        PDF values
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.pdf(x.T if x.ndim == 2 else x)

rvs(size=1, random_state=None)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Samples on simplex (shape: size x d)

Source code in src/distributions/multivariate.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Samples on simplex (shape: size x d)
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.rvs(size=size, random_state=random_state)

var()

Calculate variance for each component.

Source code in src/distributions/multivariate.py
248
249
250
251
def var(self) -> np.ndarray:
    """Calculate variance for each component."""
    alpha0 = np.sum(self.alpha)
    return (self.alpha * (alpha0 - self.alpha)) / (alpha0**2 * (alpha0 + 1))

MultivariateDistribution

Base class for multivariate distributions.

Source code in src/distributions/multivariate.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class MultivariateDistribution:
    """Base class for multivariate distributions."""

    def __init__(self, name: str, dimension: int):
        """
        Initialize multivariate distribution.

        Args:
            name: Name of the distribution
            dimension: Number of dimensions
        """
        self.name = name
        self.dimension = dimension
        self._dist = None

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate probability density function."""
        raise NotImplementedError

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate random samples."""
        raise NotImplementedError

    def mean(self) -> np.ndarray:
        """Calculate mean vector."""
        raise NotImplementedError

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix."""
        raise NotImplementedError

__init__(name, dimension)

Initialize multivariate distribution.

Parameters:

Name Type Description Default
name str

Name of the distribution

required
dimension int

Number of dimensions

required
Source code in src/distributions/multivariate.py
14
15
16
17
18
19
20
21
22
23
24
def __init__(self, name: str, dimension: int):
    """
    Initialize multivariate distribution.

    Args:
        name: Name of the distribution
        dimension: Number of dimensions
    """
    self.name = name
    self.dimension = dimension
    self._dist = None

cov()

Calculate covariance matrix.

Source code in src/distributions/multivariate.py
38
39
40
def cov(self) -> np.ndarray:
    """Calculate covariance matrix."""
    raise NotImplementedError

mean()

Calculate mean vector.

Source code in src/distributions/multivariate.py
34
35
36
def mean(self) -> np.ndarray:
    """Calculate mean vector."""
    raise NotImplementedError

pdf(x)

Calculate probability density function.

Source code in src/distributions/multivariate.py
26
27
28
def pdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate probability density function."""
    raise NotImplementedError

rvs(size=1, random_state=None)

Generate random samples.

Source code in src/distributions/multivariate.py
30
31
32
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate random samples."""
    raise NotImplementedError

MultivariateNormalDistribution

Bases: MultivariateDistribution

Multivariate Normal (Gaussian) distribution.

Source code in src/distributions/multivariate.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
class MultivariateNormalDistribution(MultivariateDistribution):
    """Multivariate Normal (Gaussian) distribution."""

    def __init__(self, mean: np.ndarray, cov: np.ndarray):
        """
        Initialize Multivariate Normal distribution.

        Args:
            mean: Mean vector (shape: d)
            cov: Covariance matrix (shape: d x d)
        """
        mean = np.asarray(mean)
        cov = np.asarray(cov)

        if mean.ndim != 1:
            raise ValueError("mean must be 1-dimensional")

        if cov.ndim != 2:
            raise ValueError("cov must be 2-dimensional")

        if cov.shape[0] != cov.shape[1]:
            raise ValueError("cov must be square")

        if len(mean) != cov.shape[0]:
            raise ValueError("mean and cov dimensions must match")

        # Check if covariance matrix is positive definite
        try:
            np.linalg.cholesky(cov)
        except np.linalg.LinAlgError as err:
            raise ValueError("cov must be positive definite") from err

        super().__init__("Multivariate Normal", len(mean))
        self.mean_vec = mean
        self.cov_mat = cov
        self._dist = stats.multivariate_normal(mean=mean, cov=cov)

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate probability density function.

        Args:
            x: Points to evaluate (shape: n x d or d)

        Returns:
            PDF values
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.pdf(x)

    def logpdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate log probability density function."""
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.logpdf(x)

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Samples (shape: size x d)
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        return self._dist.rvs(size=size, random_state=random_state)

    def mean(self) -> np.ndarray:
        """Calculate mean vector."""
        return self.mean_vec

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix."""
        return self.cov_mat

    def marginal(self, indices: list) -> "MultivariateNormalDistribution":
        """
        Get marginal distribution for selected variables.

        Args:
            indices: List of variable indices to keep

        Returns:
            Marginal distribution
        """
        if self._dist is None:
            raise ValueError("Distribution not initialized")
        indices = np.array(indices)
        marginal_mean = self.mean_vec[indices]
        marginal_cov = self.cov_mat[np.ix_(indices, indices)]
        return MultivariateNormalDistribution(marginal_mean, marginal_cov)

    def conditional(self, indices: list, values: np.ndarray) -> "MultivariateNormalDistribution":
        """
        Get conditional distribution.

        Args:
            indices: Indices of variables to condition on
            values: Values of conditioned variables

        Returns:
            Conditional distribution
        """
        indices_arr = np.array(indices)
        free_indices = np.array([i for i in range(self.dimension) if i not in indices_arr])

        # Partition mean and covariance
        mu1 = self.mean_vec[free_indices]
        mu2 = self.mean_vec[indices]
        sigma11 = self.cov_mat[np.ix_(free_indices, free_indices)]
        sigma12 = self.cov_mat[np.ix_(free_indices, indices)]
        sigma22 = self.cov_mat[np.ix_(indices, indices)]

        # Conditional parameters
        sigma22_inv = np.linalg.inv(sigma22)
        cond_mean = mu1 + sigma12 @ sigma22_inv @ (values - mu2)
        cond_cov = sigma11 - sigma12 @ sigma22_inv @ sigma12.T

        return MultivariateNormalDistribution(cond_mean, cond_cov)

    def mahalanobis(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate Mahalanobis distance.

        Args:
            x: Points (shape: n x d or d)

        Returns:
            Mahalanobis distances
        """
        x = np.atleast_2d(x)
        diff = x - self.mean_vec
        cov_inv = np.linalg.inv(self.cov_mat)
        return np.sqrt(np.sum(diff @ cov_inv * diff, axis=1))

    def __repr__(self) -> str:
        return f"MultivariateNormal(dimension={self.dimension})"

__init__(mean, cov)

Initialize Multivariate Normal distribution.

Parameters:

Name Type Description Default
mean ndarray

Mean vector (shape: d)

required
cov ndarray

Covariance matrix (shape: d x d)

required
Source code in src/distributions/multivariate.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def __init__(self, mean: np.ndarray, cov: np.ndarray):
    """
    Initialize Multivariate Normal distribution.

    Args:
        mean: Mean vector (shape: d)
        cov: Covariance matrix (shape: d x d)
    """
    mean = np.asarray(mean)
    cov = np.asarray(cov)

    if mean.ndim != 1:
        raise ValueError("mean must be 1-dimensional")

    if cov.ndim != 2:
        raise ValueError("cov must be 2-dimensional")

    if cov.shape[0] != cov.shape[1]:
        raise ValueError("cov must be square")

    if len(mean) != cov.shape[0]:
        raise ValueError("mean and cov dimensions must match")

    # Check if covariance matrix is positive definite
    try:
        np.linalg.cholesky(cov)
    except np.linalg.LinAlgError as err:
        raise ValueError("cov must be positive definite") from err

    super().__init__("Multivariate Normal", len(mean))
    self.mean_vec = mean
    self.cov_mat = cov
    self._dist = stats.multivariate_normal(mean=mean, cov=cov)

conditional(indices, values)

Get conditional distribution.

Parameters:

Name Type Description Default
indices list

Indices of variables to condition on

required
values ndarray

Values of conditioned variables

required

Returns:

Type Description
MultivariateNormalDistribution

Conditional distribution

Source code in src/distributions/multivariate.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def conditional(self, indices: list, values: np.ndarray) -> "MultivariateNormalDistribution":
    """
    Get conditional distribution.

    Args:
        indices: Indices of variables to condition on
        values: Values of conditioned variables

    Returns:
        Conditional distribution
    """
    indices_arr = np.array(indices)
    free_indices = np.array([i for i in range(self.dimension) if i not in indices_arr])

    # Partition mean and covariance
    mu1 = self.mean_vec[free_indices]
    mu2 = self.mean_vec[indices]
    sigma11 = self.cov_mat[np.ix_(free_indices, free_indices)]
    sigma12 = self.cov_mat[np.ix_(free_indices, indices)]
    sigma22 = self.cov_mat[np.ix_(indices, indices)]

    # Conditional parameters
    sigma22_inv = np.linalg.inv(sigma22)
    cond_mean = mu1 + sigma12 @ sigma22_inv @ (values - mu2)
    cond_cov = sigma11 - sigma12 @ sigma22_inv @ sigma12.T

    return MultivariateNormalDistribution(cond_mean, cond_cov)

cov()

Calculate covariance matrix.

Source code in src/distributions/multivariate.py
119
120
121
def cov(self) -> np.ndarray:
    """Calculate covariance matrix."""
    return self.cov_mat

logpdf(x)

Calculate log probability density function.

Source code in src/distributions/multivariate.py
94
95
96
97
98
def logpdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate log probability density function."""
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.logpdf(x)

mahalanobis(x)

Calculate Mahalanobis distance.

Parameters:

Name Type Description Default
x ndarray

Points (shape: n x d or d)

required

Returns:

Type Description
ndarray

Mahalanobis distances

Source code in src/distributions/multivariate.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def mahalanobis(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate Mahalanobis distance.

    Args:
        x: Points (shape: n x d or d)

    Returns:
        Mahalanobis distances
    """
    x = np.atleast_2d(x)
    diff = x - self.mean_vec
    cov_inv = np.linalg.inv(self.cov_mat)
    return np.sqrt(np.sum(diff @ cov_inv * diff, axis=1))

marginal(indices)

Get marginal distribution for selected variables.

Parameters:

Name Type Description Default
indices list

List of variable indices to keep

required

Returns:

Type Description
MultivariateNormalDistribution

Marginal distribution

Source code in src/distributions/multivariate.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def marginal(self, indices: list) -> "MultivariateNormalDistribution":
    """
    Get marginal distribution for selected variables.

    Args:
        indices: List of variable indices to keep

    Returns:
        Marginal distribution
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    indices = np.array(indices)
    marginal_mean = self.mean_vec[indices]
    marginal_cov = self.cov_mat[np.ix_(indices, indices)]
    return MultivariateNormalDistribution(marginal_mean, marginal_cov)

mean()

Calculate mean vector.

Source code in src/distributions/multivariate.py
115
116
117
def mean(self) -> np.ndarray:
    """Calculate mean vector."""
    return self.mean_vec

pdf(x)

Calculate probability density function.

Parameters:

Name Type Description Default
x ndarray

Points to evaluate (shape: n x d or d)

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/multivariate.py
80
81
82
83
84
85
86
87
88
89
90
91
92
def pdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate probability density function.

    Args:
        x: Points to evaluate (shape: n x d or d)

    Returns:
        PDF values
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.pdf(x)

rvs(size=1, random_state=None)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Samples (shape: size x d)

Source code in src/distributions/multivariate.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Samples (shape: size x d)
    """
    if self._dist is None:
        raise ValueError("Distribution not initialized")
    return self._dist.rvs(size=size, random_state=random_state)

MultivariateStudentT

Bases: MultivariateDistribution

Multivariate Student's t-distribution.

Source code in src/distributions/multivariate.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
class MultivariateStudentT(MultivariateDistribution):
    """Multivariate Student's t-distribution."""

    def __init__(self, df: float, loc: np.ndarray, shape: np.ndarray):
        """
        Initialize Multivariate Student-t distribution.

        Args:
            df: Degrees of freedom
            loc: Location vector
            shape: Shape matrix (similar to covariance)
        """
        loc = np.asarray(loc)
        shape = np.asarray(shape)

        if df <= 0:
            raise ValueError("df must be positive")

        if loc.ndim != 1:
            raise ValueError("loc must be 1-dimensional")

        if shape.ndim != 2 or shape.shape[0] != shape.shape[1]:
            raise ValueError("shape must be square matrix")

        super().__init__("Multivariate Student-t", len(loc))
        self.df = df
        self.loc_vec = loc
        self.shape_mat = shape

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """Calculate probability density function."""
        x = np.atleast_2d(x)
        d = self.dimension
        df = self.df

        diff = x - self.loc_vec
        shape_inv = np.linalg.inv(self.shape_mat)
        shape_det = np.linalg.det(self.shape_mat)

        mahalanobis_sq = np.sum(diff @ shape_inv * diff, axis=1)

        # Compute normalizing constant
        from scipy.special import gamma

        numer = gamma((df + d) / 2)
        denom = gamma(df / 2) * ((df * np.pi) ** (d / 2)) * np.sqrt(shape_det)
        normalizing = numer / denom

        # Compute PDF
        pdf_vals = normalizing * (1 + mahalanobis_sq / df) ** (-(df + d) / 2)

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate random samples."""
        rng = np.random.default_rng(random_state)

        # Generate using property: t_d = loc + sqrt(d/chi^2_d) * N(0, shape)
        chi2_samples = rng.chisquare(self.df, size=size)
        normal_samples = rng.multivariate_normal(
            np.zeros(self.dimension), self.shape_mat, size=size
        )

        samples = self.loc_vec + normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]
        return samples

    def mean(self) -> np.ndarray:
        """Calculate mean vector (only for df > 1)."""
        if self.df <= 1:
            raise ValueError("Mean only defined for df > 1")
        return self.loc_vec

    def cov(self) -> np.ndarray:
        """Calculate covariance matrix (only for df > 2)."""
        if self.df <= 2:
            raise ValueError("Covariance only defined for df > 2")
        return self.shape_mat * (self.df / (self.df - 2))

    def __repr__(self) -> str:
        return f"MultivariateStudentT(dimension={self.dimension}, df={self.df})"

__init__(df, loc, shape)

Initialize Multivariate Student-t distribution.

Parameters:

Name Type Description Default
df float

Degrees of freedom

required
loc ndarray

Location vector

required
shape ndarray

Shape matrix (similar to covariance)

required
Source code in src/distributions/multivariate.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
def __init__(self, df: float, loc: np.ndarray, shape: np.ndarray):
    """
    Initialize Multivariate Student-t distribution.

    Args:
        df: Degrees of freedom
        loc: Location vector
        shape: Shape matrix (similar to covariance)
    """
    loc = np.asarray(loc)
    shape = np.asarray(shape)

    if df <= 0:
        raise ValueError("df must be positive")

    if loc.ndim != 1:
        raise ValueError("loc must be 1-dimensional")

    if shape.ndim != 2 or shape.shape[0] != shape.shape[1]:
        raise ValueError("shape must be square matrix")

    super().__init__("Multivariate Student-t", len(loc))
    self.df = df
    self.loc_vec = loc
    self.shape_mat = shape

cov()

Calculate covariance matrix (only for df > 2).

Source code in src/distributions/multivariate.py
353
354
355
356
357
def cov(self) -> np.ndarray:
    """Calculate covariance matrix (only for df > 2)."""
    if self.df <= 2:
        raise ValueError("Covariance only defined for df > 2")
    return self.shape_mat * (self.df / (self.df - 2))

mean()

Calculate mean vector (only for df > 1).

Source code in src/distributions/multivariate.py
347
348
349
350
351
def mean(self) -> np.ndarray:
    """Calculate mean vector (only for df > 1)."""
    if self.df <= 1:
        raise ValueError("Mean only defined for df > 1")
    return self.loc_vec

pdf(x)

Calculate probability density function.

Source code in src/distributions/multivariate.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def pdf(self, x: np.ndarray) -> np.ndarray:
    """Calculate probability density function."""
    x = np.atleast_2d(x)
    d = self.dimension
    df = self.df

    diff = x - self.loc_vec
    shape_inv = np.linalg.inv(self.shape_mat)
    shape_det = np.linalg.det(self.shape_mat)

    mahalanobis_sq = np.sum(diff @ shape_inv * diff, axis=1)

    # Compute normalizing constant
    from scipy.special import gamma

    numer = gamma((df + d) / 2)
    denom = gamma(df / 2) * ((df * np.pi) ** (d / 2)) * np.sqrt(shape_det)
    normalizing = numer / denom

    # Compute PDF
    pdf_vals = normalizing * (1 + mahalanobis_sq / df) ** (-(df + d) / 2)

    return pdf_vals

rvs(size=1, random_state=None)

Generate random samples.

Source code in src/distributions/multivariate.py
334
335
336
337
338
339
340
341
342
343
344
345
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate random samples."""
    rng = np.random.default_rng(random_state)

    # Generate using property: t_d = loc + sqrt(d/chi^2_d) * N(0, shape)
    chi2_samples = rng.chisquare(self.df, size=size)
    normal_samples = rng.multivariate_normal(
        np.zeros(self.dimension), self.shape_mat, size=size
    )

    samples = self.loc_vec + normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]
    return samples

WishartDistribution

Wishart distribution (distribution over positive definite matrices).

Source code in src/distributions/multivariate.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
class WishartDistribution:
    """Wishart distribution (distribution over positive definite matrices)."""

    def __init__(self, df: int, scale: np.ndarray):
        """
        Initialize Wishart distribution.

        Args:
            df: Degrees of freedom (must be >= dimension)
            scale: Scale matrix (positive definite)
        """
        scale = np.asarray(scale)

        if scale.ndim != 2 or scale.shape[0] != scale.shape[1]:
            raise ValueError("scale must be square matrix")

        dimension = scale.shape[0]

        if df < dimension:
            raise ValueError("df must be >= dimension")

        self.name = "Wishart"
        self.dimension = dimension
        self.df = df
        self.scale_mat = scale
        self._dist = stats.wishart(df=df, scale=scale)

    def pdf(self, x: np.ndarray) -> float:
        """Calculate probability density function."""
        return self._dist.pdf(x)

    def logpdf(self, x: np.ndarray) -> float:
        """Calculate log probability density function."""
        return self._dist.logpdf(x)

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate random positive definite matrices."""
        return self._dist.rvs(size=size, random_state=random_state)

    def mean(self) -> np.ndarray:
        """Calculate mean matrix."""
        return self._dist.mean()

    def mode(self) -> np.ndarray:
        """Calculate mode matrix."""
        if self.df >= self.dimension + 1:
            return (self.df - self.dimension - 1) * self.scale_mat
        raise ValueError("Mode only defined for df >= dimension + 1")

    def __repr__(self) -> str:
        return f"Wishart(dimension={self.dimension}, df={self.df})"

__init__(df, scale)

Initialize Wishart distribution.

Parameters:

Name Type Description Default
df int

Degrees of freedom (must be >= dimension)

required
scale ndarray

Scale matrix (positive definite)

required
Source code in src/distributions/multivariate.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def __init__(self, df: int, scale: np.ndarray):
    """
    Initialize Wishart distribution.

    Args:
        df: Degrees of freedom (must be >= dimension)
        scale: Scale matrix (positive definite)
    """
    scale = np.asarray(scale)

    if scale.ndim != 2 or scale.shape[0] != scale.shape[1]:
        raise ValueError("scale must be square matrix")

    dimension = scale.shape[0]

    if df < dimension:
        raise ValueError("df must be >= dimension")

    self.name = "Wishart"
    self.dimension = dimension
    self.df = df
    self.scale_mat = scale
    self._dist = stats.wishart(df=df, scale=scale)

logpdf(x)

Calculate log probability density function.

Source code in src/distributions/multivariate.py
394
395
396
def logpdf(self, x: np.ndarray) -> float:
    """Calculate log probability density function."""
    return self._dist.logpdf(x)

mean()

Calculate mean matrix.

Source code in src/distributions/multivariate.py
402
403
404
def mean(self) -> np.ndarray:
    """Calculate mean matrix."""
    return self._dist.mean()

mode()

Calculate mode matrix.

Source code in src/distributions/multivariate.py
406
407
408
409
410
def mode(self) -> np.ndarray:
    """Calculate mode matrix."""
    if self.df >= self.dimension + 1:
        return (self.df - self.dimension - 1) * self.scale_mat
    raise ValueError("Mode only defined for df >= dimension + 1")

pdf(x)

Calculate probability density function.

Source code in src/distributions/multivariate.py
390
391
392
def pdf(self, x: np.ndarray) -> float:
    """Calculate probability density function."""
    return self._dist.pdf(x)

rvs(size=1, random_state=None)

Generate random positive definite matrices.

Source code in src/distributions/multivariate.py
398
399
400
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate random positive definite matrices."""
    return self._dist.rvs(size=size, random_state=random_state)

plot_bivariate_normal(dist, num_points=100, num_contours=10)

Plot bivariate normal distribution.

Parameters:

Name Type Description Default
dist MultivariateNormalDistribution

Multivariate normal distribution (dimension must be 2)

required
num_points int

Number of grid points

100
num_contours int

Number of contour levels

10

Returns:

Type Description
tuple[Figure, tuple[Axes, Axes]]

Figure and axes objects

Source code in src/distributions/multivariate.py
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
def plot_bivariate_normal(
    dist: MultivariateNormalDistribution, num_points: int = 100, num_contours: int = 10
) -> tuple[plt.Figure, tuple[plt.Axes, plt.Axes]]:
    """
    Plot bivariate normal distribution.

    Args:
        dist: Multivariate normal distribution (dimension must be 2)
        num_points: Number of grid points
        num_contours: Number of contour levels

    Returns:
        Figure and axes objects
    """
    if dist.dimension != 2:
        raise ValueError("Can only plot bivariate distributions")

    # Create grid
    mean = dist.mean()
    cov = dist.cov()

    # Determine plot limits based on covariance
    std1 = np.sqrt(cov[0, 0])
    std2 = np.sqrt(cov[1, 1])

    x1 = np.linspace(mean[0] - 3 * std1, mean[0] + 3 * std1, num_points)
    x2 = np.linspace(mean[1] - 3 * std2, mean[1] + 3 * std2, num_points)
    X1, X2 = np.meshgrid(x1, x2)

    # Evaluate PDF
    pos = np.dstack((X1, X2))
    Z = dist.pdf(pos)

    # Create figure with a 2-D panel and a 3-D panel side by side.
    fig = plt.figure(figsize=(14, 6))
    ax1 = fig.add_subplot(121)
    ax2 = cast(Axes3D, fig.add_subplot(122, projection="3d"))

    # Contour plot
    contour = ax1.contourf(X1, X2, Z, levels=num_contours, cmap="viridis")
    ax1.contour(X1, X2, Z, levels=num_contours, colors="white", alpha=0.3, linewidths=0.5)
    fig.colorbar(contour, ax=ax1, label="Probability Density")
    ax1.plot(mean[0], mean[1], "r*", markersize=15, label="Mean")
    ax1.set_xlabel("X₁")
    ax1.set_ylabel("X₂")
    ax1.set_title("Bivariate Normal Distribution - Contour Plot")
    ax1.legend()
    ax1.grid(True, alpha=0.3)

    # 3D surface plot
    surf = ax2.plot_surface(X1, X2, Z, cmap="viridis", alpha=0.8, edgecolor="none")
    ax2.set_xlabel("X₁")
    ax2.set_ylabel("X₂")
    ax2.set_zlabel("Probability Density")
    ax2.set_title("Bivariate Normal Distribution - 3D Surface")
    fig.colorbar(surf, ax=ax2, shrink=0.5, aspect=5)

    plt.tight_layout()
    return fig, (ax1, ax2)

plot_dirichlet_simplex(dist, num_samples=1000)

Plot Dirichlet distribution samples on simplex (for dimension 3).

Parameters:

Name Type Description Default
dist DirichletDistribution

Dirichlet distribution (dimension must be 3)

required
num_samples int

Number of samples to generate

1000

Returns:

Type Description
tuple[Figure, Axes]

Figure and axes objects

Source code in src/distributions/multivariate.py
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
def plot_dirichlet_simplex(
    dist: DirichletDistribution, num_samples: int = 1000
) -> tuple[plt.Figure, plt.Axes]:
    """
    Plot Dirichlet distribution samples on simplex (for dimension 3).

    Args:
        dist: Dirichlet distribution (dimension must be 3)
        num_samples: Number of samples to generate

    Returns:
        Figure and axes objects
    """
    if dist.dimension != 3:
        raise ValueError("Can only plot 3-dimensional Dirichlet on simplex")

    # Generate samples
    samples = dist.rvs(size=num_samples, random_state=42)

    # Create figure
    fig = plt.figure(figsize=(12, 10))
    ax = cast(Axes3D, fig.add_subplot(111, projection="3d"))

    # Plot samples
    scatter = ax.scatter(
        samples[:, 0],
        samples[:, 1],
        samples[:, 2],
        c=samples[:, 0],
        cmap="viridis",
        alpha=0.6,
        s=20,
    )

    # Plot simplex edges
    vertices = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
    for i in range(3):
        for j in range(i + 1, 3):
            ax.plot(
                [vertices[i, 0], vertices[j, 0]],
                [vertices[i, 1], vertices[j, 1]],
                [vertices[i, 2], vertices[j, 2]],
                "k-",
                linewidth=2,
                alpha=0.5,
            )

    ax.set_xlabel("X₁")
    ax.set_ylabel("X₂")
    ax.set_zlabel("X₃")
    ax.set_title(f"Dirichlet Distribution Samples\nα = {dist.alpha}")
    fig.colorbar(scatter, ax=ax, label="X₁ value", shrink=0.5)

    return fig, ax

src.distributions.copulas

Copulas for modeling dependencies between random variables.

ClaytonCopula

Bases: Copula

Clayton copula (Archimedean).

Source code in src/distributions/copulas.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
class ClaytonCopula(Copula):
    """Clayton copula (Archimedean)."""

    def __init__(self, theta: float, dimension: int = 2):
        """
        Initialize Clayton copula.

        Args:
            theta: Dependence parameter (theta >= -1/(d-1), theta != 0)
            dimension: Number of dimensions
        """
        if theta == 0:
            raise ValueError("theta must be non-zero")

        min_theta = -1.0 / (dimension - 1) if dimension > 1 else -np.inf
        if theta < min_theta:
            raise ValueError(f"theta must be >= {min_theta}")

        super().__init__("Clayton", dimension)
        self.theta = theta

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Clayton copula CDF."""
        u = np.atleast_2d(u)

        # C(u1, ..., ud) = (u1^(-θ) + ... + ud^(-θ) - d + 1)^(-1/θ)
        sum_terms = np.sum(u ** (-self.theta), axis=1)
        cdf_vals = (sum_terms - self.dimension + 1) ** (-1 / self.theta)

        return cdf_vals

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Clayton copula density (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
            )

        u = np.atleast_2d(u)
        u1, u2 = u[:, 0], u[:, 1]

        # c(u1, u2) = (1 + θ) * (u1*u2)^(-1-θ) * (u1^(-θ) + u2^(-θ) - 1)^(-2-1/θ)
        theta = self.theta

        term1 = 1 + theta
        term2 = (u1 * u2) ** (-1 - theta)
        term3 = (u1 ** (-theta) + u2 ** (-theta) - 1) ** (-2 - 1 / theta)

        pdf_vals = term1 * term2 * term3

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Clayton copula (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
            )

        rng = np.random.default_rng(random_state)

        # Algorithm: Use conditional distribution method
        u1 = rng.uniform(0, 1, size)
        v = rng.uniform(0, 1, size)

        # u2 = (u1^(-θ) * (v^(-θ/(1+θ)) - 1) + 1)^(-1/θ)
        theta = self.theta
        u2 = (u1 ** (-theta) * (v ** (-theta / (1 + theta)) - 1) + 1) ** (-1 / theta)

        return np.column_stack([u1, u2])

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau.

        Returns:
            Kendall's tau
        """
        return self.theta / (self.theta + 2)

    def __repr__(self) -> str:
        return f"ClaytonCopula(theta={self.theta}, dimension={self.dimension})"

__init__(theta, dimension=2)

Initialize Clayton copula.

Parameters:

Name Type Description Default
theta float

Dependence parameter (theta >= -1/(d-1), theta != 0)

required
dimension int

Number of dimensions

2
Source code in src/distributions/copulas.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def __init__(self, theta: float, dimension: int = 2):
    """
    Initialize Clayton copula.

    Args:
        theta: Dependence parameter (theta >= -1/(d-1), theta != 0)
        dimension: Number of dimensions
    """
    if theta == 0:
        raise ValueError("theta must be non-zero")

    min_theta = -1.0 / (dimension - 1) if dimension > 1 else -np.inf
    if theta < min_theta:
        raise ValueError(f"theta must be >= {min_theta}")

    super().__init__("Clayton", dimension)
    self.theta = theta

cdf(u)

Clayton copula CDF.

Source code in src/distributions/copulas.py
182
183
184
185
186
187
188
189
190
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Clayton copula CDF."""
    u = np.atleast_2d(u)

    # C(u1, ..., ud) = (u1^(-θ) + ... + ud^(-θ) - d + 1)^(-1/θ)
    sum_terms = np.sum(u ** (-self.theta), axis=1)
    cdf_vals = (sum_terms - self.dimension + 1) ** (-1 / self.theta)

    return cdf_vals

kendall_tau()

Calculate Kendall's tau.

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
232
233
234
235
236
237
238
239
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau.

    Returns:
        Kendall's tau
    """
    return self.theta / (self.theta + 2)

pdf(u)

Clayton copula density (bivariate only).

Source code in src/distributions/copulas.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Clayton copula density (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
        )

    u = np.atleast_2d(u)
    u1, u2 = u[:, 0], u[:, 1]

    # c(u1, u2) = (1 + θ) * (u1*u2)^(-1-θ) * (u1^(-θ) + u2^(-θ) - 1)^(-2-1/θ)
    theta = self.theta

    term1 = 1 + theta
    term2 = (u1 * u2) ** (-1 - theta)
    term3 = (u1 ** (-theta) + u2 ** (-theta) - 1) ** (-2 - 1 / theta)

    pdf_vals = term1 * term2 * term3

    return pdf_vals

rvs(size=1, random_state=None)

Generate samples from Clayton copula (bivariate only).

Source code in src/distributions/copulas.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Clayton copula (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
        )

    rng = np.random.default_rng(random_state)

    # Algorithm: Use conditional distribution method
    u1 = rng.uniform(0, 1, size)
    v = rng.uniform(0, 1, size)

    # u2 = (u1^(-θ) * (v^(-θ/(1+θ)) - 1) + 1)^(-1/θ)
    theta = self.theta
    u2 = (u1 ** (-theta) * (v ** (-theta / (1 + theta)) - 1) + 1) ** (-1 / theta)

    return np.column_stack([u1, u2])

Copula

Base class for copulas.

Source code in src/distributions/copulas.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Copula:
    """Base class for copulas."""

    def __init__(self, name: str, dimension: int = 2):
        """
        Initialize copula.

        Args:
            name: Copula name
            dimension: Number of dimensions
        """
        self.name = name
        self.dimension = dimension

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """
        Copula CDF.

        Args:
            u: Uniform(0,1) marginals (shape: n x d or d)

        Returns:
            Copula CDF values
        """
        raise NotImplementedError("Subclasses must implement cdf()")

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """
        Copula density.

        Args:
            u: Uniform(0,1) marginals

        Returns:
            Copula density values
        """
        raise NotImplementedError("Subclasses must implement pdf()")

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples from copula.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Uniform(0,1) samples (shape: size x d)
        """
        raise NotImplementedError("Subclasses must implement rvs()")

    def kendall_tau(self) -> float:
        """Calculate Kendall's tau."""
        raise NotImplementedError("Subclasses must implement kendall_tau()")

__init__(name, dimension=2)

Initialize copula.

Parameters:

Name Type Description Default
name str

Copula name

required
dimension int

Number of dimensions

2
Source code in src/distributions/copulas.py
11
12
13
14
15
16
17
18
19
20
def __init__(self, name: str, dimension: int = 2):
    """
    Initialize copula.

    Args:
        name: Copula name
        dimension: Number of dimensions
    """
    self.name = name
    self.dimension = dimension

cdf(u)

Copula CDF.

Parameters:

Name Type Description Default
u ndarray

Uniform(0,1) marginals (shape: n x d or d)

required

Returns:

Type Description
ndarray

Copula CDF values

Source code in src/distributions/copulas.py
22
23
24
25
26
27
28
29
30
31
32
def cdf(self, u: np.ndarray) -> np.ndarray:
    """
    Copula CDF.

    Args:
        u: Uniform(0,1) marginals (shape: n x d or d)

    Returns:
        Copula CDF values
    """
    raise NotImplementedError("Subclasses must implement cdf()")

kendall_tau()

Calculate Kendall's tau.

Source code in src/distributions/copulas.py
59
60
61
def kendall_tau(self) -> float:
    """Calculate Kendall's tau."""
    raise NotImplementedError("Subclasses must implement kendall_tau()")

pdf(u)

Copula density.

Parameters:

Name Type Description Default
u ndarray

Uniform(0,1) marginals

required

Returns:

Type Description
ndarray

Copula density values

Source code in src/distributions/copulas.py
34
35
36
37
38
39
40
41
42
43
44
def pdf(self, u: np.ndarray) -> np.ndarray:
    """
    Copula density.

    Args:
        u: Uniform(0,1) marginals

    Returns:
        Copula density values
    """
    raise NotImplementedError("Subclasses must implement pdf()")

rvs(size=1, random_state=None)

Generate random samples from copula.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Uniform(0,1) samples (shape: size x d)

Source code in src/distributions/copulas.py
46
47
48
49
50
51
52
53
54
55
56
57
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples from copula.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Uniform(0,1) samples (shape: size x d)
    """
    raise NotImplementedError("Subclasses must implement rvs()")

GaussianCopula

Bases: Copula

Gaussian (Normal) copula.

Source code in src/distributions/copulas.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
class GaussianCopula(Copula):
    """Gaussian (Normal) copula."""

    def __init__(self, correlation: np.ndarray):
        """
        Initialize Gaussian copula.

        Args:
            correlation: Correlation matrix (must be positive definite)
        """
        corr = np.asarray(correlation)

        if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
            raise ValueError("correlation must be square matrix")

        if not np.allclose(np.diag(corr), 1.0):
            raise ValueError("diagonal of correlation matrix must be 1")

        try:
            np.linalg.cholesky(corr)
        except np.linalg.LinAlgError as err:
            raise ValueError("correlation must be positive definite") from err

        super().__init__("Gaussian", corr.shape[0])
        self.correlation = corr

        # Create multivariate normal for sampling
        self._mvn = stats.multivariate_normal(mean=np.zeros(self.dimension), cov=corr)

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Gaussian copula CDF."""
        u = np.atleast_2d(u)

        # Transform uniforms to standard normals
        z = stats.norm.ppf(u)

        # Evaluate multivariate normal CDF
        # Note: This is computationally expensive for high dimensions
        cdf_vals = np.array([self._mvn.cdf(zi) for zi in z])

        return cdf_vals

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Gaussian copula density."""
        u = np.atleast_2d(u)
        u = np.clip(u, 1e-12, 1.0 - 1e-12)

        # Transform to standard normals
        z = stats.norm.ppf(u)

        # Correlation matrix determinant and inverse
        corr_det = np.linalg.det(self.correlation)
        corr_inv = np.linalg.inv(self.correlation)

        # Copula density
        # c(u) = |Σ|^(-1/2) * exp(z^T (Σ^(-1) - I) z / 2)
        # where z = Φ^(-1)(u)

        identity = np.eye(self.dimension)
        diff = corr_inv - identity

        pdf_vals = np.zeros(len(z))
        for i, zi in enumerate(z):
            exponent = -0.5 * zi @ diff @ zi
            pdf_vals[i] = (1 / np.sqrt(corr_det)) * np.exp(exponent)

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Gaussian copula."""
        # Sample from multivariate normal
        z = self._mvn.rvs(size=size, random_state=random_state)
        if size == 1:
            z = z.reshape(1, -1)

        # Transform to uniform
        u = stats.norm.cdf(z)

        return u

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau (for bivariate case).

        Returns:
            Kendall's tau
        """
        if self.dimension != 2:
            raise ValueError("Kendall's tau only defined for bivariate copula")

        rho = self.correlation[0, 1]
        return (2 / np.pi) * np.arcsin(rho)

    def __repr__(self) -> str:
        return f"GaussianCopula(dimension={self.dimension})"

__init__(correlation)

Initialize Gaussian copula.

Parameters:

Name Type Description Default
correlation ndarray

Correlation matrix (must be positive definite)

required
Source code in src/distributions/copulas.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(self, correlation: np.ndarray):
    """
    Initialize Gaussian copula.

    Args:
        correlation: Correlation matrix (must be positive definite)
    """
    corr = np.asarray(correlation)

    if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
        raise ValueError("correlation must be square matrix")

    if not np.allclose(np.diag(corr), 1.0):
        raise ValueError("diagonal of correlation matrix must be 1")

    try:
        np.linalg.cholesky(corr)
    except np.linalg.LinAlgError as err:
        raise ValueError("correlation must be positive definite") from err

    super().__init__("Gaussian", corr.shape[0])
    self.correlation = corr

    # Create multivariate normal for sampling
    self._mvn = stats.multivariate_normal(mean=np.zeros(self.dimension), cov=corr)

cdf(u)

Gaussian copula CDF.

Source code in src/distributions/copulas.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Gaussian copula CDF."""
    u = np.atleast_2d(u)

    # Transform uniforms to standard normals
    z = stats.norm.ppf(u)

    # Evaluate multivariate normal CDF
    # Note: This is computationally expensive for high dimensions
    cdf_vals = np.array([self._mvn.cdf(zi) for zi in z])

    return cdf_vals

kendall_tau()

Calculate Kendall's tau (for bivariate case).

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
144
145
146
147
148
149
150
151
152
153
154
155
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau (for bivariate case).

    Returns:
        Kendall's tau
    """
    if self.dimension != 2:
        raise ValueError("Kendall's tau only defined for bivariate copula")

    rho = self.correlation[0, 1]
    return (2 / np.pi) * np.arcsin(rho)

pdf(u)

Gaussian copula density.

Source code in src/distributions/copulas.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Gaussian copula density."""
    u = np.atleast_2d(u)
    u = np.clip(u, 1e-12, 1.0 - 1e-12)

    # Transform to standard normals
    z = stats.norm.ppf(u)

    # Correlation matrix determinant and inverse
    corr_det = np.linalg.det(self.correlation)
    corr_inv = np.linalg.inv(self.correlation)

    # Copula density
    # c(u) = |Σ|^(-1/2) * exp(z^T (Σ^(-1) - I) z / 2)
    # where z = Φ^(-1)(u)

    identity = np.eye(self.dimension)
    diff = corr_inv - identity

    pdf_vals = np.zeros(len(z))
    for i, zi in enumerate(z):
        exponent = -0.5 * zi @ diff @ zi
        pdf_vals[i] = (1 / np.sqrt(corr_det)) * np.exp(exponent)

    return pdf_vals

rvs(size=1, random_state=None)

Generate samples from Gaussian copula.

Source code in src/distributions/copulas.py
132
133
134
135
136
137
138
139
140
141
142
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Gaussian copula."""
    # Sample from multivariate normal
    z = self._mvn.rvs(size=size, random_state=random_state)
    if size == 1:
        z = z.reshape(1, -1)

    # Transform to uniform
    u = stats.norm.cdf(z)

    return u

GumbelCopula

Bases: Copula

Gumbel copula (Archimedean).

Source code in src/distributions/copulas.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
class GumbelCopula(Copula):
    """Gumbel copula (Archimedean)."""

    def __init__(self, theta: float, dimension: int = 2):
        """
        Initialize Gumbel copula.

        Args:
            theta: Dependence parameter (theta >= 1)
            dimension: Number of dimensions
        """
        if theta < 1:
            raise ValueError("theta must be >= 1")

        super().__init__("Gumbel", dimension)
        self.theta = theta

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Gumbel copula CDF."""
        u = np.atleast_2d(u)

        # C(u1, ..., ud) = exp(-(sum_i (-log(ui))^θ)^(1/θ))
        log_terms = (-np.log(u)) ** self.theta
        sum_terms = np.sum(log_terms, axis=1)
        cdf_vals = np.exp(-(sum_terms ** (1 / self.theta)))

        return cdf_vals

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Gumbel copula density (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
            )

        u = np.atleast_2d(u)
        u1, u2 = u[:, 0], u[:, 1]

        theta = self.theta

        # Complex formula for Gumbel copula density
        log_u1 = -np.log(u1)
        log_u2 = -np.log(u2)

        A = (log_u1**theta + log_u2**theta) ** (1 / theta)
        B = (log_u1**theta + log_u2**theta) ** (-2 + 2 / theta)
        C = (log_u1 * log_u2) ** (theta - 1)
        D = 1 + (theta - 1) * (log_u1**theta + log_u2**theta) ** (-1 / theta)

        pdf_vals = np.exp(-A) * B * C * D / (u1 * u2)

        return pdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Gumbel copula (bivariate only)."""
        if self.dimension != 2:
            raise NotImplementedError(
                "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
            )

        rng = np.random.default_rng(random_state)

        theta = self.theta
        u1 = rng.uniform(0, 1, size)
        p = rng.uniform(0, 1, size)
        u2 = np.zeros(size)

        def cond_cdf(v, u1_i, t, p_i):
            if v <= 1e-15:
                return 0.0 - p_i
            if v >= 1 - 1e-15:
                return 1.0 - p_i
            s = -np.log(v)
            a = (t**theta + s**theta) ** (1.0 / theta)
            return np.exp(-a) / u1_i * (t / a) ** (theta - 1) - p_i

        for i in range(size):
            u1_i = max(u1[i], 1e-15)
            t = -np.log(u1_i)
            p_i = p[i]

            try:
                u2[i] = brentq(cond_cdf, 1e-15, 1 - 1e-15, args=(u1_i, t, p_i))
            except ValueError:
                # Fall back to the median of the conditional distribution only
                # when the root bracket is degenerate; otherwise surface the error.
                bracket_lo = cond_cdf(1e-15, u1_i, t, p_i)
                bracket_hi = cond_cdf(1 - 1e-15, u1_i, t, p_i)
                if bracket_lo * bracket_hi > 0:
                    raise ValueError(
                        "Gumbel conditional CDF root-finding failed to bracket a root"
                    ) from None
                u2[i] = 0.5

        return np.column_stack([u1, u2])

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau.

        Returns:
            Kendall's tau
        """
        return 1 - 1 / self.theta

    def __repr__(self) -> str:
        return f"GumbelCopula(theta={self.theta}, dimension={self.dimension})"

__init__(theta, dimension=2)

Initialize Gumbel copula.

Parameters:

Name Type Description Default
theta float

Dependence parameter (theta >= 1)

required
dimension int

Number of dimensions

2
Source code in src/distributions/copulas.py
248
249
250
251
252
253
254
255
256
257
258
259
260
def __init__(self, theta: float, dimension: int = 2):
    """
    Initialize Gumbel copula.

    Args:
        theta: Dependence parameter (theta >= 1)
        dimension: Number of dimensions
    """
    if theta < 1:
        raise ValueError("theta must be >= 1")

    super().__init__("Gumbel", dimension)
    self.theta = theta

cdf(u)

Gumbel copula CDF.

Source code in src/distributions/copulas.py
262
263
264
265
266
267
268
269
270
271
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Gumbel copula CDF."""
    u = np.atleast_2d(u)

    # C(u1, ..., ud) = exp(-(sum_i (-log(ui))^θ)^(1/θ))
    log_terms = (-np.log(u)) ** self.theta
    sum_terms = np.sum(log_terms, axis=1)
    cdf_vals = np.exp(-(sum_terms ** (1 / self.theta)))

    return cdf_vals

kendall_tau()

Calculate Kendall's tau.

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
341
342
343
344
345
346
347
348
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau.

    Returns:
        Kendall's tau
    """
    return 1 - 1 / self.theta

pdf(u)

Gumbel copula density (bivariate only).

Source code in src/distributions/copulas.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Gumbel copula density (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula PDF is currently implemented for the bivariate (dimension=2) case only"
        )

    u = np.atleast_2d(u)
    u1, u2 = u[:, 0], u[:, 1]

    theta = self.theta

    # Complex formula for Gumbel copula density
    log_u1 = -np.log(u1)
    log_u2 = -np.log(u2)

    A = (log_u1**theta + log_u2**theta) ** (1 / theta)
    B = (log_u1**theta + log_u2**theta) ** (-2 + 2 / theta)
    C = (log_u1 * log_u2) ** (theta - 1)
    D = 1 + (theta - 1) * (log_u1**theta + log_u2**theta) ** (-1 / theta)

    pdf_vals = np.exp(-A) * B * C * D / (u1 * u2)

    return pdf_vals

rvs(size=1, random_state=None)

Generate samples from Gumbel copula (bivariate only).

Source code in src/distributions/copulas.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Gumbel copula (bivariate only)."""
    if self.dimension != 2:
        raise NotImplementedError(
            "Clayton copula sampling is currently implemented for the bivariate (dimension=2) case only"
        )

    rng = np.random.default_rng(random_state)

    theta = self.theta
    u1 = rng.uniform(0, 1, size)
    p = rng.uniform(0, 1, size)
    u2 = np.zeros(size)

    def cond_cdf(v, u1_i, t, p_i):
        if v <= 1e-15:
            return 0.0 - p_i
        if v >= 1 - 1e-15:
            return 1.0 - p_i
        s = -np.log(v)
        a = (t**theta + s**theta) ** (1.0 / theta)
        return np.exp(-a) / u1_i * (t / a) ** (theta - 1) - p_i

    for i in range(size):
        u1_i = max(u1[i], 1e-15)
        t = -np.log(u1_i)
        p_i = p[i]

        try:
            u2[i] = brentq(cond_cdf, 1e-15, 1 - 1e-15, args=(u1_i, t, p_i))
        except ValueError:
            # Fall back to the median of the conditional distribution only
            # when the root bracket is degenerate; otherwise surface the error.
            bracket_lo = cond_cdf(1e-15, u1_i, t, p_i)
            bracket_hi = cond_cdf(1 - 1e-15, u1_i, t, p_i)
            if bracket_lo * bracket_hi > 0:
                raise ValueError(
                    "Gumbel conditional CDF root-finding failed to bracket a root"
                ) from None
            u2[i] = 0.5

    return np.column_stack([u1, u2])

StudentTCopula

Bases: Copula

Student-t copula.

Source code in src/distributions/copulas.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
class StudentTCopula(Copula):
    """Student-t copula."""

    def __init__(self, correlation: np.ndarray, df: float):
        """
        Initialize Student-t copula.

        Args:
            correlation: Correlation matrix
            df: Degrees of freedom
        """
        corr = np.asarray(correlation)

        if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
            raise ValueError("correlation must be square matrix")

        if df <= 0:
            raise ValueError("df must be positive")

        if not np.allclose(np.diag(corr), 1.0):
            raise ValueError("diagonal of correlation matrix must be 1")

        try:
            np.linalg.cholesky(corr)
        except np.linalg.LinAlgError as err:
            raise ValueError("correlation must be positive definite") from err

        super().__init__("Student-t", corr.shape[0])
        self.correlation = corr
        self.df = df

    def cdf(self, u: np.ndarray) -> np.ndarray:
        """Student-t copula CDF.

        Note: closed-form evaluation requires multivariate-t integration and is
        not implemented; use Monte Carlo estimation via :meth:`rvs` instead.
        """
        raise NotImplementedError(
            "StudentTCopula.cdf is not implemented (requires multivariate-t "
            "integration); estimate probabilities by Monte Carlo sampling with rvs()"
        )

    def pdf(self, u: np.ndarray) -> np.ndarray:
        """Student-t copula density.

        Note: the density is not implemented; use sampling-based inference via
        :meth:`rvs` instead.
        """
        raise NotImplementedError(
            "StudentTCopula.pdf is not implemented; use sampling-based inference with rvs()"
        )

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """Generate samples from Student-t copula."""
        rng = np.random.default_rng(random_state)

        # Sample from multivariate t
        # Method: X = mu + Y * sqrt(df/S) where Y ~ N(0, Σ), S ~ chi2(df)
        normal_samples = rng.multivariate_normal(
            np.zeros(self.dimension), self.correlation, size=size
        )

        chi2_samples = rng.chisquare(self.df, size=size)

        t_samples = normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]

        # Transform to uniform using t CDF
        u = stats.t.cdf(t_samples, df=self.df)

        return u

    def kendall_tau(self) -> float:
        """
        Calculate Kendall's tau (bivariate only).

        Returns:
            Kendall's tau
        """
        if self.dimension != 2:
            raise ValueError("Kendall's tau only defined for bivariate")

        rho = self.correlation[0, 1]
        return (2 / np.pi) * np.arcsin(rho)

    def __repr__(self) -> str:
        return f"StudentTCopula(dimension={self.dimension}, df={self.df})"

__init__(correlation, df)

Initialize Student-t copula.

Parameters:

Name Type Description Default
correlation ndarray

Correlation matrix

required
df float

Degrees of freedom

required
Source code in src/distributions/copulas.py
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def __init__(self, correlation: np.ndarray, df: float):
    """
    Initialize Student-t copula.

    Args:
        correlation: Correlation matrix
        df: Degrees of freedom
    """
    corr = np.asarray(correlation)

    if corr.ndim != 2 or corr.shape[0] != corr.shape[1]:
        raise ValueError("correlation must be square matrix")

    if df <= 0:
        raise ValueError("df must be positive")

    if not np.allclose(np.diag(corr), 1.0):
        raise ValueError("diagonal of correlation matrix must be 1")

    try:
        np.linalg.cholesky(corr)
    except np.linalg.LinAlgError as err:
        raise ValueError("correlation must be positive definite") from err

    super().__init__("Student-t", corr.shape[0])
    self.correlation = corr
    self.df = df

cdf(u)

Student-t copula CDF.

Note: closed-form evaluation requires multivariate-t integration and is not implemented; use Monte Carlo estimation via :meth:rvs instead.

Source code in src/distributions/copulas.py
385
386
387
388
389
390
391
392
393
394
def cdf(self, u: np.ndarray) -> np.ndarray:
    """Student-t copula CDF.

    Note: closed-form evaluation requires multivariate-t integration and is
    not implemented; use Monte Carlo estimation via :meth:`rvs` instead.
    """
    raise NotImplementedError(
        "StudentTCopula.cdf is not implemented (requires multivariate-t "
        "integration); estimate probabilities by Monte Carlo sampling with rvs()"
    )

kendall_tau()

Calculate Kendall's tau (bivariate only).

Returns:

Type Description
float

Kendall's tau

Source code in src/distributions/copulas.py
425
426
427
428
429
430
431
432
433
434
435
436
def kendall_tau(self) -> float:
    """
    Calculate Kendall's tau (bivariate only).

    Returns:
        Kendall's tau
    """
    if self.dimension != 2:
        raise ValueError("Kendall's tau only defined for bivariate")

    rho = self.correlation[0, 1]
    return (2 / np.pi) * np.arcsin(rho)

pdf(u)

Student-t copula density.

Note: the density is not implemented; use sampling-based inference via :meth:rvs instead.

Source code in src/distributions/copulas.py
396
397
398
399
400
401
402
403
404
def pdf(self, u: np.ndarray) -> np.ndarray:
    """Student-t copula density.

    Note: the density is not implemented; use sampling-based inference via
    :meth:`rvs` instead.
    """
    raise NotImplementedError(
        "StudentTCopula.pdf is not implemented; use sampling-based inference with rvs()"
    )

rvs(size=1, random_state=None)

Generate samples from Student-t copula.

Source code in src/distributions/copulas.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """Generate samples from Student-t copula."""
    rng = np.random.default_rng(random_state)

    # Sample from multivariate t
    # Method: X = mu + Y * sqrt(df/S) where Y ~ N(0, Σ), S ~ chi2(df)
    normal_samples = rng.multivariate_normal(
        np.zeros(self.dimension), self.correlation, size=size
    )

    chi2_samples = rng.chisquare(self.df, size=size)

    t_samples = normal_samples * np.sqrt(self.df / chi2_samples)[:, np.newaxis]

    # Transform to uniform using t CDF
    u = stats.t.cdf(t_samples, df=self.df)

    return u

fit_copula_to_data(data, copula_type='gaussian', method='rank')

Fit copula to multivariate data.

Parameters:

Name Type Description Default
data ndarray

Multivariate data (n x d)

required
copula_type str

Type of copula ('gaussian', 'clayton', 'gumbel', 't')

'gaussian'
method str

Method for pseudo-observations ('rank' or 'empirical')

'rank'

Returns:

Type Description
Copula

Fitted copula object

Source code in src/distributions/copulas.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
def fit_copula_to_data(
    data: np.ndarray, copula_type: str = "gaussian", method: str = "rank"
) -> Copula:
    """
    Fit copula to multivariate data.

    Args:
        data: Multivariate data (n x d)
        copula_type: Type of copula ('gaussian', 'clayton', 'gumbel', 't')
        method: Method for pseudo-observations ('rank' or 'empirical')

    Returns:
        Fitted copula object
    """
    n, d = data.shape

    # Transform to pseudo-observations (uniform margins)
    if method == "rank":
        # Rank-based transformation
        u = np.zeros_like(data)
        for i in range(d):
            ranks = stats.rankdata(data[:, i])
            u[:, i] = ranks / (n + 1)
    else:
        # Empirical CDF
        u = np.zeros_like(data)
        for i in range(d):
            u[:, i] = stats.rankdata(data[:, i]) / n

    # Estimate copula parameters
    if copula_type == "gaussian":
        # Estimate correlation from Gaussian quantiles
        z = stats.norm.ppf(u)
        corr = np.corrcoef(z.T)
        return GaussianCopula(corr)

    elif copula_type == "clayton" and d == 2:
        # Estimate theta using Kendall's tau
        tau = float(stats.kendalltau(data[:, 0], data[:, 1])[0])
        if not np.isfinite(tau) or tau <= 0 or tau >= 1:
            raise ValueError(f"Clayton copula requires Kendall's tau in (0, 1); got {tau!r}")
        theta = 2 * tau / (1 - tau)
        return ClaytonCopula(theta, dimension=2)

    elif copula_type == "gumbel" and d == 2:
        # Estimate theta using Kendall's tau
        tau = float(stats.kendalltau(data[:, 0], data[:, 1])[0])
        if not np.isfinite(tau) or tau < 0 or tau >= 1:
            raise ValueError(f"Gumbel copula requires Kendall's tau in [0, 1); got {tau!r}")
        theta = 1 / (1 - tau)
        return GumbelCopula(theta, dimension=2)

    elif copula_type == "t":
        # Estimate correlation. Degrees of freedom are fixed at df=4 as a
        # documented simplification; full MLE over df is out of scope.
        # See https://github.com/sanskarpan/probviz/issues
        z = stats.t.ppf(u, df=4)
        corr = np.corrcoef(z.T)
        return StudentTCopula(corr, df=4)

    else:
        raise ValueError(f"Unsupported copula type: {copula_type}")

src.distributions.mixtures

Mixture distributions - combinations of multiple distributions.

BayesianGMM

Bayesian Gaussian Mixture Model with automatic component selection.

Source code in src/distributions/mixtures.py
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
class BayesianGMM:
    """Bayesian Gaussian Mixture Model with automatic component selection."""

    def __init__(
        self,
        max_components: int = 10,
        weight_concentration_prior: float = 1.0,
        max_iter: int = 200,
        tol: float = 1e-4,
    ):
        """
        Initialize Bayesian GMM.

        Args:
            max_components: Maximum number of components
            weight_concentration_prior: Dirichlet concentration prior
            max_iter: Maximum EM iterations
            tol: Convergence tolerance
        """
        self.max_components = max_components
        self.bgmm = BayesianGaussianMixture(
            n_components=max_components,
            weight_concentration_prior=weight_concentration_prior,
            max_iter=max_iter,
            tol=tol,
            random_state=42,
        )
        self.fitted = False

    def fit(self, data: np.ndarray) -> "BayesianGMM":
        """
        Fit Bayesian GMM to data.

        Args:
            data: Training data

        Returns:
            Self
        """
        data = _as_2d(data)

        self.bgmm.fit(data)
        self.fitted = True

        return self

    def predict(self, data: np.ndarray) -> np.ndarray:
        """Predict component labels."""
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.bgmm.predict(data)

    def get_active_components(self) -> int:
        """
        Get number of active components (with non-negligible weight).

        Returns:
            Number of active components
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        # Components with weight > 0.01 are considered active
        return np.sum(self.bgmm.weights_ > 0.01)

__init__(max_components=10, weight_concentration_prior=1.0, max_iter=200, tol=0.0001)

Initialize Bayesian GMM.

Parameters:

Name Type Description Default
max_components int

Maximum number of components

10
weight_concentration_prior float

Dirichlet concentration prior

1.0
max_iter int

Maximum EM iterations

200
tol float

Convergence tolerance

0.0001
Source code in src/distributions/mixtures.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def __init__(
    self,
    max_components: int = 10,
    weight_concentration_prior: float = 1.0,
    max_iter: int = 200,
    tol: float = 1e-4,
):
    """
    Initialize Bayesian GMM.

    Args:
        max_components: Maximum number of components
        weight_concentration_prior: Dirichlet concentration prior
        max_iter: Maximum EM iterations
        tol: Convergence tolerance
    """
    self.max_components = max_components
    self.bgmm = BayesianGaussianMixture(
        n_components=max_components,
        weight_concentration_prior=weight_concentration_prior,
        max_iter=max_iter,
        tol=tol,
        random_state=42,
    )
    self.fitted = False

fit(data)

Fit Bayesian GMM to data.

Parameters:

Name Type Description Default
data ndarray

Training data

required

Returns:

Type Description
BayesianGMM

Self

Source code in src/distributions/mixtures.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
def fit(self, data: np.ndarray) -> "BayesianGMM":
    """
    Fit Bayesian GMM to data.

    Args:
        data: Training data

    Returns:
        Self
    """
    data = _as_2d(data)

    self.bgmm.fit(data)
    self.fitted = True

    return self

get_active_components()

Get number of active components (with non-negligible weight).

Returns:

Type Description
int

Number of active components

Source code in src/distributions/mixtures.py
441
442
443
444
445
446
447
448
449
450
451
452
def get_active_components(self) -> int:
    """
    Get number of active components (with non-negligible weight).

    Returns:
        Number of active components
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    # Components with weight > 0.01 are considered active
    return np.sum(self.bgmm.weights_ > 0.01)

predict(data)

Predict component labels.

Source code in src/distributions/mixtures.py
432
433
434
435
436
437
438
439
def predict(self, data: np.ndarray) -> np.ndarray:
    """Predict component labels."""
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.bgmm.predict(data)

GaussianMixtureModel

Gaussian Mixture Model using sklearn.

Source code in src/distributions/mixtures.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
class GaussianMixtureModel:
    """Gaussian Mixture Model using sklearn."""

    def __init__(self, n_components: int = 2, covariance_type: str = "full", max_iter: int = 100):
        """
        Initialize Gaussian Mixture Model.

        Args:
            n_components: Number of mixture components
            covariance_type: Type of covariance ('full', 'tied', 'diag', 'spherical')
            max_iter: Maximum EM iterations
        """
        self.n_components = n_components
        self.gmm = GaussianMixture(
            n_components=n_components,
            covariance_type=covariance_type,
            max_iter=max_iter,
            random_state=42,
        )
        self.fitted = False

    def fit(self, data: np.ndarray) -> "GaussianMixtureModel":
        """
        Fit GMM to data.

        Args:
            data: Training data (n x d)

        Returns:
            Self
        """
        data = _as_2d(data)

        self.gmm.fit(data)
        self.fitted = True

        return self

    def predict(self, data: np.ndarray) -> np.ndarray:
        """
        Predict component labels.

        Args:
            data: Data to predict

        Returns:
            Component labels
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.predict(data)

    def score_samples(self, data: np.ndarray) -> np.ndarray:
        """
        Calculate log-likelihood of samples.

        Args:
            data: Data to score

        Returns:
            Log-likelihood values
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.score_samples(data)

    def pdf(self, data: np.ndarray) -> np.ndarray:
        """
        Calculate probability density.

        Args:
            data: Data points

        Returns:
            PDF values
        """
        log_pdf = self.score_samples(data)
        return np.exp(log_pdf)

    def rvs(self, size: int = 1) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples

        Returns:
            Samples
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        samples, _ = self.gmm.sample(size)
        return samples.squeeze()

    def bic(self, data: np.ndarray) -> float:
        """
        Calculate Bayesian Information Criterion.

        Args:
            data: Data

        Returns:
            BIC value
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.bic(data)

    def aic(self, data: np.ndarray) -> float:
        """
        Calculate Akaike Information Criterion.

        Args:
            data: Data

        Returns:
            AIC value
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        data = _as_2d(data)

        return self.gmm.aic(data)

    def get_parameters(self) -> dict:
        """
        Get fitted parameters.

        Returns:
            Dictionary with means, covariances, and weights
        """
        if not self.fitted:
            raise ValueError("Model must be fitted first")

        return {
            "means": self.gmm.means_,
            "covariances": self.gmm.covariances_,
            "weights": self.gmm.weights_,
        }

__init__(n_components=2, covariance_type='full', max_iter=100)

Initialize Gaussian Mixture Model.

Parameters:

Name Type Description Default
n_components int

Number of mixture components

2
covariance_type str

Type of covariance ('full', 'tied', 'diag', 'spherical')

'full'
max_iter int

Maximum EM iterations

100
Source code in src/distributions/mixtures.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def __init__(self, n_components: int = 2, covariance_type: str = "full", max_iter: int = 100):
    """
    Initialize Gaussian Mixture Model.

    Args:
        n_components: Number of mixture components
        covariance_type: Type of covariance ('full', 'tied', 'diag', 'spherical')
        max_iter: Maximum EM iterations
    """
    self.n_components = n_components
    self.gmm = GaussianMixture(
        n_components=n_components,
        covariance_type=covariance_type,
        max_iter=max_iter,
        random_state=42,
    )
    self.fitted = False

aic(data)

Calculate Akaike Information Criterion.

Parameters:

Name Type Description Default
data ndarray

Data

required

Returns:

Type Description
float

AIC value

Source code in src/distributions/mixtures.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
def aic(self, data: np.ndarray) -> float:
    """
    Calculate Akaike Information Criterion.

    Args:
        data: Data

    Returns:
        AIC value
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.aic(data)

bic(data)

Calculate Bayesian Information Criterion.

Parameters:

Name Type Description Default
data ndarray

Data

required

Returns:

Type Description
float

BIC value

Source code in src/distributions/mixtures.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def bic(self, data: np.ndarray) -> float:
    """
    Calculate Bayesian Information Criterion.

    Args:
        data: Data

    Returns:
        BIC value
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.bic(data)

fit(data)

Fit GMM to data.

Parameters:

Name Type Description Default
data ndarray

Training data (n x d)

required

Returns:

Type Description
GaussianMixtureModel

Self

Source code in src/distributions/mixtures.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def fit(self, data: np.ndarray) -> "GaussianMixtureModel":
    """
    Fit GMM to data.

    Args:
        data: Training data (n x d)

    Returns:
        Self
    """
    data = _as_2d(data)

    self.gmm.fit(data)
    self.fitted = True

    return self

get_parameters()

Get fitted parameters.

Returns:

Type Description
dict

Dictionary with means, covariances, and weights

Source code in src/distributions/mixtures.py
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def get_parameters(self) -> dict:
    """
    Get fitted parameters.

    Returns:
        Dictionary with means, covariances, and weights
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    return {
        "means": self.gmm.means_,
        "covariances": self.gmm.covariances_,
        "weights": self.gmm.weights_,
    }

pdf(data)

Calculate probability density.

Parameters:

Name Type Description Default
data ndarray

Data points

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/mixtures.py
306
307
308
309
310
311
312
313
314
315
316
317
def pdf(self, data: np.ndarray) -> np.ndarray:
    """
    Calculate probability density.

    Args:
        data: Data points

    Returns:
        PDF values
    """
    log_pdf = self.score_samples(data)
    return np.exp(log_pdf)

predict(data)

Predict component labels.

Parameters:

Name Type Description Default
data ndarray

Data to predict

required

Returns:

Type Description
ndarray

Component labels

Source code in src/distributions/mixtures.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
def predict(self, data: np.ndarray) -> np.ndarray:
    """
    Predict component labels.

    Args:
        data: Data to predict

    Returns:
        Component labels
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.predict(data)

rvs(size=1)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1

Returns:

Type Description
ndarray

Samples

Source code in src/distributions/mixtures.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def rvs(self, size: int = 1) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples

    Returns:
        Samples
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    samples, _ = self.gmm.sample(size)
    return samples.squeeze()

score_samples(data)

Calculate log-likelihood of samples.

Parameters:

Name Type Description Default
data ndarray

Data to score

required

Returns:

Type Description
ndarray

Log-likelihood values

Source code in src/distributions/mixtures.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def score_samples(self, data: np.ndarray) -> np.ndarray:
    """
    Calculate log-likelihood of samples.

    Args:
        data: Data to score

    Returns:
        Log-likelihood values
    """
    if not self.fitted:
        raise ValueError("Model must be fitted first")

    data = _as_2d(data)

    return self.gmm.score_samples(data)

MixtureDistribution

General mixture distribution.

Source code in src/distributions/mixtures.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
class MixtureDistribution:
    """General mixture distribution."""

    def __init__(self, components: list, weights: list[float] | np.ndarray):
        """
        Initialize mixture distribution.

        Args:
            components: List of component distributions
            weights: Mixing weights (must sum to 1)
        """
        weights = np.asarray(weights, dtype=float)

        if len(components) != len(weights):
            raise ValueError("Number of components must match number of weights")

        if not np.isclose(np.sum(weights), 1.0):
            raise ValueError("Weights must sum to 1")

        if np.any(weights < 0):
            raise ValueError("Weights must be non-negative")

        self.components = components
        self.weights = weights
        self.n_components = len(components)

    def pdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate probability density function.

        Args:
            x: Input values

        Returns:
            PDF values
        """
        x = np.atleast_1d(x)
        pdf_vals = np.zeros_like(x, dtype=float)

        for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
            pdf_vals += weight * component.pdf(x)

        return pdf_vals

    def cdf(self, x: np.ndarray) -> np.ndarray:
        """
        Calculate cumulative distribution function.

        Args:
            x: Input values

        Returns:
            CDF values
        """
        x = np.atleast_1d(x)
        cdf_vals = np.zeros_like(x, dtype=float)

        for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
            cdf_vals += weight * component.cdf(x)

        return cdf_vals

    def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
        """
        Generate random samples.

        Args:
            size: Number of samples
            random_state: Random seed

        Returns:
            Random samples
        """
        rng = np.random.default_rng(random_state)

        # Sample component indices according to weights
        component_indices = rng.choice(self.n_components, size=size, p=self.weights)

        # Sample from each selected component
        samples = np.zeros(size)
        for i in range(self.n_components):
            mask = component_indices == i
            n_samples = np.sum(mask)

            if n_samples > 0:
                comp_samples = self.components[i].rvs(size=n_samples)
                samples[mask] = comp_samples

        return samples

    def mean(self) -> float:
        """
        Calculate mean of mixture.

        Returns:
            Mean value
        """
        mean = 0.0
        for component, weight in zip(self.components, self.weights, strict=True):
            mean += weight * component.mean()

        return mean

    def var(self) -> float:
        """
        Calculate variance of mixture.

        Returns:
            Variance value
        """
        # Var(X) = E[Var(X|Z)] + Var(E[X|Z])
        # where Z is the component indicator

        # E[Var(X|Z)]
        var_within = 0.0
        for component, weight in zip(self.components, self.weights, strict=True):
            var_within += weight * component.var()

        # Var(E[X|Z])
        mixture_mean = self.mean()
        var_between = 0.0
        for component, weight in zip(self.components, self.weights, strict=True):
            diff = component.mean() - mixture_mean
            var_between += weight * diff**2

        return var_within + var_between

    def fit_em(
        self,
        data: np.ndarray,
        n_components: int,
        max_iter: int = 100,
        tol: float = 1e-4,
        random_state: int | None = None,
    ) -> tuple[np.ndarray, list, list[float]]:
        """
        Fit mixture model using Expectation-Maximization.

        Args:
            data: Observed data
            n_components: Number of mixture components
            max_iter: Maximum EM iterations
            tol: Convergence tolerance
            random_state: Seed for the random component initialization.
                Pass an int for reproducible fits; ``None`` (default) uses
                non-deterministic entropy.

        Returns:
            Tuple of (responsibilities, components, weights)
        """
        data = np.asarray(data, dtype=float).ravel()
        n = len(data)
        if n == 0:
            raise ValueError("data must not be empty")
        if n_components < 1:
            raise ValueError("n_components must be >= 1")
        if n_components > n:
            raise ValueError("n_components must not exceed number of data points")

        # Initialize parameters randomly
        rng = np.random.default_rng(random_state)
        weights = np.ones(n_components) / n_components
        means = rng.choice(data, size=n_components, replace=False)
        std = float(np.std(data))
        stds = np.ones(n_components) * (std if std > 0 else 1.0)

        for _ in range(max_iter):
            prev_means = means.copy()
            prev_stds = stds.copy()
            # E-step: Calculate responsibilities
            responsibilities = np.zeros((n, n_components))

            for k in range(n_components):
                component = stats.norm(loc=means[k], scale=stds[k])
                responsibilities[:, k] = weights[k] * component.pdf(data)

            # Normalize responsibilities (guard against zero total likelihood)
            row_sums = responsibilities.sum(axis=1, keepdims=True)
            row_sums[row_sums == 0] = 1.0
            responsibilities /= row_sums

            # M-step: Update parameters
            nk = responsibilities.sum(axis=0)
            new_weights = nk / n

            new_means = np.zeros(n_components)
            new_stds = np.zeros(n_components)

            for k in range(n_components):
                if nk[k] == 0:
                    new_means[k] = means[k]
                    new_stds[k] = stds[k]
                    continue
                new_means[k] = np.sum(responsibilities[:, k] * data) / nk[k]
                diff_sq = (data - new_means[k]) ** 2
                new_stds[k] = np.sqrt(np.sum(responsibilities[:, k] * diff_sq) / nk[k])
                if new_stds[k] == 0:
                    new_stds[k] = 1e-6

            weights = new_weights
            means = new_means
            stds = new_stds

            # Check convergence (after updating so results are never stale)
            if np.max(np.abs(means - prev_means)) < tol and np.max(np.abs(stds - prev_stds)) < tol:
                break

        # Create component distributions
        components = [stats.norm(loc=m, scale=s) for m, s in zip(means, stds, strict=True)]

        return responsibilities, components, weights.tolist()

    def __repr__(self) -> str:
        return f"MixtureDistribution(n_components={self.n_components})"

__init__(components, weights)

Initialize mixture distribution.

Parameters:

Name Type Description Default
components list

List of component distributions

required
weights list[float] | ndarray

Mixing weights (must sum to 1)

required
Source code in src/distributions/mixtures.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def __init__(self, components: list, weights: list[float] | np.ndarray):
    """
    Initialize mixture distribution.

    Args:
        components: List of component distributions
        weights: Mixing weights (must sum to 1)
    """
    weights = np.asarray(weights, dtype=float)

    if len(components) != len(weights):
        raise ValueError("Number of components must match number of weights")

    if not np.isclose(np.sum(weights), 1.0):
        raise ValueError("Weights must sum to 1")

    if np.any(weights < 0):
        raise ValueError("Weights must be non-negative")

    self.components = components
    self.weights = weights
    self.n_components = len(components)

cdf(x)

Calculate cumulative distribution function.

Parameters:

Name Type Description Default
x ndarray

Input values

required

Returns:

Type Description
ndarray

CDF values

Source code in src/distributions/mixtures.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def cdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate cumulative distribution function.

    Args:
        x: Input values

    Returns:
        CDF values
    """
    x = np.atleast_1d(x)
    cdf_vals = np.zeros_like(x, dtype=float)

    for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
        cdf_vals += weight * component.cdf(x)

    return cdf_vals

fit_em(data, n_components, max_iter=100, tol=0.0001, random_state=None)

Fit mixture model using Expectation-Maximization.

Parameters:

Name Type Description Default
data ndarray

Observed data

required
n_components int

Number of mixture components

required
max_iter int

Maximum EM iterations

100
tol float

Convergence tolerance

0.0001
random_state int | None

Seed for the random component initialization. Pass an int for reproducible fits; None (default) uses non-deterministic entropy.

None

Returns:

Type Description
tuple[ndarray, list, list[float]]

Tuple of (responsibilities, components, weights)

Source code in src/distributions/mixtures.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def fit_em(
    self,
    data: np.ndarray,
    n_components: int,
    max_iter: int = 100,
    tol: float = 1e-4,
    random_state: int | None = None,
) -> tuple[np.ndarray, list, list[float]]:
    """
    Fit mixture model using Expectation-Maximization.

    Args:
        data: Observed data
        n_components: Number of mixture components
        max_iter: Maximum EM iterations
        tol: Convergence tolerance
        random_state: Seed for the random component initialization.
            Pass an int for reproducible fits; ``None`` (default) uses
            non-deterministic entropy.

    Returns:
        Tuple of (responsibilities, components, weights)
    """
    data = np.asarray(data, dtype=float).ravel()
    n = len(data)
    if n == 0:
        raise ValueError("data must not be empty")
    if n_components < 1:
        raise ValueError("n_components must be >= 1")
    if n_components > n:
        raise ValueError("n_components must not exceed number of data points")

    # Initialize parameters randomly
    rng = np.random.default_rng(random_state)
    weights = np.ones(n_components) / n_components
    means = rng.choice(data, size=n_components, replace=False)
    std = float(np.std(data))
    stds = np.ones(n_components) * (std if std > 0 else 1.0)

    for _ in range(max_iter):
        prev_means = means.copy()
        prev_stds = stds.copy()
        # E-step: Calculate responsibilities
        responsibilities = np.zeros((n, n_components))

        for k in range(n_components):
            component = stats.norm(loc=means[k], scale=stds[k])
            responsibilities[:, k] = weights[k] * component.pdf(data)

        # Normalize responsibilities (guard against zero total likelihood)
        row_sums = responsibilities.sum(axis=1, keepdims=True)
        row_sums[row_sums == 0] = 1.0
        responsibilities /= row_sums

        # M-step: Update parameters
        nk = responsibilities.sum(axis=0)
        new_weights = nk / n

        new_means = np.zeros(n_components)
        new_stds = np.zeros(n_components)

        for k in range(n_components):
            if nk[k] == 0:
                new_means[k] = means[k]
                new_stds[k] = stds[k]
                continue
            new_means[k] = np.sum(responsibilities[:, k] * data) / nk[k]
            diff_sq = (data - new_means[k]) ** 2
            new_stds[k] = np.sqrt(np.sum(responsibilities[:, k] * diff_sq) / nk[k])
            if new_stds[k] == 0:
                new_stds[k] = 1e-6

        weights = new_weights
        means = new_means
        stds = new_stds

        # Check convergence (after updating so results are never stale)
        if np.max(np.abs(means - prev_means)) < tol and np.max(np.abs(stds - prev_stds)) < tol:
            break

    # Create component distributions
    components = [stats.norm(loc=m, scale=s) for m, s in zip(means, stds, strict=True)]

    return responsibilities, components, weights.tolist()

mean()

Calculate mean of mixture.

Returns:

Type Description
float

Mean value

Source code in src/distributions/mixtures.py
108
109
110
111
112
113
114
115
116
117
118
119
def mean(self) -> float:
    """
    Calculate mean of mixture.

    Returns:
        Mean value
    """
    mean = 0.0
    for component, weight in zip(self.components, self.weights, strict=True):
        mean += weight * component.mean()

    return mean

pdf(x)

Calculate probability density function.

Parameters:

Name Type Description Default
x ndarray

Input values

required

Returns:

Type Description
ndarray

PDF values

Source code in src/distributions/mixtures.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def pdf(self, x: np.ndarray) -> np.ndarray:
    """
    Calculate probability density function.

    Args:
        x: Input values

    Returns:
        PDF values
    """
    x = np.atleast_1d(x)
    pdf_vals = np.zeros_like(x, dtype=float)

    for _, (component, weight) in enumerate(zip(self.components, self.weights, strict=True)):
        pdf_vals += weight * component.pdf(x)

    return pdf_vals

rvs(size=1, random_state=None)

Generate random samples.

Parameters:

Name Type Description Default
size int

Number of samples

1
random_state int | None

Random seed

None

Returns:

Type Description
ndarray

Random samples

Source code in src/distributions/mixtures.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def rvs(self, size: int = 1, random_state: int | None = None) -> np.ndarray:
    """
    Generate random samples.

    Args:
        size: Number of samples
        random_state: Random seed

    Returns:
        Random samples
    """
    rng = np.random.default_rng(random_state)

    # Sample component indices according to weights
    component_indices = rng.choice(self.n_components, size=size, p=self.weights)

    # Sample from each selected component
    samples = np.zeros(size)
    for i in range(self.n_components):
        mask = component_indices == i
        n_samples = np.sum(mask)

        if n_samples > 0:
            comp_samples = self.components[i].rvs(size=n_samples)
            samples[mask] = comp_samples

    return samples

var()

Calculate variance of mixture.

Returns:

Type Description
float

Variance value

Source code in src/distributions/mixtures.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def var(self) -> float:
    """
    Calculate variance of mixture.

    Returns:
        Variance value
    """
    # Var(X) = E[Var(X|Z)] + Var(E[X|Z])
    # where Z is the component indicator

    # E[Var(X|Z)]
    var_within = 0.0
    for component, weight in zip(self.components, self.weights, strict=True):
        var_within += weight * component.var()

    # Var(E[X|Z])
    mixture_mean = self.mean()
    var_between = 0.0
    for component, weight in zip(self.components, self.weights, strict=True):
        diff = component.mean() - mixture_mean
        var_between += weight * diff**2

    return var_within + var_between

select_optimal_components(data, max_components=10)

Select optimal number of components using BIC.

Parameters:

Name Type Description Default
data ndarray

Training data

required
max_components int

Maximum components to try

10

Returns:

Type Description
tuple[int, dict]

Tuple of (optimal_n_components, results_dict)

Source code in src/distributions/mixtures.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
def select_optimal_components(data: np.ndarray, max_components: int = 10) -> tuple[int, dict]:
    """
    Select optimal number of components using BIC.

    Args:
        data: Training data
        max_components: Maximum components to try

    Returns:
        Tuple of (optimal_n_components, results_dict)
    """
    data = np.atleast_2d(data)
    if data.ndim == 1:
        data = data.reshape(-1, 1)

    bic_scores: list[float] = []
    aic_scores: list[float] = []

    for n in range(1, max_components + 1):
        gmm = GaussianMixtureModel(n_components=n)
        gmm.fit(data)

        bic_scores.append(gmm.bic(data))
        aic_scores.append(gmm.aic(data))

    # Lower BIC is better
    optimal_n: int = int(np.argmin(bic_scores) + 1)

    results = {
        "optimal_components": optimal_n,
        "bic_scores": bic_scores,
        "aic_scores": aic_scores,
        "components_range": list(range(1, max_components + 1)),
    }

    return optimal_n, results

src.fitting.distribution_fitter

Distribution fitting to empirical data.

BayesianEstimator

Bayesian parameter estimation.

Source code in src/fitting/distribution_fitter.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
class BayesianEstimator:
    """Bayesian parameter estimation."""

    def __init__(self, data: np.ndarray):
        """
        Initialize Bayesian estimator.

        Args:
            data: Observed data
        """
        self.data = np.asarray(data).flatten()
        self.n = len(self.data)

    def estimate_normal_mean(
        self, prior_mean: float, prior_var: float, known_variance: float
    ) -> tuple[float, float]:
        """
        Bayesian estimation of normal mean with known variance.

        Args:
            prior_mean: Prior mean
            prior_var: Prior variance
            known_variance: Known data variance

        Returns:
            Tuple of (posterior_mean, posterior_variance)
        """
        # Conjugate prior: Normal
        sample_mean = np.mean(self.data)

        # Posterior parameters
        posterior_var = 1.0 / (1.0 / prior_var + self.n / known_variance)
        posterior_mean = posterior_var * (
            prior_mean / prior_var + self.n * sample_mean / known_variance
        )

        return posterior_mean, posterior_var

    def estimate_normal_variance(
        self, prior_shape: float, prior_scale: float, known_mean: float
    ) -> tuple[float, float]:
        """
        Bayesian estimation of normal variance with known mean.

        Args:
            prior_shape: Prior shape (Inverse-Gamma)
            prior_scale: Prior scale (Inverse-Gamma)
            known_mean: Known mean

        Returns:
            Tuple of (posterior_shape, posterior_scale)
        """
        # Conjugate prior: Inverse-Gamma
        ss = np.sum((self.data - known_mean) ** 2)

        # Posterior parameters
        posterior_shape = prior_shape + self.n / 2
        posterior_scale = prior_scale + ss / 2

        return posterior_shape, posterior_scale

    def estimate_poisson_rate(self, prior_shape: float, prior_rate: float) -> tuple[float, float]:
        """
        Bayesian estimation of Poisson rate parameter.

        Args:
            prior_shape: Prior shape (Gamma)
            prior_rate: Prior rate (Gamma)

        Returns:
            Tuple of (posterior_shape, posterior_rate)
        """
        # Conjugate prior: Gamma
        total = np.sum(self.data)

        # Posterior parameters
        posterior_shape = prior_shape + total
        posterior_rate = prior_rate + self.n

        return posterior_shape, posterior_rate

    def estimate_bernoulli_p(self, prior_alpha: float, prior_beta: float) -> tuple[float, float]:
        """
        Bayesian estimation of Bernoulli success probability.

        Args:
            prior_alpha: Prior alpha (Beta)
            prior_beta: Prior beta (Beta)

        Returns:
            Tuple of (posterior_alpha, posterior_beta)
        """
        # Conjugate prior: Beta
        successes = np.sum(self.data)
        failures = self.n - successes

        # Posterior parameters
        posterior_alpha = prior_alpha + successes
        posterior_beta = prior_beta + failures

        return posterior_alpha, posterior_beta

__init__(data)

Initialize Bayesian estimator.

Parameters:

Name Type Description Default
data ndarray

Observed data

required
Source code in src/fitting/distribution_fitter.py
287
288
289
290
291
292
293
294
295
def __init__(self, data: np.ndarray):
    """
    Initialize Bayesian estimator.

    Args:
        data: Observed data
    """
    self.data = np.asarray(data).flatten()
    self.n = len(self.data)

estimate_bernoulli_p(prior_alpha, prior_beta)

Bayesian estimation of Bernoulli success probability.

Parameters:

Name Type Description Default
prior_alpha float

Prior alpha (Beta)

required
prior_beta float

Prior beta (Beta)

required

Returns:

Type Description
tuple[float, float]

Tuple of (posterior_alpha, posterior_beta)

Source code in src/fitting/distribution_fitter.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
def estimate_bernoulli_p(self, prior_alpha: float, prior_beta: float) -> tuple[float, float]:
    """
    Bayesian estimation of Bernoulli success probability.

    Args:
        prior_alpha: Prior alpha (Beta)
        prior_beta: Prior beta (Beta)

    Returns:
        Tuple of (posterior_alpha, posterior_beta)
    """
    # Conjugate prior: Beta
    successes = np.sum(self.data)
    failures = self.n - successes

    # Posterior parameters
    posterior_alpha = prior_alpha + successes
    posterior_beta = prior_beta + failures

    return posterior_alpha, posterior_beta

estimate_normal_mean(prior_mean, prior_var, known_variance)

Bayesian estimation of normal mean with known variance.

Parameters:

Name Type Description Default
prior_mean float

Prior mean

required
prior_var float

Prior variance

required
known_variance float

Known data variance

required

Returns:

Type Description
tuple[float, float]

Tuple of (posterior_mean, posterior_variance)

Source code in src/fitting/distribution_fitter.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def estimate_normal_mean(
    self, prior_mean: float, prior_var: float, known_variance: float
) -> tuple[float, float]:
    """
    Bayesian estimation of normal mean with known variance.

    Args:
        prior_mean: Prior mean
        prior_var: Prior variance
        known_variance: Known data variance

    Returns:
        Tuple of (posterior_mean, posterior_variance)
    """
    # Conjugate prior: Normal
    sample_mean = np.mean(self.data)

    # Posterior parameters
    posterior_var = 1.0 / (1.0 / prior_var + self.n / known_variance)
    posterior_mean = posterior_var * (
        prior_mean / prior_var + self.n * sample_mean / known_variance
    )

    return posterior_mean, posterior_var

estimate_normal_variance(prior_shape, prior_scale, known_mean)

Bayesian estimation of normal variance with known mean.

Parameters:

Name Type Description Default
prior_shape float

Prior shape (Inverse-Gamma)

required
prior_scale float

Prior scale (Inverse-Gamma)

required
known_mean float

Known mean

required

Returns:

Type Description
tuple[float, float]

Tuple of (posterior_shape, posterior_scale)

Source code in src/fitting/distribution_fitter.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
def estimate_normal_variance(
    self, prior_shape: float, prior_scale: float, known_mean: float
) -> tuple[float, float]:
    """
    Bayesian estimation of normal variance with known mean.

    Args:
        prior_shape: Prior shape (Inverse-Gamma)
        prior_scale: Prior scale (Inverse-Gamma)
        known_mean: Known mean

    Returns:
        Tuple of (posterior_shape, posterior_scale)
    """
    # Conjugate prior: Inverse-Gamma
    ss = np.sum((self.data - known_mean) ** 2)

    # Posterior parameters
    posterior_shape = prior_shape + self.n / 2
    posterior_scale = prior_scale + ss / 2

    return posterior_shape, posterior_scale

estimate_poisson_rate(prior_shape, prior_rate)

Bayesian estimation of Poisson rate parameter.

Parameters:

Name Type Description Default
prior_shape float

Prior shape (Gamma)

required
prior_rate float

Prior rate (Gamma)

required

Returns:

Type Description
tuple[float, float]

Tuple of (posterior_shape, posterior_rate)

Source code in src/fitting/distribution_fitter.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def estimate_poisson_rate(self, prior_shape: float, prior_rate: float) -> tuple[float, float]:
    """
    Bayesian estimation of Poisson rate parameter.

    Args:
        prior_shape: Prior shape (Gamma)
        prior_rate: Prior rate (Gamma)

    Returns:
        Tuple of (posterior_shape, posterior_rate)
    """
    # Conjugate prior: Gamma
    total = np.sum(self.data)

    # Posterior parameters
    posterior_shape = prior_shape + total
    posterior_rate = prior_rate + self.n

    return posterior_shape, posterior_rate

DistributionFitter

Fit probability distributions to empirical data.

Source code in src/fitting/distribution_fitter.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
class DistributionFitter:
    """Fit probability distributions to empirical data."""

    def __init__(self, data: np.ndarray):
        """
        Initialize distribution fitter.

        Args:
            data: Empirical data to fit
        """
        self.data = np.asarray(data).flatten()
        self.n = len(self.data)

        if self.n < 2:
            raise ValueError("Need at least 2 data points")

    def fit_all(self, distributions: list[str] | None = None) -> dict[str, dict]:
        """
        Fit multiple distributions and rank by goodness of fit.

        Args:
            distributions: List of distribution names to try.
                          If None, tries all supported distributions.

        Returns:
            Dictionary of results sorted by fit quality (AIC)
        """
        if distributions is None:
            distributions = [
                "norm",
                "expon",
                "gamma",
                "beta",
                "lognorm",
                "weibull_min",
                "t",
                "chi2",
                "uniform",
                "cauchy",
            ]

        results = {}

        logger.info(
            "Starting distribution fitting for %d candidate(s) on %d data points",
            len(distributions),
            self.n,
        )

        for dist_name in distributions:
            try:
                result = self.fit_distribution(dist_name)
                results[dist_name] = result
            except Exception as e:
                logger.warning("Failed to fit distribution '%s': %s", dist_name, e)
                warnings.warn(f"Failed to fit {dist_name}: {e}", stacklevel=2)
                continue

        # Sort by AIC (lower is better)
        sorted_results = dict(sorted(results.items(), key=lambda x: x[1]["aic"]))

        if sorted_results:
            best = next(iter(sorted_results))
            best_aic = sorted_results[best]["aic"]
            logger.info(
                "Distribution fitting complete: best='%s' (AIC=%.2f), %d/%d succeeded",
                best,
                best_aic,
                len(sorted_results),
                len(distributions),
            )
        else:
            logger.warning("Distribution fitting complete: no distributions fit successfully")

        return sorted_results

    def fit_distribution(self, dist_name: str) -> dict:
        """
        Fit a specific distribution using Maximum Likelihood Estimation.

        Args:
            dist_name: Name of scipy.stats distribution

        Returns:
            Dictionary with fit results
        """
        dist = getattr(stats, dist_name)

        # SciPy's optimizers can emit transient RuntimeWarnings while trying a
        # candidate that is a poor match for the data. Treat that candidate as
        # an ordinary fit attempt without leaking numerical noise to callers.
        with warnings.catch_warnings():
            warnings.filterwarnings("ignore", category=RuntimeWarning, module=r"scipy(?:\..*)?")

            # Fit using MLE
            params = dist.fit(self.data)

            # Calculate likelihood
            log_likelihood = np.sum(dist.logpdf(self.data, *params))

        # Calculate information criteria
        k = len(params)  # Number of parameters
        aic = 2 * k - 2 * log_likelihood
        bic = k * np.log(self.n) - 2 * log_likelihood

        # Perform goodness-of-fit tests
        ks_statistic, ks_pvalue = stats.kstest(self.data, lambda x: dist.cdf(x, *params))

        logger.debug(
            "Fitted '%s': params=%s, AIC=%.2f, BIC=%.2f, KS_p=%.4f",
            dist_name,
            params,
            aic,
            bic,
            ks_pvalue,
        )

        return {
            "distribution": dist_name,
            "parameters": params,
            "log_likelihood": log_likelihood,
            "aic": aic,
            "bic": bic,
            "ks_statistic": ks_statistic,
            "ks_pvalue": ks_pvalue,
            "fitted_dist": dist(*params),
        }

    def fit_normal(self) -> tuple[float, float]:
        """
        Fit normal distribution using MLE.

        Returns:
            Tuple of (mu, sigma)
        """
        mu = np.mean(self.data)
        sigma = np.std(self.data, ddof=1)
        return mu, sigma

    def fit_exponential(self) -> float:
        """
        Fit exponential distribution using MLE.

        Returns:
            Lambda parameter
        """
        lambda_param = 1.0 / np.mean(self.data)
        return lambda_param

    def fit_gamma_mle(self) -> tuple[float, float]:
        """
        Fit gamma distribution using Maximum Likelihood Estimation.

        Returns:
            Tuple of (shape, scale)
        """
        # Use scipy's built-in MLE
        shape, loc, scale = stats.gamma.fit(self.data, floc=0)
        return shape, scale

    def fit_gamma_mom(self) -> tuple[float, float]:
        """
        Fit gamma distribution using Method of Moments.

        Returns:
            Tuple of (shape, scale)
        """
        mean = np.mean(self.data)
        var = np.var(self.data, ddof=1)

        # shape = mean^2 / var
        # scale = var / mean
        shape = mean**2 / var
        scale = var / mean

        return shape, scale

    def fit_beta(self) -> tuple[float, float]:
        """
        Fit beta distribution (data must be in [0, 1]).

        Returns:
            Tuple of (alpha, beta)
        """
        if np.any(self.data < 0) or np.any(self.data > 1):
            raise ValueError("Beta distribution requires data in [0, 1]")

        # Method of moments
        mean = np.mean(self.data)
        var = np.var(self.data, ddof=1)

        # Solve for alpha and beta
        common = mean * (1 - mean) / var - 1
        alpha = mean * common
        beta = (1 - mean) * common

        return alpha, beta

    def fit_weibull(self) -> tuple[float, float]:
        """
        Fit Weibull distribution using MLE.

        Returns:
            Tuple of (shape, scale)
        """
        shape, loc, scale = stats.weibull_min.fit(self.data, floc=0)
        return shape, scale

    def fit_lognormal(self) -> tuple[float, float]:
        """
        Fit lognormal distribution.

        Returns:
            Tuple of (mu, sigma) of underlying normal
        """
        if np.any(self.data <= 0):
            raise ValueError("Lognormal requires positive data")

        log_data = np.log(self.data)
        mu = np.mean(log_data)
        sigma = np.std(log_data, ddof=1)

        return mu, sigma

    def qq_plot_data(self, dist_name: str, params: tuple) -> tuple[np.ndarray, np.ndarray]:
        """
        Generate data for Q-Q plot.

        Args:
            dist_name: Distribution name
            params: Distribution parameters

        Returns:
            Tuple of (theoretical_quantiles, sample_quantiles)
        """
        dist = getattr(stats, dist_name)

        # Sort data
        sorted_data = np.sort(self.data)

        # Calculate empirical quantiles
        p = (np.arange(self.n) + 0.5) / self.n

        # Calculate theoretical quantiles
        theoretical_quantiles = dist.ppf(p, *params)

        return theoretical_quantiles, sorted_data

    def calculate_residuals(self, dist_name: str, params: tuple) -> np.ndarray:
        """
        Calculate standardized residuals.

        Args:
            dist_name: Distribution name
            params: Distribution parameters

        Returns:
            Standardized residuals
        """
        dist = getattr(stats, dist_name)

        # Calculate CDF values for data
        cdf_vals = dist.cdf(self.data, *params)

        # Transform to standard normal
        residuals = stats.norm.ppf(cdf_vals)

        return residuals

__init__(data)

Initialize distribution fitter.

Parameters:

Name Type Description Default
data ndarray

Empirical data to fit

required
Source code in src/fitting/distribution_fitter.py
17
18
19
20
21
22
23
24
25
26
27
28
def __init__(self, data: np.ndarray):
    """
    Initialize distribution fitter.

    Args:
        data: Empirical data to fit
    """
    self.data = np.asarray(data).flatten()
    self.n = len(self.data)

    if self.n < 2:
        raise ValueError("Need at least 2 data points")

calculate_residuals(dist_name, params)

Calculate standardized residuals.

Parameters:

Name Type Description Default
dist_name str

Distribution name

required
params tuple

Distribution parameters

required

Returns:

Type Description
ndarray

Standardized residuals

Source code in src/fitting/distribution_fitter.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def calculate_residuals(self, dist_name: str, params: tuple) -> np.ndarray:
    """
    Calculate standardized residuals.

    Args:
        dist_name: Distribution name
        params: Distribution parameters

    Returns:
        Standardized residuals
    """
    dist = getattr(stats, dist_name)

    # Calculate CDF values for data
    cdf_vals = dist.cdf(self.data, *params)

    # Transform to standard normal
    residuals = stats.norm.ppf(cdf_vals)

    return residuals

fit_all(distributions=None)

Fit multiple distributions and rank by goodness of fit.

Parameters:

Name Type Description Default
distributions list[str] | None

List of distribution names to try. If None, tries all supported distributions.

None

Returns:

Type Description
dict[str, dict]

Dictionary of results sorted by fit quality (AIC)

Source code in src/fitting/distribution_fitter.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def fit_all(self, distributions: list[str] | None = None) -> dict[str, dict]:
    """
    Fit multiple distributions and rank by goodness of fit.

    Args:
        distributions: List of distribution names to try.
                      If None, tries all supported distributions.

    Returns:
        Dictionary of results sorted by fit quality (AIC)
    """
    if distributions is None:
        distributions = [
            "norm",
            "expon",
            "gamma",
            "beta",
            "lognorm",
            "weibull_min",
            "t",
            "chi2",
            "uniform",
            "cauchy",
        ]

    results = {}

    logger.info(
        "Starting distribution fitting for %d candidate(s) on %d data points",
        len(distributions),
        self.n,
    )

    for dist_name in distributions:
        try:
            result = self.fit_distribution(dist_name)
            results[dist_name] = result
        except Exception as e:
            logger.warning("Failed to fit distribution '%s': %s", dist_name, e)
            warnings.warn(f"Failed to fit {dist_name}: {e}", stacklevel=2)
            continue

    # Sort by AIC (lower is better)
    sorted_results = dict(sorted(results.items(), key=lambda x: x[1]["aic"]))

    if sorted_results:
        best = next(iter(sorted_results))
        best_aic = sorted_results[best]["aic"]
        logger.info(
            "Distribution fitting complete: best='%s' (AIC=%.2f), %d/%d succeeded",
            best,
            best_aic,
            len(sorted_results),
            len(distributions),
        )
    else:
        logger.warning("Distribution fitting complete: no distributions fit successfully")

    return sorted_results

fit_beta()

Fit beta distribution (data must be in [0, 1]).

Returns:

Type Description
tuple[float, float]

Tuple of (alpha, beta)

Source code in src/fitting/distribution_fitter.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def fit_beta(self) -> tuple[float, float]:
    """
    Fit beta distribution (data must be in [0, 1]).

    Returns:
        Tuple of (alpha, beta)
    """
    if np.any(self.data < 0) or np.any(self.data > 1):
        raise ValueError("Beta distribution requires data in [0, 1]")

    # Method of moments
    mean = np.mean(self.data)
    var = np.var(self.data, ddof=1)

    # Solve for alpha and beta
    common = mean * (1 - mean) / var - 1
    alpha = mean * common
    beta = (1 - mean) * common

    return alpha, beta

fit_distribution(dist_name)

Fit a specific distribution using Maximum Likelihood Estimation.

Parameters:

Name Type Description Default
dist_name str

Name of scipy.stats distribution

required

Returns:

Type Description
dict

Dictionary with fit results

Source code in src/fitting/distribution_fitter.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def fit_distribution(self, dist_name: str) -> dict:
    """
    Fit a specific distribution using Maximum Likelihood Estimation.

    Args:
        dist_name: Name of scipy.stats distribution

    Returns:
        Dictionary with fit results
    """
    dist = getattr(stats, dist_name)

    # SciPy's optimizers can emit transient RuntimeWarnings while trying a
    # candidate that is a poor match for the data. Treat that candidate as
    # an ordinary fit attempt without leaking numerical noise to callers.
    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", category=RuntimeWarning, module=r"scipy(?:\..*)?")

        # Fit using MLE
        params = dist.fit(self.data)

        # Calculate likelihood
        log_likelihood = np.sum(dist.logpdf(self.data, *params))

    # Calculate information criteria
    k = len(params)  # Number of parameters
    aic = 2 * k - 2 * log_likelihood
    bic = k * np.log(self.n) - 2 * log_likelihood

    # Perform goodness-of-fit tests
    ks_statistic, ks_pvalue = stats.kstest(self.data, lambda x: dist.cdf(x, *params))

    logger.debug(
        "Fitted '%s': params=%s, AIC=%.2f, BIC=%.2f, KS_p=%.4f",
        dist_name,
        params,
        aic,
        bic,
        ks_pvalue,
    )

    return {
        "distribution": dist_name,
        "parameters": params,
        "log_likelihood": log_likelihood,
        "aic": aic,
        "bic": bic,
        "ks_statistic": ks_statistic,
        "ks_pvalue": ks_pvalue,
        "fitted_dist": dist(*params),
    }

fit_exponential()

Fit exponential distribution using MLE.

Returns:

Type Description
float

Lambda parameter

Source code in src/fitting/distribution_fitter.py
153
154
155
156
157
158
159
160
161
def fit_exponential(self) -> float:
    """
    Fit exponential distribution using MLE.

    Returns:
        Lambda parameter
    """
    lambda_param = 1.0 / np.mean(self.data)
    return lambda_param

fit_gamma_mle()

Fit gamma distribution using Maximum Likelihood Estimation.

Returns:

Type Description
tuple[float, float]

Tuple of (shape, scale)

Source code in src/fitting/distribution_fitter.py
163
164
165
166
167
168
169
170
171
172
def fit_gamma_mle(self) -> tuple[float, float]:
    """
    Fit gamma distribution using Maximum Likelihood Estimation.

    Returns:
        Tuple of (shape, scale)
    """
    # Use scipy's built-in MLE
    shape, loc, scale = stats.gamma.fit(self.data, floc=0)
    return shape, scale

fit_gamma_mom()

Fit gamma distribution using Method of Moments.

Returns:

Type Description
tuple[float, float]

Tuple of (shape, scale)

Source code in src/fitting/distribution_fitter.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def fit_gamma_mom(self) -> tuple[float, float]:
    """
    Fit gamma distribution using Method of Moments.

    Returns:
        Tuple of (shape, scale)
    """
    mean = np.mean(self.data)
    var = np.var(self.data, ddof=1)

    # shape = mean^2 / var
    # scale = var / mean
    shape = mean**2 / var
    scale = var / mean

    return shape, scale

fit_lognormal()

Fit lognormal distribution.

Returns:

Type Description
tuple[float, float]

Tuple of (mu, sigma) of underlying normal

Source code in src/fitting/distribution_fitter.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def fit_lognormal(self) -> tuple[float, float]:
    """
    Fit lognormal distribution.

    Returns:
        Tuple of (mu, sigma) of underlying normal
    """
    if np.any(self.data <= 0):
        raise ValueError("Lognormal requires positive data")

    log_data = np.log(self.data)
    mu = np.mean(log_data)
    sigma = np.std(log_data, ddof=1)

    return mu, sigma

fit_normal()

Fit normal distribution using MLE.

Returns:

Type Description
tuple[float, float]

Tuple of (mu, sigma)

Source code in src/fitting/distribution_fitter.py
142
143
144
145
146
147
148
149
150
151
def fit_normal(self) -> tuple[float, float]:
    """
    Fit normal distribution using MLE.

    Returns:
        Tuple of (mu, sigma)
    """
    mu = np.mean(self.data)
    sigma = np.std(self.data, ddof=1)
    return mu, sigma

fit_weibull()

Fit Weibull distribution using MLE.

Returns:

Type Description
tuple[float, float]

Tuple of (shape, scale)

Source code in src/fitting/distribution_fitter.py
212
213
214
215
216
217
218
219
220
def fit_weibull(self) -> tuple[float, float]:
    """
    Fit Weibull distribution using MLE.

    Returns:
        Tuple of (shape, scale)
    """
    shape, loc, scale = stats.weibull_min.fit(self.data, floc=0)
    return shape, scale

qq_plot_data(dist_name, params)

Generate data for Q-Q plot.

Parameters:

Name Type Description Default
dist_name str

Distribution name

required
params tuple

Distribution parameters

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (theoretical_quantiles, sample_quantiles)

Source code in src/fitting/distribution_fitter.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def qq_plot_data(self, dist_name: str, params: tuple) -> tuple[np.ndarray, np.ndarray]:
    """
    Generate data for Q-Q plot.

    Args:
        dist_name: Distribution name
        params: Distribution parameters

    Returns:
        Tuple of (theoretical_quantiles, sample_quantiles)
    """
    dist = getattr(stats, dist_name)

    # Sort data
    sorted_data = np.sort(self.data)

    # Calculate empirical quantiles
    p = (np.arange(self.n) + 0.5) / self.n

    # Calculate theoretical quantiles
    theoretical_quantiles = dist.ppf(p, *params)

    return theoretical_quantiles, sorted_data

GoodnessOfFit

Goodness of fit tests.

Source code in src/fitting/distribution_fitter.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
class GoodnessOfFit:
    """Goodness of fit tests."""

    @staticmethod
    def chi_square_test(
        observed: np.ndarray, expected: np.ndarray, df: int | None = None
    ) -> tuple[float, float]:
        """
        Chi-square goodness of fit test.

        Args:
            observed: Observed frequencies
            expected: Expected frequencies
            df: Degrees of freedom (if None, calculated automatically)

        Returns:
            Tuple of (chi2_statistic, p_value)
        """
        chi2_stat, p_value = stats.chisquare(observed, expected)

        return chi2_stat, p_value

    @staticmethod
    def kolmogorov_smirnov_test(
        data: np.ndarray, cdf_function: Callable[..., Any]
    ) -> tuple[float, float]:
        """
        Kolmogorov-Smirnov test.

        Args:
            data: Sample data
            cdf_function: Theoretical CDF function

        Returns:
            Tuple of (ks_statistic, p_value)
        """
        ks_stat, p_value = stats.kstest(data, cdf_function)

        return ks_stat, p_value

    @staticmethod
    def anderson_darling_test(data: np.ndarray, dist: str = "norm") -> dict:
        """
        Anderson-Darling test.

        Args:
            data: Sample data
            dist: Distribution name ('norm', 'expon', 'logistic', 'gumbel', 'extreme1')

        Returns:
            Dictionary with test results
        """
        import scipy

        scipy_major = int(scipy.__version__.split(".")[0])
        scipy_minor = int(scipy.__version__.split(".")[1])

        if scipy_major > 1 or (scipy_major == 1 and scipy_minor >= 17):
            result = stats.anderson(data, dist=dist, method="interpolate")
            return {
                "statistic": result.statistic,
                "pvalue": result.pvalue,
            }
        else:
            result = stats.anderson(data, dist=dist)
            return {
                "statistic": result.statistic,
                "critical_values": result.critical_values,
                "significance_levels": result.significance_level,
            }

    @staticmethod
    def shapiro_wilk_test(data: np.ndarray) -> tuple[float, float]:
        """
        Shapiro-Wilk test for normality.

        Args:
            data: Sample data

        Returns:
            Tuple of (w_statistic, p_value)
        """
        if len(data) > 5000:
            warnings.warn("Shapiro-Wilk test may be unreliable for large samples", stacklevel=2)

        w_stat, p_value = stats.shapiro(data)

        return w_stat, p_value

    @staticmethod
    def jarque_bera_test(data: np.ndarray) -> tuple[float, float]:
        """
        Jarque-Bera test for normality.

        Args:
            data: Sample data

        Returns:
            Tuple of (jb_statistic, p_value)
        """
        jb_stat, p_value = stats.jarque_bera(data)

        return jb_stat, p_value

anderson_darling_test(data, dist='norm') staticmethod

Anderson-Darling test.

Parameters:

Name Type Description Default
data ndarray

Sample data

required
dist str

Distribution name ('norm', 'expon', 'logistic', 'gumbel', 'extreme1')

'norm'

Returns:

Type Description
dict

Dictionary with test results

Source code in src/fitting/distribution_fitter.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
@staticmethod
def anderson_darling_test(data: np.ndarray, dist: str = "norm") -> dict:
    """
    Anderson-Darling test.

    Args:
        data: Sample data
        dist: Distribution name ('norm', 'expon', 'logistic', 'gumbel', 'extreme1')

    Returns:
        Dictionary with test results
    """
    import scipy

    scipy_major = int(scipy.__version__.split(".")[0])
    scipy_minor = int(scipy.__version__.split(".")[1])

    if scipy_major > 1 or (scipy_major == 1 and scipy_minor >= 17):
        result = stats.anderson(data, dist=dist, method="interpolate")
        return {
            "statistic": result.statistic,
            "pvalue": result.pvalue,
        }
    else:
        result = stats.anderson(data, dist=dist)
        return {
            "statistic": result.statistic,
            "critical_values": result.critical_values,
            "significance_levels": result.significance_level,
        }

chi_square_test(observed, expected, df=None) staticmethod

Chi-square goodness of fit test.

Parameters:

Name Type Description Default
observed ndarray

Observed frequencies

required
expected ndarray

Expected frequencies

required
df int | None

Degrees of freedom (if None, calculated automatically)

None

Returns:

Type Description
tuple[float, float]

Tuple of (chi2_statistic, p_value)

Source code in src/fitting/distribution_fitter.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
@staticmethod
def chi_square_test(
    observed: np.ndarray, expected: np.ndarray, df: int | None = None
) -> tuple[float, float]:
    """
    Chi-square goodness of fit test.

    Args:
        observed: Observed frequencies
        expected: Expected frequencies
        df: Degrees of freedom (if None, calculated automatically)

    Returns:
        Tuple of (chi2_statistic, p_value)
    """
    chi2_stat, p_value = stats.chisquare(observed, expected)

    return chi2_stat, p_value

jarque_bera_test(data) staticmethod

Jarque-Bera test for normality.

Parameters:

Name Type Description Default
data ndarray

Sample data

required

Returns:

Type Description
tuple[float, float]

Tuple of (jb_statistic, p_value)

Source code in src/fitting/distribution_fitter.py
476
477
478
479
480
481
482
483
484
485
486
487
488
489
@staticmethod
def jarque_bera_test(data: np.ndarray) -> tuple[float, float]:
    """
    Jarque-Bera test for normality.

    Args:
        data: Sample data

    Returns:
        Tuple of (jb_statistic, p_value)
    """
    jb_stat, p_value = stats.jarque_bera(data)

    return jb_stat, p_value

kolmogorov_smirnov_test(data, cdf_function) staticmethod

Kolmogorov-Smirnov test.

Parameters:

Name Type Description Default
data ndarray

Sample data

required
cdf_function Callable[..., Any]

Theoretical CDF function

required

Returns:

Type Description
tuple[float, float]

Tuple of (ks_statistic, p_value)

Source code in src/fitting/distribution_fitter.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
@staticmethod
def kolmogorov_smirnov_test(
    data: np.ndarray, cdf_function: Callable[..., Any]
) -> tuple[float, float]:
    """
    Kolmogorov-Smirnov test.

    Args:
        data: Sample data
        cdf_function: Theoretical CDF function

    Returns:
        Tuple of (ks_statistic, p_value)
    """
    ks_stat, p_value = stats.kstest(data, cdf_function)

    return ks_stat, p_value

shapiro_wilk_test(data) staticmethod

Shapiro-Wilk test for normality.

Parameters:

Name Type Description Default
data ndarray

Sample data

required

Returns:

Type Description
tuple[float, float]

Tuple of (w_statistic, p_value)

Source code in src/fitting/distribution_fitter.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
@staticmethod
def shapiro_wilk_test(data: np.ndarray) -> tuple[float, float]:
    """
    Shapiro-Wilk test for normality.

    Args:
        data: Sample data

    Returns:
        Tuple of (w_statistic, p_value)
    """
    if len(data) > 5000:
        warnings.warn("Shapiro-Wilk test may be unreliable for large samples", stacklevel=2)

    w_stat, p_value = stats.shapiro(data)

    return w_stat, p_value

src.monte_carlo.simulator

Monte Carlo simulation engine for complex probability calculations.

MonteCarloSimulator

Advanced Monte Carlo simulation engine.

Source code in src/monte_carlo/simulator.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
class MonteCarloSimulator:
    """Advanced Monte Carlo simulation engine."""

    def __init__(self, random_seed: int | None = None):
        """
        Initialize Monte Carlo simulator.

        Args:
            random_seed: Random seed for reproducibility
        """
        self.random_seed = random_seed
        self._rng = np.random.RandomState(random_seed)

    def simulate(
        self,
        func: Callable,
        num_samples: int = 10000,
        track_convergence: bool = False,
        confidence_level: float = 0.95,
        **kwargs: Any,
    ) -> SimulationResult:
        """
        Run Monte Carlo simulation.

        Args:
            func: Function that generates one sample
            num_samples: Number of Monte Carlo samples
            track_convergence: Whether to track convergence
            confidence_level: Confidence level for interval
            **kwargs: Additional arguments passed to func

        Returns:
            SimulationResult object
        """
        logger.info(
            "Monte Carlo simulation starting: %d samples, convergence=%s",
            num_samples,
            track_convergence,
        )
        start_time = time.perf_counter()

        samples = np.array([func(**kwargs) for _ in range(num_samples)])

        # Calculate statistics
        mean = np.mean(samples)
        std = np.std(samples, ddof=1)
        var = np.var(samples, ddof=1)
        median = np.median(samples)

        # Calculate quantiles
        quantiles = {
            0.01: np.percentile(samples, 1),
            0.05: np.percentile(samples, 5),
            0.25: np.percentile(samples, 25),
            0.50: np.percentile(samples, 50),
            0.75: np.percentile(samples, 75),
            0.95: np.percentile(samples, 95),
            0.99: np.percentile(samples, 99),
        }

        # Confidence interval
        alpha = 1 - confidence_level
        ci_lower = np.percentile(samples, 100 * alpha / 2)
        ci_upper = np.percentile(samples, 100 * (1 - alpha / 2))

        # Track convergence if requested
        convergence_data = None
        if track_convergence:
            convergence_data = self._calculate_convergence(samples)

        elapsed = time.perf_counter() - start_time
        logger.info(
            "Monte Carlo simulation completed: %d samples in %.2fs, mean=%.4f, std=%.4f",
            num_samples,
            elapsed,
            mean,
            std,
        )

        return SimulationResult(
            mean=mean,
            std=std,
            var=var,
            median=median,
            quantiles=quantiles,
            samples=samples,
            confidence_interval=(ci_lower, ci_upper),
            convergence_data=convergence_data,
        )

    def _calculate_convergence(self, samples: np.ndarray) -> np.ndarray:
        """Calculate running mean for convergence analysis."""
        running_mean = np.cumsum(samples) / np.arange(1, len(samples) + 1)
        return running_mean

    def estimate_probability(
        self, event_func: Callable, num_samples: int = 100000, confidence_level: float = 0.95
    ) -> dict:
        """
        Estimate probability of an event using Monte Carlo.

        Args:
            event_func: Function that returns True/False for event occurrence
            num_samples: Number of Monte Carlo samples
            confidence_level: Confidence level for interval

        Returns:
            Dictionary with probability estimate and confidence interval
        """
        outcomes = np.array([event_func() for _ in range(num_samples)])
        prob_estimate = np.mean(outcomes)

        # Wilson score interval for binomial proportion
        z = stats.norm.ppf((1 + confidence_level) / 2)
        n = num_samples

        denominator = 1 + z**2 / n
        center = (prob_estimate + z**2 / (2 * n)) / denominator
        margin = (
            z * np.sqrt(prob_estimate * (1 - prob_estimate) / n + z**2 / (4 * n**2)) / denominator
        )

        ci_lower = max(0, center - margin)
        ci_upper = min(1, center + margin)

        return {
            "probability": prob_estimate,
            "confidence_interval": (ci_lower, ci_upper),
            "num_samples": num_samples,
            "num_successes": int(np.sum(outcomes)),
        }

    def estimate_expectation(
        self, random_var_func: Callable, num_samples: int = 10000
    ) -> tuple[float, float]:
        """
        Estimate expectation using Monte Carlo.

        Args:
            random_var_func: Function that generates random variable values
            num_samples: Number of Monte Carlo samples

        Returns:
            Tuple of (expectation_estimate, standard_error)
        """
        samples = np.array([random_var_func() for _ in range(num_samples)])
        expectation = np.mean(samples)
        std_error = np.std(samples, ddof=1) / np.sqrt(num_samples)

        return expectation, std_error

    def importance_sampling(
        self,
        target_func: Callable,
        proposal_sampler: Callable,
        proposal_pdf: Callable,
        target_pdf: Callable,
        num_samples: int = 10000,
    ) -> tuple[float, float]:
        """
        Importance sampling for rare event estimation.

        Args:
            target_func: Function to compute on target distribution
            proposal_sampler: Function to sample from proposal distribution
            proposal_pdf: PDF of proposal distribution
            target_pdf: PDF of target distribution
            num_samples: Number of samples

        Returns:
            Tuple of (estimate, standard_error)
        """
        # Generate samples from proposal
        samples = np.array([proposal_sampler() for _ in range(num_samples)])

        # Calculate importance weights
        weights = target_pdf(samples) / proposal_pdf(samples)

        # Calculate weighted average
        values = np.array([target_func(s) for s in samples])
        estimate = np.average(values, weights=weights)

        # Calculate effective sample size
        ess = np.sum(weights) ** 2 / np.sum(weights**2)

        # Standard error
        std_error = np.sqrt(np.average((values - estimate) ** 2, weights=weights) / ess)

        return estimate, std_error

    def stratified_sampling(
        self,
        func: Callable,
        strata_bounds: list[tuple[float, float]],
        num_samples_per_stratum: int = 1000,
    ) -> SimulationResult:
        """
        Stratified sampling for variance reduction.

        Args:
            func: Function to evaluate
            strata_bounds: List of (lower, upper) bounds for each stratum
            num_samples_per_stratum: Samples per stratum

        Returns:
            SimulationResult object
        """
        all_samples: list = []

        for lower, upper in strata_bounds:
            # Uniform sampling within stratum
            stratum_samples = self._rng.uniform(lower, upper, num_samples_per_stratum)
            values = np.array([func(s) for s in stratum_samples])
            all_samples.extend(values)

        samples = np.array(all_samples)

        return SimulationResult(
            mean=np.mean(samples),
            std=np.std(samples, ddof=1),
            var=np.var(samples, ddof=1),
            median=np.median(samples),
            quantiles={
                0.05: np.percentile(samples, 5),
                0.50: np.percentile(samples, 50),
                0.95: np.percentile(samples, 95),
            },
            samples=samples,
            confidence_interval=(np.percentile(samples, 2.5), np.percentile(samples, 97.5)),
        )

    def bootstrap(
        self,
        data: np.ndarray,
        statistic: Callable,
        num_bootstrap: int = 10000,
        confidence_level: float = 0.95,
    ) -> dict:
        """
        Bootstrap resampling for estimating sampling distribution.

        Args:
            data: Original data
            statistic: Function to compute statistic (takes data, returns scalar)
            num_bootstrap: Number of bootstrap samples
            confidence_level: Confidence level for interval

        Returns:
            Dictionary with bootstrap results
        """
        n = len(data)
        bootstrap_stats = np.zeros(num_bootstrap)

        for i in range(num_bootstrap):
            # Resample with replacement
            bootstrap_sample = self._rng.choice(data, size=n, replace=True)
            bootstrap_stats[i] = statistic(bootstrap_sample)

        # Calculate confidence interval
        alpha = 1 - confidence_level
        ci_lower = np.percentile(bootstrap_stats, 100 * alpha / 2)
        ci_upper = np.percentile(bootstrap_stats, 100 * (1 - alpha / 2))

        return {
            "estimate": statistic(data),
            "bootstrap_mean": np.mean(bootstrap_stats),
            "bootstrap_std": np.std(bootstrap_stats, ddof=1),
            "confidence_interval": (ci_lower, ci_upper),
            "bootstrap_distribution": bootstrap_stats,
        }

    def permutation_test(
        self,
        group1: np.ndarray,
        group2: np.ndarray,
        test_statistic: Callable,
        num_permutations: int = 10000,
    ) -> dict:
        """
        Permutation test for hypothesis testing.

        Args:
            group1: First group data
            group2: Second group data
            test_statistic: Function that computes test statistic from (group1, group2)
            num_permutations: Number of permutations

        Returns:
            Dictionary with test results
        """
        observed_stat = test_statistic(group1, group2)

        # Pool data
        pooled = np.concatenate([group1, group2])
        n1 = len(group1)

        # Permutation distribution
        perm_stats = np.zeros(num_permutations)

        for i in range(num_permutations):
            # Shuffle and split
            shuffled = self._rng.permutation(pooled)
            perm_group1 = shuffled[:n1]
            perm_group2 = shuffled[n1:]

            perm_stats[i] = test_statistic(perm_group1, perm_group2)

        # Calculate p-value (two-tailed)
        p_value = np.mean(np.abs(perm_stats) >= np.abs(observed_stat))

        return {
            "observed_statistic": observed_stat,
            "p_value": p_value,
            "permutation_distribution": perm_stats,
        }

__init__(random_seed=None)

Initialize Monte Carlo simulator.

Parameters:

Name Type Description Default
random_seed int | None

Random seed for reproducibility

None
Source code in src/monte_carlo/simulator.py
32
33
34
35
36
37
38
39
40
def __init__(self, random_seed: int | None = None):
    """
    Initialize Monte Carlo simulator.

    Args:
        random_seed: Random seed for reproducibility
    """
    self.random_seed = random_seed
    self._rng = np.random.RandomState(random_seed)

bootstrap(data, statistic, num_bootstrap=10000, confidence_level=0.95)

Bootstrap resampling for estimating sampling distribution.

Parameters:

Name Type Description Default
data ndarray

Original data

required
statistic Callable

Function to compute statistic (takes data, returns scalar)

required
num_bootstrap int

Number of bootstrap samples

10000
confidence_level float

Confidence level for interval

0.95

Returns:

Type Description
dict

Dictionary with bootstrap results

Source code in src/monte_carlo/simulator.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def bootstrap(
    self,
    data: np.ndarray,
    statistic: Callable,
    num_bootstrap: int = 10000,
    confidence_level: float = 0.95,
) -> dict:
    """
    Bootstrap resampling for estimating sampling distribution.

    Args:
        data: Original data
        statistic: Function to compute statistic (takes data, returns scalar)
        num_bootstrap: Number of bootstrap samples
        confidence_level: Confidence level for interval

    Returns:
        Dictionary with bootstrap results
    """
    n = len(data)
    bootstrap_stats = np.zeros(num_bootstrap)

    for i in range(num_bootstrap):
        # Resample with replacement
        bootstrap_sample = self._rng.choice(data, size=n, replace=True)
        bootstrap_stats[i] = statistic(bootstrap_sample)

    # Calculate confidence interval
    alpha = 1 - confidence_level
    ci_lower = np.percentile(bootstrap_stats, 100 * alpha / 2)
    ci_upper = np.percentile(bootstrap_stats, 100 * (1 - alpha / 2))

    return {
        "estimate": statistic(data),
        "bootstrap_mean": np.mean(bootstrap_stats),
        "bootstrap_std": np.std(bootstrap_stats, ddof=1),
        "confidence_interval": (ci_lower, ci_upper),
        "bootstrap_distribution": bootstrap_stats,
    }

estimate_expectation(random_var_func, num_samples=10000)

Estimate expectation using Monte Carlo.

Parameters:

Name Type Description Default
random_var_func Callable

Function that generates random variable values

required
num_samples int

Number of Monte Carlo samples

10000

Returns:

Type Description
tuple[float, float]

Tuple of (expectation_estimate, standard_error)

Source code in src/monte_carlo/simulator.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def estimate_expectation(
    self, random_var_func: Callable, num_samples: int = 10000
) -> tuple[float, float]:
    """
    Estimate expectation using Monte Carlo.

    Args:
        random_var_func: Function that generates random variable values
        num_samples: Number of Monte Carlo samples

    Returns:
        Tuple of (expectation_estimate, standard_error)
    """
    samples = np.array([random_var_func() for _ in range(num_samples)])
    expectation = np.mean(samples)
    std_error = np.std(samples, ddof=1) / np.sqrt(num_samples)

    return expectation, std_error

estimate_probability(event_func, num_samples=100000, confidence_level=0.95)

Estimate probability of an event using Monte Carlo.

Parameters:

Name Type Description Default
event_func Callable

Function that returns True/False for event occurrence

required
num_samples int

Number of Monte Carlo samples

100000
confidence_level float

Confidence level for interval

0.95

Returns:

Type Description
dict

Dictionary with probability estimate and confidence interval

Source code in src/monte_carlo/simulator.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def estimate_probability(
    self, event_func: Callable, num_samples: int = 100000, confidence_level: float = 0.95
) -> dict:
    """
    Estimate probability of an event using Monte Carlo.

    Args:
        event_func: Function that returns True/False for event occurrence
        num_samples: Number of Monte Carlo samples
        confidence_level: Confidence level for interval

    Returns:
        Dictionary with probability estimate and confidence interval
    """
    outcomes = np.array([event_func() for _ in range(num_samples)])
    prob_estimate = np.mean(outcomes)

    # Wilson score interval for binomial proportion
    z = stats.norm.ppf((1 + confidence_level) / 2)
    n = num_samples

    denominator = 1 + z**2 / n
    center = (prob_estimate + z**2 / (2 * n)) / denominator
    margin = (
        z * np.sqrt(prob_estimate * (1 - prob_estimate) / n + z**2 / (4 * n**2)) / denominator
    )

    ci_lower = max(0, center - margin)
    ci_upper = min(1, center + margin)

    return {
        "probability": prob_estimate,
        "confidence_interval": (ci_lower, ci_upper),
        "num_samples": num_samples,
        "num_successes": int(np.sum(outcomes)),
    }

importance_sampling(target_func, proposal_sampler, proposal_pdf, target_pdf, num_samples=10000)

Importance sampling for rare event estimation.

Parameters:

Name Type Description Default
target_func Callable

Function to compute on target distribution

required
proposal_sampler Callable

Function to sample from proposal distribution

required
proposal_pdf Callable

PDF of proposal distribution

required
target_pdf Callable

PDF of target distribution

required
num_samples int

Number of samples

10000

Returns:

Type Description
tuple[float, float]

Tuple of (estimate, standard_error)

Source code in src/monte_carlo/simulator.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def importance_sampling(
    self,
    target_func: Callable,
    proposal_sampler: Callable,
    proposal_pdf: Callable,
    target_pdf: Callable,
    num_samples: int = 10000,
) -> tuple[float, float]:
    """
    Importance sampling for rare event estimation.

    Args:
        target_func: Function to compute on target distribution
        proposal_sampler: Function to sample from proposal distribution
        proposal_pdf: PDF of proposal distribution
        target_pdf: PDF of target distribution
        num_samples: Number of samples

    Returns:
        Tuple of (estimate, standard_error)
    """
    # Generate samples from proposal
    samples = np.array([proposal_sampler() for _ in range(num_samples)])

    # Calculate importance weights
    weights = target_pdf(samples) / proposal_pdf(samples)

    # Calculate weighted average
    values = np.array([target_func(s) for s in samples])
    estimate = np.average(values, weights=weights)

    # Calculate effective sample size
    ess = np.sum(weights) ** 2 / np.sum(weights**2)

    # Standard error
    std_error = np.sqrt(np.average((values - estimate) ** 2, weights=weights) / ess)

    return estimate, std_error

permutation_test(group1, group2, test_statistic, num_permutations=10000)

Permutation test for hypothesis testing.

Parameters:

Name Type Description Default
group1 ndarray

First group data

required
group2 ndarray

Second group data

required
test_statistic Callable

Function that computes test statistic from (group1, group2)

required
num_permutations int

Number of permutations

10000

Returns:

Type Description
dict

Dictionary with test results

Source code in src/monte_carlo/simulator.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
def permutation_test(
    self,
    group1: np.ndarray,
    group2: np.ndarray,
    test_statistic: Callable,
    num_permutations: int = 10000,
) -> dict:
    """
    Permutation test for hypothesis testing.

    Args:
        group1: First group data
        group2: Second group data
        test_statistic: Function that computes test statistic from (group1, group2)
        num_permutations: Number of permutations

    Returns:
        Dictionary with test results
    """
    observed_stat = test_statistic(group1, group2)

    # Pool data
    pooled = np.concatenate([group1, group2])
    n1 = len(group1)

    # Permutation distribution
    perm_stats = np.zeros(num_permutations)

    for i in range(num_permutations):
        # Shuffle and split
        shuffled = self._rng.permutation(pooled)
        perm_group1 = shuffled[:n1]
        perm_group2 = shuffled[n1:]

        perm_stats[i] = test_statistic(perm_group1, perm_group2)

    # Calculate p-value (two-tailed)
    p_value = np.mean(np.abs(perm_stats) >= np.abs(observed_stat))

    return {
        "observed_statistic": observed_stat,
        "p_value": p_value,
        "permutation_distribution": perm_stats,
    }

simulate(func, num_samples=10000, track_convergence=False, confidence_level=0.95, **kwargs)

Run Monte Carlo simulation.

Parameters:

Name Type Description Default
func Callable

Function that generates one sample

required
num_samples int

Number of Monte Carlo samples

10000
track_convergence bool

Whether to track convergence

False
confidence_level float

Confidence level for interval

0.95
**kwargs Any

Additional arguments passed to func

{}

Returns:

Type Description
SimulationResult

SimulationResult object

Source code in src/monte_carlo/simulator.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def simulate(
    self,
    func: Callable,
    num_samples: int = 10000,
    track_convergence: bool = False,
    confidence_level: float = 0.95,
    **kwargs: Any,
) -> SimulationResult:
    """
    Run Monte Carlo simulation.

    Args:
        func: Function that generates one sample
        num_samples: Number of Monte Carlo samples
        track_convergence: Whether to track convergence
        confidence_level: Confidence level for interval
        **kwargs: Additional arguments passed to func

    Returns:
        SimulationResult object
    """
    logger.info(
        "Monte Carlo simulation starting: %d samples, convergence=%s",
        num_samples,
        track_convergence,
    )
    start_time = time.perf_counter()

    samples = np.array([func(**kwargs) for _ in range(num_samples)])

    # Calculate statistics
    mean = np.mean(samples)
    std = np.std(samples, ddof=1)
    var = np.var(samples, ddof=1)
    median = np.median(samples)

    # Calculate quantiles
    quantiles = {
        0.01: np.percentile(samples, 1),
        0.05: np.percentile(samples, 5),
        0.25: np.percentile(samples, 25),
        0.50: np.percentile(samples, 50),
        0.75: np.percentile(samples, 75),
        0.95: np.percentile(samples, 95),
        0.99: np.percentile(samples, 99),
    }

    # Confidence interval
    alpha = 1 - confidence_level
    ci_lower = np.percentile(samples, 100 * alpha / 2)
    ci_upper = np.percentile(samples, 100 * (1 - alpha / 2))

    # Track convergence if requested
    convergence_data = None
    if track_convergence:
        convergence_data = self._calculate_convergence(samples)

    elapsed = time.perf_counter() - start_time
    logger.info(
        "Monte Carlo simulation completed: %d samples in %.2fs, mean=%.4f, std=%.4f",
        num_samples,
        elapsed,
        mean,
        std,
    )

    return SimulationResult(
        mean=mean,
        std=std,
        var=var,
        median=median,
        quantiles=quantiles,
        samples=samples,
        confidence_interval=(ci_lower, ci_upper),
        convergence_data=convergence_data,
    )

stratified_sampling(func, strata_bounds, num_samples_per_stratum=1000)

Stratified sampling for variance reduction.

Parameters:

Name Type Description Default
func Callable

Function to evaluate

required
strata_bounds list[tuple[float, float]]

List of (lower, upper) bounds for each stratum

required
num_samples_per_stratum int

Samples per stratum

1000

Returns:

Type Description
SimulationResult

SimulationResult object

Source code in src/monte_carlo/simulator.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def stratified_sampling(
    self,
    func: Callable,
    strata_bounds: list[tuple[float, float]],
    num_samples_per_stratum: int = 1000,
) -> SimulationResult:
    """
    Stratified sampling for variance reduction.

    Args:
        func: Function to evaluate
        strata_bounds: List of (lower, upper) bounds for each stratum
        num_samples_per_stratum: Samples per stratum

    Returns:
        SimulationResult object
    """
    all_samples: list = []

    for lower, upper in strata_bounds:
        # Uniform sampling within stratum
        stratum_samples = self._rng.uniform(lower, upper, num_samples_per_stratum)
        values = np.array([func(s) for s in stratum_samples])
        all_samples.extend(values)

    samples = np.array(all_samples)

    return SimulationResult(
        mean=np.mean(samples),
        std=np.std(samples, ddof=1),
        var=np.var(samples, ddof=1),
        median=np.median(samples),
        quantiles={
            0.05: np.percentile(samples, 5),
            0.50: np.percentile(samples, 50),
            0.95: np.percentile(samples, 95),
        },
        samples=samples,
        confidence_interval=(np.percentile(samples, 2.5), np.percentile(samples, 97.5)),
    )

QuasiMonteCarloSimulator

Quasi-Monte Carlo using low-discrepancy sequences.

Source code in src/monte_carlo/simulator.py
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
class QuasiMonteCarloSimulator:
    """Quasi-Monte Carlo using low-discrepancy sequences."""

    @staticmethod
    def halton_sequence(n: int, base: int = 2) -> np.ndarray:
        """
        Generate Halton sequence.

        Args:
            n: Number of points
            base: Base for sequence

        Returns:
            Array of Halton sequence values
        """
        sequence = np.zeros(n)

        for i in range(n):
            f = 1.0
            r = 0.0
            j = i + 1

            while j > 0:
                f = f / base
                r = r + f * (j % base)
                j = j // base

            sequence[i] = r

        return sequence

    @staticmethod
    def sobol_sequence(n: int, dim: int = 1) -> np.ndarray:
        """
        Generate Sobol sequence (requires scipy >= 1.7.0).

        Args:
            n: Number of points
            dim: Dimension

        Returns:
            Array of shape (n, dim)
        """
        from scipy.stats import qmc

        sampler = qmc.Sobol(d=dim, scramble=True)
        return sampler.random(n)

    def integrate_qmc(
        self, func: Callable, bounds: list[tuple[float, float]], num_points: int = 10000
    ) -> float:
        """
        Quasi-Monte Carlo integration.

        Args:
            func: Function to integrate
            bounds: Integration bounds for each dimension
            num_points: Number of QMC points

        Returns:
            Integral estimate
        """
        dim = len(bounds)

        # Generate Sobol sequence
        qmc_points = self.sobol_sequence(num_points, dim)

        # Transform to integration bounds
        for i, (lower, upper) in enumerate(bounds):
            qmc_points[:, i] = lower + (upper - lower) * qmc_points[:, i]

        # Evaluate function
        values = np.array([func(*point) for point in qmc_points])

        # Calculate volume
        volume = np.prod([upper - lower for lower, upper in bounds])

        return volume * np.mean(values)

halton_sequence(n, base=2) staticmethod

Generate Halton sequence.

Parameters:

Name Type Description Default
n int

Number of points

required
base int

Base for sequence

2

Returns:

Type Description
ndarray

Array of Halton sequence values

Source code in src/monte_carlo/simulator.py
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
@staticmethod
def halton_sequence(n: int, base: int = 2) -> np.ndarray:
    """
    Generate Halton sequence.

    Args:
        n: Number of points
        base: Base for sequence

    Returns:
        Array of Halton sequence values
    """
    sequence = np.zeros(n)

    for i in range(n):
        f = 1.0
        r = 0.0
        j = i + 1

        while j > 0:
            f = f / base
            r = r + f * (j % base)
            j = j // base

        sequence[i] = r

    return sequence

integrate_qmc(func, bounds, num_points=10000)

Quasi-Monte Carlo integration.

Parameters:

Name Type Description Default
func Callable

Function to integrate

required
bounds list[tuple[float, float]]

Integration bounds for each dimension

required
num_points int

Number of QMC points

10000

Returns:

Type Description
float

Integral estimate

Source code in src/monte_carlo/simulator.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def integrate_qmc(
    self, func: Callable, bounds: list[tuple[float, float]], num_points: int = 10000
) -> float:
    """
    Quasi-Monte Carlo integration.

    Args:
        func: Function to integrate
        bounds: Integration bounds for each dimension
        num_points: Number of QMC points

    Returns:
        Integral estimate
    """
    dim = len(bounds)

    # Generate Sobol sequence
    qmc_points = self.sobol_sequence(num_points, dim)

    # Transform to integration bounds
    for i, (lower, upper) in enumerate(bounds):
        qmc_points[:, i] = lower + (upper - lower) * qmc_points[:, i]

    # Evaluate function
    values = np.array([func(*point) for point in qmc_points])

    # Calculate volume
    volume = np.prod([upper - lower for lower, upper in bounds])

    return volume * np.mean(values)

sobol_sequence(n, dim=1) staticmethod

Generate Sobol sequence (requires scipy >= 1.7.0).

Parameters:

Name Type Description Default
n int

Number of points

required
dim int

Dimension

1

Returns:

Type Description
ndarray

Array of shape (n, dim)

Source code in src/monte_carlo/simulator.py
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@staticmethod
def sobol_sequence(n: int, dim: int = 1) -> np.ndarray:
    """
    Generate Sobol sequence (requires scipy >= 1.7.0).

    Args:
        n: Number of points
        dim: Dimension

    Returns:
        Array of shape (n, dim)
    """
    from scipy.stats import qmc

    sampler = qmc.Sobol(d=dim, scramble=True)
    return sampler.random(n)

SimulationResult dataclass

Results from Monte Carlo simulation.

Source code in src/monte_carlo/simulator.py
15
16
17
18
19
20
21
22
23
24
25
26
@dataclass
class SimulationResult:
    """Results from Monte Carlo simulation."""

    mean: float
    std: float
    var: float
    median: float
    quantiles: dict[float, float]
    samples: np.ndarray
    confidence_interval: tuple[float, float]
    convergence_data: np.ndarray | None = None

VarianceReduction

Variance reduction techniques.

Source code in src/monte_carlo/simulator.py
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
class VarianceReduction:
    """Variance reduction techniques."""

    @staticmethod
    def antithetic_variates(
        sampler: Callable, func: Callable, num_pairs: int = 5000
    ) -> tuple[float, float]:
        """
        Antithetic variates for variance reduction.

        Args:
            sampler: Function that generates uniform(0,1) samples
            func: Function to evaluate
            num_pairs: Number of antithetic pairs

        Returns:
            Tuple of (estimate, standard_error)
        """
        estimates_list = []

        for _ in range(num_pairs):
            u = sampler()
            # Antithetic variate
            u_anti = 1 - u

            y1 = func(u)
            y2 = func(u_anti)

            # Average of pair
            estimates_list.append((y1 + y2) / 2)

        estimates = np.array(estimates_list)
        return np.mean(estimates), np.std(estimates, ddof=1) / np.sqrt(num_pairs)

    @staticmethod
    def control_variates(
        target_sampler: Callable,
        target_func: Callable,
        control_func: Callable,
        control_mean: float,
        num_samples: int = 10000,
    ) -> tuple[float, float]:
        """
        Control variates for variance reduction.

        Args:
            target_sampler: Function to generate samples
            target_func: Function to evaluate (unknown expectation)
            control_func: Control function (known expectation)
            control_mean: Known expectation of control function
            num_samples: Number of samples

        Returns:
            Tuple of (estimate, standard_error)
        """
        samples = [target_sampler() for _ in range(num_samples)]
        y_values = np.array([target_func(s) for s in samples])
        c_values = np.array([control_func(s) for s in samples])

        # Optimal coefficient
        cov = np.cov(y_values, c_values)[0, 1]
        var_c = np.var(c_values, ddof=1)
        beta = cov / var_c if var_c > 0 else 0

        # Control variate estimate
        controlled = y_values - beta * (c_values - control_mean)

        return float(np.mean(controlled)), float(np.std(controlled, ddof=1) / np.sqrt(num_samples))

antithetic_variates(sampler, func, num_pairs=5000) staticmethod

Antithetic variates for variance reduction.

Parameters:

Name Type Description Default
sampler Callable

Function that generates uniform(0,1) samples

required
func Callable

Function to evaluate

required
num_pairs int

Number of antithetic pairs

5000

Returns:

Type Description
tuple[float, float]

Tuple of (estimate, standard_error)

Source code in src/monte_carlo/simulator.py
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
@staticmethod
def antithetic_variates(
    sampler: Callable, func: Callable, num_pairs: int = 5000
) -> tuple[float, float]:
    """
    Antithetic variates for variance reduction.

    Args:
        sampler: Function that generates uniform(0,1) samples
        func: Function to evaluate
        num_pairs: Number of antithetic pairs

    Returns:
        Tuple of (estimate, standard_error)
    """
    estimates_list = []

    for _ in range(num_pairs):
        u = sampler()
        # Antithetic variate
        u_anti = 1 - u

        y1 = func(u)
        y2 = func(u_anti)

        # Average of pair
        estimates_list.append((y1 + y2) / 2)

    estimates = np.array(estimates_list)
    return np.mean(estimates), np.std(estimates, ddof=1) / np.sqrt(num_pairs)

control_variates(target_sampler, target_func, control_func, control_mean, num_samples=10000) staticmethod

Control variates for variance reduction.

Parameters:

Name Type Description Default
target_sampler Callable

Function to generate samples

required
target_func Callable

Function to evaluate (unknown expectation)

required
control_func Callable

Control function (known expectation)

required
control_mean float

Known expectation of control function

required
num_samples int

Number of samples

10000

Returns:

Type Description
tuple[float, float]

Tuple of (estimate, standard_error)

Source code in src/monte_carlo/simulator.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
@staticmethod
def control_variates(
    target_sampler: Callable,
    target_func: Callable,
    control_func: Callable,
    control_mean: float,
    num_samples: int = 10000,
) -> tuple[float, float]:
    """
    Control variates for variance reduction.

    Args:
        target_sampler: Function to generate samples
        target_func: Function to evaluate (unknown expectation)
        control_func: Control function (known expectation)
        control_mean: Known expectation of control function
        num_samples: Number of samples

    Returns:
        Tuple of (estimate, standard_error)
    """
    samples = [target_sampler() for _ in range(num_samples)]
    y_values = np.array([target_func(s) for s in samples])
    c_values = np.array([control_func(s) for s in samples])

    # Optimal coefficient
    cov = np.cov(y_values, c_values)[0, 1]
    var_c = np.var(c_values, ddof=1)
    beta = cov / var_c if var_c > 0 else 0

    # Control variate estimate
    controlled = y_values - beta * (c_values - control_mean)

    return float(np.mean(controlled)), float(np.std(controlled, ddof=1) / np.sqrt(num_samples))

src.statistical_tests

Statistical tests and hypothesis testing utilities.

Note: This module is named 'statistical_tests' to avoid collision with Python's built-in 'statistics' module.

anova(*samples, alpha=0.05)

Perform one-way ANOVA.

Parameters:

Name Type Description Default
*samples ndarray

Variable number of sample groups

()
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool]

Dictionary with test results

Source code in src/statistical_tests/hypothesis_tests.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def anova(*samples: np.ndarray, alpha: float = 0.05) -> dict[str, float | bool]:
    """
    Perform one-way ANOVA.

    Args:
        *samples: Variable number of sample groups
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    statistic, p_value = stats.f_oneway(*samples)
    reject_null = p_value < alpha

    df_between = len(samples) - 1
    df_within = sum(len(s) for s in samples) - len(samples)

    return {
        "test": "One-way ANOVA",
        "f_statistic": float(statistic),
        "p_value": float(p_value),
        "df_between": df_between,
        "df_within": df_within,
        "reject_null": reject_null,
        "alpha": alpha,
        "interpretation": f"{'Reject' if reject_null else 'Fail to reject'} null hypothesis at α={alpha}",
        "conclusion": f"Group means are {'significantly different' if reject_null else 'not significantly different'}",
    }

chi_square_test(observed, expected=None, alpha=0.05)

Perform chi-square goodness-of-fit test.

Parameters:

Name Type Description Default
observed ndarray

Observed frequencies

required
expected ndarray | None

Expected frequencies (uniform if None)

None
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool | int]

Dictionary with test results

Source code in src/statistical_tests/hypothesis_tests.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def chi_square_test(
    observed: np.ndarray, expected: np.ndarray | None = None, alpha: float = 0.05
) -> dict[str, float | bool | int]:
    """
    Perform chi-square goodness-of-fit test.

    Args:
        observed: Observed frequencies
        expected: Expected frequencies (uniform if None)
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    if expected is None:
        # Assume uniform distribution
        expected = np.ones_like(observed) * np.mean(observed)

    statistic, p_value = stats.chisquare(observed, expected)
    df = len(observed) - 1
    reject_null = p_value < alpha

    return {
        "test": "Chi-square goodness-of-fit",
        "statistic": float(statistic),
        "p_value": float(p_value),
        "degrees_of_freedom": df,
        "reject_null": reject_null,
        "alpha": alpha,
        "interpretation": f"{'Reject' if reject_null else 'Fail to reject'} null hypothesis at α={alpha}",
    }

correlation_matrix(data, method='pearson', return_pvalues=False)

Compute correlation matrix.

Parameters:

Name Type Description Default
data ndarray

2D array where each column is a variable

required
method str

'pearson', 'spearman', or 'kendall'

'pearson'
return_pvalues bool

Whether to return p-values

False

Returns:

Type Description
dict[str, ndarray]

Dictionary with correlation matrix and optionally p-values

Source code in src/statistical_tests/descriptive.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def correlation_matrix(
    data: np.ndarray, method: str = "pearson", return_pvalues: bool = False
) -> dict[str, np.ndarray]:
    """
    Compute correlation matrix.

    Args:
        data: 2D array where each column is a variable
        method: 'pearson', 'spearman', or 'kendall'
        return_pvalues: Whether to return p-values

    Returns:
        Dictionary with correlation matrix and optionally p-values
    """
    data = np.asarray(data)
    if data.ndim == 1:
        data = data.reshape(-1, 1)

    n_vars = data.shape[1]
    corr_matrix = np.zeros((n_vars, n_vars))
    p_matrix = np.zeros((n_vars, n_vars)) if return_pvalues else None

    for i in range(n_vars):
        for j in range(n_vars):
            if i == j:
                corr_matrix[i, j] = 1.0
                if p_matrix is not None:
                    p_matrix[i, j] = 0.0
            else:
                if method == "pearson":
                    corr, pval = stats.pearsonr(data[:, i], data[:, j])
                elif method == "spearman":
                    corr, pval = stats.spearmanr(data[:, i], data[:, j])
                elif method == "kendall":
                    corr, pval = stats.kendalltau(data[:, i], data[:, j])
                else:
                    raise ValueError(f"Unknown method: {method}")

                corr_matrix[i, j] = corr
                if p_matrix is not None:
                    p_matrix[i, j] = pval

    result: dict[str, np.ndarray] = {
        "correlation_matrix": corr_matrix,
        "method": method,  # type: ignore[dict-item]
    }

    if p_matrix is not None:
        result["p_values"] = p_matrix

    return result

correlation_test(x, y, method='pearson', alpha=0.05)

Test for correlation between two variables.

Parameters:

Name Type Description Default
x ndarray

First variable

required
y ndarray

Second variable

required
method str

'pearson', 'spearman', or 'kendall'

'pearson'
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool | str]

Dictionary with test results

Source code in src/statistical_tests/hypothesis_tests.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def correlation_test(
    x: np.ndarray, y: np.ndarray, method: str = "pearson", alpha: float = 0.05
) -> dict[str, float | bool | str]:
    """
    Test for correlation between two variables.

    Args:
        x: First variable
        y: Second variable
        method: 'pearson', 'spearman', or 'kendall'
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    if method == "pearson":
        statistic, p_value = stats.pearsonr(x, y)
        test_name = "Pearson correlation"
    elif method == "spearman":
        statistic, p_value = stats.spearmanr(x, y)
        test_name = "Spearman rank correlation"
    elif method == "kendall":
        statistic, p_value = stats.kendalltau(x, y)
        test_name = "Kendall's tau"
    else:
        raise ValueError(f"Unknown method: {method}")

    reject_null = p_value < alpha

    # Interpret strength
    abs_corr = abs(statistic)
    if abs_corr < 0.3:
        strength = "weak"
    elif abs_corr < 0.7:
        strength = "moderate"
    else:
        strength = "strong"

    direction = "positive" if statistic > 0 else "negative"

    return {
        "test": test_name,
        "correlation": float(statistic),
        "p_value": float(p_value),
        "reject_null": reject_null,
        "alpha": alpha,
        "interpretation": f"{'Significant' if reject_null else 'Non-significant'} {strength} {direction} correlation",
        "strength": strength,
        "direction": direction,
    }

describe(data, percentiles=None)

Comprehensive descriptive statistics.

Parameters:

Name Type Description Default
data ndarray

Input data

required
percentiles list[float] | None

List of percentiles to compute (default: [25, 50, 75])

None

Returns:

Type Description
dict

Dictionary with descriptive statistics

Source code in src/statistical_tests/descriptive.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def describe(data: np.ndarray, percentiles: list[float] | None = None) -> dict:
    """
    Comprehensive descriptive statistics.

    Args:
        data: Input data
        percentiles: List of percentiles to compute (default: [25, 50, 75])

    Returns:
        Dictionary with descriptive statistics
    """
    if percentiles is None:
        percentiles = [25, 50, 75]

    data = np.asarray(data).flatten()

    # Basic statistics
    n = len(data)
    mean = np.mean(data)
    std = np.std(data, ddof=1)
    var = np.var(data, ddof=1)

    # Median and MAD
    median = np.median(data)
    mad = np.median(np.abs(data - median))

    # Range
    min_val = np.min(data)
    max_val = np.max(data)
    range_val = max_val - min_val

    # Quartiles and IQR
    q1, q3 = np.percentile(data, [25, 75])
    iqr = q3 - q1

    # Shape measures
    skewness = stats.skew(data)
    kurtosis = stats.kurtosis(data)

    # Standard error
    se = std / np.sqrt(n)

    # Coefficient of variation
    cv = (std / mean) * 100 if mean != 0 else np.inf

    # Percentiles
    percentile_values = {f"p{int(p)}": np.percentile(data, p) for p in percentiles}

    return {
        "count": n,
        "mean": float(mean),
        "std": float(std),
        "var": float(var),
        "se": float(se),
        "cv": float(cv),
        "min": float(min_val),
        "max": float(max_val),
        "range": float(range_val),
        "median": float(median),
        "mad": float(mad),
        "q1": float(q1),
        "q3": float(q3),
        "iqr": float(iqr),
        "skewness": float(skewness),
        "kurtosis": float(kurtosis),
        **{k: float(v) for k, v in percentile_values.items()},
    }

friedman_test(*samples, alpha=0.05)

Friedman test (nonparametric alternative to repeated measures ANOVA).

Parameters:

Name Type Description Default
*samples ndarray

Variable number of sample groups (must have same length)

()
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool | int]

Dictionary with test results

Source code in src/statistical_tests/nonparametric.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def friedman_test(*samples: np.ndarray, alpha: float = 0.05) -> dict[str, float | bool | int]:
    """
    Friedman test (nonparametric alternative to repeated measures ANOVA).

    Args:
        *samples: Variable number of sample groups (must have same length)
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    # Check that all samples have same length
    lengths = [len(s) for s in samples]
    if len(set(lengths)) > 1:
        raise ValueError("All samples must have the same length for Friedman test")

    statistic, p_value = stats.friedmanchisquare(*samples)
    reject_null = p_value < alpha

    k = len(samples)  # number of treatments
    n = lengths[0]  # number of blocks
    df = k - 1

    return {
        "test": "Friedman test",
        "statistic": float(statistic),
        "p_value": float(p_value),
        "degrees_of_freedom": df,
        "n_treatments": k,
        "n_blocks": n,
        "reject_null": reject_null,
        "alpha": alpha,
        "interpretation": f"{'Reject' if reject_null else 'Fail to reject'} null hypothesis",
        "conclusion": f"Treatment effects are {'different' if reject_null else 'not significantly different'}",
    }

kruskal_wallis(*samples, alpha=0.05)

Kruskal-Wallis H test (nonparametric alternative to one-way ANOVA).

Parameters:

Name Type Description Default
*samples ndarray

Variable number of sample groups

()
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool | int]

Dictionary with test results

Source code in src/statistical_tests/nonparametric.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def kruskal_wallis(*samples: np.ndarray, alpha: float = 0.05) -> dict[str, float | bool | int]:
    """
    Kruskal-Wallis H test (nonparametric alternative to one-way ANOVA).

    Args:
        *samples: Variable number of sample groups
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    statistic, p_value = stats.kruskal(*samples)
    reject_null = p_value < alpha

    df = len(samples) - 1

    return {
        "test": "Kruskal-Wallis H test",
        "h_statistic": float(statistic),
        "p_value": float(p_value),
        "degrees_of_freedom": df,
        "n_groups": len(samples),
        "reject_null": reject_null,
        "alpha": alpha,
        "interpretation": f"{'Reject' if reject_null else 'Fail to reject'} null hypothesis",
        "conclusion": f"Group distributions are {'different' if reject_null else 'not significantly different'}",
    }

mann_whitney_u(sample1, sample2, alternative='two-sided', alpha=0.05)

Mann-Whitney U test (nonparametric alternative to two-sample t-test).

Parameters:

Name Type Description Default
sample1 ndarray

First sample

required
sample2 ndarray

Second sample

required
alternative str

'two-sided', 'less', or 'greater'

'two-sided'
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool | str]

Dictionary with test results

Source code in src/statistical_tests/nonparametric.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def mann_whitney_u(
    sample1: np.ndarray, sample2: np.ndarray, alternative: str = "two-sided", alpha: float = 0.05
) -> dict[str, float | bool | str]:
    """
    Mann-Whitney U test (nonparametric alternative to two-sample t-test).

    Args:
        sample1: First sample
        sample2: Second sample
        alternative: 'two-sided', 'less', or 'greater'
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    statistic, p_value = stats.mannwhitneyu(sample1, sample2, alternative=alternative)

    reject_null = p_value < alpha

    return {
        "test": "Mann-Whitney U test",
        "statistic": float(statistic),
        "p_value": float(p_value),
        "reject_null": reject_null,
        "alpha": alpha,
        "alternative": alternative,
        "interpretation": f"{'Reject' if reject_null else 'Fail to reject'} null hypothesis",
        "conclusion": f"Distributions are {'different' if reject_null else 'not significantly different'}",
    }

normality_tests(data, alpha=0.05)

Run multiple normality tests.

Parameters:

Name Type Description Default
data ndarray

Sample data

required
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, dict[str, float | bool]]

Dictionary with results from multiple tests

Source code in src/statistical_tests/hypothesis_tests.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def normality_tests(data: np.ndarray, alpha: float = 0.05) -> dict[str, dict[str, float | bool]]:
    """
    Run multiple normality tests.

    Args:
        data: Sample data
        alpha: Significance level

    Returns:
        Dictionary with results from multiple tests
    """
    results = {}

    # Shapiro-Wilk test
    if len(data) <= 5000:  # SW test has sample size limits
        sw_stat, sw_p = stats.shapiro(data)
        results["shapiro_wilk"] = {
            "statistic": float(sw_stat),
            "p_value": float(sw_p),
            "reject_null": sw_p < alpha,
            "conclusion": "Non-normal" if sw_p < alpha else "Normal",
        }

    # Kolmogorov-Smirnov test against a fitted Normal (frozen CDF callable:
    # scipy>=1.18 no longer forwards string-form `args` positionally).
    norm_cdf = stats.norm(loc=np.mean(data), scale=np.std(data)).cdf
    ks_stat, ks_p = stats.kstest(data, norm_cdf)
    results["kolmogorov_smirnov"] = {
        "statistic": float(ks_stat),
        "p_value": float(ks_p),
        "reject_null": ks_p < alpha,
        "conclusion": "Non-normal" if ks_p < alpha else "Normal",
    }

    # Anderson-Darling test
    # SciPy 1.17 warns that its legacy critical-value result will change. Keep
    # the established result shape until the package can expose the new p-value
    # API without breaking callers that consume critical_value.
    with warnings.catch_warnings():
        warnings.filterwarnings(
            "ignore",
            message=r"As of SciPy 1\.17, users must choose a p-value calculation method.*",
            category=FutureWarning,
        )
        ad_result = stats.anderson(data, dist="norm")
    # Find critical value for given alpha
    critical_idx = {0.15: 0, 0.10: 1, 0.05: 2, 0.025: 3, 0.01: 4}.get(alpha, 2)
    results["anderson_darling"] = {
        "statistic": float(ad_result.statistic),
        "critical_value": float(ad_result.critical_values[critical_idx]),
        "significance_level": float(ad_result.significance_level[critical_idx]),
        "reject_null": ad_result.statistic > ad_result.critical_values[critical_idx],
        "conclusion": (
            "Non-normal"
            if ad_result.statistic > ad_result.critical_values[critical_idx]
            else "Normal"
        ),
    }

    # Jarque-Bera test
    jb_stat, jb_p = stats.jarque_bera(data)
    results["jarque_bera"] = {
        "statistic": float(jb_stat),
        "p_value": float(jb_p),
        "reject_null": jb_p < alpha,
        "conclusion": "Non-normal" if jb_p < alpha else "Normal",
    }

    # Overall consensus
    reject_count = sum(1 for r in results.values() if r.get("reject_null", False))
    total_tests = len(results)

    results["consensus"] = {
        "tests_rejecting_normality": reject_count,
        "total_tests": total_tests,
        "conclusion": "Likely non-normal" if reject_count > total_tests / 2 else "Likely normal",
    }

    return results

outlier_detection(data, method='iqr', threshold=1.5)

Detect outliers using various methods.

Parameters:

Name Type Description Default
data ndarray

Input data

required
method str

'iqr', 'zscore', or 'mad'

'iqr'
threshold float

Threshold for outlier detection - IQR: typically 1.5 or 3.0 - Z-score: typically 2.5 or 3.0 - MAD: typically 2.5 or 3.0

1.5

Returns:

Type Description
dict[str, ndarray]

Dictionary with outlier information

Source code in src/statistical_tests/descriptive.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def outlier_detection(
    data: np.ndarray, method: str = "iqr", threshold: float = 1.5
) -> dict[str, np.ndarray]:
    """
    Detect outliers using various methods.

    Args:
        data: Input data
        method: 'iqr', 'zscore', or 'mad'
        threshold: Threshold for outlier detection
                  - IQR: typically 1.5 or 3.0
                  - Z-score: typically 2.5 or 3.0
                  - MAD: typically 2.5 or 3.0

    Returns:
        Dictionary with outlier information
    """
    data = np.asarray(data).flatten()

    if method == "iqr":
        q1, q3 = np.percentile(data, [25, 75])
        iqr = q3 - q1
        lower_bound = q1 - threshold * iqr
        upper_bound = q3 + threshold * iqr
        outliers = (data < lower_bound) | (data > upper_bound)

    elif method == "zscore":
        z_scores = np.abs(stats.zscore(data))
        outliers = z_scores > threshold
        lower_bound = np.mean(data) - threshold * np.std(data)
        upper_bound = np.mean(data) + threshold * np.std(data)

    elif method == "mad":
        median = np.median(data)
        mad = np.median(np.abs(data - median))
        modified_z_scores = 0.6745 * (data - median) / mad if mad != 0 else np.zeros_like(data)
        outliers = np.abs(modified_z_scores) > threshold
        lower_bound = median - threshold * mad / 0.6745
        upper_bound = median + threshold * mad / 0.6745

    else:
        raise ValueError(f"Unknown method: {method}")

    outlier_indices = np.where(outliers)[0]
    outlier_values = data[outliers]

    return {
        "method": method,
        "threshold": threshold,
        "n_outliers": int(np.sum(outliers)),
        "outlier_proportion": float(np.mean(outliers)),
        "outlier_indices": outlier_indices,
        "outlier_values": outlier_values,
        "lower_bound": float(lower_bound),
        "upper_bound": float(upper_bound),
        "is_outlier": outliers,
    }

quantile_summary(data, n_quantiles=4)

Compute quantile summary.

Parameters:

Name Type Description Default
data ndarray

Input data

required
n_quantiles int

Number of quantiles (4 for quartiles, 10 for deciles, etc.)

4

Returns:

Type Description
dict[str, ndarray]

Dictionary with quantile information

Source code in src/statistical_tests/descriptive.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def quantile_summary(data: np.ndarray, n_quantiles: int = 4) -> dict[str, np.ndarray]:
    """
    Compute quantile summary.

    Args:
        data: Input data
        n_quantiles: Number of quantiles (4 for quartiles, 10 for deciles, etc.)

    Returns:
        Dictionary with quantile information
    """
    data = np.asarray(data).flatten()

    # Compute quantile boundaries
    quantiles = np.linspace(0, 100, n_quantiles + 1)
    boundaries = np.percentile(data, quantiles)

    # Assign data points to quantiles
    quantile_labels = np.searchsorted(boundaries[1:-1], data)

    # Count per quantile
    counts = np.bincount(quantile_labels, minlength=n_quantiles)

    return {
        "n_quantiles": n_quantiles,
        "boundaries": boundaries,
        "labels": quantile_labels,
        "counts": counts,
        "proportions": counts / len(data),
    }

t_test(sample1, sample2=None, mu=0, alternative='two-sided', alpha=0.05)

Perform t-test (one-sample or two-sample).

Parameters:

Name Type Description Default
sample1 ndarray

First sample data

required
sample2 ndarray | None

Second sample data (None for one-sample test)

None
mu float

Hypothesized mean (for one-sample test)

0
alternative str

'two-sided', 'less', or 'greater'

'two-sided'
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool | str]

Dictionary with test results

Source code in src/statistical_tests/hypothesis_tests.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def t_test(
    sample1: np.ndarray,
    sample2: np.ndarray | None = None,
    mu: float = 0,
    alternative: str = "two-sided",
    alpha: float = 0.05,
) -> dict[str, float | bool | str]:
    """
    Perform t-test (one-sample or two-sample).

    Args:
        sample1: First sample data
        sample2: Second sample data (None for one-sample test)
        mu: Hypothesized mean (for one-sample test)
        alternative: 'two-sided', 'less', or 'greater'
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    if sample2 is None:
        # One-sample t-test
        statistic, p_value = stats.ttest_1samp(sample1, mu, alternative=alternative)
        test_type = "One-sample t-test"
    else:
        # Two-sample t-test
        statistic, p_value = stats.ttest_ind(sample1, sample2, alternative=alternative)
        test_type = "Two-sample t-test"

    reject_null = p_value < alpha

    return {
        "test": test_type,
        "statistic": float(statistic),
        "p_value": float(p_value),
        "reject_null": reject_null,
        "alpha": alpha,
        "alternative": alternative,
        "interpretation": f"{'Reject' if reject_null else 'Fail to reject'} null hypothesis at α={alpha}",
    }

wilcoxon_signed_rank(sample1, sample2=None, alternative='two-sided', alpha=0.05)

Wilcoxon signed-rank test (nonparametric paired test).

Parameters:

Name Type Description Default
sample1 ndarray

First sample or differences

required
sample2 ndarray | None

Second sample (None if sample1 contains differences)

None
alternative str

'two-sided', 'less', or 'greater'

'two-sided'
alpha float

Significance level

0.05

Returns:

Type Description
dict[str, float | bool | str]

Dictionary with test results

Source code in src/statistical_tests/nonparametric.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def wilcoxon_signed_rank(
    sample1: np.ndarray,
    sample2: np.ndarray | None = None,
    alternative: str = "two-sided",
    alpha: float = 0.05,
) -> dict[str, float | bool | str]:
    """
    Wilcoxon signed-rank test (nonparametric paired test).

    Args:
        sample1: First sample or differences
        sample2: Second sample (None if sample1 contains differences)
        alternative: 'two-sided', 'less', or 'greater'
        alpha: Significance level

    Returns:
        Dictionary with test results
    """
    if sample2 is not None:
        # Compute differences
        differences = sample1 - sample2
    else:
        differences = sample1

    # Remove zeros
    differences = differences[differences != 0]

    if len(differences) == 0:
        return {
            "test": "Wilcoxon signed-rank test",
            "error": "No non-zero differences found",
            "statistic": np.nan,
            "p_value": np.nan,
            "reject_null": False,
        }

    statistic, p_value = stats.wilcoxon(differences, alternative=alternative)

    reject_null = p_value < alpha

    return {
        "test": "Wilcoxon signed-rank test",
        "statistic": float(statistic),
        "p_value": float(p_value),
        "reject_null": reject_null,
        "alpha": alpha,
        "alternative": alternative,
        "n_differences": len(differences),
        "interpretation": f"{'Reject' if reject_null else 'Fail to reject'} null hypothesis",
        "conclusion": f"Paired observations are {'different' if reject_null else 'not significantly different'}",
    }

src.utils.validation

Input validation utilities.

validate_array(array, shape=None, ndim=None, dtype=None, min_length=None, name='array')

Validate array properties.

Parameters:

Name Type Description Default
array ndarray

Array to validate

required
shape tuple | None

Expected shape (None to skip check)

None
ndim int | None

Expected number of dimensions (None to skip check)

None
dtype type | None

Expected data type (None to skip check)

None
min_length int | None

Minimum length for first dimension (None to skip check)

None
name str

Name of parameter for error messages

'array'

Returns:

Type Description
ndarray

Validated array

Raises:

Type Description
ValueError

If array does not meet requirements

Source code in src/utils/validation.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def validate_array(
    array: np.ndarray,
    shape: tuple | None = None,
    ndim: int | None = None,
    dtype: type | None = None,
    min_length: int | None = None,
    name: str = "array",
) -> np.ndarray:
    """
    Validate array properties.

    Args:
        array: Array to validate
        shape: Expected shape (None to skip check)
        ndim: Expected number of dimensions (None to skip check)
        dtype: Expected data type (None to skip check)
        min_length: Minimum length for first dimension (None to skip check)
        name: Name of parameter for error messages

    Returns:
        Validated array

    Raises:
        ValueError: If array does not meet requirements
    """
    array = np.asarray(array)

    if shape is not None and array.shape != shape:
        raise ValueError(f"{name} must have shape {shape}, got {array.shape}")

    if ndim is not None and array.ndim != ndim:
        raise ValueError(f"{name} must have {ndim} dimensions, got {array.ndim}")

    if dtype is not None and array.dtype != dtype:
        try:
            array = array.astype(dtype)
        except (ValueError, TypeError):
            raise ValueError(f"{name} must have dtype {dtype}, got {array.dtype}") from None

    if min_length is not None and array.shape[0] < min_length:
        raise ValueError(f"{name} must have at least {min_length} elements, got {array.shape[0]}")

    return array

validate_correlation_matrix(corr, name='correlation')

Validate that matrix is a valid correlation matrix.

Parameters:

Name Type Description Default
corr ndarray

Correlation matrix

required
name str

Name of parameter for error messages

'correlation'

Returns:

Type Description
ndarray

Validated correlation matrix

Raises:

Type Description
ValueError

If matrix is not valid correlation

Source code in src/utils/validation.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def validate_correlation_matrix(corr: np.ndarray, name: str = "correlation") -> np.ndarray:
    """
    Validate that matrix is a valid correlation matrix.

    Args:
        corr: Correlation matrix
        name: Name of parameter for error messages

    Returns:
        Validated correlation matrix

    Raises:
        ValueError: If matrix is not valid correlation
    """
    corr = np.asarray(corr)

    # First validate as covariance matrix
    validate_covariance_matrix(corr, name)

    # Check diagonal is 1
    if not np.allclose(np.diag(corr), 1.0):
        raise ValueError(f"{name} must have 1s on diagonal")

    # Check all elements in [-1, 1]
    if np.any(corr < -1) or np.any(corr > 1):
        raise ValueError(f"{name} elements must be in [-1, 1]")

    return corr

validate_covariance_matrix(cov, name='covariance')

Validate that matrix is a valid covariance matrix.

Parameters:

Name Type Description Default
cov ndarray

Covariance matrix

required
name str

Name of parameter for error messages

'covariance'

Returns:

Type Description
ndarray

Validated covariance matrix

Raises:

Type Description
ValueError

If matrix is not valid covariance

Source code in src/utils/validation.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def validate_covariance_matrix(cov: np.ndarray, name: str = "covariance") -> np.ndarray:
    """
    Validate that matrix is a valid covariance matrix.

    Args:
        cov: Covariance matrix
        name: Name of parameter for error messages

    Returns:
        Validated covariance matrix

    Raises:
        ValueError: If matrix is not valid covariance
    """
    cov = np.asarray(cov)

    # Check square
    if cov.ndim != 2 or cov.shape[0] != cov.shape[1]:
        raise ValueError(f"{name} must be square, got shape {cov.shape}")

    # Check symmetric
    if not np.allclose(cov, cov.T):
        raise ValueError(f"{name} must be symmetric")

    # Check positive semi-definite
    eigenvalues = np.linalg.eigvalsh(cov)
    if np.any(eigenvalues < -1e-10):  # Small tolerance for numerical errors
        raise ValueError(f"{name} must be positive semi-definite")

    return cov

validate_in_range(value, lower, upper, name='value', inclusive='both')

Validate that value(s) are in specified range.

Parameters:

Name Type Description Default
value float | ndarray

Value(s) to validate

required
lower float

Lower bound

required
upper float

Upper bound

required
name str

Name of parameter for error messages

'value'
inclusive str

'both', 'lower', 'upper', or 'neither'

'both'

Returns:

Type Description
float | ndarray

Validated value

Raises:

Type Description
ValueError

If value is not in range

Source code in src/utils/validation.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def validate_in_range(
    value: float | np.ndarray,
    lower: float,
    upper: float,
    name: str = "value",
    inclusive: str = "both",
) -> float | np.ndarray:
    """
    Validate that value(s) are in specified range.

    Args:
        value: Value(s) to validate
        lower: Lower bound
        upper: Upper bound
        name: Name of parameter for error messages
        inclusive: 'both', 'lower', 'upper', or 'neither'

    Returns:
        Validated value

    Raises:
        ValueError: If value is not in range
    """
    value = np.asarray(value)

    if inclusive == "both":
        condition = (value >= lower) & (value <= upper)
        range_str = f"[{lower}, {upper}]"
    elif inclusive == "lower":
        condition = (value >= lower) & (value < upper)
        range_str = f"[{lower}, {upper})"
    elif inclusive == "upper":
        condition = (value > lower) & (value <= upper)
        range_str = f"({lower}, {upper}]"
    elif inclusive == "neither":
        condition = (value > lower) & (value < upper)
        range_str = f"({lower}, {upper})"
    else:
        raise ValueError(f"Unknown inclusive option: {inclusive}")

    if not np.all(condition):
        raise ValueError(f"{name} must be in range {range_str}, got {value}")

    return value.item() if value.ndim == 0 else value

validate_integer(value, name='value')

Validate that value is an integer.

Parameters:

Name Type Description Default
value int | float

Value to validate

required
name str

Name of parameter for error messages

'value'

Returns:

Type Description
int

Validated integer

Raises:

Type Description
ValueError

If value is not an integer

Source code in src/utils/validation.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def validate_integer(value: int | float, name: str = "value") -> int:
    """
    Validate that value is an integer.

    Args:
        value: Value to validate
        name: Name of parameter for error messages

    Returns:
        Validated integer

    Raises:
        ValueError: If value is not an integer
    """
    if not isinstance(value, (int, np.integer)):
        if isinstance(value, float) and value.is_integer():
            return int(value)
        raise ValueError(f"{name} must be an integer, got {value} (type: {type(value)})")

    return int(value)

validate_nonnegative(value, name='value')

Validate that value(s) are non-negative (>= 0).

Parameters:

Name Type Description Default
value float | ndarray

Value(s) to validate

required
name str

Name of parameter for error messages

'value'

Returns:

Type Description
float | ndarray

Validated value

Raises:

Type Description
ValueError

If value is negative

Source code in src/utils/validation.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def validate_nonnegative(value: float | np.ndarray, name: str = "value") -> float | np.ndarray:
    """
    Validate that value(s) are non-negative (>= 0).

    Args:
        value: Value(s) to validate
        name: Name of parameter for error messages

    Returns:
        Validated value

    Raises:
        ValueError: If value is negative
    """
    value = np.asarray(value)

    if np.any(value < 0):
        raise ValueError(f"{name} must be non-negative, got {value}")

    return value.item() if value.ndim == 0 else value

validate_positive(value, name='value')

Validate that value(s) are strictly positive.

Parameters:

Name Type Description Default
value float | ndarray

Value(s) to validate

required
name str

Name of parameter for error messages

'value'

Returns:

Type Description
float | ndarray

Validated value

Raises:

Type Description
ValueError

If value is not positive

Source code in src/utils/validation.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def validate_positive(value: float | np.ndarray, name: str = "value") -> float | np.ndarray:
    """
    Validate that value(s) are strictly positive.

    Args:
        value: Value(s) to validate
        name: Name of parameter for error messages

    Returns:
        Validated value

    Raises:
        ValueError: If value is not positive
    """
    value = np.asarray(value)

    if np.any(value <= 0):
        raise ValueError(f"{name} must be positive, got {value}")

    return value.item() if value.ndim == 0 else value

validate_probability(p, name='probability')

Validate that value(s) are valid probabilities in [0, 1].

Parameters:

Name Type Description Default
p float | ndarray

Probability value(s)

required
name str

Name of parameter for error messages

'probability'

Returns:

Type Description
float | ndarray

Validated probability

Raises:

Type Description
ValueError

If probability is not in [0, 1]

Source code in src/utils/validation.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def validate_probability(p: float | np.ndarray, name: str = "probability") -> float | np.ndarray:
    """
    Validate that value(s) are valid probabilities in [0, 1].

    Args:
        p: Probability value(s)
        name: Name of parameter for error messages

    Returns:
        Validated probability

    Raises:
        ValueError: If probability is not in [0, 1]
    """
    p = np.asarray(p)

    if np.any(p < 0) or np.any(p > 1):
        raise ValueError(f"{name} must be in [0, 1], got {p}")

    return p.item() if p.ndim == 0 else p

src.utils.data_preprocessing

Data preprocessing utilities.

bin_data(data, n_bins=None, bins=None, method='equal_width', return_bins=False)

Bin continuous data into discrete bins.

Parameters:

Name Type Description Default
data ndarray

Input data

required
n_bins int | None

Number of bins (if bins not provided)

None
bins ndarray | None

Explicit bin edges

None
method str

'equal_width', 'equal_frequency', or 'custom'

'equal_width'
return_bins bool

Whether to return bin edges

False

Returns:

Type Description
ndarray | tuple[ndarray, ndarray]

Bin indices for each data point, optionally with bin edges

Source code in src/utils/data_preprocessing.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def bin_data(
    data: np.ndarray,
    n_bins: int | None = None,
    bins: np.ndarray | None = None,
    method: str = "equal_width",
    return_bins: bool = False,
) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
    """
    Bin continuous data into discrete bins.

    Args:
        data: Input data
        n_bins: Number of bins (if bins not provided)
        bins: Explicit bin edges
        method: 'equal_width', 'equal_frequency', or 'custom'
        return_bins: Whether to return bin edges

    Returns:
        Bin indices for each data point, optionally with bin edges
    """
    data = np.asarray(data)

    if n_bins is None and bins is None:
        n_bins = int(np.sqrt(len(data)))  # Sturges' rule approximation

    if bins is None:
        assert n_bins is not None
        if method == "equal_width":
            bins = np.linspace(np.min(data), np.max(data), n_bins + 1)
        elif method == "equal_frequency":
            bins = np.percentile(data, np.linspace(0, 100, n_bins + 1))
        else:
            raise ValueError(f"Unknown method: {method}")

    # Assign data to bins
    bin_indices = np.digitize(data, bins) - 1

    # Handle edge cases
    bin_indices = np.clip(bin_indices, 0, len(bins) - 2)

    if return_bins:
        return bin_indices, bins
    return bin_indices

box_cox_transform(data, lambda_param=None)

Apply Box-Cox power transformation.

Parameters:

Name Type Description Default
data ndarray

Input data (must be positive)

required
lambda_param float | None

Transformation parameter (estimated if None)

None

Returns:

Type Description
ndarray | tuple[ndarray, float]

Transformed data and lambda parameter (if estimated)

Source code in src/utils/data_preprocessing.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def box_cox_transform(
    data: np.ndarray, lambda_param: float | None = None
) -> np.ndarray | tuple[np.ndarray, float]:
    """
    Apply Box-Cox power transformation.

    Args:
        data: Input data (must be positive)
        lambda_param: Transformation parameter (estimated if None)

    Returns:
        Transformed data and lambda parameter (if estimated)
    """
    data = np.asarray(data)

    if np.any(data <= 0):
        raise ValueError("Box-Cox requires positive data")

    if lambda_param is None:
        # Estimate optimal lambda
        transformed, lambda_param = stats.boxcox(data)
        return transformed, lambda_param
    else:
        # Use provided lambda
        if lambda_param == 0:
            return np.log(data)
        else:
            return (data**lambda_param - 1) / lambda_param

handle_missing(data, method='mean', fill_value=None)

Handle missing values (NaN) in data.

Parameters:

Name Type Description Default
data ndarray

Input data with potential NaN values

required
method str

'mean', 'median', 'mode', 'forward_fill', 'backward_fill', or 'constant'

'mean'
fill_value float | None

Value to use for 'constant' method

None

Returns:

Type Description
ndarray

Data with missing values filled

Source code in src/utils/data_preprocessing.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def handle_missing(
    data: np.ndarray, method: str = "mean", fill_value: float | None = None
) -> np.ndarray:
    """
    Handle missing values (NaN) in data.

    Args:
        data: Input data with potential NaN values
        method: 'mean', 'median', 'mode', 'forward_fill', 'backward_fill', or 'constant'
        fill_value: Value to use for 'constant' method

    Returns:
        Data with missing values filled
    """
    data = np.asarray(data).copy()
    mask = np.isnan(data)

    if not np.any(mask):
        return data  # No missing values

    if method == "mean":
        fill = np.nanmean(data)
    elif method == "median":
        fill = np.nanmedian(data)
    elif method == "mode":
        # For continuous data, use median as approximation
        fill = np.nanmedian(data)
    elif method == "constant":
        if fill_value is None:
            raise ValueError("fill_value must be provided for 'constant' method")
        fill = fill_value
    elif method == "forward_fill":
        # Forward fill
        for i in range(len(data)):
            if np.isnan(data[i]) and i > 0:
                data[i] = data[i - 1]
        # Fill any remaining NaNs at the start
        first_valid = np.where(~np.isnan(data))[0]
        if len(first_valid) > 0:
            data[: first_valid[0]] = data[first_valid[0]]
        return data
    elif method == "backward_fill":
        # Backward fill
        for i in range(len(data) - 1, -1, -1):
            if np.isnan(data[i]) and i < len(data) - 1:
                data[i] = data[i + 1]
        # Fill any remaining NaNs at the end
        last_valid = np.where(~np.isnan(data))[0]
        if len(last_valid) > 0:
            data[last_valid[-1] + 1 :] = data[last_valid[-1]]
        return data
    else:
        raise ValueError(f"Unknown method: {method}")

    data[mask] = fill
    return data

log_transform(data, shift=0.0, base='e')

Apply logarithmic transformation to data.

Parameters:

Name Type Description Default
data ndarray

Input data

required
shift float

Value to add before taking log (for handling zeros/negatives)

0.0
base str

'e', '10', or '2'

'e'

Returns:

Type Description
ndarray

Log-transformed data

Source code in src/utils/data_preprocessing.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
def log_transform(data: np.ndarray, shift: float = 0.0, base: str = "e") -> np.ndarray:
    """
    Apply logarithmic transformation to data.

    Args:
        data: Input data
        shift: Value to add before taking log (for handling zeros/negatives)
        base: 'e', '10', or '2'

    Returns:
        Log-transformed data
    """
    data = np.asarray(data) + shift

    if np.any(data <= 0):
        raise ValueError("Cannot take log of non-positive values. Use shift parameter.")

    if base == "e":
        return np.log(data)
    elif base == "10":
        return np.log10(data)
    elif base == "2":
        return np.log2(data)
    else:
        raise ValueError(f"Unknown base: {base}")

normalize(data, method='minmax', feature_range=(0, 1), return_params=False)

Normalize data to specified range.

Parameters:

Name Type Description Default
data ndarray

Input data

required
method str

'minmax' or 'maxabs'

'minmax'
feature_range tuple[float, float]

Target range (min, max)

(0, 1)
return_params bool

Whether to return normalization parameters

False

Returns:

Type Description
ndarray | tuple[ndarray, dict]

Normalized data, optionally with parameters

Source code in src/utils/data_preprocessing.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def normalize(
    data: np.ndarray,
    method: str = "minmax",
    feature_range: tuple[float, float] = (0, 1),
    return_params: bool = False,
) -> np.ndarray | tuple[np.ndarray, dict]:
    """
    Normalize data to specified range.

    Args:
        data: Input data
        method: 'minmax' or 'maxabs'
        feature_range: Target range (min, max)
        return_params: Whether to return normalization parameters

    Returns:
        Normalized data, optionally with parameters
    """
    data = np.asarray(data)

    if method == "minmax":
        min_val = np.min(data)
        max_val = np.max(data)
        range_val = max_val - min_val

        if range_val == 0:
            normalized = np.full_like(data, (feature_range[0] + feature_range[1]) / 2)
        else:
            # Scale to [0, 1]
            normalized = (data - min_val) / range_val
            # Scale to feature_range
            normalized = normalized * (feature_range[1] - feature_range[0]) + feature_range[0]

        params = {"min": min_val, "max": max_val, "feature_range": feature_range}

    elif method == "maxabs":
        max_abs = np.max(np.abs(data))

        if max_abs == 0:
            normalized = data
        else:
            normalized = data / max_abs

        params = {"max_abs": max_abs}

    else:
        raise ValueError(f"Unknown method: {method}")

    if return_params:
        return normalized, params
    return normalized

remove_outliers(data, method='iqr', threshold=1.5, return_mask=False)

Remove outliers from data.

Parameters:

Name Type Description Default
data ndarray

Input data

required
method str

'iqr', 'zscore', or 'mad'

'iqr'
threshold float

Threshold for outlier detection

1.5
return_mask bool

Whether to return boolean mask of inliers

False

Returns:

Type Description
ndarray | tuple[ndarray, ndarray]

Data with outliers removed, optionally with inlier mask

Source code in src/utils/data_preprocessing.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def remove_outliers(
    data: np.ndarray, method: str = "iqr", threshold: float = 1.5, return_mask: bool = False
) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
    """
    Remove outliers from data.

    Args:
        data: Input data
        method: 'iqr', 'zscore', or 'mad'
        threshold: Threshold for outlier detection
        return_mask: Whether to return boolean mask of inliers

    Returns:
        Data with outliers removed, optionally with inlier mask
    """
    data = np.asarray(data)

    if method == "iqr":
        q1, q3 = np.percentile(data, [25, 75])
        iqr = q3 - q1
        lower_bound = q1 - threshold * iqr
        upper_bound = q3 + threshold * iqr
        mask = (data >= lower_bound) & (data <= upper_bound)

    elif method == "zscore":
        z_scores = np.abs(stats.zscore(data))
        mask = z_scores <= threshold

    elif method == "mad":
        median = np.median(data)
        mad = np.median(np.abs(data - median))
        if mad == 0:
            mask = np.ones(len(data), dtype=bool)
        else:
            modified_z_scores = 0.6745 * np.abs(data - median) / mad
            mask = modified_z_scores <= threshold

    else:
        raise ValueError(f"Unknown method: {method}")

    cleaned_data = data[mask]

    if return_mask:
        return cleaned_data, mask
    return cleaned_data

standardize(data, return_params=False)

Standardize data to zero mean and unit variance (Z-score normalization).

Parameters:

Name Type Description Default
data ndarray

Input data

required
return_params bool

Whether to return standardization parameters

False

Returns:

Type Description
ndarray | tuple[ndarray, dict]

Standardized data, optionally with parameters (mean, std)

Source code in src/utils/data_preprocessing.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def standardize(
    data: np.ndarray, return_params: bool = False
) -> np.ndarray | tuple[np.ndarray, dict]:
    """
    Standardize data to zero mean and unit variance (Z-score normalization).

    Args:
        data: Input data
        return_params: Whether to return standardization parameters

    Returns:
        Standardized data, optionally with parameters (mean, std)
    """
    data = np.asarray(data)
    mean = np.mean(data)
    std = np.std(data, ddof=1)

    if std == 0 or np.isnan(std):
        standardized = data - mean
    else:
        standardized = (data - mean) / std

    if return_params:
        return standardized, {"mean": mean, "std": std}
    return standardized

src.utils.plotting

Plotting utilities for probability distributions.

plot_correlation_heatmap(data, labels=None, method='pearson', figsize=(10, 8), annot=True, cmap='coolwarm')

Create correlation matrix heatmap.

Parameters:

Name Type Description Default
data ndarray

2D array where each column is a variable

required
labels list[str] | None

Variable names

None
method str

'pearson', 'spearman', or 'kendall'

'pearson'
figsize tuple[float, float]

Figure size

(10, 8)
annot bool

Whether to annotate cells with values

True
cmap str

Colormap name

'coolwarm'

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def plot_correlation_heatmap(
    data: np.ndarray,
    labels: list[str] | None = None,
    method: str = "pearson",
    figsize: tuple[float, float] = (10, 8),
    annot: bool = True,
    cmap: str = "coolwarm",
) -> plt.Figure:
    """
    Create correlation matrix heatmap.

    Args:
        data: 2D array where each column is a variable
        labels: Variable names
        method: 'pearson', 'spearman', or 'kendall'
        figsize: Figure size
        annot: Whether to annotate cells with values
        cmap: Colormap name

    Returns:
        Matplotlib figure
    """
    data = np.asarray(data)
    if data.size == 0:
        raise ValueError("data must not be empty")
    if data.ndim == 1:
        data = data.reshape(-1, 1)

    n_vars = data.shape[1]

    # Compute correlation matrix
    if method == "pearson":
        corr_matrix = np.corrcoef(data.T)
    else:
        corr_matrix = np.zeros((n_vars, n_vars))
        for i in range(n_vars):
            for j in range(n_vars):
                if i == j:
                    corr_matrix[i, j] = 1.0
                else:
                    if method == "spearman":
                        corr, _ = stats.spearmanr(data[:, i], data[:, j])
                    elif method == "kendall":
                        corr, _ = stats.kendalltau(data[:, i], data[:, j])
                    else:
                        raise ValueError(f"Unknown method: {method}")
                    corr_matrix[i, j] = corr

    # Create figure
    fig, ax = plt.subplots(figsize=figsize)

    # Heatmap
    im = ax.imshow(corr_matrix, cmap=cmap, vmin=-1, vmax=1, aspect="auto")

    # Labels
    if labels is None:
        labels = [f"Var {i+1}" for i in range(n_vars)]

    ax.set_xticks(np.arange(n_vars))
    ax.set_yticks(np.arange(n_vars))
    ax.set_xticklabels(labels, rotation=45, ha="right")
    ax.set_yticklabels(labels)

    # Annotations
    if annot:
        for i in range(n_vars):
            for j in range(n_vars):
                ax.text(
                    j,
                    i,
                    f"{corr_matrix[i, j]:.2f}",
                    ha="center",
                    va="center",
                    color="white" if abs(corr_matrix[i, j]) > 0.5 else "black",
                    fontsize=9,
                )

    # Colorbar
    cbar = plt.colorbar(im, ax=ax)
    cbar.set_label("Correlation", rotation=270, labelpad=20)

    ax.set_title(f"{method.capitalize()} Correlation Matrix")

    plt.tight_layout()
    return fig

plot_distribution_comparison(data, distributions, fitted_params=None, bins=30, figsize=(12, 6))

Compare empirical data with fitted distributions.

Parameters:

Name Type Description Default
data ndarray

Empirical data

required
distributions list[str]

List of distribution names

required
fitted_params dict | None

Dictionary of fitted parameters for each distribution

None
bins int

Number of histogram bins

30
figsize tuple[float, float]

Figure size

(12, 6)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def plot_distribution_comparison(
    data: np.ndarray,
    distributions: list[str],
    fitted_params: dict | None = None,
    bins: int = 30,
    figsize: tuple[float, float] = (12, 6),
) -> plt.Figure:
    """
    Compare empirical data with fitted distributions.

    Args:
        data: Empirical data
        distributions: List of distribution names
        fitted_params: Dictionary of fitted parameters for each distribution
        bins: Number of histogram bins
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)

    # Histogram
    ax1.hist(data, bins=bins, density=True, alpha=0.6, color="gray", label="Data")

    # Plot fitted distributions
    x = np.linspace(np.min(data), np.max(data), 1000)

    for dist_name in distributions:
        dist = getattr(stats, dist_name)

        if fitted_params and dist_name in fitted_params:
            params = fitted_params[dist_name]
        else:
            params = dist.fit(data)

        pdf = dist.pdf(x, *params)
        ax1.plot(x, pdf, label=f"{dist_name}", linewidth=2)

    ax1.set_xlabel("Value")
    ax1.set_ylabel("Density")
    ax1.set_title("Distribution Comparison")
    ax1.legend()
    ax1.grid(alpha=0.3)

    # CDF comparison
    empirical_cdf = np.arange(1, len(sorted(data)) + 1) / len(data)
    ax2.plot(sorted(data), empirical_cdf, "o", markersize=2, alpha=0.6, label="Empirical")

    for dist_name in distributions:
        dist = getattr(stats, dist_name)

        if fitted_params and dist_name in fitted_params:
            params = fitted_params[dist_name]
        else:
            params = dist.fit(data)

        cdf = dist.cdf(x, *params)
        ax2.plot(x, cdf, label=f"{dist_name}", linewidth=2)

    ax2.set_xlabel("Value")
    ax2.set_ylabel("Cumulative Probability")
    ax2.set_title("CDF Comparison")
    ax2.legend()
    ax2.grid(alpha=0.3)

    plt.tight_layout()
    return fig

plot_histogram_with_fit(data, dist='norm', params=None, bins=30, figsize=(10, 6))

Plot histogram with fitted distribution overlay.

Parameters:

Name Type Description Default
data ndarray

Empirical data

required
dist str

Distribution name

'norm'
params tuple | None

Distribution parameters (fitted if None)

None
bins int

Number of histogram bins

30
figsize tuple[float, float]

Figure size

(10, 6)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def plot_histogram_with_fit(
    data: np.ndarray,
    dist: str = "norm",
    params: tuple | None = None,
    bins: int = 30,
    figsize: tuple[float, float] = (10, 6),
) -> plt.Figure:
    """
    Plot histogram with fitted distribution overlay.

    Args:
        data: Empirical data
        dist: Distribution name
        params: Distribution parameters (fitted if None)
        bins: Number of histogram bins
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    data = np.asarray(data)
    if data.size == 0:
        raise ValueError("data must not be empty")
    distribution = getattr(stats, dist)

    if params is None:
        params = distribution.fit(data)

    fig, ax = plt.subplots(figsize=figsize)

    # Histogram
    n, bins_edges, patches = ax.hist(
        data, bins=bins, density=True, alpha=0.6, color="skyblue", edgecolor="black", label="Data"
    )

    # Fitted distribution
    x = np.linspace(np.min(data), np.max(data), 1000)
    pdf = distribution.pdf(x, *params)
    ax.plot(x, pdf, "r-", linewidth=2, label=f"Fitted {dist}")

    # Statistics text
    param_str = ", ".join([f"{p:.3f}" for p in params])
    stats_text = f"Parameters: {param_str}\n"
    stats_text += f"Mean: {np.mean(data):.3f}\n"
    stats_text += f"Std: {np.std(data):.3f}"

    ax.text(
        0.95,
        0.95,
        stats_text,
        transform=ax.transAxes,
        verticalalignment="top",
        horizontalalignment="right",
        bbox=dict(boxstyle="round", facecolor="white", alpha=0.8),
        fontsize=10,
    )

    ax.set_xlabel("Value")
    ax.set_ylabel("Density")
    ax.set_title(f"Histogram with Fitted {dist.capitalize()} Distribution")
    ax.legend()
    ax.grid(alpha=0.3)

    plt.tight_layout()
    return fig

plot_probability_bands(data, dist='norm', params=None, confidence_levels=None, figsize=(12, 6))

Plot data with probability bands.

Parameters:

Name Type Description Default
data ndarray

Time series or sequential data

required
dist str

Distribution name

'norm'
params tuple | None

Distribution parameters

None
confidence_levels list[float] | None

List of confidence levels

None
figsize tuple[float, float]

Figure size

(12, 6)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def plot_probability_bands(
    data: np.ndarray,
    dist: str = "norm",
    params: tuple | None = None,
    confidence_levels: list[float] | None = None,
    figsize: tuple[float, float] = (12, 6),
) -> plt.Figure:
    """
    Plot data with probability bands.

    Args:
        data: Time series or sequential data
        dist: Distribution name
        params: Distribution parameters
        confidence_levels: List of confidence levels
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    distribution = getattr(stats, dist)

    if params is None:
        params = distribution.fit(data)

    levels = [0.68, 0.95, 0.997] if confidence_levels is None else list(confidence_levels)

    fig, ax = plt.subplots(figsize=figsize)

    # Plot data
    x = np.arange(len(data))
    ax.plot(x, data, "k-", linewidth=1.5, label="Data", zorder=5)

    # Mean line
    mean = distribution.mean(*params)
    ax.axhline(y=mean, color="red", linestyle="--", linewidth=2, label="Mean", zorder=4)

    # Probability bands
    colors = ["lightblue", "lightgreen", "lightyellow"]

    # NOTE: plain zip is intentional — extra confidence levels reuse the
    # first len(colors) bands; strict=True would reject valid custom inputs.
    for level, color in zip(levels, colors):  # noqa: B905
        alpha = (1 - level) / 2
        lower = distribution.ppf(alpha, *params)
        upper = distribution.ppf(1 - alpha, *params)

        ax.fill_between(
            x, lower, upper, alpha=0.3, color=color, label=f"{level*100:.1f}% CI", zorder=1
        )

    ax.set_xlabel("Index")
    ax.set_ylabel("Value")
    ax.set_title("Data with Probability Bands")
    ax.legend()
    ax.grid(alpha=0.3)

    plt.tight_layout()
    return fig

plot_qq(data, dist='norm', params=None, figsize=(8, 8))

Create Q-Q plot for distribution fit assessment.

Parameters:

Name Type Description Default
data ndarray

Empirical data

required
dist str

Distribution name

'norm'
params tuple | None

Distribution parameters (fitted if None)

None
figsize tuple[float, float]

Figure size

(8, 8)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def plot_qq(
    data: np.ndarray,
    dist: str = "norm",
    params: tuple | None = None,
    figsize: tuple[float, float] = (8, 8),
) -> plt.Figure:
    """
    Create Q-Q plot for distribution fit assessment.

    Args:
        data: Empirical data
        dist: Distribution name
        params: Distribution parameters (fitted if None)
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    data = np.asarray(data)
    if data.size == 0:
        raise ValueError("data must not be empty")
    distribution = getattr(stats, dist)

    if params is None:
        params = distribution.fit(data)

    fig, ax = plt.subplots(figsize=figsize)

    # Theoretical quantiles
    sorted_data = np.sort(data)
    n = len(data)
    theoretical_quantiles = distribution.ppf(np.linspace(0.01, 0.99, n), *params)

    # Q-Q plot
    ax.scatter(theoretical_quantiles, sorted_data, alpha=0.6, s=20)

    # Reference line
    min_val = min(np.min(theoretical_quantiles), np.min(sorted_data))
    max_val = max(np.max(theoretical_quantiles), np.max(sorted_data))
    ax.plot([min_val, max_val], [min_val, max_val], "r--", linewidth=2, label="Perfect fit")

    ax.set_xlabel("Theoretical Quantiles")
    ax.set_ylabel("Sample Quantiles")
    ax.set_title(f"Q-Q Plot ({dist} distribution)")
    ax.legend()
    ax.grid(alpha=0.3)

    plt.tight_layout()
    return fig

src.utils.logger

Structured logging infrastructure with JSON and console output.

get_correlation_id()

Get the current correlation ID.

Source code in src/utils/logger.py
108
109
110
def get_correlation_id() -> str | None:
    """Get the current correlation ID."""
    return _correlation_id.get()

get_logger(name)

Get or create a logger with the default console configuration.

Parameters:

Name Type Description Default
name str

Logger name (typically name).

required

Returns:

Type Description
Logger

Logger instance (created with default setup if not yet initialized).

Source code in src/utils/logger.py
154
155
156
157
158
159
160
161
162
163
164
165
166
def get_logger(name: str) -> logging.Logger:
    """
    Get or create a logger with the default console configuration.

    Args:
        name: Logger name (typically __name__).

    Returns:
        Logger instance (created with default setup if not yet initialized).
    """
    if name not in _loggers_initialized:
        return setup_logger(name)
    return logging.getLogger(name)

log_error(logger, message, exc=None, extra=None)

Log an error with full exception context.

Parameters:

Name Type Description Default
logger Logger

Logger instance to use.

required
message str

Human-readable error message.

required
exc Exception | None

Exception instance (if available, to attach traceback).

None
extra dict[str, Any] | None

Optional additional context data.

None
Source code in src/utils/logger.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def log_error(
    logger: logging.Logger,
    message: str,
    exc: Exception | None = None,
    extra: dict[str, Any] | None = None,
) -> None:
    """
    Log an error with full exception context.

    Args:
        logger: Logger instance to use.
        message: Human-readable error message.
        exc: Exception instance (if available, to attach traceback).
        extra: Optional additional context data.
    """
    extra_data = extra or {}
    if exc:
        extra_data["error_type"] = type(exc).__name__
        extra_data["error_message"] = str(exc)

    record = logger.makeRecord(
        logger.name,
        logging.ERROR,
        "(unknown)",
        0,
        message,
        (),
        exc_info=sys.exc_info() if exc is None else (type(exc), exc, exc.__traceback__),
    )
    if extra_data:
        record._extra = extra_data  # type: ignore[attr-defined]
    logger.handle(record)

log_execution_time(logger=None)

Decorator to log function execution time.

Usage

@log_execution_time(logger) def my_function(): ...

Parameters:

Name Type Description Default
logger Logger | None

Logger instance to use. If None, logger for the module is used.

None
Source code in src/utils/logger.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def log_execution_time(logger: logging.Logger | None = None):
    """
    Decorator to log function execution time.

    Usage:
        @log_execution_time(logger)
        def my_function():
            ...

    Args:
        logger: Logger instance to use. If None, logger for the module is used.
    """

    def decorator(func: F) -> F:
        @functools.wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            _logger = logger or get_logger(func.__module__)
            start = time.perf_counter()
            try:
                result = func(*args, **kwargs)
                elapsed = time.perf_counter() - start
                _logger.debug(
                    "%s.%s completed in %.4f ms",
                    func.__module__,
                    func.__qualname__,
                    elapsed * 1000,
                )
                return result
            except Exception:
                elapsed = time.perf_counter() - start
                _logger.warning(
                    "%s.%s failed after %.4f ms",
                    func.__module__,
                    func.__qualname__,
                    elapsed * 1000,
                )
                raise

        return wrapper  # type: ignore[return-value]

    if callable(logger):
        func = logger
        logger = None
        return decorator(func)

    return decorator

set_correlation_id(correlation_id=None)

Set or generate a correlation ID for request tracking.

Source code in src/utils/logger.py
101
102
103
104
105
def set_correlation_id(correlation_id: str | None = None) -> str:
    """Set or generate a correlation ID for request tracking."""
    cid = correlation_id or str(uuid.uuid4())
    _correlation_id.set(cid)
    return cid

setup_logger(name='root', level=logging.INFO, structured=False)

Set up a logger with the specified configuration.

Parameters:

Name Type Description Default
name str

Logger name (use name for module-level loggers).

'root'
level int

Logging level (DEBUG, INFO, WARNING, ERROR).

INFO
structured bool

If True, emit JSON lines; otherwise pretty console output.

False

Returns:

Type Description
Logger

Configured logger instance.

Source code in src/utils/logger.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def setup_logger(
    name: str = "root",
    level: int = logging.INFO,
    structured: bool = False,
) -> logging.Logger:
    """
    Set up a logger with the specified configuration.

    Args:
        name: Logger name (use __name__ for module-level loggers).
        level: Logging level (DEBUG, INFO, WARNING, ERROR).
        structured: If True, emit JSON lines; otherwise pretty console output.

    Returns:
        Configured logger instance.
    """
    with _setup_lock:
        if name in _loggers_initialized:
            return logging.getLogger(name)

        logger = logging.getLogger(name)
        logger.setLevel(level)
        logger.propagate = False

        if logger.handlers:
            logger.handlers.clear()

        handler = logging.StreamHandler(sys.stdout)
        handler.setLevel(level)

        if structured:
            handler.setFormatter(_StructuredFormatter())
        else:
            handler.setFormatter(_ConsoleFormatter())

        logger.addHandler(handler)
        _loggers_initialized[name] = True

        return logger

src.visualizers

Visualization helpers for probability distributions.

This package provides the public visualization API. The heavy lifting lives in :mod:src.utils.plotting; the functions here add distribution-aware wrappers so both src.visualizers and src.utils.plotting import paths work.

plot_cdf(distribution, x=None, num_points=500, ax=None)

Plot the CDF of a distribution (see :func:plot_pdf for args).

Source code in src/visualizers/__init__.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def plot_cdf(
    distribution: Any,
    x: np.ndarray | None = None,
    num_points: int = 500,
    ax: plt.Axes | None = None,
) -> tuple[plt.Figure, plt.Axes]:
    """Plot the CDF of a distribution (see :func:`plot_pdf` for args)."""
    x = _infer_grid(distribution, x, num_points)
    y = np.asarray(distribution.cdf(x))
    if ax is None:
        fig, ax_out = plt.subplots()
    else:
        ax_out = ax
        # An Axes handed in by the caller always belongs to a Figure;
        # the stub type is wider (Figure | SubFigure | None).
        fig = cast(plt.Figure, ax_out.figure)
    ax_out.plot(np.asarray(x), y)
    ax_out.set_xlabel("x")
    ax_out.set_ylabel("Cumulative probability")
    ax_out.set_title(f"{distribution!r} - CDF")
    ax_out.grid(True, alpha=0.3)
    return fig, ax_out

plot_comparison(distributions, x=None, num_points=500)

Overlay the PDFs/PMFs of several distributions on shared axes.

Source code in src/visualizers/__init__.py
113
114
115
116
117
118
119
120
121
122
def plot_comparison(
    distributions: list, x: np.ndarray | None = None, num_points: int = 500
) -> tuple[plt.Figure, plt.Axes]:
    """Overlay the PDFs/PMFs of several distributions on shared axes."""
    fig, ax = plt.subplots()
    for dist in distributions:
        plot_pdf(dist, x=x, num_points=num_points, ax=ax)
    ax.legend([f"{d!r}" for d in distributions])
    ax.set_title("Distribution comparison")
    return fig, ax

plot_correlation_heatmap(data, labels=None, method='pearson', figsize=(10, 8), annot=True, cmap='coolwarm')

Create correlation matrix heatmap.

Parameters:

Name Type Description Default
data ndarray

2D array where each column is a variable

required
labels list[str] | None

Variable names

None
method str

'pearson', 'spearman', or 'kendall'

'pearson'
figsize tuple[float, float]

Figure size

(10, 8)
annot bool

Whether to annotate cells with values

True
cmap str

Colormap name

'coolwarm'

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def plot_correlation_heatmap(
    data: np.ndarray,
    labels: list[str] | None = None,
    method: str = "pearson",
    figsize: tuple[float, float] = (10, 8),
    annot: bool = True,
    cmap: str = "coolwarm",
) -> plt.Figure:
    """
    Create correlation matrix heatmap.

    Args:
        data: 2D array where each column is a variable
        labels: Variable names
        method: 'pearson', 'spearman', or 'kendall'
        figsize: Figure size
        annot: Whether to annotate cells with values
        cmap: Colormap name

    Returns:
        Matplotlib figure
    """
    data = np.asarray(data)
    if data.size == 0:
        raise ValueError("data must not be empty")
    if data.ndim == 1:
        data = data.reshape(-1, 1)

    n_vars = data.shape[1]

    # Compute correlation matrix
    if method == "pearson":
        corr_matrix = np.corrcoef(data.T)
    else:
        corr_matrix = np.zeros((n_vars, n_vars))
        for i in range(n_vars):
            for j in range(n_vars):
                if i == j:
                    corr_matrix[i, j] = 1.0
                else:
                    if method == "spearman":
                        corr, _ = stats.spearmanr(data[:, i], data[:, j])
                    elif method == "kendall":
                        corr, _ = stats.kendalltau(data[:, i], data[:, j])
                    else:
                        raise ValueError(f"Unknown method: {method}")
                    corr_matrix[i, j] = corr

    # Create figure
    fig, ax = plt.subplots(figsize=figsize)

    # Heatmap
    im = ax.imshow(corr_matrix, cmap=cmap, vmin=-1, vmax=1, aspect="auto")

    # Labels
    if labels is None:
        labels = [f"Var {i+1}" for i in range(n_vars)]

    ax.set_xticks(np.arange(n_vars))
    ax.set_yticks(np.arange(n_vars))
    ax.set_xticklabels(labels, rotation=45, ha="right")
    ax.set_yticklabels(labels)

    # Annotations
    if annot:
        for i in range(n_vars):
            for j in range(n_vars):
                ax.text(
                    j,
                    i,
                    f"{corr_matrix[i, j]:.2f}",
                    ha="center",
                    va="center",
                    color="white" if abs(corr_matrix[i, j]) > 0.5 else "black",
                    fontsize=9,
                )

    # Colorbar
    cbar = plt.colorbar(im, ax=ax)
    cbar.set_label("Correlation", rotation=270, labelpad=20)

    ax.set_title(f"{method.capitalize()} Correlation Matrix")

    plt.tight_layout()
    return fig

plot_distribution_comparison(data, distributions, fitted_params=None, bins=30, figsize=(12, 6))

Compare empirical data with fitted distributions.

Parameters:

Name Type Description Default
data ndarray

Empirical data

required
distributions list[str]

List of distribution names

required
fitted_params dict | None

Dictionary of fitted parameters for each distribution

None
bins int

Number of histogram bins

30
figsize tuple[float, float]

Figure size

(12, 6)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def plot_distribution_comparison(
    data: np.ndarray,
    distributions: list[str],
    fitted_params: dict | None = None,
    bins: int = 30,
    figsize: tuple[float, float] = (12, 6),
) -> plt.Figure:
    """
    Compare empirical data with fitted distributions.

    Args:
        data: Empirical data
        distributions: List of distribution names
        fitted_params: Dictionary of fitted parameters for each distribution
        bins: Number of histogram bins
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)

    # Histogram
    ax1.hist(data, bins=bins, density=True, alpha=0.6, color="gray", label="Data")

    # Plot fitted distributions
    x = np.linspace(np.min(data), np.max(data), 1000)

    for dist_name in distributions:
        dist = getattr(stats, dist_name)

        if fitted_params and dist_name in fitted_params:
            params = fitted_params[dist_name]
        else:
            params = dist.fit(data)

        pdf = dist.pdf(x, *params)
        ax1.plot(x, pdf, label=f"{dist_name}", linewidth=2)

    ax1.set_xlabel("Value")
    ax1.set_ylabel("Density")
    ax1.set_title("Distribution Comparison")
    ax1.legend()
    ax1.grid(alpha=0.3)

    # CDF comparison
    empirical_cdf = np.arange(1, len(sorted(data)) + 1) / len(data)
    ax2.plot(sorted(data), empirical_cdf, "o", markersize=2, alpha=0.6, label="Empirical")

    for dist_name in distributions:
        dist = getattr(stats, dist_name)

        if fitted_params and dist_name in fitted_params:
            params = fitted_params[dist_name]
        else:
            params = dist.fit(data)

        cdf = dist.cdf(x, *params)
        ax2.plot(x, cdf, label=f"{dist_name}", linewidth=2)

    ax2.set_xlabel("Value")
    ax2.set_ylabel("Cumulative Probability")
    ax2.set_title("CDF Comparison")
    ax2.legend()
    ax2.grid(alpha=0.3)

    plt.tight_layout()
    return fig

plot_histogram_with_fit(data, dist='norm', params=None, bins=30, figsize=(10, 6))

Plot histogram with fitted distribution overlay.

Parameters:

Name Type Description Default
data ndarray

Empirical data

required
dist str

Distribution name

'norm'
params tuple | None

Distribution parameters (fitted if None)

None
bins int

Number of histogram bins

30
figsize tuple[float, float]

Figure size

(10, 6)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def plot_histogram_with_fit(
    data: np.ndarray,
    dist: str = "norm",
    params: tuple | None = None,
    bins: int = 30,
    figsize: tuple[float, float] = (10, 6),
) -> plt.Figure:
    """
    Plot histogram with fitted distribution overlay.

    Args:
        data: Empirical data
        dist: Distribution name
        params: Distribution parameters (fitted if None)
        bins: Number of histogram bins
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    data = np.asarray(data)
    if data.size == 0:
        raise ValueError("data must not be empty")
    distribution = getattr(stats, dist)

    if params is None:
        params = distribution.fit(data)

    fig, ax = plt.subplots(figsize=figsize)

    # Histogram
    n, bins_edges, patches = ax.hist(
        data, bins=bins, density=True, alpha=0.6, color="skyblue", edgecolor="black", label="Data"
    )

    # Fitted distribution
    x = np.linspace(np.min(data), np.max(data), 1000)
    pdf = distribution.pdf(x, *params)
    ax.plot(x, pdf, "r-", linewidth=2, label=f"Fitted {dist}")

    # Statistics text
    param_str = ", ".join([f"{p:.3f}" for p in params])
    stats_text = f"Parameters: {param_str}\n"
    stats_text += f"Mean: {np.mean(data):.3f}\n"
    stats_text += f"Std: {np.std(data):.3f}"

    ax.text(
        0.95,
        0.95,
        stats_text,
        transform=ax.transAxes,
        verticalalignment="top",
        horizontalalignment="right",
        bbox=dict(boxstyle="round", facecolor="white", alpha=0.8),
        fontsize=10,
    )

    ax.set_xlabel("Value")
    ax.set_ylabel("Density")
    ax.set_title(f"Histogram with Fitted {dist.capitalize()} Distribution")
    ax.legend()
    ax.grid(alpha=0.3)

    plt.tight_layout()
    return fig

plot_pdf(distribution, x=None, num_points=500, ax=None)

Plot the PDF/PMF of a distribution.

Parameters:

Name Type Description Default
distribution Any

Any object exposing pdf(x).

required
x ndarray | None

Evaluation points. If None, inferred from the distribution support.

None
num_points int

Points to use when inferring the grid.

500
ax Axes | None

Optional matplotlib axes to draw on.

None

Returns:

Type Description
tuple[Figure, Axes]

Tuple of (figure, axes).

Source code in src/visualizers/__init__.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def plot_pdf(
    distribution: Any,
    x: np.ndarray | None = None,
    num_points: int = 500,
    ax: plt.Axes | None = None,
) -> tuple[plt.Figure, plt.Axes]:
    """Plot the PDF/PMF of a distribution.

    Args:
        distribution: Any object exposing ``pdf(x)``.
        x: Evaluation points. If None, inferred from the distribution support.
        num_points: Points to use when inferring the grid.
        ax: Optional matplotlib axes to draw on.

    Returns:
        Tuple of (figure, axes).
    """
    x = _infer_grid(distribution, x, num_points)
    y = np.asarray(distribution.pdf(x))
    if ax is None:
        fig, ax_out = plt.subplots()
    else:
        ax_out = ax
        # An Axes handed in by the caller always belongs to a Figure;
        # the stub type is wider (Figure | SubFigure | None).
        fig = cast(plt.Figure, ax_out.figure)
    ax_out.plot(np.asarray(x), y)
    ax_out.set_xlabel("x")
    ax_out.set_ylabel("Density / Mass")
    ax_out.set_title(f"{distribution!r} - PDF/PMF")
    ax_out.grid(True, alpha=0.3)
    return fig, ax_out

plot_probability_bands(data, dist='norm', params=None, confidence_levels=None, figsize=(12, 6))

Plot data with probability bands.

Parameters:

Name Type Description Default
data ndarray

Time series or sequential data

required
dist str

Distribution name

'norm'
params tuple | None

Distribution parameters

None
confidence_levels list[float] | None

List of confidence levels

None
figsize tuple[float, float]

Figure size

(12, 6)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def plot_probability_bands(
    data: np.ndarray,
    dist: str = "norm",
    params: tuple | None = None,
    confidence_levels: list[float] | None = None,
    figsize: tuple[float, float] = (12, 6),
) -> plt.Figure:
    """
    Plot data with probability bands.

    Args:
        data: Time series or sequential data
        dist: Distribution name
        params: Distribution parameters
        confidence_levels: List of confidence levels
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    distribution = getattr(stats, dist)

    if params is None:
        params = distribution.fit(data)

    levels = [0.68, 0.95, 0.997] if confidence_levels is None else list(confidence_levels)

    fig, ax = plt.subplots(figsize=figsize)

    # Plot data
    x = np.arange(len(data))
    ax.plot(x, data, "k-", linewidth=1.5, label="Data", zorder=5)

    # Mean line
    mean = distribution.mean(*params)
    ax.axhline(y=mean, color="red", linestyle="--", linewidth=2, label="Mean", zorder=4)

    # Probability bands
    colors = ["lightblue", "lightgreen", "lightyellow"]

    # NOTE: plain zip is intentional — extra confidence levels reuse the
    # first len(colors) bands; strict=True would reject valid custom inputs.
    for level, color in zip(levels, colors):  # noqa: B905
        alpha = (1 - level) / 2
        lower = distribution.ppf(alpha, *params)
        upper = distribution.ppf(1 - alpha, *params)

        ax.fill_between(
            x, lower, upper, alpha=0.3, color=color, label=f"{level*100:.1f}% CI", zorder=1
        )

    ax.set_xlabel("Index")
    ax.set_ylabel("Value")
    ax.set_title("Data with Probability Bands")
    ax.legend()
    ax.grid(alpha=0.3)

    plt.tight_layout()
    return fig

plot_qq(data, dist='norm', params=None, figsize=(8, 8))

Create Q-Q plot for distribution fit assessment.

Parameters:

Name Type Description Default
data ndarray

Empirical data

required
dist str

Distribution name

'norm'
params tuple | None

Distribution parameters (fitted if None)

None
figsize tuple[float, float]

Figure size

(8, 8)

Returns:

Type Description
Figure

Matplotlib figure

Source code in src/utils/plotting.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def plot_qq(
    data: np.ndarray,
    dist: str = "norm",
    params: tuple | None = None,
    figsize: tuple[float, float] = (8, 8),
) -> plt.Figure:
    """
    Create Q-Q plot for distribution fit assessment.

    Args:
        data: Empirical data
        dist: Distribution name
        params: Distribution parameters (fitted if None)
        figsize: Figure size

    Returns:
        Matplotlib figure
    """
    data = np.asarray(data)
    if data.size == 0:
        raise ValueError("data must not be empty")
    distribution = getattr(stats, dist)

    if params is None:
        params = distribution.fit(data)

    fig, ax = plt.subplots(figsize=figsize)

    # Theoretical quantiles
    sorted_data = np.sort(data)
    n = len(data)
    theoretical_quantiles = distribution.ppf(np.linspace(0.01, 0.99, n), *params)

    # Q-Q plot
    ax.scatter(theoretical_quantiles, sorted_data, alpha=0.6, s=20)

    # Reference line
    min_val = min(np.min(theoretical_quantiles), np.min(sorted_data))
    max_val = max(np.max(theoretical_quantiles), np.max(sorted_data))
    ax.plot([min_val, max_val], [min_val, max_val], "r--", linewidth=2, label="Perfect fit")

    ax.set_xlabel("Theoretical Quantiles")
    ax.set_ylabel("Sample Quantiles")
    ax.set_title(f"Q-Q Plot ({dist} distribution)")
    ax.legend()
    ax.grid(alpha=0.3)

    plt.tight_layout()
    return fig

src.cli

Command-line interface for the Probability Distribution Visualizer.