skfolio 0.0.1__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.
- skfolio/__init__.py +29 -0
- skfolio/cluster/__init__.py +8 -0
- skfolio/cluster/_hierarchical.py +387 -0
- skfolio/datasets/__init__.py +20 -0
- skfolio/datasets/_base.py +389 -0
- skfolio/datasets/data/__init__.py +0 -0
- skfolio/datasets/data/factors_dataset.csv.gz +0 -0
- skfolio/datasets/data/sp500_dataset.csv.gz +0 -0
- skfolio/datasets/data/sp500_index.csv.gz +0 -0
- skfolio/distance/__init__.py +26 -0
- skfolio/distance/_base.py +55 -0
- skfolio/distance/_distance.py +574 -0
- skfolio/exceptions.py +30 -0
- skfolio/measures/__init__.py +76 -0
- skfolio/measures/_enums.py +355 -0
- skfolio/measures/_measures.py +607 -0
- skfolio/metrics/__init__.py +3 -0
- skfolio/metrics/_scorer.py +121 -0
- skfolio/model_selection/__init__.py +18 -0
- skfolio/model_selection/_combinatorial.py +407 -0
- skfolio/model_selection/_validation.py +194 -0
- skfolio/model_selection/_walk_forward.py +221 -0
- skfolio/moments/__init__.py +41 -0
- skfolio/moments/covariance/__init__.py +29 -0
- skfolio/moments/covariance/_base.py +101 -0
- skfolio/moments/covariance/_covariance.py +1108 -0
- skfolio/moments/expected_returns/__init__.py +21 -0
- skfolio/moments/expected_returns/_base.py +31 -0
- skfolio/moments/expected_returns/_expected_returns.py +415 -0
- skfolio/optimization/__init__.py +36 -0
- skfolio/optimization/_base.py +147 -0
- skfolio/optimization/cluster/__init__.py +13 -0
- skfolio/optimization/cluster/_nco.py +348 -0
- skfolio/optimization/cluster/hierarchical/__init__.py +13 -0
- skfolio/optimization/cluster/hierarchical/_base.py +440 -0
- skfolio/optimization/cluster/hierarchical/_herc.py +406 -0
- skfolio/optimization/cluster/hierarchical/_hrp.py +368 -0
- skfolio/optimization/convex/__init__.py +16 -0
- skfolio/optimization/convex/_base.py +1944 -0
- skfolio/optimization/convex/_distributionally_robust.py +392 -0
- skfolio/optimization/convex/_maximum_diversification.py +417 -0
- skfolio/optimization/convex/_mean_risk.py +974 -0
- skfolio/optimization/convex/_risk_budgeting.py +560 -0
- skfolio/optimization/ensemble/__init__.py +6 -0
- skfolio/optimization/ensemble/_base.py +87 -0
- skfolio/optimization/ensemble/_stacking.py +326 -0
- skfolio/optimization/naive/__init__.py +3 -0
- skfolio/optimization/naive/_naive.py +173 -0
- skfolio/population/__init__.py +3 -0
- skfolio/population/_population.py +883 -0
- skfolio/portfolio/__init__.py +13 -0
- skfolio/portfolio/_base.py +1096 -0
- skfolio/portfolio/_multi_period_portfolio.py +610 -0
- skfolio/portfolio/_portfolio.py +842 -0
- skfolio/pre_selection/__init__.py +7 -0
- skfolio/pre_selection/_pre_selection.py +342 -0
- skfolio/preprocessing/__init__.py +3 -0
- skfolio/preprocessing/_returns.py +114 -0
- skfolio/prior/__init__.py +18 -0
- skfolio/prior/_base.py +63 -0
- skfolio/prior/_black_litterman.py +238 -0
- skfolio/prior/_empirical.py +163 -0
- skfolio/prior/_factor_model.py +268 -0
- skfolio/typing.py +50 -0
- skfolio/uncertainty_set/__init__.py +23 -0
- skfolio/uncertainty_set/_base.py +108 -0
- skfolio/uncertainty_set/_bootstrap.py +281 -0
- skfolio/uncertainty_set/_empirical.py +237 -0
- skfolio/utils/__init__.py +0 -0
- skfolio/utils/bootstrap.py +115 -0
- skfolio/utils/equations.py +350 -0
- skfolio/utils/sorting.py +117 -0
- skfolio/utils/stats.py +466 -0
- skfolio/utils/tools.py +567 -0
- skfolio-0.0.1.dist-info/LICENSE +29 -0
- skfolio-0.0.1.dist-info/METADATA +568 -0
- skfolio-0.0.1.dist-info/RECORD +79 -0
- skfolio-0.0.1.dist-info/WHEEL +5 -0
- skfolio-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,115 @@
|
|
1
|
+
"""Bootstrap module."""
|
2
|
+
|
3
|
+
# Author: Hugo Delatte <delatte.hugo@gmail.com>
|
4
|
+
# License: BSD 3 clause
|
5
|
+
|
6
|
+
import numpy as np
|
7
|
+
|
8
|
+
__all__ = ["stationary_bootstrap"]
|
9
|
+
|
10
|
+
|
11
|
+
def optimal_block_size(x: np.ndarray) -> float:
|
12
|
+
"""Compute the optimal block size for a single series using Politis & White
|
13
|
+
algorithm [1]_.
|
14
|
+
|
15
|
+
Parameters
|
16
|
+
----------
|
17
|
+
x : ndarray
|
18
|
+
The input 1D-array.
|
19
|
+
|
20
|
+
Returns
|
21
|
+
-------
|
22
|
+
value : float
|
23
|
+
The optimal block size.
|
24
|
+
|
25
|
+
References
|
26
|
+
----------
|
27
|
+
.. [1] "Automatic Block-Length Selection for the Dependent Bootstrap".
|
28
|
+
Politis & White (2004).
|
29
|
+
|
30
|
+
.. [2] "Correction to Automatic Block-Length Selection for the Dependent Bootstrap".
|
31
|
+
Patton, Politis & White (2009).
|
32
|
+
"""
|
33
|
+
n = x.shape[0]
|
34
|
+
eps = x - x.mean(0)
|
35
|
+
b_max = np.ceil(min(3 * np.sqrt(n), n / 3))
|
36
|
+
kn = max(5, int(np.log10(n)))
|
37
|
+
m_max = int(np.ceil(np.sqrt(n))) + kn
|
38
|
+
cv = 2 * np.sqrt(np.log10(n) / n)
|
39
|
+
acv = np.zeros(m_max + 1)
|
40
|
+
abs_acorr = np.zeros(m_max + 1)
|
41
|
+
opt_m = None
|
42
|
+
for i in range(m_max + 1):
|
43
|
+
v1 = eps[i + 1 :] @ eps[i + 1 :]
|
44
|
+
v2 = eps[: -(i + 1)] @ eps[: -(i + 1)]
|
45
|
+
cross_prod = eps[i:] @ eps[: n - i]
|
46
|
+
acv[i] = cross_prod / n
|
47
|
+
abs_acorr[i] = np.abs(cross_prod) / np.sqrt(v1 * v2)
|
48
|
+
if i >= kn:
|
49
|
+
if np.all(abs_acorr[i - kn : i] < cv) and opt_m is None:
|
50
|
+
opt_m = i - kn
|
51
|
+
m = 2 * max(opt_m, 1) if opt_m is not None else m_max
|
52
|
+
m = min(m, m_max)
|
53
|
+
g = 0.0
|
54
|
+
lr_acv = acv[0]
|
55
|
+
for k in range(1, m + 1):
|
56
|
+
lam = 1 if k / m <= 1 / 2 else 2 * (1 - k / m)
|
57
|
+
g += 2 * lam * k * acv[k]
|
58
|
+
lr_acv += 2 * lam * acv[k]
|
59
|
+
d = 2 * lr_acv**2
|
60
|
+
b = ((2 * g**2) / d) ** (1 / 3) * n ** (1 / 3)
|
61
|
+
b = min(b, b_max)
|
62
|
+
return b
|
63
|
+
|
64
|
+
|
65
|
+
def stationary_bootstrap(
|
66
|
+
returns: np.ndarray,
|
67
|
+
n_bootstrap_samples: int,
|
68
|
+
block_size: float | None = None,
|
69
|
+
seed: int | None = None,
|
70
|
+
) -> np.ndarray:
|
71
|
+
"""Creates `n_bootstrap_samples` samples from a multivariate return series via
|
72
|
+
stationary bootstrapping.
|
73
|
+
|
74
|
+
Parameters
|
75
|
+
----------
|
76
|
+
returns: ndarray of shape (n_observations, n_assets)
|
77
|
+
The returns array.
|
78
|
+
|
79
|
+
n_bootstrap_samples: int
|
80
|
+
The number of bootstrap samples to generate.
|
81
|
+
|
82
|
+
block_size: float, optional
|
83
|
+
The block size.
|
84
|
+
If this is set to None, we estimate the optimal block size using Politis &
|
85
|
+
White algorithm for all individual asset and the median.
|
86
|
+
|
87
|
+
seed: int, optional
|
88
|
+
Random seed used to initialize the pseudo-random number generator
|
89
|
+
|
90
|
+
Returns
|
91
|
+
-------
|
92
|
+
value: ndarray
|
93
|
+
The sample returns of shape (reps, nb observations, nb assets)
|
94
|
+
|
95
|
+
"""
|
96
|
+
np.random.seed(seed=seed)
|
97
|
+
n_observations, n_assets = returns.shape
|
98
|
+
x = np.vstack((returns, returns))
|
99
|
+
# Loop over reps bootstraps
|
100
|
+
if block_size is None:
|
101
|
+
block_size = np.median(
|
102
|
+
[optimal_block_size(returns[:, i]) for i in range(n_assets)]
|
103
|
+
)
|
104
|
+
|
105
|
+
indices = np.random.randint(
|
106
|
+
n_observations, size=(n_bootstrap_samples, n_observations)
|
107
|
+
)
|
108
|
+
cond = np.random.rand(n_bootstrap_samples, n_observations) >= 1.0 / block_size
|
109
|
+
# TODO: don't use loop
|
110
|
+
for i in range(n_bootstrap_samples):
|
111
|
+
for j in range(1, n_observations):
|
112
|
+
if cond[i, j]:
|
113
|
+
indices[i, j] = indices[i, j - 1] + 1
|
114
|
+
indices[indices > 2 * n_observations] = 0
|
115
|
+
return x[indices, :]
|
@@ -0,0 +1,350 @@
|
|
1
|
+
"""Equation module"""
|
2
|
+
|
3
|
+
# Author: Hugo Delatte <delatte.hugo@gmail.com>
|
4
|
+
# License: BSD 3 clause
|
5
|
+
|
6
|
+
import re
|
7
|
+
import warnings
|
8
|
+
|
9
|
+
import numpy as np
|
10
|
+
import numpy.typing as npt
|
11
|
+
|
12
|
+
from skfolio.exceptions import EquationToMatrixError, GroupNotFoundError
|
13
|
+
|
14
|
+
__all__ = ["equations_to_matrix"]
|
15
|
+
|
16
|
+
|
17
|
+
def equations_to_matrix(
|
18
|
+
groups: npt.ArrayLike,
|
19
|
+
equations: npt.ArrayLike,
|
20
|
+
sum_to_one: bool = False,
|
21
|
+
raise_if_group_missing: bool = False,
|
22
|
+
names: tuple[str, str] = ("groups", "equations"),
|
23
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
24
|
+
"""Convert a list of linear equations into the left and right matrices of the
|
25
|
+
inequality A <= B.
|
26
|
+
|
27
|
+
Parameters
|
28
|
+
----------
|
29
|
+
groups : array-like of shape (n_groups, n_assets)
|
30
|
+
2D array of assets groups.
|
31
|
+
|
32
|
+
Examples:
|
33
|
+
groups = np.array(
|
34
|
+
[
|
35
|
+
["SPX", "SX5E", "NKY", "TLT"],
|
36
|
+
["Equity", "Equity", "Equity", "Bond"],
|
37
|
+
["US", "Europe", "Japan", "US"],
|
38
|
+
]
|
39
|
+
)
|
40
|
+
|
41
|
+
equations : array-like of shape (n_equations,)
|
42
|
+
1D array of equations.
|
43
|
+
|
44
|
+
Example of valid equation patterns:
|
45
|
+
* "number_1 * group_1 + number_3 <= number_4 * group_3 + number_5"
|
46
|
+
* "group_1 >= number * group_2"
|
47
|
+
* "group_1 <= number"
|
48
|
+
* "group_1 >= number"
|
49
|
+
|
50
|
+
"group_1" and "group_2" are the group names defined in `groups`.
|
51
|
+
The second expression means that the sum of all assets in "group_1" should be
|
52
|
+
less or equal to "number" times the sum of all assets in "group_2".
|
53
|
+
|
54
|
+
Examples:
|
55
|
+
equations = [
|
56
|
+
"Equity <= 3 * Bond",
|
57
|
+
"US >= 1.5",
|
58
|
+
"Europe >= 0.5 * Japan",
|
59
|
+
"Japan <= 1",
|
60
|
+
"3*SPX + 5*SX5E <= 2*TLT + 3",
|
61
|
+
]
|
62
|
+
|
63
|
+
sum_to_one : bool
|
64
|
+
If this is set to True, all elements in a group sum to one (used in the `views`
|
65
|
+
of the Black-Litterman model).
|
66
|
+
|
67
|
+
raise_if_group_missing : bool, default=False
|
68
|
+
If this is set to True, an error is raised when a group is not found in the
|
69
|
+
groups, otherwise only a warning is shown.
|
70
|
+
The default is False.
|
71
|
+
|
72
|
+
names : tuple[str, str], default=('groups', 'equations')
|
73
|
+
The group and equation names used in error messages.
|
74
|
+
The default is `('groups', 'equations')`.
|
75
|
+
|
76
|
+
Returns
|
77
|
+
-------
|
78
|
+
left: ndarray of shape (n_equations, n_assets)
|
79
|
+
right: ndarray of shape (n_equations,)
|
80
|
+
The left and right matrices of the inequality A <= B.
|
81
|
+
If none of the group inside the equations are part of the groups, `None` is
|
82
|
+
returned.
|
83
|
+
"""
|
84
|
+
groups = np.asarray(groups)
|
85
|
+
equations = np.asarray(equations)
|
86
|
+
if groups.ndim != 2:
|
87
|
+
raise ValueError(
|
88
|
+
f"`{names[0]}` must be a 2D array, got {groups.ndim}D array instead."
|
89
|
+
)
|
90
|
+
if equations.ndim != 1:
|
91
|
+
raise ValueError(
|
92
|
+
f"`{names[1]}` must be a 1D array, got {equations.ndim}D array instead."
|
93
|
+
)
|
94
|
+
|
95
|
+
n_equations = len(equations)
|
96
|
+
n_assets = groups.shape[1]
|
97
|
+
a = np.zeros((n_equations, n_assets))
|
98
|
+
b = np.zeros(n_equations)
|
99
|
+
for i, string in enumerate(equations):
|
100
|
+
try:
|
101
|
+
left, right = _string_to_equation(
|
102
|
+
groups=groups,
|
103
|
+
string=string,
|
104
|
+
sum_to_one=sum_to_one,
|
105
|
+
)
|
106
|
+
a[i] = left
|
107
|
+
b[i] = right
|
108
|
+
except GroupNotFoundError as e:
|
109
|
+
if raise_if_group_missing:
|
110
|
+
raise
|
111
|
+
warnings.warn(str(e), stacklevel=2)
|
112
|
+
return a, b
|
113
|
+
|
114
|
+
|
115
|
+
def _matching_array(values: np.ndarray, key: str, sum_to_one: bool) -> np.ndarray:
|
116
|
+
"""Takes in a 2D array of strings, a key string, and a boolean flag.
|
117
|
+
It returns a 1D array where the value is 1 if there is a match between the key and
|
118
|
+
any value in the 2D array, and 0 otherwise. The returned array can be scaled to
|
119
|
+
have a sum of one if the flag is set to True.
|
120
|
+
|
121
|
+
Parameters
|
122
|
+
----------
|
123
|
+
values : ndarray of shape (n, m)
|
124
|
+
2D-array of strings.
|
125
|
+
|
126
|
+
key : str
|
127
|
+
String to match in the values.
|
128
|
+
|
129
|
+
sum_to_one : bool
|
130
|
+
If this is set to True, the matching 1D-array is scaled to have a sum of one.
|
131
|
+
|
132
|
+
Returns
|
133
|
+
-------
|
134
|
+
matching_array : ndarray of shape (n, )
|
135
|
+
Matching 1D-array.
|
136
|
+
"""
|
137
|
+
arr = np.any(values == key, axis=0)
|
138
|
+
if not arr.any():
|
139
|
+
raise EquationToMatrixError(f"Unable to find '{key}' in '{values}'")
|
140
|
+
if sum_to_one:
|
141
|
+
s = np.sum(arr)
|
142
|
+
else:
|
143
|
+
s = 1
|
144
|
+
return arr / s
|
145
|
+
|
146
|
+
|
147
|
+
_operator_mapping = {">=": -1, "<=": 1, "==": 1, "=": 1}
|
148
|
+
_operator_signs = {"+": 1, "-": -1}
|
149
|
+
|
150
|
+
|
151
|
+
def _inequality_operator_sign(operator: str) -> int:
|
152
|
+
"""Convert the operators '>=', "==" and '<=' into the corresponding integer
|
153
|
+
values -1, 1 and 1, respectively.
|
154
|
+
|
155
|
+
Parameters
|
156
|
+
----------
|
157
|
+
operator : str
|
158
|
+
Operator: '>=' or '<='.
|
159
|
+
|
160
|
+
Returns
|
161
|
+
-------
|
162
|
+
value : int
|
163
|
+
Operator sign: 1 or -1.
|
164
|
+
"""
|
165
|
+
try:
|
166
|
+
return _operator_mapping[operator]
|
167
|
+
except KeyError:
|
168
|
+
raise EquationToMatrixError(
|
169
|
+
f"operator '{operator}' is not valid. It should be '<=' or '>='"
|
170
|
+
) from None
|
171
|
+
|
172
|
+
|
173
|
+
def _operator_sign(operator: str) -> int:
|
174
|
+
"""Convert the operators '+' and '-' into 1 or -1
|
175
|
+
|
176
|
+
Parameters
|
177
|
+
----------
|
178
|
+
operator : str
|
179
|
+
Operator: '+' and '-'.
|
180
|
+
|
181
|
+
Returns
|
182
|
+
-------
|
183
|
+
value : int
|
184
|
+
Operator sign: 1 or -1.
|
185
|
+
"""
|
186
|
+
try:
|
187
|
+
return _operator_signs[operator]
|
188
|
+
except KeyError:
|
189
|
+
raise EquationToMatrixError(
|
190
|
+
f"operator '{operator}' is not valid. It should be be '+' or '-'"
|
191
|
+
) from None
|
192
|
+
|
193
|
+
|
194
|
+
def _string_to_float(string: str) -> float:
|
195
|
+
"""Convert the factor string into a float.
|
196
|
+
|
197
|
+
Parameters
|
198
|
+
----------
|
199
|
+
string : str
|
200
|
+
The factor string.
|
201
|
+
|
202
|
+
Returns
|
203
|
+
-------
|
204
|
+
value : int
|
205
|
+
The factor string converted to float.
|
206
|
+
"""
|
207
|
+
try:
|
208
|
+
return float(string)
|
209
|
+
except ValueError:
|
210
|
+
raise EquationToMatrixError(f"Unable to convert {string} into float") from None
|
211
|
+
|
212
|
+
|
213
|
+
def _string_to_equation(
|
214
|
+
groups: np.ndarray,
|
215
|
+
string: str,
|
216
|
+
sum_to_one: bool,
|
217
|
+
) -> tuple[np.ndarray, float]:
|
218
|
+
"""Convert a string to a left 1D-array and right float of the form:
|
219
|
+
`groups @ left <= right`.
|
220
|
+
|
221
|
+
Parameters
|
222
|
+
----------
|
223
|
+
groups : ndarray of shape (n_groups, n_assets)
|
224
|
+
Groups 2D-array
|
225
|
+
|
226
|
+
string : str
|
227
|
+
String to convert
|
228
|
+
|
229
|
+
sum_to_one : bool
|
230
|
+
If this is set to True, the 1D-array is scaled to have a sum of one.
|
231
|
+
|
232
|
+
Returns
|
233
|
+
-------
|
234
|
+
left: 1D-array of shape (n_assets,)
|
235
|
+
right: float
|
236
|
+
"""
|
237
|
+
n = groups.shape[1]
|
238
|
+
operators = ["-", "+", "*", ">=", "<=", "==", "="]
|
239
|
+
invalid_operators = [">", "<"]
|
240
|
+
pattern = re.compile(r"((?:" + "|\\".join(operators) + r"))")
|
241
|
+
invalid_pattern = re.compile(r"((?:" + "|\\".join(invalid_operators) + r"))")
|
242
|
+
err_msg = f"Wrong pattern encountered while converting the string '{string}'"
|
243
|
+
|
244
|
+
res = re.split(pattern, string)
|
245
|
+
res = [x.strip() for x in res]
|
246
|
+
res = [x for x in res if x != ""]
|
247
|
+
iterator = iter(res)
|
248
|
+
group_names = set(groups.flatten())
|
249
|
+
|
250
|
+
def is_group(name: str) -> bool:
|
251
|
+
return name in group_names
|
252
|
+
|
253
|
+
left = np.zeros(n)
|
254
|
+
right = 0
|
255
|
+
main_sign = 1
|
256
|
+
inequality_sign = None
|
257
|
+
e = next(iterator, None)
|
258
|
+
i = 0
|
259
|
+
while True:
|
260
|
+
i += 1
|
261
|
+
if i > 1e6:
|
262
|
+
raise RecursionError(err_msg)
|
263
|
+
if e is None:
|
264
|
+
break
|
265
|
+
sign = 1
|
266
|
+
if e in [">=", "<=", "==", "="]:
|
267
|
+
main_sign = -1
|
268
|
+
inequality_sign = _inequality_operator_sign(e)
|
269
|
+
e = next(iterator, None)
|
270
|
+
if e in ["-", "+"]:
|
271
|
+
sign *= _operator_sign(e)
|
272
|
+
e = next(iterator, None)
|
273
|
+
elif e in ["-", "+"]:
|
274
|
+
sign *= _operator_sign(e)
|
275
|
+
e = next(iterator, None)
|
276
|
+
elif e == "*":
|
277
|
+
raise EquationToMatrixError(
|
278
|
+
f"{err_msg}: the character '{e}' is wrongly positioned"
|
279
|
+
)
|
280
|
+
sign *= main_sign
|
281
|
+
# next can only be a number or a group
|
282
|
+
if e is None or e in operators:
|
283
|
+
raise EquationToMatrixError(
|
284
|
+
f"{err_msg}: the character '{e}' is wrongly positioned"
|
285
|
+
)
|
286
|
+
if is_group(e):
|
287
|
+
arr = _matching_array(values=groups, key=e, sum_to_one=sum_to_one)
|
288
|
+
# next can only be a '*' or an ['-', '+', '>=', '<=', '==', '='] or None
|
289
|
+
e = next(iterator, None)
|
290
|
+
if e is None or e in ["-", "+", ">=", "<=", "==", "="]:
|
291
|
+
left += sign * arr
|
292
|
+
elif e == "*":
|
293
|
+
# next can only a number
|
294
|
+
e = next(iterator, None)
|
295
|
+
try:
|
296
|
+
number = float(e)
|
297
|
+
except ValueError:
|
298
|
+
invalid_ops = invalid_pattern.findall(e)
|
299
|
+
if len(invalid_ops) > 0:
|
300
|
+
raise EquationToMatrixError(
|
301
|
+
f"{invalid_ops[0]} is an invalid operator. Valid operators"
|
302
|
+
f" are: {operators}"
|
303
|
+
) from None
|
304
|
+
raise GroupNotFoundError(
|
305
|
+
f"{err_msg}: the group '{e}' is missing from the groups"
|
306
|
+
f" {groups}"
|
307
|
+
) from None
|
308
|
+
|
309
|
+
left += number * sign * arr
|
310
|
+
e = next(iterator, None)
|
311
|
+
else:
|
312
|
+
raise EquationToMatrixError(
|
313
|
+
f"{err_msg}: the character '{e}' is wrongly positioned"
|
314
|
+
)
|
315
|
+
else:
|
316
|
+
try:
|
317
|
+
number = float(e)
|
318
|
+
except ValueError:
|
319
|
+
invalid_ops = invalid_pattern.findall(e)
|
320
|
+
if len(invalid_ops) > 0:
|
321
|
+
raise EquationToMatrixError(
|
322
|
+
f"{invalid_ops[0]} is an invalid operator. Valid operators are:"
|
323
|
+
f" {operators}"
|
324
|
+
) from None
|
325
|
+
raise GroupNotFoundError(
|
326
|
+
f"{err_msg}: the group '{e}' is missing from the groups {groups}"
|
327
|
+
) from None
|
328
|
+
# next can only be a '*' or an operator or None
|
329
|
+
e = next(iterator, None)
|
330
|
+
if e == "*":
|
331
|
+
# next can only a group
|
332
|
+
e = next(iterator, None)
|
333
|
+
if not is_group(e):
|
334
|
+
raise EquationToMatrixError(
|
335
|
+
f"{err_msg}: the character '{e}' is wrongly positioned"
|
336
|
+
)
|
337
|
+
arr = _matching_array(values=groups, key=e, sum_to_one=sum_to_one)
|
338
|
+
left += number * sign * arr
|
339
|
+
e = next(iterator, None)
|
340
|
+
elif e is None or e in ["-", "+", ">=", "<=", "==", "="]:
|
341
|
+
right += number * sign
|
342
|
+
else:
|
343
|
+
raise EquationToMatrixError(
|
344
|
+
f"{err_msg}: the character '{e}' is wrongly positioned"
|
345
|
+
)
|
346
|
+
|
347
|
+
left *= inequality_sign
|
348
|
+
right *= -inequality_sign
|
349
|
+
|
350
|
+
return left, right
|
skfolio/utils/sorting.py
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
"""Fast non-dominated sorting module"""
|
2
|
+
|
3
|
+
# Author: Hugo Delatte <delatte.hugo@gmail.com>
|
4
|
+
# License: BSD 3 clause
|
5
|
+
|
6
|
+
import numpy as np
|
7
|
+
|
8
|
+
__all__ = ["dominate", "non_denominated_sort"]
|
9
|
+
|
10
|
+
|
11
|
+
def dominate(fitness_1: np.ndarray, fitness_2: np.ndarray) -> bool:
|
12
|
+
"""Compute the domination of two fitness arrays.
|
13
|
+
|
14
|
+
Domination of `fitness_1` over `fitness_2` means that each objective (value) of
|
15
|
+
`fitness_1` is not strictly worse than the corresponding objective of `fitness_2`
|
16
|
+
and at least one objective is strictly better.
|
17
|
+
|
18
|
+
Parameters
|
19
|
+
----------
|
20
|
+
fitness_1 : ndarray of floats of shape (n_objectives,)
|
21
|
+
Fitness array 1.
|
22
|
+
|
23
|
+
fitness_2 : ndarray of floats of shape (n_objectives,)
|
24
|
+
Fitness array 2.
|
25
|
+
|
26
|
+
Returns
|
27
|
+
-------
|
28
|
+
is_dominated : bool
|
29
|
+
Ture if `fitness_1` dominates `fitness_2`, False otherwise.
|
30
|
+
"""
|
31
|
+
if fitness_1.ndim != fitness_2.ndim != 1:
|
32
|
+
raise ValueError("fitness_1 and fitness_2 must be 1D array")
|
33
|
+
not_equal = False
|
34
|
+
for self_value, other_value in zip(fitness_1, fitness_2, strict=True):
|
35
|
+
if self_value > other_value:
|
36
|
+
not_equal = True
|
37
|
+
elif self_value < other_value:
|
38
|
+
return False
|
39
|
+
return not_equal
|
40
|
+
|
41
|
+
|
42
|
+
def non_denominated_sort(
|
43
|
+
fitnesses: np.ndarray, first_front_only: bool
|
44
|
+
) -> list[list[int]]:
|
45
|
+
"""Fast non-dominated sorting.
|
46
|
+
|
47
|
+
Sort the fitnesses into different non-domination levels.
|
48
|
+
Complexity O(MN^2) where M is the number of objectives and N the number of
|
49
|
+
portfolios.
|
50
|
+
|
51
|
+
Parameters
|
52
|
+
----------
|
53
|
+
fitnesses: ndarray of shape(n, n_fitness)
|
54
|
+
Fitnesses array.
|
55
|
+
|
56
|
+
first_front_only : bool
|
57
|
+
If this is set to True, only the first front is computed and returned.
|
58
|
+
|
59
|
+
Returns
|
60
|
+
-------
|
61
|
+
fronts: list[list[int]]
|
62
|
+
A list of Pareto fronts (lists), the first list includes non-dominated fitnesses.
|
63
|
+
"""
|
64
|
+
n = len(fitnesses)
|
65
|
+
fronts = []
|
66
|
+
if n == 0:
|
67
|
+
return fronts
|
68
|
+
|
69
|
+
# final rank that will be returned
|
70
|
+
n_ranked = 0
|
71
|
+
ranked = np.array([0 for _ in range(n)])
|
72
|
+
|
73
|
+
# for each portfolio a list of all portfolios that are dominated by this one
|
74
|
+
is_dominating = [[x for x in range(0)] for _ in range(n)]
|
75
|
+
|
76
|
+
# storage for the number of solutions dominated this one
|
77
|
+
n_dominated = [0 for _ in range(n)]
|
78
|
+
|
79
|
+
current_front = [x for x in range(0)]
|
80
|
+
|
81
|
+
for i in range(n):
|
82
|
+
for j in range(i + 1, n):
|
83
|
+
if dominate(fitnesses[i], fitnesses[j]):
|
84
|
+
is_dominating[i].append(j)
|
85
|
+
n_dominated[j] += 1
|
86
|
+
elif dominate(fitnesses[j], fitnesses[i]):
|
87
|
+
is_dominating[j].append(i)
|
88
|
+
n_dominated[i] += 1
|
89
|
+
|
90
|
+
if n_dominated[i] == 0:
|
91
|
+
current_front.append(i)
|
92
|
+
ranked[i] = 1.0
|
93
|
+
n_ranked += 1
|
94
|
+
|
95
|
+
# append the first front to the current front
|
96
|
+
fronts.append(current_front)
|
97
|
+
|
98
|
+
if first_front_only:
|
99
|
+
return fronts
|
100
|
+
|
101
|
+
# while not all solutions are assigned to a pareto front
|
102
|
+
while n_ranked < n:
|
103
|
+
next_front = []
|
104
|
+
# for each portfolio in the current front
|
105
|
+
for i in current_front:
|
106
|
+
# all solutions that are dominated by this portfolio
|
107
|
+
for j in is_dominating[i]:
|
108
|
+
n_dominated[j] -= 1
|
109
|
+
if n_dominated[j] == 0:
|
110
|
+
next_front.append(j)
|
111
|
+
ranked[j] = 1.0
|
112
|
+
n_ranked += 1
|
113
|
+
|
114
|
+
fronts.append(next_front)
|
115
|
+
current_front = next_front
|
116
|
+
|
117
|
+
return fronts
|