%%{init: {"themeVariables": {"fontSize": "18px", "fontFamily": "system-ui, -apple-system, Segoe UI, sans-serif"}, "flowchart": {"nodeSpacing": 50, "rankSpacing": 60, "padding": 14}}}%%
flowchart TB
classDef source fill:#e8f0fb,stroke:#2f5d9e,stroke-width:2px,color:#0b0b0b
classDef step fill:#ffffff,stroke:#52514e,stroke-width:1.5px,color:#0b0b0b
classDef output fill:#e6f4ea,stroke:#2e7d4f,stroke-width:2px,color:#0b0b0b
A["<b>hoopR box scores</b><br/>2002-2026"]:::source
N["<b>nba-data (ESPN)</b><br/>current season + schedule"]:::source
C["<b>Per-game efficiency</b>"]:::step
R["<b>Lagged rolling means</b><br/>3, 5, and 10 games"]:::step
E["<b>Pre-game Elo</b>"]:::step
G["<b>Game-level features</b>"]:::step
V["<b>Rolling-origin validation</b><br/>2015-2025"]:::output
H["<b>Holdout</b><br/>2026"]:::output
L["<b>Live 2027</b><br/>logged before tip-off"]:::output
A --> C
N --> C
C --> R
C --> E
R --> G
E --> G
G --> V
G --> H
G --> L
Methods
Pipeline
The analysis is a targets pipeline with packages pinned by renv. targets::tar_make() rebuilds every table, figure, and the paper. Current-season data comes from nba-data, an R package that collects, validates, and stores ESPN game data.
Keeping the future out
- Features use earlier games only. A team’s rolling value for game t is the mean of its games t−w to t−1 in the same season. Elo ratings are recorded before each game’s result is applied.
- This is tested. A test changes the result of one mid-season game and checks that no feature for that game, or any earlier game, changes, while later games do.
- Evaluation runs forward in time. Each validation season is predicted by a model trained on all earlier seasons. The classification threshold comes from the last validation season, not the test season. Penalties are tuned with folds made of whole seasons.
- The 2025-26 test season is not used to choose between models. The live 2026-27 season is the only fully untouched test.
Adding a model
Models live in R/model_registry.R. A model is a function that returns its name, its feature set, and a fit function:
model_random_forest <- function(games) {
spec <- parsnip::rand_forest(trees = 500, min_n = 50) |>
parsnip::set_engine("ranger") |>
parsnip::set_mode("classification")
list(
name = "Random forest: rolling + Elo",
features = feature_sets(games)$rolling_elo,
fit = fit_parsnip(spec)
)
}Add "random_forest" to MODEL_IDS and run targets::tar_make(). Only the new model is fitted; it appears in every table and figure, and the live job starts logging it the next day.
Adding a feature
Features are built in R/features.R. Add the new column in build_games() using only earlier games (for example with lagged_mean()), add it to a feature set in feature_sets(), and run the tests. The leakage test in tests/testthat/test-features.R checks every column whose name contains roll, Elo, or matchup, or ends in _diff or _ratio. Name new features to match, or add them to that pattern.