Skip to main content

Methods

Pipeline

%%{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

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.