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.
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: numerai-tools
3
- Version: 0.6.0.dev0
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,<3.0.0)
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)