Wunder Fund RNN Challenge — Causal GRU Ensemble
A 5-member ensemble of causal GRUs that predicts the next 32-feature market state from a stream of prior states, built for the (finished) Wunder Fund market-state forecasting challenge. Inference is online (one row at a time), single-CPU-core, and deterministic.
📦 Code, training pipeline, and full write-up: https://github.com/msrishav-28/wunder-fund-market-state-forecasting
Results
Cross-validated R² (sequence-grouped folds, the leaderboard-comparable metric):
| Model | Dev CV mean R² |
|---|---|
| Ridge causal baseline | 0.327 |
| Single causal GRU (d256) | 0.389 |
| GRU ensemble (3×d256 + 2×d384) | 0.3922 |
For reference, the competition's public leaderboard #1 was 0.3920 and the finals winner 0.3964; this ensemble's cross-validated 0.3922 matches the public top. Single-core inference is 4.17 ms/prediction (~32 min for the full ~517-sequence test, limit 60), and predictions are deterministic.
Why a GRU
The features are pre-whitened (≈N(0,1), no heavy tails) and mean-reverting (predicting the current state gives R² ≈ −0.37). A VAR(p) linear model saturates at ≈0.32, so the remaining signal is nonlinear temporal structure — captured by a unidirectional GRU that emits a next-state prediction at every step and carries its hidden state across the sequence (O(1) work per online prediction). Training and stepwise inference are numerically identical because a GRU is a pure recurrence. See the technical report.
Files
gru/d256_s{42,123,7}.pt # 3 GRU members, d_model=256, 2 layers
gru/d384_s{42,123}.pt # 2 GRU members, d_model=384, 2 layers
solution.py # competition entry point (online PredictionModel)
src/ # model + inference code
config/folds.json # locked sequence-grouped CV folds
reports/ # technical report
Each checkpoint stores model_state_dict + a config describing the
architecture, so src/models/sequence_inference.py::load_gru_checkpoint rebuilds
it without external metadata.
Usage
import numpy as np, torch
from src.models.sequence_inference import GRUStatefulPredictionModel, load_gru_checkpoint
from src.models.ensemble_predictor import EnsemblePredictionModel
from pathlib import Path
members = [GRUStatefulPredictionModel(load_gru_checkpoint(p))
for p in sorted(Path("gru").glob("*.pt"))]
model = EnsemblePredictionModel(members) # equal-weight average
class DP: # minimal DataPoint
def __init__(s, seq_ix, step, need, state):
s.seq_ix, s.step_in_seq, s.need_prediction, s.state = seq_ix, step, need, state
# feed states one at a time; reset on a new seq_ix; predicts the NEXT state
for t in range(1000):
pred = model.predict(DP(0, t, t >= 100, np.random.randn(32).astype("float32")))
# pred is None during warm-up (steps 0..99), else an (32,) float32 vector
Or load the packaged solution.py directly (auto-discovers models/submission/gru/*.pt).
Training
Trained on an NVIDIA RTX 3050 (4 GB) at ~1.8 s/epoch; full-sequence BPTT with a loss masked to the scored steps. Reproduction commands are in the GitHub README.
License
MIT. Trained only on the competition-provided data.