skfolio 0.6.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. skfolio/__init__.py +7 -7
  2. skfolio/cluster/__init__.py +2 -2
  3. skfolio/cluster/_hierarchical.py +2 -2
  4. skfolio/datasets/__init__.py +3 -3
  5. skfolio/datasets/_base.py +2 -2
  6. skfolio/datasets/data/__init__.py +1 -0
  7. skfolio/distance/__init__.py +4 -4
  8. skfolio/distance/_base.py +2 -2
  9. skfolio/distance/_distance.py +11 -10
  10. skfolio/distribution/__init__.py +56 -0
  11. skfolio/distribution/_base.py +203 -0
  12. skfolio/distribution/copula/__init__.py +35 -0
  13. skfolio/distribution/copula/_base.py +456 -0
  14. skfolio/distribution/copula/_clayton.py +539 -0
  15. skfolio/distribution/copula/_gaussian.py +407 -0
  16. skfolio/distribution/copula/_gumbel.py +560 -0
  17. skfolio/distribution/copula/_independent.py +196 -0
  18. skfolio/distribution/copula/_joe.py +609 -0
  19. skfolio/distribution/copula/_selection.py +111 -0
  20. skfolio/distribution/copula/_student_t.py +486 -0
  21. skfolio/distribution/copula/_utils.py +509 -0
  22. skfolio/distribution/multivariate/__init__.py +11 -0
  23. skfolio/distribution/multivariate/_base.py +241 -0
  24. skfolio/distribution/multivariate/_utils.py +632 -0
  25. skfolio/distribution/multivariate/_vine_copula.py +1254 -0
  26. skfolio/distribution/univariate/__init__.py +19 -0
  27. skfolio/distribution/univariate/_base.py +308 -0
  28. skfolio/distribution/univariate/_gaussian.py +136 -0
  29. skfolio/distribution/univariate/_johnson_su.py +152 -0
  30. skfolio/distribution/univariate/_normal_inverse_gaussian.py +153 -0
  31. skfolio/distribution/univariate/_selection.py +85 -0
  32. skfolio/distribution/univariate/_student_t.py +144 -0
  33. skfolio/exceptions.py +8 -8
  34. skfolio/measures/__init__.py +24 -24
  35. skfolio/measures/_enums.py +7 -7
  36. skfolio/measures/_measures.py +4 -7
  37. skfolio/metrics/__init__.py +2 -0
  38. skfolio/metrics/_scorer.py +4 -4
  39. skfolio/model_selection/__init__.py +4 -4
  40. skfolio/model_selection/_combinatorial.py +15 -12
  41. skfolio/model_selection/_validation.py +2 -2
  42. skfolio/model_selection/_walk_forward.py +3 -3
  43. skfolio/moments/__init__.py +11 -11
  44. skfolio/moments/covariance/__init__.py +6 -6
  45. skfolio/moments/covariance/_base.py +1 -1
  46. skfolio/moments/covariance/_denoise_covariance.py +3 -2
  47. skfolio/moments/covariance/_detone_covariance.py +3 -2
  48. skfolio/moments/covariance/_empirical_covariance.py +3 -2
  49. skfolio/moments/covariance/_ew_covariance.py +3 -2
  50. skfolio/moments/covariance/_gerber_covariance.py +3 -2
  51. skfolio/moments/covariance/_graphical_lasso_cv.py +1 -1
  52. skfolio/moments/covariance/_implied_covariance.py +3 -8
  53. skfolio/moments/covariance/_ledoit_wolf.py +1 -1
  54. skfolio/moments/covariance/_oas.py +1 -1
  55. skfolio/moments/covariance/_shrunk_covariance.py +1 -1
  56. skfolio/moments/expected_returns/__init__.py +2 -2
  57. skfolio/moments/expected_returns/_base.py +1 -1
  58. skfolio/moments/expected_returns/_empirical_mu.py +3 -2
  59. skfolio/moments/expected_returns/_equilibrium_mu.py +3 -2
  60. skfolio/moments/expected_returns/_ew_mu.py +3 -2
  61. skfolio/moments/expected_returns/_shrunk_mu.py +4 -3
  62. skfolio/optimization/__init__.py +12 -10
  63. skfolio/optimization/_base.py +2 -2
  64. skfolio/optimization/cluster/__init__.py +3 -1
  65. skfolio/optimization/cluster/_nco.py +10 -9
  66. skfolio/optimization/cluster/hierarchical/__init__.py +3 -1
  67. skfolio/optimization/cluster/hierarchical/_base.py +1 -2
  68. skfolio/optimization/cluster/hierarchical/_herc.py +4 -3
  69. skfolio/optimization/cluster/hierarchical/_hrp.py +4 -3
  70. skfolio/optimization/convex/__init__.py +5 -3
  71. skfolio/optimization/convex/_base.py +10 -9
  72. skfolio/optimization/convex/_distributionally_robust.py +8 -5
  73. skfolio/optimization/convex/_maximum_diversification.py +8 -6
  74. skfolio/optimization/convex/_mean_risk.py +10 -8
  75. skfolio/optimization/convex/_risk_budgeting.py +6 -4
  76. skfolio/optimization/ensemble/__init__.py +2 -0
  77. skfolio/optimization/ensemble/_base.py +2 -2
  78. skfolio/optimization/ensemble/_stacking.py +3 -3
  79. skfolio/optimization/naive/__init__.py +3 -1
  80. skfolio/optimization/naive/_naive.py +4 -3
  81. skfolio/population/__init__.py +2 -0
  82. skfolio/population/_population.py +34 -7
  83. skfolio/portfolio/__init__.py +1 -1
  84. skfolio/portfolio/_base.py +43 -8
  85. skfolio/portfolio/_multi_period_portfolio.py +3 -2
  86. skfolio/portfolio/_portfolio.py +5 -4
  87. skfolio/pre_selection/__init__.py +3 -1
  88. skfolio/pre_selection/_drop_correlated.py +3 -3
  89. skfolio/pre_selection/_select_complete.py +31 -30
  90. skfolio/pre_selection/_select_k_extremes.py +3 -3
  91. skfolio/pre_selection/_select_non_dominated.py +3 -3
  92. skfolio/pre_selection/_select_non_expiring.py +8 -6
  93. skfolio/preprocessing/__init__.py +2 -0
  94. skfolio/preprocessing/_returns.py +2 -2
  95. skfolio/prior/__init__.py +7 -3
  96. skfolio/prior/_base.py +2 -2
  97. skfolio/prior/_black_litterman.py +7 -4
  98. skfolio/prior/_empirical.py +5 -2
  99. skfolio/prior/_factor_model.py +10 -5
  100. skfolio/prior/_synthetic_data.py +239 -0
  101. skfolio/synthetic_returns/__init__.py +1 -0
  102. skfolio/typing.py +7 -7
  103. skfolio/uncertainty_set/__init__.py +7 -5
  104. skfolio/uncertainty_set/_base.py +5 -4
  105. skfolio/uncertainty_set/_bootstrap.py +1 -1
  106. skfolio/uncertainty_set/_empirical.py +1 -1
  107. skfolio/utils/__init__.py +1 -0
  108. skfolio/utils/bootstrap.py +2 -2
  109. skfolio/utils/equations.py +13 -10
  110. skfolio/utils/sorting.py +2 -2
  111. skfolio/utils/stats.py +15 -15
  112. skfolio/utils/tools.py +86 -22
  113. {skfolio-0.6.0.dist-info → skfolio-0.8.0.dist-info}/METADATA +122 -46
  114. skfolio-0.8.0.dist-info/RECORD +120 -0
  115. {skfolio-0.6.0.dist-info → skfolio-0.8.0.dist-info}/WHEEL +1 -1
  116. skfolio-0.6.0.dist-info/RECORD +0 -95
  117. {skfolio-0.6.0.dist-info → skfolio-0.8.0.dist-info/licenses}/LICENSE +0 -0
  118. {skfolio-0.6.0.dist-info → skfolio-0.8.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,509 @@
1
+ """Bivariate Copula Utils."""
2
+
3
+ # Copyright (c) 2025
4
+ # Author: Hugo Delatte <delatte.hugo@gmail.com>
5
+ # Credits: Matteo Manzi, Vincent Maladière, Carlo Nicolini
6
+ # SPDX-License-Identifier: BSD-3-Clause
7
+
8
+ import operator
9
+ import warnings
10
+ from collections.abc import Callable
11
+ from enum import Enum
12
+
13
+ import numpy as np
14
+ import numpy.typing as npt
15
+ import plotly.graph_objects as go
16
+ import scipy.optimize as so
17
+ import scipy.stats as st
18
+
19
+
20
+ class CopulaRotation(Enum):
21
+ r"""Enum representing the rotation (in degrees) to apply to a bivariate copula.
22
+
23
+ It follows the standard clockwise convention:
24
+
25
+ - `CopulaRotation.R0` (0°): :math:`(u, v) \mapsto (u, v)`
26
+ - `CopulaRotation.R90` (90°): :math:`(u, v) \mapsto (v,\, 1 - u)`
27
+ - `CopulaRotation.R180` (180°): :math:`(u, v) \mapsto (1 - u,\, 1 - v)`
28
+ - `CopulaRotation.R270` (270°): :math:`(u, v) \mapsto (1 - v,\, u)`
29
+
30
+ Attributes
31
+ ----------
32
+ R0 : int
33
+ No rotation (0°).
34
+ R90 : int
35
+ 90° rotation.
36
+ R180 : int
37
+ 180° rotation.
38
+ R270 : int
39
+ 270° rotation.
40
+ """
41
+
42
+ R0 = "0°"
43
+ R90 = "90°"
44
+ R180 = "180°"
45
+ R270 = "270°"
46
+
47
+ def __str__(self) -> str:
48
+ """String representation."""
49
+ return self.value
50
+
51
+
52
+ def compute_pseudo_observations(X: npt.ArrayLike) -> np.ndarray:
53
+ """
54
+ Compute pseudo-observations by ranking each column of the data and scaling the
55
+ ranks.
56
+
57
+ The goal of computing pseudo-observations is to transform your raw data into a
58
+ form that has uniform marginal distributions on the open interval (0, 1). This is
59
+ particularly useful in copula modeling and other statistical methods where the
60
+ dependence structure is of primary interest, independent of the marginal
61
+ distributions.
62
+
63
+ This function transforms each column of the input data into pseudo-observations
64
+ on the (0, 1) interval. For each column, the ranks (starting at 1) are divided by
65
+ (n_samples + 1) to avoid 0 and 1 values, which are problematic for many copula
66
+ methods.
67
+
68
+ Parameters
69
+ ----------
70
+ X : array-like of shape (n_observations, n_assets)
71
+ Input data.
72
+
73
+ Returns
74
+ -------
75
+ pseudo_observations: ndarray of shape (n_observations, n_assets)
76
+ An array of pseudo-observations corresponding to the ranks scaled to (0, 1).
77
+ """
78
+ X = np.asarray(X)
79
+ # Compute column-wise ranks; rankdata returns ranks starting at 1.
80
+ ranks = st.rankdata(X, axis=0)
81
+ n_samples = X.shape[0]
82
+ pseudo_observations = ranks / (n_samples + 1)
83
+ return pseudo_observations
84
+
85
+
86
+ def empirical_tail_concentration(X: npt.ArrayLike, quantiles: np.ndarray) -> np.ndarray:
87
+ """
88
+ Compute empirical tail concentration for the two variables in X.
89
+ This function computes the concentration at each quantile provided.
90
+
91
+ The tail concentration are estimated as:
92
+ - Lower tail: λ_L(q) = P(U₂ ≤ q | U₁ ≤ q)
93
+ - Upper tail: λ_U(q) = P(U₂ ≥ q | U₁ ≥ q)
94
+
95
+ where U₁ and U₂ are the pseudo-observations.
96
+
97
+ Parameters
98
+ ----------
99
+ X : array-like of shape (n_observations, 2)
100
+ A 2D array with exactly 2 columns representing the pseudo-observations.
101
+
102
+ quantiles : array-like of shape (n_quantiles,)
103
+ A 1D array of quantile levels (values between 0 and 1) at which to compute the
104
+ concentration.
105
+
106
+ Returns
107
+ -------
108
+ concentration : ndarray of shape (n_quantiles,)
109
+ An array of empirical tail concentration values for the given quantiles.
110
+
111
+ References
112
+ ----------
113
+ .. [1] "Quantitative Risk Management: Concepts, Techniques, and Tools",
114
+ McNeil, Frey, Embrechts (2005)
115
+
116
+ Raises
117
+ ------
118
+ ValueError
119
+ If X is not a 2D array with exactly 2 columns or if quantiles are not in [0, 1].
120
+ """
121
+ X = np.asarray(X)
122
+ if X.ndim != 2 or X.shape[1] != 2:
123
+ raise ValueError("X must be a 2D array with exactly 2 columns.")
124
+ if not np.all((X >= 0) & (X <= 1)):
125
+ raise ValueError("X must be pseudo-observation in the interval `[0, 1]`")
126
+ quantiles = np.asarray(quantiles)
127
+ if not np.all((quantiles >= 0) & (quantiles <= 1)):
128
+ raise ValueError("quantiles must be between 0.0 and 1.0.")
129
+
130
+ def func(q: np.ndarray, is_lower: bool) -> np.ndarray:
131
+ op = operator.le if is_lower else operator.ge
132
+ cond = op(X[:, 0, np.newaxis], q)
133
+ count = np.count_nonzero(cond, axis=0).astype(float)
134
+ mask = count != 0
135
+ count[mask] = (
136
+ np.count_nonzero(cond & op(X[:, 1, np.newaxis], q), axis=0)[mask]
137
+ / count[mask]
138
+ )
139
+ return count
140
+
141
+ concentration = np.where(
142
+ quantiles <= 0.5, func(quantiles, True), func(quantiles, False)
143
+ )
144
+ return concentration
145
+
146
+
147
+ def plot_tail_concentration(
148
+ tail_concentration_dict: dict[str, npt.ArrayLike],
149
+ quantiles: np.ndarray,
150
+ title: str = "Empirical Tail Dependencies",
151
+ smoothing: float | None = 0.5,
152
+ ) -> go.Figure:
153
+ """
154
+ Plot the empirical tail concentration curves.
155
+
156
+ This function takes a dictionary where keys are dataset names and values are the
157
+ corresponding tail concentration arrays computed at the given quantiles. It then
158
+ creates a Plotly figure with the tail concentration curves. The x-axis (quantiles)
159
+ and y-axis (tail concentration) are both formatted as percentages.
160
+
161
+ Parameters
162
+ ----------
163
+ tail_concentration_dict : dict[str, ArrayLike]
164
+ A dictionary mapping dataset names to their tail concentration values.
165
+
166
+ quantiles : array-like of shape (n_quantiles,)
167
+ The quantile levels at which the tail concentration has been computed.
168
+
169
+ title : str, default="Empirical Tail Dependencies"
170
+ The title for the plot.
171
+
172
+ smoothing : float or None, default=0.5
173
+ Smoothing parameter for the spline line shape. If provided, the curves will be
174
+ smoothed using a spline interpolation.
175
+
176
+ Returns
177
+ -------
178
+ fig : go.Figure
179
+ A Plotly figure object containing the tail concentration curves.
180
+
181
+ Raises
182
+ ------
183
+ ValueError
184
+ If the smoothing parameter is not in the allowed range.
185
+ """
186
+ if smoothing is not None and not (0 <= smoothing <= 1.3):
187
+ raise ValueError("The smoothing parameter must be between 0 and 1.3.")
188
+
189
+ quantiles = np.asarray(quantiles)
190
+ traces = []
191
+ # Determine the line shape and include the smoothing parameter if applicable.
192
+ if smoothing is not None:
193
+ line_dict = {"shape": "spline", "smoothing": smoothing}
194
+ else:
195
+ line_dict = {"shape": "linear"}
196
+
197
+ # Iterate over each dataset name and its corresponding data array.
198
+ for name, concentration in tail_concentration_dict.items():
199
+ concentration = np.asarray(concentration)
200
+ trace = go.Scatter(
201
+ x=quantiles,
202
+ y=np.asarray(concentration),
203
+ mode="lines",
204
+ name=name,
205
+ line=line_dict,
206
+ )
207
+ traces.append(trace)
208
+
209
+ # Create the Plotly figure.
210
+ fig = go.Figure(data=traces)
211
+ fig.update_layout(
212
+ title=title,
213
+ xaxis_title="Quantile",
214
+ yaxis_title="Tail Concentration",
215
+ )
216
+ # Update both axes to show percentages with an enhanced grid.
217
+ fig.update_xaxes(
218
+ range=[0, 1],
219
+ tickformat=".0%",
220
+ dtick=0.05,
221
+ showgrid=True,
222
+ gridwidth=1,
223
+ gridcolor="lightgrey",
224
+ )
225
+ fig.update_yaxes(
226
+ range=[0, 1],
227
+ tickformat=".0%",
228
+ dtick=0.05,
229
+ showgrid=True,
230
+ gridwidth=1,
231
+ gridcolor="lightgrey",
232
+ )
233
+
234
+ return fig
235
+
236
+
237
+ def _select_rotation_itau(
238
+ func: Callable, X: np.ndarray, theta: float
239
+ ) -> CopulaRotation:
240
+ """
241
+ Select the optimal copula rotation based on a provided function.
242
+
243
+ This helper function applies each rotation defined in CopulaRotation to the data X,
244
+ computes a criterion value using the provided function (which takes X and theta as
245
+ arguments), and returns the rotation that minimizes this value.
246
+
247
+ Parameters
248
+ ----------
249
+ func : Callable
250
+ A function that computes a criterion (e.g., a negative log-likelihood) for a given
251
+ rotated dataset and copula parameter theta.
252
+
253
+ X : ndarray of shape (n_observations, 2)
254
+ A 2D array of bivariate inputs.
255
+
256
+ theta : float
257
+ The copula parameter to be used in the criterion function.
258
+
259
+ Returns
260
+ -------
261
+ CopulaRotation
262
+ The rotation (an element of CopulaRotation) that minimizes the criterion value.
263
+ """
264
+ results = {}
265
+ for rotation in CopulaRotation:
266
+ X_rotated = _apply_copula_rotation(X, rotation=rotation)
267
+ results[rotation] = func(X=X_rotated, theta=theta)
268
+ best_rotation = min(results, key=results.get)
269
+ return best_rotation
270
+
271
+
272
+ def _select_theta_and_rotation_mle(
273
+ func: Callable, X: np.ndarray, bounds: tuple[float, float], tolerance: float = 1e-4
274
+ ) -> tuple[float, CopulaRotation]:
275
+ """
276
+ Select the optimal copula parameter theta and rotation using maximum likelihood
277
+ estimation.
278
+
279
+ For each rotation defined in CopulaRotation, this function applies the rotation to
280
+ X, then minimizes the negative log-likelihood over theta using a bounded scalar
281
+ optimization. It returns the theta and rotation that yield the minimum criterion
282
+ value.
283
+
284
+ Parameters
285
+ ----------
286
+ func : Callable
287
+ A function that computes the negative log-likelihood (or similar criterion) for a
288
+ given value of theta and rotated data X.
289
+
290
+ X : ndarray of shape (n_observations, 2)
291
+ A 2D array of bivariate inputs.
292
+
293
+ bounds : tuple[float, float]
294
+ The lower and upper bounds for the copula parameter theta.
295
+
296
+ tolerance : float, default=1e-4
297
+ The tolerance for the scalar minimization optimization.
298
+
299
+ Returns
300
+ -------
301
+ tuple
302
+ A tuple (theta, rotation) where theta is the optimal copula parameter and
303
+ rotation is the corresponding CopulaRotation.
304
+
305
+ Raises
306
+ ------
307
+ RuntimeError
308
+ If the optimization fails for all rotations.
309
+ """
310
+ results = []
311
+ for rotation in CopulaRotation:
312
+ X_rotated = _apply_copula_rotation(X, rotation=rotation)
313
+ result = so.minimize_scalar(
314
+ func,
315
+ args=(X_rotated,),
316
+ bounds=bounds,
317
+ method="bounded",
318
+ options={"xatol": tolerance},
319
+ )
320
+ if result.success:
321
+ results.append(
322
+ {
323
+ "neg_log_likelihood": result.fun,
324
+ "theta": result.x,
325
+ "rotation": rotation,
326
+ }
327
+ )
328
+ else:
329
+ warnings.warn(
330
+ f"Optimization failed for rotation {rotation}: {result.message}",
331
+ RuntimeWarning,
332
+ stacklevel=2,
333
+ )
334
+ if len(results) == 0:
335
+ raise RuntimeError("Optimization failed for all rotations")
336
+
337
+ best = min(results, key=lambda d: d["neg_log_likelihood"])
338
+ return best["theta"], best["rotation"]
339
+
340
+
341
+ def _apply_copula_rotation(X: npt.ArrayLike, rotation: CopulaRotation) -> np.ndarray:
342
+ r"""Apply a bivariate copula rotation using the standard (clockwise) convention.
343
+
344
+ The transformations are defined as follows:
345
+
346
+ - `CopulaRotation.R0` (0°): :math:`(u, v) \mapsto (u, v)`
347
+ - `CopulaRotation.R90` (90°): :math:`(u, v) \mapsto (v,\, 1 - u)`
348
+ - `CopulaRotation.R180` (180°): :math:`(u, v) \mapsto (1 - u,\, 1 - v)`
349
+ - `CopulaRotation.R270` (270°): :math:`(u, v) \mapsto (1 - v,\, u)`
350
+
351
+ Parameters
352
+ ----------
353
+ X : array-like of shape (n_observations, 2)
354
+ An array of bivariate inputs `(u, v)` where each row represents a
355
+ bivariate observation.
356
+
357
+ rotation : CopulaRotation
358
+ The rotation to apply to the copula (default is no rotation).
359
+
360
+ Returns
361
+ -------
362
+ rotated_X: ndarray of shape (n_observations, 2)
363
+ The rotated data array.
364
+ """
365
+ match rotation:
366
+ case CopulaRotation.R0:
367
+ # No rotation
368
+ pass
369
+ case CopulaRotation.R90:
370
+ # (u, v) -> (v, 1 - u)
371
+ X = np.column_stack([X[:, 1], 1.0 - X[:, 0]])
372
+ case CopulaRotation.R180:
373
+ # (u, v) -> (1 - u, 1 - v)
374
+ X = 1.0 - X
375
+ case CopulaRotation.R270:
376
+ # (u, v) -> (1 - v, u)
377
+ X = np.column_stack([1.0 - X[:, 1], X[:, 0]])
378
+ case _:
379
+ raise ValueError(f"Unsupported rotation: {rotation}")
380
+ return X
381
+
382
+
383
+ def _apply_margin_swap(X: np.ndarray, first_margin: bool) -> np.ndarray:
384
+ """
385
+ Swap the columns of X if first_margin is False.
386
+
387
+ If first_margin is True, X is returned unchanged; otherwise, the columns
388
+ of X are swapped.
389
+
390
+ Parameters
391
+ ----------
392
+ X : ndarray of shape (n_observations, 2)
393
+ A 2D array of bivariate inputs (u, v).
394
+ first_margin : bool
395
+ If True, no swap is performed; if False, the columns of X are swapped.
396
+
397
+ Returns
398
+ -------
399
+ X_swapped : ndarray of shape (n_observations, 2)
400
+ The data array with columns swapped if first_margin is False.
401
+ """
402
+ assert X.ndim == 2
403
+ assert X.shape[1] == 2
404
+ if first_margin:
405
+ return X[:, [1, 0]]
406
+ return X
407
+
408
+
409
+ def _apply_rotation_cdf(
410
+ func: Callable, X: np.ndarray, rotation: CopulaRotation, **kwargs
411
+ ) -> np.ndarray:
412
+ """
413
+ Apply a copula rotation to X and compute the corresponding CDF values.
414
+
415
+ Parameters
416
+ ----------
417
+ func : Callable
418
+ A function that computes the CDF given data X and additional keyword arguments.
419
+
420
+ X : ndarray of shape (n_observations, 2)
421
+ A 2D array of bivariate inputs.
422
+
423
+ rotation : CopulaRotation
424
+ The rotation to apply.
425
+
426
+ **kwargs
427
+ Additional keyword arguments to pass to the CDF function.
428
+
429
+ Returns
430
+ -------
431
+ rotated_cdf : ndarray of shape (n_observations,)
432
+ The transformed CDF values after applying the rotation.
433
+ """
434
+ rotated_X = _apply_copula_rotation(X, rotation=rotation)
435
+ cdf = func(X=rotated_X, **kwargs)
436
+
437
+ match rotation:
438
+ case CopulaRotation.R0:
439
+ pass
440
+ case CopulaRotation.R90:
441
+ cdf = X[:, 1] - cdf
442
+ case CopulaRotation.R180:
443
+ cdf = np.sum(X, axis=1) - 1 + cdf
444
+ case CopulaRotation.R270:
445
+ cdf = X[:, 0] - cdf
446
+ case _:
447
+ raise ValueError(f"Unsupported rotation: {rotation}")
448
+
449
+ return cdf
450
+
451
+
452
+ def _apply_rotation_partial_derivatives(
453
+ func: Callable,
454
+ X: np.ndarray,
455
+ rotation: CopulaRotation,
456
+ first_margin: bool,
457
+ **kwargs,
458
+ ) -> np.ndarray:
459
+ """
460
+ Apply a copula rotation to X and compute the corresponding partial derivatives.
461
+
462
+ This function rotates the data X using the specified rotation and then computes
463
+ the partial derivative (h-function) using the provided function. The result is then
464
+ adjusted according to the rotation and the margin of interest.
465
+
466
+ Parameters
467
+ ----------
468
+ func : Callable
469
+ A function that computes the partial derivative (h-function) given X, the
470
+ margin, and any additional keyword arguments.
471
+
472
+ X : ndarray of shape (n_observations, 2)
473
+ A 2D array of bivariate inputs.
474
+
475
+ rotation : CopulaRotation
476
+ The rotation to apply.
477
+
478
+ first_margin : bool
479
+ If True, compute the partial derivative with respect to the first margin;
480
+ otherwise, compute it with respect to the second margin.
481
+
482
+ **kwargs
483
+ Additional keyword arguments to pass to the partial derivative function.
484
+
485
+ Returns
486
+ -------
487
+ z : ndarray of shape (n_observations,)
488
+ The transformed partial derivative values after applying the rotation.
489
+ """
490
+ rotated_X = _apply_copula_rotation(X, rotation=rotation)
491
+
492
+ match rotation:
493
+ case CopulaRotation.R0:
494
+ z = func(X=rotated_X, first_margin=first_margin, **kwargs)
495
+ case CopulaRotation.R90:
496
+ if first_margin:
497
+ z = func(X=rotated_X, first_margin=not first_margin, **kwargs)
498
+ else:
499
+ z = 1 - func(X=rotated_X, first_margin=not first_margin, **kwargs)
500
+ case CopulaRotation.R180:
501
+ z = 1 - func(X=rotated_X, first_margin=first_margin, **kwargs)
502
+ case CopulaRotation.R270:
503
+ if first_margin:
504
+ z = 1 - func(X=rotated_X, first_margin=not first_margin, **kwargs)
505
+ else:
506
+ z = func(X=rotated_X, first_margin=not first_margin, **kwargs)
507
+ case _:
508
+ raise ValueError(f"Unsupported rotation: {rotation}")
509
+ return z
@@ -0,0 +1,11 @@
1
+ """Multivariate Distribution module."""
2
+
3
+ from skfolio.distribution.multivariate._base import BaseMultivariateDist
4
+ from skfolio.distribution.multivariate._utils import DependenceMethod
5
+ from skfolio.distribution.multivariate._vine_copula import VineCopula
6
+
7
+ __all__ = [
8
+ "BaseMultivariateDist",
9
+ "DependenceMethod",
10
+ "VineCopula",
11
+ ]