Can a model pick a better fantasy kicker than the Vegas line? Maybe by a little, and this repo is mostly about how I measured how little.
It forecasts weekly NFL kicker fantasy points, scores every method on the start/sit decision a manager actually makes, publishes a ranked board of every starting kicker each week, and keeps the results that did not work next to the ones that did.
- Start the kicker whose offence Vegas expects to score most. That is the implied team total, worked out from the point spread and the over/under, and that one number captures most of the value any method here finds.
- The model adds a small edge on top of it. A ridge regression on 16 pre-kickoff features gains 0.427 points a week over a coin flip when choosing between two of the week's starters, against 0.353 for the implied team total alone, out of 2.458 for an oracle who always starts the better kicker. It is ahead of the line in 6 of 8 backtest seasons; with eight seasons that edge is suggestive, not established (season-level p = 0.095).
- Why the ceiling is low. A kicker's week is mostly field goals, and weekly field-goal attempts showed close to no out-of-sample skill (R² +0.003 for the count model and +0.007 for the ridge, both under 0.01). Extra-point attempts, which are touchdowns, are the predictable part (R² +0.126). A kicker forecast is mostly a touchdown forecast, which is why the betting line is hard to beat.
- Scored live in 2026. Each week's board is committed before kickoff and scored after the games. Over the four counted weeks it beat the implied total by 0.09 points a week, with a 95% t interval of −0.26 to +0.45: too few weeks to tell them apart.
| data | 14,638 kicker-games from nflverse, 1999 to 2026 week 7; the model uses the 5,945 regular-season kicker-games from 2015 on |
| target | each week's actual fantasy points |
| ranking | a ridge regression on 16 named features, all known before kickoff |
| distribution | two LightGBM count models for field-goal and extra-point attempts, simulated through the scoring rule |
| output | a weekly board in reports/, with a floor and ceiling (20th and 90th percentiles) and P(15+) for every starter |
Every method is scored on one decision: take two of the week's starting kickers at random, start the one the method prefers, and measure the points gained over starting either at random. The lift is the exact expectation over every pair of starters in the week, not a simulation. Eight season-forward folds, 2018 to 2025: each test season is predicted by a model trained only on the seasons before it. 4,211 kicker-games in 141 weeks.
Two caveats. The betting inputs are closing lines, so the backtest describes a lineup set near kickoff, while the board is built earlier in the week on an earlier line. And I chose the 16 features, the penalty and the ridge itself while looking at these seasons, so this is not a clean holdout; the live 2026 boards are.
| rank by | lift, pts/wk | share of oracle |
|---|---|---|
| ridge on 16 features, which orders the board | 0.427 | 17.4% |
| count model's expected points | 0.362 | 14.7% |
| Vegas implied team total alone | 0.353 | 14.4% |
| kicker's own career average | 0.180 | 7.3% |
| a constant 8.0 | 0.000 | 0.0% |
The two comparisons that matter are against simple rules a manager could use instead:
| ridge minus | difference, pts/wk | 141 weeks as units | 8 seasons as units | seasons ahead |
|---|---|---|---|---|
| Vegas implied total alone | +0.074 | 95% CI [+0.023, +0.124], p = 0.004 | t-test p = 0.095, CI [−0.016, +0.162] | 6 of 8 |
| kicker career average | +0.247 | 95% CI [+0.156, +0.335], p < 0.0001 | t-test p = 0.0014, CI [+0.132, +0.362] | 8 of 8 |
The first column of tests resamples weeks (a block bootstrap), which says the edge over the line is clear week to week inside these eight seasons. The second treats each season as one observation, which is the right unit for "will it beat the line next year", and there the edge over the line is not significant. It loses 2019 and 2021, and most of it comes from 2022 onward, when ranking by the line alone fell from 0.423 to 0.286 points a week while the ridge held at about 0.43:
Fourteen challengers, each tuned only on seasons before its test season, were
run against the ridge in model_bakeoff.py: count GLMs, gradient-boosted and
pairwise rankers, a model of the residual after the betting line, a ridge with
its penalty chosen by an inner loop, and others. None is distinguishable from it
after Holm correction for testing fourteen models at once. The closest, the ridge plus a per-kicker effect pulled toward the league average,
is +0.012 a week (95% CI −0.010 to +0.034), ahead in 7 of 8 seasons. The
model-selection notes have the full
table.
score_boards.py scores each published 2026 board against what happened once
its games are final. A week counts as a forecast only if its board was committed
before kickoff. Weeks 3, 4 and 7 are the boards as first committed: each was
later regenerated after its games, and those reruns are kept apart in
reports/reruns/ so they cannot stand in for the forecast. Weeks 5 and 6 were
first committed after their games, so they are shown and not counted.
| week | kickers scored | board lift | implied-total lift | board minus implied | counted |
|---|---|---|---|---|---|
| 2 | 30 | −0.122 | +0.116 | −0.238 | yes |
| 3 | 32 | −0.240 | −0.456 | +0.216 | yes |
| 4 | 32 | +0.968 | +0.739 | +0.229 | yes, commit clock only |
| 5 | 28 | +1.294 | +0.522 | +0.771 | no |
| 6 | 25 | +0.473 | −0.608 | +1.082 | no |
| 7 | 26 | +0.837 | +0.669 | +0.168 | yes |
| weeks 2, 3, 4, 7 | 120 | +0.361 | +0.267 | +0.094 |
Over the four counted weeks the board beat ranking by the implied total by 0.09 points a week, with a 95% t interval of −0.26 to +0.45. Four weeks cannot tell the two apart. Week 4's commit is dated before its games, but GitHub first received it afterwards; counting only the boards GitHub had before kickoff (weeks 2, 3 and 7), the edge is +0.05. The week 8 board was committed and pushed on 2026-10-27, before the week's first game, and is scored once its games are final. Listed kickers who did not kick are left out of that week's lift.
The board also gives each kicker a full points distribution from a second model: two LightGBM count models for field-goal and extra-point attempts, drawn and pushed through the scoring rule by Monte Carlo. The Poisson is the training objective; the draws are narrower binomials. Pooled over all 4,211 test rows its stated chance of a 15-point week matches how often it happened (8.39% predicted, 8.41% realised). Kicker by kicker it barely beats a constant: Brier skill over the base rate +0.001 (95% CI −0.004 to +0.006), AUC 0.55, and a calibration slope of 0.55, so the stated chances are too spread out. At 8+ and 10+ points it over-predicts by about two percentage points.
Read P(15+) as a rough flag for upside, not as a probability to bet on.
The current version is a rebuild. Most of what it does differently came from finding these:
- The first model never forecast anything. My 2023 version regressed a team's kicker points on three six-week team averages, fit on the same rows it was judged on. No number in it was ever out of sample.
- Observed weather does not exist before kickoff. Temperature and wind ranked 3rd and 7th by importance, and none of the 255 unplayed games in the schedule file carried either. They are replaced by stadium climate: the venue's typical wind in that month in earlier seasons, zero indoors.
- Retractable-roof state is a game-day decision.
openandclosednever appear on an unplayed game, yet 15.1% of training rows carried one. The first fix keyed the building on a column the table did not have, so both roof flags became constant and "16 features" were 14. The flags now come from the game's venue, and the backtest refuses to run if any of the 16 stops varying. - An exclusion list is not a feature set. The first feature list admitted every numeric column nobody had banned. When 17 columns were merged into the frame, all 17 were accepted silently; three were same-game outcomes, and MAE fell from 3.49 to 2.65. The features are now an explicit list of 16 names.
- The 2021 season was missing. The upstream kicking release skips it, and every metric looked healthy without its 561 kicker-games. The build now rebuilds any missing season from play-by-play and stops if a season is incomplete.
- A fresh clone quietly built 4 of 12 seasons of coach data. The fourth-down features read whatever play-by-play happened to be cached, so a new checkout imputed them for most seasons and the backtest still ran green. The build now downloads every season and fails on a gap, and the backtest checks each feature's coverage season by season.
- The headline was briefly a number the code could not print. The README
quoted the ridge while
backtest.pynever fit one. The ridge is now a row in that table, and every result table and chart in this README is regenerated from a CSV inreports/.
Kept because a null result is a result.
Three more feature families were built and re-tested under the current ridge
with the exact lift (experiments/feature_families/ridge_retest.py): MAE is mean
absolute error in points; within-week ρ is the rank correlation between
forecast and result among that week's kickers.
| added to the 16 | columns | MAE | within-week ρ | lift, pts/wk |
|---|---|---|---|---|
| nothing | 0 | 3.472 | 0.171 | 0.427 |
| red-zone efficiency | +27 | 3.476 | 0.149 | 0.370 |
| drive-level offence | +21 | 3.482 | 0.167 | 0.418 |
| leg strength and stadium | +13 | 3.473 | 0.159 | 0.396 |
| all three | +61 | 3.485 | 0.141 | 0.347 |
Every family lowered the lift. Red-zone and all three did so by margins the week-block bootstrap separates from zero; for drive-level and leg strength the interval includes zero. None beats its own columns shuffled within each season on lift; drive-level offence comes closest, ahead of 46 of 50 shuffles.
Willingness to attempt long field goals was measured separately
(experiments/fifty_plus/). League-wide, the share of fourth downs in 50+
range that became field-goal attempts rose from 0.274 in 2015 to 0.440 in 2025.
But a team gets a median of 9 such chances a season, so much of the
team-to-team spread is sampling noise (about three-quarters of it in the median
season), and adding a team rate pulled toward the league average to the ridge gains +0.002 a week (95% CI
−0.007 to +0.012, p = 0.64), ahead in 4 of 8 seasons. Not added.
Several earlier claims were retracted when the evidence did not hold them up.
Three of them: field-goal attempts were described as having negative skill
(R² −0.054); on the current models they have about zero. A separate "ceiling"
board was said to be needed to catch big weeks; ranking by expected points
already hits 15-point weeks more often than random in the backtest (9.2%
against 8.4%, point estimates with no interval). And P(15+) was called calibrated; it
is right on average only. METHOD.md has every retraction and the
numbers behind it.
- Score the decision, not the error. By MAE the count model (3.493) barely beats a constant (3.504); on the start/sit decision it gains 0.362 a week and the constant gains nothing.
- Benchmark against the market, and report the share of an oracle, so a small edge reads as small.
- Name every feature, check each exists before kickoff, and assert that each varies and is populated in every season.
- Count seasons, not weeks, when the question is next season.
Python 3.11 or newer.
pip install -r requirements.txt # or: pip install -e ".[dev]"
export OMP_NUM_THREADS=1 # LightGBM thread contention can turn a one-second fit into minutes
python scripts/run_pipeline.py # every step below but the bake-off, in order; --bakeoff adds itor one step at a time:
python src/kicker_analysis/build_dataset.py # nflverse to one row per kicker per game
python src/kicker_analysis/fourth_down.py # coach aggression, every season 2015 on
python src/kicker_analysis/backtest.py # the tables above
python src/kicker_analysis/model_bakeoff.py # 14 challengers against the ridge, a few minutes
python src/kicker_analysis/figures.py # the charts above
python src/kicker_analysis/score_boards.py # the live 2026 record
python src/kicker_analysis/predict_week.py # this week's boardInputs are fetched live, so a rerun on later data can move the last digit;
reports/data_manifest.json records the hash of every input behind the tracked
CSVs. Every module in src/ carries an assert-based --self-check that runs
offline on synthetic data. pytest -q runs all of them with the network
blocked (install requirements-dev.txt first, which adds pytest), and CI runs
that on Python 3.11 to 3.13.
src/kicker_analysis/
build_dataset.py nflverse to one row per kicker per game, with parity checks
fourth_down.py coach aggression on 4th down in FG range, per team-game
features.py lagged features, all knowable before kickoff
model.py RidgeRanker (ordering) + CompoundKickerModel (distribution)
backtest.py season-forward evaluation on the decision metric
model_bakeoff.py 14 challengers against the ridge, nested and Holm-corrected
predict_week.py this week's board
score_boards.py the published 2026 boards against what happened
figures.py the charts, drawn only from the tracked CSVs
evaluate.py bootstrap utilities
feat_*.py three candidate feature families, none adopted
scripts/run_pipeline.py every step in order, or every module's --self-check
tests/ pytest over every self-check, offline; run by CI
reports/ tracked evidence CSVs and the weekly boards
experiments/ the feature-family and 50+ studies
notes/ dated decision write-ups
legacy/ the R and Python versions this replaced, and why each was retired
docs/ which module writes and reads which file; every column defined
METHOD.md the full argument, every number, and what was retracted
Play-by-play and the kicking release are from nflverse-data (CC BY 4.0), and schedules and betting lines from nflverse/nfldata. No keys needed. Code is MIT licensed.
More of my work and contact details are at twhite.dev.