numerai-tools 0.6.0.dev0__tar.gz → 0.6.1.dev1__tar.gz
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.
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/PKG-INFO +8 -3
- numerai_tools-0.6.1.dev1/numerai_tools/scoring.py +673 -0
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/numerai_tools/signals.py +6 -3
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/numerai_tools/submissions.py +1 -2
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/pyproject.toml +8 -3
- numerai_tools-0.6.0.dev0/numerai_tools/data.py +0 -133
- numerai_tools-0.6.0.dev0/numerai_tools/indexing.py +0 -106
- numerai_tools-0.6.0.dev0/numerai_tools/math.py +0 -263
- numerai_tools-0.6.0.dev0/numerai_tools/scoring.py +0 -290
- numerai_tools-0.6.0.dev0/numerai_tools/typing.py +0 -6
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/LICENSE +0 -0
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/README.md +0 -0
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/numerai_tools/__init__.py +0 -0
- {numerai_tools-0.6.0.dev0 → numerai_tools-0.6.1.dev1}/numerai_tools/py.typed +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: numerai-tools
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1.dev1
|
|
4
4
|
Summary: A collection of open-source tools to help interact with Numerai, model data, and automate submissions.
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Numerai Engineering
|
|
7
8
|
Author-email: engineering@numer.ai
|
|
8
9
|
Requires-Python: >=3.11
|
|
@@ -13,9 +14,13 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
13
14
|
Classifier: Operating System :: OS Independent
|
|
14
15
|
Classifier: Programming Language :: Python
|
|
15
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
21
|
Classifier: Topic :: Scientific/Engineering
|
|
17
22
|
Requires-Dist: numpy (>=2.0.0,<3.0.0)
|
|
18
|
-
Requires-Dist: pandas (>=2.2.2,<
|
|
23
|
+
Requires-Dist: pandas (>=2.2.2,<4.0.0)
|
|
19
24
|
Requires-Dist: scikit-learn (>=1.5.0,<2.0.0)
|
|
20
25
|
Requires-Dist: scipy (>=1.13.0,<2.0.0)
|
|
21
26
|
Project-URL: Documentation, https://docs.numer.ai/
|
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
from typing import List, Literal, Tuple, Union, Optional, TypeVar, cast, Any
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy import stats
|
|
6
|
+
from sklearn.preprocessing import OneHotEncoder # type: ignore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# sometimes when we match up the target/prediction indices,
|
|
10
|
+
# changes in stock universe causes some stocks to enter / leave,
|
|
11
|
+
# this ensures we don't filter too much
|
|
12
|
+
DEFAULT_MAX_FILTERED_INDEX_RATIO = 0.2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
S1 = TypeVar("S1", bound=Union[pd.DataFrame, pd.Series])
|
|
16
|
+
S2 = TypeVar("S2", bound=Union[pd.DataFrame, pd.Series])
|
|
17
|
+
RANK_METHOD_TYPE = Literal["average", "min", "max", "first", "dense"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def filter_sort_index(
|
|
21
|
+
s1: S1, s2: S2, max_filtered_ratio: float = DEFAULT_MAX_FILTERED_INDEX_RATIO
|
|
22
|
+
) -> Tuple[S1, S2]:
|
|
23
|
+
"""Filters the indices of the given series to match each other,
|
|
24
|
+
then sorts the indices, then checks that we didn't filter too many indices
|
|
25
|
+
before returning the filtered and sorted series.
|
|
26
|
+
|
|
27
|
+
Arguments:
|
|
28
|
+
s1: Union[pd.DataFrame, pd.Series] - the first dataset to filter and sort
|
|
29
|
+
s2: Union[pd.DataFrame, pd.Series] - the second dataset to filter and sort
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Tuple[
|
|
33
|
+
Union[pd.DataFrame, pd.Series],
|
|
34
|
+
Union[pd.DataFrame, pd.Series],
|
|
35
|
+
] - the filtered and sorted datasets
|
|
36
|
+
"""
|
|
37
|
+
ids = s1.dropna().index.intersection(s2.dropna().index)
|
|
38
|
+
# ensure we didn't filter too many ids
|
|
39
|
+
assert len(ids) / len(s1) >= (1 - max_filtered_ratio), (
|
|
40
|
+
"s1 does not have enough overlapping ids with s2,"
|
|
41
|
+
f" must have >= {round(1-max_filtered_ratio,2)*100}% overlapping ids"
|
|
42
|
+
)
|
|
43
|
+
assert len(ids) / len(s2) >= (1 - max_filtered_ratio), (
|
|
44
|
+
"s2 does not have enough overlapping ids with s1,"
|
|
45
|
+
f" must have >= {round(1-max_filtered_ratio,2)*100}% overlapping ids"
|
|
46
|
+
)
|
|
47
|
+
return cast(S1, s1.loc[ids].sort_index()), cast(S2, s2.loc[ids].sort_index())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def filter_sort_index_many(
|
|
51
|
+
inputs: List[Any],
|
|
52
|
+
max_filtered_ratio: float = DEFAULT_MAX_FILTERED_INDEX_RATIO,
|
|
53
|
+
) -> List[Any]:
|
|
54
|
+
"""Filters the indices of the given list of series to match each other,
|
|
55
|
+
then sorts the indices, then checks that we didn't filter too many indices
|
|
56
|
+
before returning the filtered and sorted series.
|
|
57
|
+
|
|
58
|
+
Arguments:
|
|
59
|
+
inputs: List[Union[pd.DataFrame, pd.Series]] - the list of datasets to filter and sort
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List[Union[pd.DataFrame, pd.Series]] - the filtered and sorted datasets
|
|
63
|
+
"""
|
|
64
|
+
assert len(inputs) > 0, "List must contain at least one element"
|
|
65
|
+
ids = inputs[0].dropna().index
|
|
66
|
+
for i in range(1, len(inputs)):
|
|
67
|
+
ids = ids.intersection(inputs[i].dropna().index)
|
|
68
|
+
result = [inputs[i].loc[ids].sort_index() for i in range(len(inputs))]
|
|
69
|
+
# ensure we didn't filter too many ids
|
|
70
|
+
for i in range(len(result)):
|
|
71
|
+
assert len(result[i]) / len(inputs[i]) >= (1 - max_filtered_ratio), (
|
|
72
|
+
f"inputs[{i}] does not have enough overlapping ids with the others,"
|
|
73
|
+
f" must have >= {round(1-max_filtered_ratio,2)*100}% overlapping ids"
|
|
74
|
+
)
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def filter_sort_top_bottom(
|
|
79
|
+
s: pd.Series, top_bottom: int
|
|
80
|
+
) -> Tuple[pd.Series, pd.Series]:
|
|
81
|
+
"""Filters the series according to the top n and bottom n values
|
|
82
|
+
then sorts the index and returns two filtered and sorted series
|
|
83
|
+
for the top and bottom values respectively.
|
|
84
|
+
|
|
85
|
+
Arguments:
|
|
86
|
+
s: pd.Series - the data to filter and sort
|
|
87
|
+
top_bottom: int - the number of top n and bottom n values to keep
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Tuple[pd.Series, pd.Series] - the filtered and sorted top and bottom series respectively
|
|
91
|
+
"""
|
|
92
|
+
tb_idx = np.argsort(s, kind="stable")
|
|
93
|
+
bot = s.iloc[tb_idx[:top_bottom]]
|
|
94
|
+
top = s.iloc[tb_idx[-top_bottom:]]
|
|
95
|
+
return top.sort_index(), bot.sort_index()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def filter_sort_top_bottom_concat(s: pd.Series, top_bottom: int) -> pd.Series:
|
|
99
|
+
"""Similar to filter_sort_top_bottom, but concatenates the top and bottom series
|
|
100
|
+
into 1 series and then sorts the index.
|
|
101
|
+
|
|
102
|
+
Arguments:
|
|
103
|
+
s: pd.Series - the data to filter and sort
|
|
104
|
+
top_bottom: int - the number of top n and bottom n values to keep
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
pd.Series - the concatenated and sorted series of top and bottom values
|
|
108
|
+
"""
|
|
109
|
+
top, bot = filter_sort_top_bottom(s, top_bottom)
|
|
110
|
+
return pd.concat([top, bot]).sort_index()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def rank_series(s: pd.Series, method: RANK_METHOD_TYPE = "average") -> pd.Series:
|
|
114
|
+
"""Percentile rank a pandas Series, centering values around 0.5.
|
|
115
|
+
|
|
116
|
+
Arguments:
|
|
117
|
+
s: pd.Series - the data to rank
|
|
118
|
+
method: str - the pandas ranking method to use, options:
|
|
119
|
+
'average' (default) - keeps ties
|
|
120
|
+
'first' - breaks ties by index
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
pd.Series - the ranked Series
|
|
124
|
+
"""
|
|
125
|
+
assert np.array_equal(s.index.sort_values(), s.index), "unsorted index found"
|
|
126
|
+
return (s.rank(method=method) - 0.5) / s.count()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def rank(s: S1, method: RANK_METHOD_TYPE = "average") -> S1:
|
|
130
|
+
"""Percentile rank each columns or series, centering values around 0.5
|
|
131
|
+
|
|
132
|
+
Arguments:
|
|
133
|
+
s: pd.DataFrame | pd.Series - the data to rank
|
|
134
|
+
method: str - the pandas ranking method to use, options:
|
|
135
|
+
'average' (default) - keeps ties
|
|
136
|
+
'first' - breaks ties by index
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
pd.DataFrame | pd.Series - the ranked input data
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(s, pd.Series):
|
|
142
|
+
return cast(S1, rank_series(s, method))
|
|
143
|
+
else:
|
|
144
|
+
return s.apply(lambda series: rank(series, method=method))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def tie_broken_rank(df: pd.DataFrame) -> pd.DataFrame:
|
|
148
|
+
"""Rank columns, breaking ties by index."""
|
|
149
|
+
return rank(df, "first")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def tie_kept_rank(s: S1) -> S1:
|
|
153
|
+
"""Rank columns, but keep ties."""
|
|
154
|
+
return cast(S1, rank(s, "average"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def min_max_normalize(s: pd.Series) -> pd.Series:
|
|
158
|
+
"""Scale a series to be between 0 and 1."""
|
|
159
|
+
return (s - s.min()) / (s.max() - s.min())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def variance_normalize(df: pd.DataFrame) -> pd.DataFrame:
|
|
163
|
+
"""Scale a df such that all columns have std == 1."""
|
|
164
|
+
return df / np.std(df, axis=0)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def weight_normalize(s: S1) -> S1:
|
|
168
|
+
"""Scale a input such that all columns have absolute value sum == 1."""
|
|
169
|
+
return cast(S1, s / s.abs().sum(axis=0))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def center(s: S1) -> S1:
|
|
173
|
+
"""Shift the input such that all columns have mean == 0."""
|
|
174
|
+
return cast(S1, s - s.mean())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def standardize(df: pd.DataFrame) -> pd.DataFrame:
|
|
178
|
+
"""Scale a df such that all columns have mean == 0 and std == 1."""
|
|
179
|
+
return variance_normalize(center(df))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def validate_indices(live_targets: pd.Series, predictions: pd.Series) -> None:
|
|
183
|
+
# ensure the ids are equivalent and sorted
|
|
184
|
+
assert np.array_equal(predictions.index, live_targets.index.sort_values())
|
|
185
|
+
assert np.array_equal(live_targets.index, live_targets.index.sort_values())
|
|
186
|
+
assert np.array_equal(predictions.index, predictions.index.sort_values())
|
|
187
|
+
# ensure no nans
|
|
188
|
+
assert not predictions.isna().any()
|
|
189
|
+
assert not live_targets.isna().any()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def correlation(live_targets: pd.Series, predictions: pd.Series) -> float:
|
|
193
|
+
validate_indices(live_targets, predictions)
|
|
194
|
+
# calculate correlation coefficient
|
|
195
|
+
return np.corrcoef(live_targets, predictions)[0, 1]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def tie_broken_rank_correlation(target: pd.Series, predictions: pd.Series) -> float:
|
|
199
|
+
# percentile rank the predictions and get the correlation with the target
|
|
200
|
+
ranked_predictions = tie_broken_rank(predictions.to_frame())[predictions.name]
|
|
201
|
+
return correlation(target, ranked_predictions)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def spearman_correlation(target: pd.Series, predictions: pd.Series) -> float:
|
|
205
|
+
validate_indices(target, predictions)
|
|
206
|
+
return target.corr(predictions, method="spearman")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pearson_correlation(
|
|
210
|
+
target: pd.Series, predictions: pd.Series, top_bottom: Optional[int] = None
|
|
211
|
+
) -> float:
|
|
212
|
+
if top_bottom is not None and top_bottom > 0:
|
|
213
|
+
predictions = filter_sort_top_bottom_concat(predictions, top_bottom)
|
|
214
|
+
target, predictions = filter_sort_index(
|
|
215
|
+
target, predictions, (1 - top_bottom / len(target))
|
|
216
|
+
)
|
|
217
|
+
validate_indices(target, predictions)
|
|
218
|
+
return target.corr(predictions, method="pearson")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def sharpe_ratio(s: pd.Series) -> float:
|
|
222
|
+
# calculate the sharpe ratio of a series
|
|
223
|
+
return np.mean(s) / np.std(s)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def power(df: pd.DataFrame, p: float) -> pd.DataFrame:
|
|
227
|
+
"""Raise given predictions series to the given power.
|
|
228
|
+
|
|
229
|
+
Arguments:
|
|
230
|
+
df: pd.DataFrame - the data to raise to the given power
|
|
231
|
+
p: float - the power to which we exponentiate the data
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
pd.DataFrame - the predictions raised to the given power,
|
|
235
|
+
each column should be at least 90% correlated with the original data
|
|
236
|
+
"""
|
|
237
|
+
assert not df.isna().any().any(), "Data contains NaNs"
|
|
238
|
+
assert np.array_equal(df.index.sort_values(), df.index), "Index is not sorted"
|
|
239
|
+
result = cast(pd.DataFrame, np.sign(df) * np.abs(df) ** p)
|
|
240
|
+
assert ((result.std() == 0) | (result.corrwith(df) >= 0.9)).all()
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def gaussian(df: pd.DataFrame) -> pd.DataFrame:
|
|
245
|
+
"""Gaussianize each column of a pandas DataFrame using a normal percent point func.
|
|
246
|
+
Effectively scales each column such that mean == 0 and std == 1.
|
|
247
|
+
|
|
248
|
+
Arguments:
|
|
249
|
+
df: pd.DataFrame - the data to gaussianize
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
pd.DataFrame - the gaussianized data
|
|
253
|
+
"""
|
|
254
|
+
assert np.array_equal(df.index.sort_values(), df.index)
|
|
255
|
+
return df.apply(lambda series: cast(np.ndarray, stats.norm.ppf(series)))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def orthogonalize(v: np.ndarray, u: np.ndarray) -> np.ndarray:
|
|
259
|
+
"""Orthogonalizes v with respect to u by projecting v onto u,
|
|
260
|
+
then subtracting that projection from v.
|
|
261
|
+
|
|
262
|
+
This will reach the same result as the neutralize
|
|
263
|
+
function when v and u are centered single column vectors,
|
|
264
|
+
but this is much faster.
|
|
265
|
+
|
|
266
|
+
Arguments:
|
|
267
|
+
v: np.ndarray - the vector to orthogonalize
|
|
268
|
+
u: np.ndarray - the vector orthogonalize v
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
np.ndarray - the orthogonalized vector v
|
|
272
|
+
"""
|
|
273
|
+
return v - np.outer(u, (v.T @ u) / (u.T @ u))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def stake_weight(
|
|
277
|
+
predictions: pd.DataFrame,
|
|
278
|
+
stakes: pd.Series,
|
|
279
|
+
) -> pd.Series:
|
|
280
|
+
"""Create a stake-weighted meta model from the given predictions and stakes.
|
|
281
|
+
|
|
282
|
+
Arguments:
|
|
283
|
+
predictions: pd.DataFrame - the predictions to weight
|
|
284
|
+
stakes: pd.Series - the stakes to use as weights
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
pd.Series - the stake-weighted meta model
|
|
288
|
+
"""
|
|
289
|
+
return (predictions[stakes.index] * stakes).sum(axis=1) / stakes.sum()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def correlation_contribution(
|
|
293
|
+
predictions: pd.DataFrame,
|
|
294
|
+
meta_model: pd.Series,
|
|
295
|
+
live_targets: pd.Series,
|
|
296
|
+
top_bottom: Optional[int] = None,
|
|
297
|
+
) -> pd.Series:
|
|
298
|
+
"""Calculate how much the given predictions contribute to the
|
|
299
|
+
given Meta Model's correlation with the target.
|
|
300
|
+
|
|
301
|
+
Then calculate contributive correlation by:
|
|
302
|
+
1. tie-kept ranking each prediction and the meta model
|
|
303
|
+
2. gaussianizing each prediction and the meta model
|
|
304
|
+
3. orthogonalizing each prediction wrt the meta model
|
|
305
|
+
4. dot product the orthogonalized predictions and the targets
|
|
306
|
+
then normalize by the length of the target (equivalent to covariance)
|
|
307
|
+
|
|
308
|
+
This is 100% correlated with the following formula:
|
|
309
|
+
pearson_corr(
|
|
310
|
+
live_targets, 0.999 * meta_model + 0.001 * predictions
|
|
311
|
+
) - pearson_corr(
|
|
312
|
+
live_targets, meta_model
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
Arguments:
|
|
316
|
+
predictions: pd.DataFrame - the predictions to evaluate
|
|
317
|
+
meta_model: pd.Series - the meta model to evaluate against
|
|
318
|
+
live_targets: pd.Series - the live targets to evaluate against
|
|
319
|
+
top_bottom: Optional[int] - the number of top and bottom predictions to use
|
|
320
|
+
when calculating the correlation. Results in
|
|
321
|
+
2*top_bottom predictions.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
pd.Series - the resulting contributive correlation
|
|
325
|
+
scores for each column in predictions
|
|
326
|
+
"""
|
|
327
|
+
# filter and sort preds, mm, and targets wrt each other
|
|
328
|
+
meta_model, predictions = filter_sort_index(meta_model, predictions)
|
|
329
|
+
live_targets, predictions = filter_sort_index(live_targets, predictions)
|
|
330
|
+
live_targets, meta_model = filter_sort_index(live_targets, meta_model)
|
|
331
|
+
|
|
332
|
+
# rank and normalize meta model and predictions so mean=0 and std=1
|
|
333
|
+
p = gaussian(tie_kept_rank(predictions)).values
|
|
334
|
+
m = gaussian(tie_kept_rank(meta_model.to_frame()))[meta_model.name].values
|
|
335
|
+
|
|
336
|
+
# orthogonalize predictions wrt meta model
|
|
337
|
+
neutral_preds = orthogonalize(p, cast(np.ndarray, m))
|
|
338
|
+
|
|
339
|
+
# convert target to buckets [-2, -1, 0, 1, 2]
|
|
340
|
+
if (live_targets >= 0).all() and (live_targets <= 1).all():
|
|
341
|
+
live_targets = live_targets * 4
|
|
342
|
+
live_targets -= live_targets.mean()
|
|
343
|
+
|
|
344
|
+
if top_bottom is not None and top_bottom > 0:
|
|
345
|
+
# filter each column to its top and bottom n predictions
|
|
346
|
+
neutral_preds_df = pd.DataFrame(
|
|
347
|
+
neutral_preds, columns=predictions.columns, index=predictions.index
|
|
348
|
+
).apply(lambda p: filter_sort_top_bottom_concat(p, top_bottom))
|
|
349
|
+
mmc_matrix = (
|
|
350
|
+
# create a dataframe for targets to match the filtered predictions
|
|
351
|
+
neutral_preds_df.apply(
|
|
352
|
+
lambda p: filter_sort_index(
|
|
353
|
+
p,
|
|
354
|
+
live_targets,
|
|
355
|
+
(1 - top_bottom / len(live_targets)),
|
|
356
|
+
)[1]
|
|
357
|
+
)
|
|
358
|
+
.fillna(0)
|
|
359
|
+
.T.values
|
|
360
|
+
# then fill NaNs with 0 so we don't get NaNs in the dot product
|
|
361
|
+
# and mutiply target w/ neutral preds to get MMC
|
|
362
|
+
) @ neutral_preds_df.fillna(0).values
|
|
363
|
+
# only the diagonal is the proper score
|
|
364
|
+
mmc = np.diag(mmc_matrix) / (top_bottom * 2)
|
|
365
|
+
else:
|
|
366
|
+
# multiply target and neutralized predictions
|
|
367
|
+
# this is equivalent to covariance b/c mean = 0
|
|
368
|
+
target_values = cast(np.ndarray, live_targets.to_numpy())
|
|
369
|
+
mmc = (target_values @ neutral_preds) / len(live_targets)
|
|
370
|
+
return pd.Series(mmc, index=predictions.columns)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def neutralize(
|
|
374
|
+
df: pd.DataFrame,
|
|
375
|
+
neutralizers: pd.DataFrame,
|
|
376
|
+
proportion: float = 1.0,
|
|
377
|
+
) -> pd.DataFrame:
|
|
378
|
+
"""Neutralize each column of a given DataFrame by each feature in a given
|
|
379
|
+
neutralizers DataFrame. Neutralization uses least-squares regression to
|
|
380
|
+
find the orthogonal projection of each column onto the neutralizers, then
|
|
381
|
+
subtracts the result from the original predictions.
|
|
382
|
+
|
|
383
|
+
Arguments:
|
|
384
|
+
df: pd.DataFrame - the data with columns to neutralize
|
|
385
|
+
neutralizers: pd.DataFrame - the neutralizer data with features as columns
|
|
386
|
+
proportion: float - the degree to which neutralization occurs
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
pd.DataFrame - the neutralized data
|
|
390
|
+
"""
|
|
391
|
+
assert not df.isna().any().any(), "Data contains NaNs"
|
|
392
|
+
assert not neutralizers.isna().any().any(), "Neutralizers contain NaNs"
|
|
393
|
+
assert len(df.index) == len(neutralizers.index), "Indices don't match"
|
|
394
|
+
assert (df.index == neutralizers.index).all(), "Indices don't match"
|
|
395
|
+
zero_std_cols = df.columns[df.std() == 0]
|
|
396
|
+
if len(zero_std_cols) > 0:
|
|
397
|
+
df = df.copy()
|
|
398
|
+
df.loc[:, zero_std_cols] = np.nan
|
|
399
|
+
df_arr = df.values
|
|
400
|
+
neutralizer_arr = neutralizers.values
|
|
401
|
+
neutralizer_arr = np.hstack(
|
|
402
|
+
# add a column of 1s to the neutralizer array in case neutralizer_arr is a single column
|
|
403
|
+
(neutralizer_arr, np.array([1] * len(neutralizer_arr)).reshape(-1, 1))
|
|
404
|
+
)
|
|
405
|
+
least_squares = np.linalg.lstsq(neutralizer_arr, df_arr, rcond=1e-6)[0]
|
|
406
|
+
adjustments = proportion * neutralizer_arr.dot(least_squares)
|
|
407
|
+
neutral = df_arr - adjustments
|
|
408
|
+
return pd.DataFrame(neutral, index=df.index, columns=df.columns)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def one_hot_encode(
|
|
412
|
+
df: pd.DataFrame, columns: List[str], dtype: type = np.float64
|
|
413
|
+
) -> pd.DataFrame:
|
|
414
|
+
"""One-hot encodes specified columns in a pandas dataframe.
|
|
415
|
+
Each column i should have x_i discrete values (eg. categories, bucket values, etc.)
|
|
416
|
+
and will be converted to x_i columns that each have 0s for rows that don't have
|
|
417
|
+
the associated value and 1s for rows that do have that value.
|
|
418
|
+
|
|
419
|
+
Arguments:
|
|
420
|
+
df: pd.DataFrame - the data with columns to one-hot encode
|
|
421
|
+
columns: List[str] - list of columns names to replace w/ one-hot encoding
|
|
422
|
+
dtype: type = np.float64 - the target datatype for the resulting columns
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
pd.DataFrame - original data, but specified cols replaced w/ one-hot encoding
|
|
426
|
+
"""
|
|
427
|
+
for col in columns:
|
|
428
|
+
encoder = OneHotEncoder(dtype=dtype)
|
|
429
|
+
one_hot = encoder.fit_transform(df[[col]])
|
|
430
|
+
one_hot = pd.DataFrame(
|
|
431
|
+
one_hot.toarray(),
|
|
432
|
+
columns=encoder.get_feature_names_out(),
|
|
433
|
+
index=df.index,
|
|
434
|
+
)
|
|
435
|
+
df = df.join(one_hot).drop(columns=col)
|
|
436
|
+
return df
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def tie_kept_rank__gaussianize__pow_1_5(df: pd.DataFrame) -> pd.DataFrame:
|
|
440
|
+
"""Perform the 3 functions in order on the given pandas DataFrame.
|
|
441
|
+
Will tie-kept rank then gaussianize then exponentiate to the 1.5 power.
|
|
442
|
+
|
|
443
|
+
Arguments:
|
|
444
|
+
df: pd.DataFrame - the data to transform
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
pd.DataFrame - the resulting data after applying the 3 functions
|
|
448
|
+
"""
|
|
449
|
+
return power(gaussian(tie_kept_rank(df)), 1.5)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def tie_kept_rank__gaussianize__neutralize__variance_normalize(
|
|
453
|
+
df: pd.DataFrame, neutralizers: pd.DataFrame
|
|
454
|
+
) -> pd.DataFrame:
|
|
455
|
+
"""Perform the 4 functions in order on the given pandas DataFrame.
|
|
456
|
+
1. tie-kept rank each column
|
|
457
|
+
2. gaussianize each column
|
|
458
|
+
3. neutralize each column to the neutralizers
|
|
459
|
+
4. variance normalize each column
|
|
460
|
+
|
|
461
|
+
Arguments:
|
|
462
|
+
df: pd.DataFrame - the data to transform
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
pd.DataFrame - the resulting data after applying the 3 functions
|
|
466
|
+
"""
|
|
467
|
+
return variance_normalize(neutralize(gaussian(tie_kept_rank(df)), neutralizers))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def numerai_corr(
|
|
471
|
+
predictions: pd.DataFrame,
|
|
472
|
+
targets: pd.Series,
|
|
473
|
+
max_filtered_index_ratio: float = DEFAULT_MAX_FILTERED_INDEX_RATIO,
|
|
474
|
+
top_bottom: Optional[int] = None,
|
|
475
|
+
target_pow15: bool = True,
|
|
476
|
+
) -> pd.Series:
|
|
477
|
+
"""Calculates the canonical Numerai correlation.
|
|
478
|
+
1. Re-center the target on 0
|
|
479
|
+
2. filter and sort indices
|
|
480
|
+
3. apply tie_kept_rank__gaussianize__pow_1_5 to the predictions
|
|
481
|
+
4. raise the targets to the 1.5 power
|
|
482
|
+
5. calculate the pearson correlation between the predictions and targets.
|
|
483
|
+
|
|
484
|
+
Arguments:
|
|
485
|
+
predictions: pd.DataFrame - the predictions to evaluate
|
|
486
|
+
targets: pd.Series - the live targets to evaluate against
|
|
487
|
+
max_filtered_index_ratio: float - the maximum ratio of indices that can be dropped
|
|
488
|
+
when matching up the targets and predictions
|
|
489
|
+
top_bottom: Optional[int] - the number of top and bottom predictions to use
|
|
490
|
+
when calculating the correlation. Results in
|
|
491
|
+
2*top_bottom predictions.
|
|
492
|
+
target_pow15: bool - whether to raise the targets to the 1.5 power, defaults to True.
|
|
493
|
+
Set to False if you are passing in returns as the targets.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
pd.Series - the resulting correlation scores for each column in predictions
|
|
497
|
+
"""
|
|
498
|
+
targets = center(targets)
|
|
499
|
+
targets, predictions = filter_sort_index(
|
|
500
|
+
targets, predictions, max_filtered_index_ratio
|
|
501
|
+
)
|
|
502
|
+
predictions = tie_kept_rank__gaussianize__pow_1_5(predictions)
|
|
503
|
+
if target_pow15:
|
|
504
|
+
targets = power(targets.to_frame(), 1.5)[targets.name]
|
|
505
|
+
scores = predictions.apply(
|
|
506
|
+
lambda sub: pearson_correlation(targets, sub, top_bottom)
|
|
507
|
+
)
|
|
508
|
+
return scores
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def feature_neutral_corr(
|
|
512
|
+
predictions: pd.DataFrame,
|
|
513
|
+
features: pd.DataFrame,
|
|
514
|
+
targets: pd.Series,
|
|
515
|
+
top_bottom: Optional[int] = None,
|
|
516
|
+
):
|
|
517
|
+
"""Calculates the canonical Numerai feature-neutral correlation.
|
|
518
|
+
1. neutralize predictions relative to the features
|
|
519
|
+
2. calculate the numerai_corr between the neutralized predictions and targets
|
|
520
|
+
|
|
521
|
+
Arguments:
|
|
522
|
+
predictions: pd.DataFrame - the predictions to evaluate
|
|
523
|
+
features: pd.DataFrame - the features to neutralize the predictions against
|
|
524
|
+
targets: pd.Series - the live targets to evaluate against
|
|
525
|
+
top_bottom: Optional[int] - the number of top and bottom predictions to use
|
|
526
|
+
when calculating the correlation. Results in
|
|
527
|
+
2*top_bottom predictions.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
pd.Series - the resulting correlation scores for each column in predictions
|
|
531
|
+
"""
|
|
532
|
+
neutral_preds = tie_kept_rank__gaussianize__neutralize__variance_normalize(
|
|
533
|
+
predictions, features
|
|
534
|
+
)
|
|
535
|
+
return numerai_corr(neutral_preds, targets, top_bottom=top_bottom)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def max_feature_correlation(
|
|
539
|
+
s: pd.Series,
|
|
540
|
+
features: pd.DataFrame,
|
|
541
|
+
top_bottom: Optional[int] = None,
|
|
542
|
+
) -> Tuple[str, float]:
|
|
543
|
+
"""Calculates the maximum correlation between the given series and each feature
|
|
544
|
+
and returns the name of the feature and the correlation with that feature.
|
|
545
|
+
|
|
546
|
+
Arguments:
|
|
547
|
+
s: pd.Series - the series to calculate correlations against
|
|
548
|
+
features: pd.DataFrame - the features to calculate correlations against
|
|
549
|
+
top_bottom: Optional[int] - the number of top and bottom predictions to use
|
|
550
|
+
when calculating the correlation. Results in
|
|
551
|
+
2*top_bottom predictions.
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
Tuple[str, float] - the name of the feature with the highest correlation
|
|
555
|
+
and the correlation with that feature
|
|
556
|
+
"""
|
|
557
|
+
feature_correlations = features.apply(
|
|
558
|
+
lambda f: pearson_correlation(f, s, top_bottom)
|
|
559
|
+
)
|
|
560
|
+
feature_correlations = feature_correlations.abs()
|
|
561
|
+
max_feature = feature_correlations.idxmax()
|
|
562
|
+
max_corr = feature_correlations[max_feature]
|
|
563
|
+
return str(max_feature), max_corr
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def generate_neutralized_weights(
|
|
567
|
+
predictions: pd.DataFrame,
|
|
568
|
+
neutralizers: pd.DataFrame,
|
|
569
|
+
sample_weights: pd.Series,
|
|
570
|
+
center_and_normalize: bool = False,
|
|
571
|
+
) -> pd.DataFrame:
|
|
572
|
+
assert not predictions.isna().any().any(), "Predictions contain NaNs"
|
|
573
|
+
assert not neutralizers.isna().any().any(), "Normalization factors contain NaNs"
|
|
574
|
+
assert not sample_weights.isna().any(), "Weights contain NaNs"
|
|
575
|
+
ranked_predictions = tie_kept_rank__gaussianize__pow_1_5(predictions)
|
|
576
|
+
ranked_predictions, neutralizers, sample_weights = filter_sort_index_many(
|
|
577
|
+
[ranked_predictions, neutralizers, sample_weights]
|
|
578
|
+
)
|
|
579
|
+
neutral_weights = ranked_predictions.apply(
|
|
580
|
+
lambda s_prime: (
|
|
581
|
+
s_prime - neutralizers @ (neutralizers.T @ (sample_weights * s_prime))
|
|
582
|
+
)
|
|
583
|
+
* sample_weights
|
|
584
|
+
)
|
|
585
|
+
if center_and_normalize:
|
|
586
|
+
neutral_weights = weight_normalize(center(neutral_weights))
|
|
587
|
+
return neutral_weights
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def alpha(
|
|
591
|
+
predictions: pd.DataFrame,
|
|
592
|
+
neutralizers: pd.DataFrame,
|
|
593
|
+
sample_weights: pd.Series,
|
|
594
|
+
targets: pd.Series,
|
|
595
|
+
) -> pd.Series:
|
|
596
|
+
"""Calculates the "alpha" score:
|
|
597
|
+
- rank, normalize, and power the signal
|
|
598
|
+
- convert signal into neutralized weights
|
|
599
|
+
- multiplying the weights by the targets
|
|
600
|
+
|
|
601
|
+
Arguments:
|
|
602
|
+
predictions: pd.DataFrame - the predictions to evaluate
|
|
603
|
+
neutralizers: pd.DataFrame - the neutralization columns
|
|
604
|
+
sample_weights: pd.Series - the universe sampling weights
|
|
605
|
+
targets: pd.Series - the live targets to evaluate against
|
|
606
|
+
"""
|
|
607
|
+
targets = center(targets)
|
|
608
|
+
predictions, targets = filter_sort_index(predictions, targets)
|
|
609
|
+
weights = generate_neutralized_weights(predictions, neutralizers, sample_weights)
|
|
610
|
+
alpha_scores = weights.apply(lambda w: w @ targets) / len(targets)
|
|
611
|
+
return alpha_scores
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def meta_portfolio_contribution(
|
|
615
|
+
predictions: pd.DataFrame,
|
|
616
|
+
stakes: pd.Series,
|
|
617
|
+
neutralizers: pd.DataFrame,
|
|
618
|
+
sample_weights: pd.Series,
|
|
619
|
+
targets: pd.Series,
|
|
620
|
+
) -> pd.Series:
|
|
621
|
+
"""Calculates the "meta portfolio" gradient w.r.t. stakes:
|
|
622
|
+
- rank, normalize, and power each signal
|
|
623
|
+
- convert each signal into neutralized weights
|
|
624
|
+
- center weights across samples (explicit W_c = C W)
|
|
625
|
+
- generate the stake-weighted portfolio
|
|
626
|
+
- calculate the gradient of the portfolio w.r.t. the stakes
|
|
627
|
+
- multiply by the (centered) targets
|
|
628
|
+
|
|
629
|
+
Arguments:
|
|
630
|
+
predictions: pd.DataFrame - the predictions to evaluate
|
|
631
|
+
stakes: pd.Series - the stakes to use as weights
|
|
632
|
+
neutralizers: pd.DataFrame - the neutralization columns
|
|
633
|
+
sample_weights: pd.Series - the universe sampling weights
|
|
634
|
+
targets: pd.Series - the live targets to evaluate against
|
|
635
|
+
"""
|
|
636
|
+
# Align predictions and targets on the same index / universe
|
|
637
|
+
predictions, targets = filter_sort_index(predictions, targets)
|
|
638
|
+
|
|
639
|
+
# Center targets in sample space: t_c = C t
|
|
640
|
+
targets = center(targets)
|
|
641
|
+
|
|
642
|
+
# Normalize stakes to sum to 1
|
|
643
|
+
stake_weights = weight_normalize(stakes.fillna(0))
|
|
644
|
+
assert np.isclose(stake_weights.sum(), 1), "Stakes must sum to 1"
|
|
645
|
+
|
|
646
|
+
# Generate neutralized weights W(predictions, neutralizers, sample_weights)
|
|
647
|
+
weights = generate_neutralized_weights(predictions, neutralizers, sample_weights)
|
|
648
|
+
|
|
649
|
+
# Extract aligned matrices/vectors
|
|
650
|
+
w = cast(np.ndarray, weights[stakes.index].values) # W ∈ R^{N×K}
|
|
651
|
+
s = cast(np.ndarray, stake_weights.values) # s ∈ R^K
|
|
652
|
+
t = cast(np.ndarray, targets.values) # t_c ∈ R^N (already centered)
|
|
653
|
+
|
|
654
|
+
# Explicit centering of weights across samples:
|
|
655
|
+
# W_c = C W = W - 1 μ^T, where μ is the column-wise mean of W
|
|
656
|
+
w_centered = w - w.mean(axis=0, keepdims=True) # W_c
|
|
657
|
+
|
|
658
|
+
# Centered prediction vector v = W_c s
|
|
659
|
+
v = w_centered @ s # v ∈ R^N, already mean ~ 0
|
|
660
|
+
# Optionally re-center to remove numerical drift
|
|
661
|
+
v = v - v.mean()
|
|
662
|
+
|
|
663
|
+
# Its L2 norm r = ||v||
|
|
664
|
+
l2_norm = np.sqrt(np.sum(v**2))
|
|
665
|
+
|
|
666
|
+
# Residualize W_c against v:
|
|
667
|
+
# residualized_w ≈ R_v W_c = (I - v v^T / ||v||^2) W_c
|
|
668
|
+
residualized_w = orthogonalize(w_centered, v)
|
|
669
|
+
|
|
670
|
+
# Gradient: ∇_s α = (1 / ||v||) (R_v W_c)^T t_c
|
|
671
|
+
mpc = (residualized_w.T @ t).squeeze() / l2_norm
|
|
672
|
+
mpc /= 50
|
|
673
|
+
return pd.Series(mpc, index=stakes.index)
|