Downloads · 30 days
0
Qazybek/smartplay-fpl-v12
smartplay-fpl-v12 is a tabular regression model from Qazybek. Use it for the tabular regression task on the model card, and read the license before you ship it in a product. It is set up for xgboost. The card lists the license as cc-by-nc-4.0.
The model generating the expected points published on smartplayfpl.com. It predicts how many FPL points a player will score in an upcoming fixture, from 251 features built on the public Fantasy Premier League API and…
Downloads · 30 days
0
Access
Public
Updated Aug 7, 2026
Repo size
332 MB
Likes
0
Public
Click a slice to open those files.
.ubj332 MB · 99%
From the Hugging Face model README
The model generating the expected points published on smartplayfpl.com. It predicts how many FPL points a player will score in an upcoming fixture, from 251 features built on the public Fantasy Premier League API and Understat.
Training data, the merge pipeline and the evaluation harness are open at github.com/qazybekb/smartplayfpl.
Two stages per position, then a blend.
Stage 1 — the calibrated multibucket base. Points in FPL are not normally distributed; they are a spike at zero, a mass at 1-2, a fat middle and a long haul tail. Modelling that directly with a single regressor underfits the tail, so the base splits it:
p60 P(player reaches 60 minutes)
non60 E[points | under 60 minutes]
bucket P(bucket k | 60+ minutes) k = 0:(<=0) 1:(1-2) 2:(3-9) 3:(>=10)
bucketreg E[points | 60+ minutes, bucket k] four regressors
pred_if60 = Σ_k calibrated_p_k × bucketreg_k
pred = p60 × pred_if60 + (1 - p60) × non60
That is 7 models per position, 28 in total, plus a calibration file that gamma-corrects and reweights the bucket probabilities.
Stage 2 — the direct blend. A direct points regressor is trained per position and mixed in for outfield players only:
GKP = base
DEF / MID / FWD = 0.75 × base + 0.25 × direct
output clipped at 0.0
Goalkeepers stay entirely on the base because the blend did not improve their validation scores.
| Path | What it is |
|---|---|
base/*.ubj | The 28 multibucket models, XGBoost binary format |
base/bucket_calibration.json | Gamma + weights applied to bucket probabilities |
blend/direct_{GKP,DEF,MID,FWD}.json | The direct regressors |
blend/blend.json | Blend weights and the exact formula above |
blend/feature_cols.json | The 251 features, in the order the models expect |
walk_forward_accuracy.json | Per-gameweek evaluation, see below |
direct_GKP.json ships for reproducibility but is not used at inference —
blend.json lists GKP under excluded_positions.
Weights are XGBoost's own formats (.ubj binary, .json text), not pickle.
Both load with Booster.load_model in any XGBoost ≥ 2.0 and carry no executable
code, so you are not trusting us not to have put something nasty in a pickle.
from huggingface_hub import snapshot_download
import xgboost as xgb, json, numpy as np
path = snapshot_download("Qazybek/smartplay-fpl-v12")
cols = json.load(open(f"{path}/blend/feature_cols.json")) # 251, order matters
blend = json.load(open(f"{path}/blend/blend.json"))
# X must be a (n, 251) array whose columns are `cols`, in that order.
def predict(X, position):
def load(p):
b = xgb.Booster(); b.load_model(p); return b
d = xgb.DMatrix(X, feature_names=cols)
p60 = load(f"{path}/base/p60_{position}.ubj").predict(d)
non60 = np.clip(load(f"{path}/base/non60_{position}.ubj").predict(d), 0, None)
probs = load(f"{path}/base/bucket_{position}.ubj").predict(d) # (n, 4)
cal = json.load(open(f"{path}/base/bucket_calibration.json"))[position]
w = np.array([cal["w0"], cal["w1"], cal["w2"], cal["w3"]])
p = np.clip(probs, 1e-12, 1.0) ** cal["gamma"] * w
probs = p / p.sum(1, keepdims=True)
bucket_preds = np.column_stack([
np.clip(load(f"{path}/base/bucketreg_{position}_{k}.ubj").predict(d), 0, None)
for k in range(4)
])
base = p60 * (probs * bucket_preds).sum(1) + (1 - p60) * non60
if position in blend["excluded_positions"]:
return np.clip(base, 0, None)
direct = load(f"{path}/blend/direct_{position}.json").predict(d)
return np.clip(blend["multibucket_weight"] * base
+ blend["direct_weight"] * direct, 0, None)
Building the 251 features is the hard part, not the inference. The feature pipeline and the training table are in the GitHub repo.
Four leakage-controlled walk-forward windows over 2025-26 — 25,234
player-gameweeks across 32 gameweeks. Every test gameweek falls after the
data used to train its fold; no test result reaches its own features or labels.
Folds: GW1-8, GW15-22, GW23-30, GW31-38. Per-gameweek numbers are in
walk_forward_accuracy.json.
| Metric | v12 | previous version |
|---|---|---|
| MAE | 0.979 | 0.994 |
| NDCG@10 | 0.380 | 0.352 |
| Spearman | 0.729 | 0.728 |
Spearman did not improve. The 95% bootstrap interval on that change includes zero. v12 is a consistent error reduction and a top-pick ranking improvement, not a gain across the board.
MAE 0.979 is pooled over the whole player pool, and about 61% of those rows are players who never came on. Predicting zero for an unused substitute is easy and correct, and it drags the average down. Scored only on players who actually started (60+ minutes), the same published projections give MAE ≈ 2.35 and Spearman ≈ 0.14. Neither figure is wrong — they describe different populations. Comparing one model on one population against another model on the other is the most common way to get this comparison wrong.
GW9-14 are absent. They were not part of the published selection suite.
CC BY-NC 4.0 — non-commercial use with attribution. The training data in the GitHub repository is under the same terms.
@software{smartplay_fpl_v12,
title = {SmartPlay v12: Expected Points for Fantasy Premier League},
author = {SmartPlay},
year = {2026},
url = {https://huggingface.co/Qazybek/smartplay-fpl-v12}
}