Add reproducible v4 weekly champion release
Browse files- README.md +101 -0
- __pycache__/inference.cpython-311.pyc +0 -0
- inference.py +152 -0
- manifest.json +53 -0
- requirements.txt +9 -0
README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
tags:
|
| 3 |
+
- numerai
|
| 4 |
+
- tabular-regression
|
| 5 |
+
- finance
|
| 6 |
+
- lightgbm
|
| 7 |
+
- catboost
|
| 8 |
+
- weekly-model
|
| 9 |
+
pipeline_tag: tabular-regression
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Numerai Weekly Champion v4
|
| 13 |
+
|
| 14 |
+
This repository publishes the exact model bundle currently used by Noptus' validated Numerai submission pipeline. It is intended as a reproducible research artifact and a starting point for ensemble-diversity work—not as investment advice or a promise of tournament performance.
|
| 15 |
+
|
| 16 |
+
## Current release
|
| 17 |
+
|
| 18 |
+
- Release: `v4-20260801-180455`
|
| 19 |
+
- Verified live round: `1333`
|
| 20 |
+
- Data schema: Numerai `v5.2`
|
| 21 |
+
- Inputs: 780 `medium` features plus 8 public benchmark-model columns
|
| 22 |
+
- Components: two benchmark-aware LightGBM models, six multi-target LightGBM models, one residual LightGBM model, and one CatBoost model
|
| 23 |
+
- Bundle size: approximately 142 MiB
|
| 24 |
+
- SHA-256: `79db5f41f3506e8e10a8b96c60a927f6a9ca202e49e03304ceaa9d304116b8d9`
|
| 25 |
+
|
| 26 |
+
The bundle was promoted over the previous local champion on a 57-era untouched holdout:
|
| 27 |
+
|
| 28 |
+
| Metric | v4 | previous champion |
|
| 29 |
+
|---|---:|---:|
|
| 30 |
+
| Mean Numerai CORR | 0.010453 | 0.001821 |
|
| 31 |
+
| Sharpe | 0.7712 | 0.1499 |
|
| 32 |
+
| Positive-era consistency | 75.44% | 52.63% |
|
| 33 |
+
| Maximum drawdown proxy | -0.01360 | -0.02329 |
|
| 34 |
+
|
| 35 |
+
These are historical offline measurements, not live-performance guarantees. The model remains experimental, can decay under regime change, and should not be used to make financial decisions.
|
| 36 |
+
|
| 37 |
+
## Load and predict
|
| 38 |
+
|
| 39 |
+
Install the pinned runtime dependencies:
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
pip install -r requirements.txt
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Download the files and run inference on the public Numerai live and benchmark-model frames:
|
| 46 |
+
|
| 47 |
+
```python
|
| 48 |
+
from huggingface_hub import hf_hub_download
|
| 49 |
+
import joblib
|
| 50 |
+
import pandas as pd
|
| 51 |
+
|
| 52 |
+
from inference import predict_ranked
|
| 53 |
+
|
| 54 |
+
repo_id = "Noptus/numerai-weekly-v4"
|
| 55 |
+
model_path = hf_hub_download(repo_id, "ensemble_v4.pkl")
|
| 56 |
+
bundle = joblib.load(model_path)
|
| 57 |
+
|
| 58 |
+
live = pd.read_parquet("live.parquet")
|
| 59 |
+
benchmarks = pd.read_parquet("live_benchmark_models.parquet")
|
| 60 |
+
benchmark_columns = [c for c in benchmarks.columns if c != "era"]
|
| 61 |
+
live = live.join(benchmarks[benchmark_columns], how="left")
|
| 62 |
+
|
| 63 |
+
submission = pd.DataFrame(
|
| 64 |
+
{"prediction": predict_ranked(live, bundle)},
|
| 65 |
+
index=live.index,
|
| 66 |
+
)
|
| 67 |
+
submission.index.name = "id"
|
| 68 |
+
submission.to_csv("predictions.csv")
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
The included command-line entry point performs the same base inference:
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
python inference.py \
|
| 75 |
+
--model ensemble_v4.pkl \
|
| 76 |
+
--live live.parquet \
|
| 77 |
+
--benchmarks live_benchmark_models.parquet \
|
| 78 |
+
--output predictions.csv
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
The production system derives several slot-specific submissions by applying different feature and benchmark neutralization settings after this base ensemble. Those operational credentials and live submissions are intentionally excluded.
|
| 82 |
+
|
| 83 |
+
## Reproducibility and safety
|
| 84 |
+
|
| 85 |
+
`manifest.json` records the source revision, metric split, dependency versions, and hashes. Numerai datasets, target labels, live predictions, API credentials, and staking information are not included.
|
| 86 |
+
|
| 87 |
+
The checkpoint uses Python pickle serialization because it contains native LightGBM and CatBoost estimators. Pickle can execute code while loading: verify the SHA-256 and load only artifacts you trust. Reconstructing the component estimators in native, non-pickle formats is planned for a later release.
|
| 88 |
+
|
| 89 |
+
## Research context
|
| 90 |
+
|
| 91 |
+
Three subsequent frozen-prediction experiments did not displace this champion:
|
| 92 |
+
|
| 93 |
+
- extra tree families were highly redundant with the core (pairwise prediction correlations 0.81–0.94);
|
| 94 |
+
- equal and shrinkage weighting lost to purged walk-forward coordinate ascent;
|
| 95 |
+
- a raw-magnitude residual stack lost to the existing rank blend.
|
| 96 |
+
|
| 97 |
+
Negative results are retained because they narrow the useful next step: seek genuinely different input signal—currently the official v5.3 feature families—rather than adding more tree implementations over the same v5.2 inputs.
|
| 98 |
+
|
| 99 |
+
## License and use
|
| 100 |
+
|
| 101 |
+
No explicit model or software license has been selected for this first release. Numerai data and benchmark-model files are governed by their own terms and are not redistributed here. Verify the applicable terms before reuse or redistribution.
|
__pycache__/inference.cpython-311.pyc
ADDED
|
Binary file (10.6 kB). View file
|
|
|
inference.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Standalone, credential-free inference for the Numerai weekly v4 bundle."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import joblib
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from scipy.stats import norm, rankdata
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
REQUIRED_COMPONENTS = (
|
| 17 |
+
"benchmark_era_boost",
|
| 18 |
+
"multi_target",
|
| 19 |
+
"residual",
|
| 20 |
+
"catboost",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_bundle(path: str | Path) -> dict[str, Any]:
|
| 25 |
+
"""Load a trusted model bundle and validate its public inference contract."""
|
| 26 |
+
bundle = joblib.load(Path(path))
|
| 27 |
+
if not isinstance(bundle, dict):
|
| 28 |
+
raise TypeError("expected a dictionary model bundle")
|
| 29 |
+
missing = [name for name in REQUIRED_COMPONENTS if name not in bundle]
|
| 30 |
+
if missing:
|
| 31 |
+
raise ValueError(f"bundle is missing components: {', '.join(missing)}")
|
| 32 |
+
if "calibrated_weights" not in bundle or "config" not in bundle:
|
| 33 |
+
raise ValueError("bundle is missing calibrated_weights or config")
|
| 34 |
+
return bundle
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _columns(bundle: dict[str, Any]) -> tuple[list[str], list[str], list[str]]:
|
| 38 |
+
config = bundle["config"]
|
| 39 |
+
features = config.get("features")
|
| 40 |
+
benchmark_columns = config.get("bench_cols")
|
| 41 |
+
all_columns = config.get("all_feature_cols")
|
| 42 |
+
if not all(isinstance(value, list) for value in (features, benchmark_columns, all_columns)):
|
| 43 |
+
raise ValueError("bundle config does not contain serialized feature-name lists")
|
| 44 |
+
return features, benchmark_columns, all_columns
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _gaussianize(values: np.ndarray) -> np.ndarray:
|
| 48 |
+
ranked = rankdata(values, method="average") / (len(values) + 1)
|
| 49 |
+
return norm.ppf(ranked)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def predict(frame: pd.DataFrame, bundle: dict[str, Any]) -> np.ndarray:
|
| 53 |
+
"""Return the exact pre-neutralization Gaussian v4 ensemble prediction."""
|
| 54 |
+
features, _, all_columns = _columns(bundle)
|
| 55 |
+
missing = sorted(set(all_columns) - set(frame.columns))
|
| 56 |
+
if missing:
|
| 57 |
+
preview = ", ".join(missing[:8])
|
| 58 |
+
raise ValueError(f"input is missing {len(missing)} columns; first missing: {preview}")
|
| 59 |
+
|
| 60 |
+
n_rows = len(frame)
|
| 61 |
+
if n_rows < 2:
|
| 62 |
+
raise ValueError("at least two rows are required for cross-sectional ranking")
|
| 63 |
+
|
| 64 |
+
x_full = frame[all_columns].to_numpy()
|
| 65 |
+
x_features = frame[features].to_numpy()
|
| 66 |
+
components: dict[str, np.ndarray] = {}
|
| 67 |
+
|
| 68 |
+
models = bundle["benchmark_era_boost"]
|
| 69 |
+
components["benchmark_era_boost"] = np.mean(
|
| 70 |
+
[model.predict(x_full) for model in models], axis=0
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
target_predictions = [
|
| 74 |
+
rankdata(model.predict(x_full), method="average") / n_rows
|
| 75 |
+
for model in bundle["multi_target"].values()
|
| 76 |
+
]
|
| 77 |
+
components["multi_target"] = np.mean(target_predictions, axis=0)
|
| 78 |
+
|
| 79 |
+
models = bundle["residual"]
|
| 80 |
+
components["residual"] = np.mean(
|
| 81 |
+
[model.predict(x_features) for model in models], axis=0
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
models = bundle["catboost"]
|
| 85 |
+
components["catboost"] = np.mean(
|
| 86 |
+
[model.predict(x_full) for model in models], axis=0
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
for optional_name in ("xgboost", "lgb_dart"):
|
| 90 |
+
models = bundle.get(optional_name)
|
| 91 |
+
if models:
|
| 92 |
+
components[optional_name] = np.mean(
|
| 93 |
+
[model.predict(x_full) for model in models], axis=0
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
horizon_models = bundle.get("horizon60")
|
| 97 |
+
if horizon_models:
|
| 98 |
+
components["horizon60"] = np.mean(
|
| 99 |
+
[
|
| 100 |
+
rankdata(model.predict(x_full), method="average") / n_rows
|
| 101 |
+
for model in horizon_models.values()
|
| 102 |
+
],
|
| 103 |
+
axis=0,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
weights = {
|
| 107 |
+
name: float(weight)
|
| 108 |
+
for name, weight in bundle["calibrated_weights"].items()
|
| 109 |
+
if name in components
|
| 110 |
+
}
|
| 111 |
+
total_weight = sum(weights.values())
|
| 112 |
+
if total_weight <= 0:
|
| 113 |
+
raise ValueError("bundle has no positively weighted active components")
|
| 114 |
+
|
| 115 |
+
ensemble = np.zeros(n_rows, dtype=np.float64)
|
| 116 |
+
for name, weight in weights.items():
|
| 117 |
+
component_rank = rankdata(components[name], method="average") / (n_rows + 1)
|
| 118 |
+
ensemble += (weight / total_weight) * component_rank
|
| 119 |
+
return _gaussianize(ensemble)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def predict_ranked(frame: pd.DataFrame, bundle: dict[str, Any]) -> np.ndarray:
|
| 123 |
+
"""Return submission-shaped predictions strictly between zero and one."""
|
| 124 |
+
gaussian_prediction = predict(frame, bundle)
|
| 125 |
+
return rankdata(gaussian_prediction, method="average") / (len(frame) + 1)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def main() -> int:
|
| 129 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 130 |
+
parser.add_argument("--model", type=Path, required=True)
|
| 131 |
+
parser.add_argument("--live", type=Path, required=True)
|
| 132 |
+
parser.add_argument("--benchmarks", type=Path, required=True)
|
| 133 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 134 |
+
args = parser.parse_args()
|
| 135 |
+
|
| 136 |
+
bundle = load_bundle(args.model)
|
| 137 |
+
live = pd.read_parquet(args.live)
|
| 138 |
+
benchmarks = pd.read_parquet(args.benchmarks)
|
| 139 |
+
benchmark_columns = [column for column in benchmarks.columns if column != "era"]
|
| 140 |
+
live = live.join(benchmarks[benchmark_columns], how="left")
|
| 141 |
+
predictions = predict_ranked(live, bundle)
|
| 142 |
+
|
| 143 |
+
output = pd.DataFrame({"prediction": predictions}, index=live.index)
|
| 144 |
+
output.index.name = "id"
|
| 145 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
output.to_csv(args.output)
|
| 147 |
+
print(f"wrote {len(output):,} predictions to {args.output}")
|
| 148 |
+
return 0
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
raise SystemExit(main())
|
manifest.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"release": "v4-20260801-180455",
|
| 3 |
+
"published_date": "2026-08-14",
|
| 4 |
+
"verified_live_round": 1333,
|
| 5 |
+
"source": {
|
| 6 |
+
"repository": "https://github.com/Noptus/numerai-tournament",
|
| 7 |
+
"commit": "19fd7d8f6baea4af96f87d7295c83025fde53984",
|
| 8 |
+
"worktree_had_uncommitted_research_changes": true
|
| 9 |
+
},
|
| 10 |
+
"data": {
|
| 11 |
+
"version": "v5.2",
|
| 12 |
+
"included": false,
|
| 13 |
+
"feature_set": "medium",
|
| 14 |
+
"feature_count": 780,
|
| 15 |
+
"benchmark_model_column_count": 8
|
| 16 |
+
},
|
| 17 |
+
"validation": {
|
| 18 |
+
"calibration_eras": 120,
|
| 19 |
+
"purge_eras": 8,
|
| 20 |
+
"requested_holdout_eras": 60,
|
| 21 |
+
"scored_holdout_eras": 57,
|
| 22 |
+
"mean_corr": 0.010453262665121218,
|
| 23 |
+
"std_corr": 0.01355527777438434,
|
| 24 |
+
"sharpe": 0.771157561470213,
|
| 25 |
+
"consistency": 0.7543859649122807,
|
| 26 |
+
"max_drawdown_proxy": -0.013603461343878933
|
| 27 |
+
},
|
| 28 |
+
"inference_verification": {
|
| 29 |
+
"live_rows": 7016,
|
| 30 |
+
"production_function": "submit_v4.predict_v4",
|
| 31 |
+
"max_absolute_difference": 0.0,
|
| 32 |
+
"exact_array_equal": true,
|
| 33 |
+
"all_finite": true
|
| 34 |
+
},
|
| 35 |
+
"artifacts": {
|
| 36 |
+
"ensemble_v4.pkl": {
|
| 37 |
+
"sha256": "79db5f41f3506e8e10a8b96c60a927f6a9ca202e49e03304ceaa9d304116b8d9"
|
| 38 |
+
},
|
| 39 |
+
"promotion.json": {
|
| 40 |
+
"sha256": "fc158b8bbfc1ec33fdb556357c1b6a47db9423622beaac54aacd60649c573d16"
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
"runtime": {
|
| 44 |
+
"python": "3.11.15",
|
| 45 |
+
"numpy": "2.4.6",
|
| 46 |
+
"pandas": "3.0.3",
|
| 47 |
+
"scipy": "1.17.1",
|
| 48 |
+
"scikit_learn": "1.9.0",
|
| 49 |
+
"lightgbm": "4.6.0",
|
| 50 |
+
"catboost": "1.2.10",
|
| 51 |
+
"joblib": "1.5.3"
|
| 52 |
+
}
|
| 53 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
huggingface-hub>=0.34,<2
|
| 2 |
+
joblib==1.5.3
|
| 3 |
+
numpy==2.4.6
|
| 4 |
+
pandas==3.0.3
|
| 5 |
+
scipy==1.17.1
|
| 6 |
+
scikit-learn==1.9.0
|
| 7 |
+
lightgbm==4.6.0
|
| 8 |
+
catboost==1.2.10
|
| 9 |
+
pyarrow>=14
|