The short answer: combine results tell teams much less about wide receiver success than draft position does, and most of what they do tell is already reflected in where a receiver gets drafted.
Every spring, wide receivers run, jump and get measured at the NFL Scouting Combine, and teams spend real draft capital on those numbers. This project asks: do combine results predict which wide receivers become successful early in their careers, and do they tell teams anything beyond what draft position already captures?
The data covers three NFL seasons (2023–2025), and each draft class is observed only from its rookie year onward. The 2023 class is followed for three seasons, the 2024 class for two and the 2025 class for one.
A simple “did he succeed, yes or no?” comparison would be unfair here. A 2025 rookie who has not broken out after one season is not a failure; his story just is not finished. Survival analysis is built for exactly this situation. Instead of asking whether a receiver succeeded, it asks how long it took, and players whose story is unfinished are marked as censored: they count for the seasons we observed and are not treated as failures.
The full analysis follows this roadmap:
| Stage | What it does | Question it answers |
|---|---|---|
| Data build | Defines success, computes season stats, matches players, structures the data | Which receivers succeeded, and when? |
| Step 1 | Kaplan-Meier curve | How quickly do receivers succeed overall? |
| Step 2 | Kaplan-Meier by draft round + log-rank test | Does draft round matter? |
| Step 3 | Discrete-time hazard model | Do key combine measures predict success, with and without draft position? |
| Step 4 | Cox models, one measure at a time | Which of all 13 combine measures relate to success? |
| Step 5 | Firth models + drill-skipping check | What about the drills most receivers skipped? |
| Model checks | Assumption tests and data checks | Can the results be trusted? |
This section walks through every decision made before any model was fit: where the data came from, who is included, how success was defined and measured, and how the data was shaped for survival analysis.
players), combine measurements and drill results
(combine_results), and career totals including Pro Bowl and
All-Pro selections (player_career_successes).nflreadr R
package: season-by-season receiving statistics, per-game snap
counts (sourced from Pro Football Reference), draft picks and player IDs
for 2023–2025.The Big Data Bowl only provides career totals, so it can say whether a receiver has ever been productive but not when. Survival analysis needs the timing, so the season-by-season detail comes from nflverse.
The build script at the end of this section creates three analysis files. They are loaded here so the examples below can use them.
panel <- read_csv(file.path(OUT_DIR, "wr_player_season_panel.csv"), show_col_types = FALSE)
pp <- read_csv(file.path(OUT_DIR, "wr_person_period.csv"), show_col_types = FALSE)
surv_df <- read_csv(file.path(OUT_DIR, "wr_survival.csv"), show_col_types = FALSE)
players <- read_csv(file.path(BDB_DIR, "players.csv"), show_col_types = FALSE)
combine <- read_csv(file.path(BDB_DIR, "combine_results.csv"), show_col_types = FALSE)
wr <- players |>
filter(nfl_position == "WR", draft_year %in% 2023:2025) |>
left_join(combine |> select(-any_of("draft_year")), by = "nfl_id")
surv_df <- surv_df |>
left_join(select(wr, nfl_id, draft_round, draft_overall_pick), by = "nfl_id") |>
mutate(round_grp = case_when(draft_round <= 2 ~ "Rounds 1-2",
draft_round <= 4 ~ "Rounds 3-4",
TRUE ~ "Rounds 5-7 / undrafted"))
Every player listed as a wide receiver in the Big Data Bowl
players table who entered the league in 2023, 2024 or 2025.
That gives 106 receivers: 38 from 2023, 31 from 2024
and 37 from 2025. Undrafted free agents are included, since they are
part of the real population of NFL receivers.
Success had to be defined specifically for wide receivers, since a good season looks completely different for a receiver than for a lineman or a pass rusher. Three options were considered.
1. Pro Bowl or All-Pro selection (considered, then dropped). These are the most obvious markers of success, but they are rare for young receivers and the Big Data Bowl only records career totals, not the season of each selection. Without the season, they cannot be used in a survival model. As a check, every receiver with a career Pro Bowl or All-Pro selection was compared against the final success list (see Model checks).
2. A top-32 receiving season (used). A receiver succeeds in a season if he ranks in the top 32 among all NFL wide receivers in regular-season receiving yards. There are 32 teams, so a top-32 finish is roughly a “number one receiver”-level season for some team. Ranking against all receivers in the league, not just young ones, keeps the bar the same for everyone. A rank cutoff also adjusts automatically to each season, unlike a fixed number such as 1,000 yards.
3. A featured starting role (used). A receiver also succeeds if, in the same season, he played at least 65% of his team’s offensive snaps and received at least 18% of his team’s targets. The snap share shows he is an every-down starter; the target share shows the offense actually throws to him. Both are required because some receivers play a lot of snaps as blockers or decoys without being part of the passing game.
A receiver’s success season is the first season he meets either rule. Seasons where a receiver had no recorded statistics (inactive, injured or out of the league) count as not successful.
Each criterion was computed from the nflverse data, regular season only:
The Big Data Bowl and nflverse use different player IDs, so each receiver had to be matched in two stages:
This matched 97 of 106 receivers to the receiving statistics and 98 to the snap counts. Eight of the nine unmatched players were undrafted and one was a seventh-round pick. They are treated as unsuccessful, which is very unlikely to be wrong for players at that draft position.
Each receiver is turned into one row per season he was “at risk” of a first success. A receiver keeps getting rows until he succeeds or the data ends:
The table below shows four example receivers to make this concrete.
examples <- c("Puka Nacua", "Jaxon Smith-Njigba", "Marvin Mims", "Jack Bech")
pp |>
filter(display_name %in% examples) |>
arrange(match(display_name, examples), period) |>
group_by(nfl_id) |>
mutate(Status = case_when(success == 1 ~ "Succeeded: leaves the at-risk group",
period == max(period) ~ "Censored: data ends, outcome unknown",
TRUE ~ "Not yet: stays at risk")) |>
ungroup() |>
transmute(Player = display_name, `Draft class` = draft_year, Season = season,
`Season #` = period, `Successful season?` = ifelse(success == 1, "Yes", "No"),
Status) |>
kable(align = "lrrrcl", caption = "How four receivers appear in the survival data")
| Player | Draft class | Season | Season # | Successful season? | Status |
|---|---|---|---|---|---|
| Puka Nacua | 2023 | 2023 | 1 | Yes | Succeeded: leaves the at-risk group |
| Jaxon Smith-Njigba | 2023 | 2023 | 1 | No | Not yet: stays at risk |
| Jaxon Smith-Njigba | 2023 | 2024 | 2 | Yes | Succeeded: leaves the at-risk group |
| Marvin Mims | 2023 | 2023 | 1 | No | Not yet: stays at risk |
| Marvin Mims | 2023 | 2024 | 2 | No | Not yet: stays at risk |
| Marvin Mims | 2023 | 2025 | 3 | No | Censored: data ends, outcome unknown |
| Jack Bech | 2025 | 2025 | 1 | No | Censored: data ends, outcome unknown |
How to read this table: Puka Nacua succeeded as a rookie, so he has one row. Jaxon Smith-Njigba needed two seasons. Marvin Mims was observed for three seasons without meeting either success rule, so he is censored after 2025. Jack Bech, a 2025 rookie, has only one season of data and is censored after it. Neither Mims nor Bech counts as a “failure”; their stories are simply unfinished.
This layout is used directly by the discrete-time model in Step 3. The Kaplan-Meier and Cox models in Steps 1, 2 and 4 use a condensed version with one row per receiver: how many seasons he was observed and whether he succeeded.
The script below performs Steps A through D. It is shown for reference and not re-run when this document is knit; the files it creates were loaded at the start of this section.
library(nflreadr)
SEASONS <- 2023:2025; LAST_SEASON <- 2025
SNAP_CUTOFF <- 0.65; TGT_CUTOFF <- 0.18; YDS_RANK_CUT <- 32
norm_name <- function(x) x |> str_to_lower() |>
str_replace_all("\\b(jr|sr|ii|iii|iv|v)\\b\\.?", "") |>
str_replace_all("[^a-z ]", "") |> str_squish()
# 1. Big Data Bowl receivers
players <- read_csv(file.path(BDB_DIR, "players.csv"))
combine <- read_csv(file.path(BDB_DIR, "combine_results.csv"))
wr <- players |>
filter(nfl_position == "WR", draft_year %in% SEASONS) |>
left_join(combine |> select(-any_of("draft_year")), by = "nfl_id") |>
mutate(name_key = norm_name(display_name))
# 2. Match to nflverse IDs: draft year + pick first, then name + draft year
draft_ids <- load_draft_picks(SEASONS) |>
select(draft_year = season, draft_overall_pick = pick, gsis_id, pfr_id = pfr_player_id)
nfl_players_raw <- load_players()
nv_cols <- names(nfl_players_raw)
pfr_col <- intersect(c("pfr_id", "pfr_player_id"), nv_cols)[1]
yr_draft <- if ("draft_year" %in% nv_cols) nfl_players_raw$draft_year else NA
yr_rookie <- if ("rookie_season" %in% nv_cols) nfl_players_raw$rookie_season else NA
nfl_players <- nfl_players_raw |>
mutate(name_key = norm_name(display_name),
nv_draft_year = as.numeric(coalesce(as.numeric(yr_draft), as.numeric(yr_rookie))),
gsis_id_nm = gsis_id, pfr_id_nm = .data[[pfr_col]]) |>
select(name_key, nv_draft_year, gsis_id_nm, pfr_id_nm) |>
filter(!is.na(nv_draft_year)) |>
distinct(name_key, nv_draft_year, .keep_all = TRUE)
wr_ids <- wr |>
left_join(draft_ids, by = c("draft_year", "draft_overall_pick")) |>
left_join(nfl_players, by = c("name_key", "draft_year" = "nv_draft_year")) |>
mutate(gsis_id = coalesce(gsis_id, gsis_id_nm), pfr_id = coalesce(pfr_id, pfr_id_nm))
# 3a. Receiving-yard rank among all WRs and team target share
pstats <- load_player_stats(SEASONS) |> filter(season_type == "REG") |>
mutate(targets = coalesce(targets, 0), receiving_yards = coalesce(receiving_yards, 0))
team_tgts <- pstats |> group_by(season, team) |> summarise(team_targets = sum(targets), .groups = "drop")
player_tgt_share <- pstats |>
group_by(player_id, season, team) |> summarise(targets = sum(targets), .groups = "drop") |>
left_join(team_tgts, by = c("season", "team")) |>
mutate(tgt_share = targets / team_targets) |>
group_by(player_id, season) |> summarise(tgt_share = max(tgt_share), .groups = "drop")
wr_yds_rank <- pstats |> filter(position == "WR") |>
group_by(player_id, season) |> summarise(rec_yards = sum(receiving_yards), .groups = "drop") |>
group_by(season) |> mutate(yds_rank = min_rank(desc(rec_yards))) |> ungroup()
season_rec <- full_join(wr_yds_rank, player_tgt_share, by = c("player_id", "season")) |>
rename(gsis_id = player_id)
# 3b. Share of the team's full-season offensive snaps
snaps <- load_snap_counts(SEASONS) |> filter(game_type == "REG")
team_snaps <- snaps |>
group_by(season, game_id, team) |> summarise(t = max(offense_snaps), .groups = "drop") |>
group_by(season, team) |> summarise(team_off_snaps = sum(t), .groups = "drop")
season_snaps <- snaps |>
group_by(pfr_id = pfr_player_id, season, team) |>
summarise(off_snaps = sum(offense_snaps), .groups = "drop") |>
left_join(team_snaps, by = c("season", "team")) |>
mutate(snap_share = off_snaps / team_off_snaps) |>
group_by(pfr_id, season) |> summarise(snap_share = max(snap_share), .groups = "drop")
# 4. Player-season panel and success flag
panel <- wr_ids |>
select(nfl_id, display_name, draft_year, gsis_id, pfr_id) |>
mutate(season = purrr::map(draft_year, ~ .x:LAST_SEASON)) |> unnest(season) |>
mutate(period = season - draft_year + 1) |>
left_join(season_rec, by = c("gsis_id", "season")) |>
left_join(season_snaps, by = c("pfr_id", "season")) |>
mutate(across(c(snap_share, tgt_share), ~ coalesce(.x, 0)),
yds_rank = coalesce(yds_rank, 999L),
crit_yards = yds_rank <= YDS_RANK_CUT,
crit_role = snap_share >= SNAP_CUTOFF & tgt_share >= TGT_CUTOFF,
success = as.integer(crit_yards | crit_role))
# 5. Person-period data: keep seasons up to and including the first success
pp <- panel |> arrange(nfl_id, period) |> group_by(nfl_id) |>
filter(lag(cumsum(success), default = 0) == 0) |> ungroup()
surv_df <- pp |> group_by(nfl_id, display_name, draft_year) |>
summarise(time = max(period), event = max(success), .groups = "drop")
write_csv(panel, "wr_player_season_panel.csv")
write_csv(pp, "wr_person_period.csv")
write_csv(surv_df, "wr_survival.csv")
surv_df |>
group_by(`Draft class` = as.character(draft_year)) |>
summarise(`Seasons observed` = 2026 - first(draft_year),
Receivers = n(),
Successes = sum(event),
`Success rate` = scales::percent(mean(event), 0.1)) |>
bind_rows(tibble(`Draft class` = "Total", Receivers = nrow(surv_df),
Successes = sum(surv_df$event),
`Success rate` = scales::percent(mean(surv_df$event), 0.1))) |>
kable(align = "lrrrr")
| Draft class | Seasons observed | Receivers | Successes | Success rate |
|---|---|---|---|---|
| 2023 | 3 | 38 | 8 | 21.1% |
| 2024 | 2 | 31 | 3 | 9.7% |
| 2025 | 1 | 37 | 0 | 0.0% |
| Total | 106 | 11 | 10.4% |
panel |>
filter(success == 1) |>
group_by(nfl_id) |>
slice_min(period, with_ties = FALSE) |>
ungroup() |>
left_join(select(wr, nfl_id, draft_round, draft_overall_pick), by = "nfl_id") |>
mutate(`How he qualified` = case_when(crit_yards & crit_role ~ "Top-32 yards + featured role",
crit_yards ~ "Top-32 yards",
TRUE ~ "Featured role"),
Pick = ifelse(is.na(draft_overall_pick), "Undrafted", as.character(draft_overall_pick))) |>
arrange(draft_year, period) |>
transmute(Player = display_name, Class = draft_year, Round = draft_round, Pick,
`First successful season` = paste0(season, " (year ", period, ")"),
`Receiving yards` = rec_yards, `How he qualified`) |>
kable(align = "lrrrlrl",
caption = "The 11 receivers who reached a successful season")
| Player | Class | Round | Pick | First successful season | Receiving yards | How he qualified |
|---|---|---|---|---|---|---|
| Jordan Addison | 2023 | 1 | 23 | 2023 (year 1) | 911 | Top-32 yards |
| Rashee Rice | 2023 | 2 | 55 | 2023 (year 1) | 938 | Top-32 yards |
| Puka Nacua | 2023 | 5 | 177 | 2023 (year 1) | 1486 | Top-32 yards + featured role |
| Jaxon Smith-Njigba | 2023 | 1 | 20 | 2024 (year 2) | 1130 | Top-32 yards + featured role |
| Jayden Reed | 2023 | 2 | 50 | 2024 (year 2) | 857 | Top-32 yards |
| Quentin Johnston | 2023 | 1 | 21 | 2025 (year 3) | 735 | Top-32 yards |
| Michael Wilson | 2023 | 3 | 94 | 2025 (year 3) | 1006 | Top-32 yards + featured role |
| Tre Tucker | 2023 | 3 | 100 | 2025 (year 3) | 696 | Featured role |
| Rome Odunze | 2024 | 1 | 9 | 2024 (year 1) | 734 | Featured role |
| Brian Thomas | 2024 | 1 | 23 | 2024 (year 1) | 1282 | Top-32 yards + featured role |
| Ladd McConkey | 2024 | 2 | 34 | 2024 (year 1) | 1149 | Top-32 yards + featured role |
What this shows: only 11 of 106 receivers (10%) reached a successful season. Eight came from the 2023 class, three from 2024 and none from 2025. The list matches football intuition: Puka Nacua, Jaxon Smith-Njigba, Rome Odunze, Brian Thomas and Ladd McConkey are the names a fan would expect.
Why it matters for everything below: with only 11 successes, every model has limited power. Confidence intervals will be wide, and many real effects may not reach statistical significance.
The Kaplan-Meier estimator builds the survival curve one season at a time. In each season it takes the receivers still “at risk” (not yet successful and still being observed), checks how many succeed, and chains those probabilities together:
\[ \hat S(t) = \prod_{t_j \le t} \left(1 - \frac{\text{successes in season } j}{\text{receivers at risk in season } j}\right) \]
Censored receivers, like the 2025 class after their rookie year, simply drop out of the at-risk group. That lets every receiver contribute without being wrongly counted as a failure.
km <- survfit(Surv(time, event) ~ 1, data = surv_df)
km_sum <- summary(km)
tibble(Season = km_sum$time,
`Receivers at risk` = km_sum$n.risk,
`First successes` = km_sum$n.event,
`Chance of success that season` = scales::percent(km_sum$n.event / km_sum$n.risk, 0.1),
`Cumulative success F(t)` = scales::percent(1 - km_sum$surv, 0.1),
`95% CI` = sprintf("%s – %s", scales::percent(1 - km_sum$upper, 0.1),
scales::percent(1 - km_sum$lower, 0.1))) |>
kable(align = "rrrrrr")
| Season | Receivers at risk | First successes | Chance of success that season | Cumulative success F(t) | 95% CI |
|---|---|---|---|---|---|
| 1 | 106 | 6 | 5.7% | 5.7% | 1.2% – 10.0% |
| 2 | 63 | 2 | 3.2% | 8.7% | 2.6% – 14.4% |
| 3 | 33 | 3 | 9.1% | 17.0% | 5.8% – 26.8% |
km_df <- tidy(km) |>
select(time, estimate, conf.low, conf.high) |>
bind_rows(tibble(time = 0, estimate = 1, conf.low = 1, conf.high = 1)) |>
arrange(time)
ggplot(km_df, aes(time, 1 - estimate)) +
geom_step(aes(y = 1 - conf.high), linetype = "dashed", color = "grey55") +
geom_step(aes(y = 1 - conf.low), linetype = "dashed", color = "grey55") +
geom_step(linewidth = 1.2, color = "#18BC9C") +
scale_x_continuous(breaks = 0:3, labels = c("Draft", "Year 1", "Year 2", "Year 3")) +
scale_y_continuous(labels = scales::percent) +
labs(x = NULL, y = "Share of receivers who have succeeded",
title = "Cumulative share of WRs with a successful season",
subtitle = "Solid line = estimate; dashed lines = 95% confidence interval") +
theme_minimal()
How to read this chart: the line starts at 0% on draft day and steps up each season as receivers have their first successful season. A bigger step means more receivers broke through that year. The dashed lines show the uncertainty; the wider they are, the less sure the estimate.
What it says: about 1 in 6 receivers succeeds by year three. The estimate climbs from 5.7% after year one to 8.7% after year two and 17.0% after year three.
Why the year-three jump is uncertain: only the 2023 class has reached a third season, so just 33 receivers drive that estimate. That is why its confidence interval is so wide (5.8% to 26.8%). The jump does fit the idea that some receivers, such as Quentin Johnston, Michael Wilson and Tre Tucker, need a few years to earn a featured role.
Draft position is the most obvious alternative explanation for success, so before testing any combine measure, this step checks how much draft round alone separates receivers. Rounds are grouped into three bands (1–2, 3–4, and 5–7 plus undrafted) so each group has enough receivers for a meaningful curve. The same Kaplan-Meier method from Step 1 is applied to each group separately.
km_rd <- survfit(Surv(time, event) ~ round_grp, data = surv_df)
grp_levels <- c("Rounds 1-2", "Rounds 3-4", "Rounds 5-7 / undrafted")
km_rd_df <- tidy(km_rd) |>
mutate(group = sub("round_grp=", "", strata)) |>
select(group, time, estimate) |>
bind_rows(tibble(group = grp_levels, time = 0, estimate = 1)) |>
mutate(group = factor(group, levels = grp_levels)) |>
arrange(group, time) |>
mutate(text = paste0(group, "<br>Year ", time,
"<br>Cumulative success: ", scales::percent(1 - estimate, 0.1)))
p_rd <- ggplot(km_rd_df, aes(time, 1 - estimate, color = group, group = group, text = text)) +
geom_step(linewidth = 1.2) +
scale_x_continuous(breaks = 0:3, labels = c("Draft", "Year 1", "Year 2", "Year 3")) +
scale_y_continuous(labels = scales::percent) +
scale_color_manual(values = c("#2C3E50", "#E67E22", "#18BC9C")) +
labs(x = NULL, y = "Share of receivers who have succeeded", color = NULL,
title = "Cumulative WR success by draft round") +
theme_minimal()
ggplotly(p_rd, tooltip = "text") |> layout(legend = list(orientation = "h", y = -0.2))
How to read this chart: each line is the same cumulative-success curve as above, split by draft round. Hover over a line to see the exact percentage. The further apart the lines, the more draft round matters.
lr <- survdiff(Surv(time, event) ~ round_grp, data = surv_df)
lr_p <- pchisq(lr$chisq, length(lr$n) - 1, lower.tail = FALSE)
tibble(`Draft group` = sub("round_grp=", "", names(lr$n)),
Receivers = as.integer(lr$n),
`Actual successes` = lr$obs,
`Expected if round didn't matter` = round(lr$exp, 1)) |>
kable(align = "lrrr",
caption = sprintf("Log-rank test: chi-square = %.1f, p = %s",
lr$chisq, format.pval(lr_p, digits = 2)))
| Draft group | Receivers | Actual successes | Expected if round didn’t matter |
|---|---|---|---|
| Rounds 1-2 | 18 | 8 | 1.6 |
| Rounds 3-4 | 27 | 2 | 2.6 |
| Rounds 5-7 / undrafted | 61 | 1 | 6.8 |
How to read this table: the log-rank test compares how many successes each group actually had with how many it would have had if draft round made no difference. A large gap between “actual” and “expected”, and a small p-value, means the groups truly differ.
What it says: draft round is the strongest predictor in the entire analysis. Receivers taken in rounds 1–2 produced 8 successes when only 1.6 were expected. Late-round and undrafted receivers produced 1 when 6.8 were expected. The test is overwhelming (p ≈ 0.0000001).
Over 60% of early-round receivers succeed by year three. Mid-round receivers show nothing until year three, and the lone late-round success is Puka Nacua, a fifth-round pick who broke out as a rookie.
Why this complicates the combine question: teams use combine results when they draft, so combine scores and draft position are tangled together. High picks also get more snaps and targets simply because teams invest in them. The models below test each combine measure both on its own and after holding draft position fixed, which is the key comparison in this report.
A discrete-time hazard model treats each season as a separate yes/no trial. Each receiver contributes one row per season he is still at risk, and a logistic regression estimates how combine measures change the chance of success in a given season:
\[ \text{logit}\big(\text{chance of success in season } t\big) = \text{season baseline}_t + \beta_1 \cdot \text{40} + \beta_2 \cdot \text{vertical} + \dots \]
Why only four measures? A common guideline is about 10 successes per predictor, and this data has 11 successes. Even four measures stretches that guideline, so this step tests a small, deliberate set: the 40-yard dash (speed), the vertical and broad jump (explosiveness) and weight (size). Step 4 then tests every measure individually.
Two versions are compared. Model 1 uses combine measures only. Model 2 adds draft position, measured as the log of the overall pick, with undrafted players assigned pick 263.
combine_vars <- c("forty", "vertical", "broad_jump", "combine_weight")
model_df <- pp |>
left_join(select(wr, nfl_id, draft_overall_pick, all_of(combine_vars)), by = "nfl_id") |>
mutate(period_f = factor(period),
log_pick = log(coalesce(draft_overall_pick, 263)),
across(all_of(combine_vars),
~ as.numeric(scale(if (cur_column() %in% lower_is_better) -.x else .x)),
.names = "z_{.col}"))
m1 <- glm(success ~ period_f + z_forty + z_vertical + z_broad_jump + z_combine_weight,
family = binomial, data = model_df)
m2 <- update(m1, . ~ . + log_pick)
label_terms <- c(period_f2 = "Season 2 (vs. 1)", period_f3 = "Season 3 (vs. 1)",
z_forty = "40-yard dash", z_vertical = "Vertical jump",
z_broad_jump = "Broad jump", z_combine_weight = "Weight",
log_pick = "Draft pick (log)")
or_table <- function(m, name) {
tidy(m, exponentiate = TRUE, conf.int = TRUE) |>
filter(!term %in% c("(Intercept)", "period_f2", "period_f3")) |>
transmute(Measure = label_terms[term],
!!paste(name, "OR (95% CI)") := sprintf("%.2f (%.2f–%.2f)", estimate, conf.low, conf.high),
!!paste(name, "p") := round(p.value, 3))
}
left_join(or_table(m1, "Combine only:"), or_table(m2, "With draft pick:"), by = "Measure") |>
mutate(across(everything(), ~ ifelse(is.na(.x), "—", as.character(.x)))) |>
kable(align = "lrrrr",
caption = "Odds ratios per 1 SD better. Above 1 = higher chance of success.")
| Measure | Combine only: OR (95% CI) | Combine only: p | With draft pick: OR (95% CI) | With draft pick: p |
|---|---|---|---|---|
| 40-yard dash | 1.83 (0.75–5.27) | 0.217 | 0.63 (0.12–3.05) | 0.548 |
| Vertical jump | 1.63 (0.64–4.67) | 0.328 | 1.30 (0.25–8.39) | 0.759 |
| Broad jump | 0.46 (0.17–1.17) | 0.11 | 0.12 (0.01–0.94) | 0.085 |
| Weight | 0.91 (0.40–2.05) | 0.814 | 0.37 (0.06–1.38) | 0.19 |
lrt <- anova(m1, m2, test = "LRT")
How to read this table: each odds ratio says how a 1 SD better result changes a receiver’s odds of success in a given season. Compare the two columns: if a measure looks useful in “Combine only” but shrinks toward 1 in “With draft pick”, its value was already captured by where the receiver was drafted.
What it says:
Don’t take the draft-pick number literally. Its odds ratio (about 0.008 per log unit) implies that doubling the pick number, say pick 20 versus pick 40, cuts the odds of success by about 97%. That is too steep to be realistic. With only 11 successes, draft position almost perfectly separates successful and unsuccessful receivers, and R warned about exactly this. The direction is clear; the exact size is not.
The Cox proportional hazards model estimates how each measure shifts the chance of success, without assuming a particular shape for how that chance changes over time:
\[ \text{hazard}(t \mid x) = \text{baseline hazard}(t) \times e^{\beta x} \]
With only 11 successes, a model with all 13 measures at once would badly overfit. Instead, each measure is tested on its own, then again after adjusting for draft position. Because 13 measures are tested, an FDR-adjusted p-value guards against false positives. Drills with fewer than 5 successes among the receivers who ran them are handled separately in Step 5.
Why a Cox model in addition to Step 3? The discrete-time model needs a separate season baseline and can only hold a few measures. The Cox model makes no assumption about how the baseline chance of success changes from season to season, and fitting one measure at a time lets every combine measure be tested with all the receivers who have it.
Handling ties: because success can only happen in season 1, 2 or 3, many receivers succeed in the same season. The models use the exact method for ties, which treats each season as its own group of receivers at risk. It is the most accurate option when time is measured in a few whole seasons.
cox_metrics <- intersect(names(pretty_metric), names(wr))
to_inches <- function(x) {
if (is.numeric(x)) return(x)
x <- as.character(x)
out <- suppressWarnings(as.numeric(x))
dash <- grepl("-", x)
out[dash] <- sapply(strsplit(x[dash], "-"), function(p) as.numeric(p[1]) * 12 + as.numeric(p[2]))
four <- !dash & nchar(x) == 4 & !is.na(out)
out[four] <- as.numeric(substr(x[four], 1, 1)) * 12 + as.numeric(substr(x[four], 2, 3)) +
as.numeric(substr(x[four], 4, 4)) / 8
out
}
cox_df <- surv_df |>
select(nfl_id, time, event, draft_overall_pick) |>
left_join(select(wr, nfl_id, all_of(cox_metrics)), by = "nfl_id") |>
mutate(across(all_of(cox_metrics), to_inches),
log_pick = log(coalesce(draft_overall_pick, 263)),
across(all_of(cox_metrics),
~ as.numeric(scale(if (cur_column() %in% lower_is_better) -.x else .x)),
.names = "z_{.col}"))
MIN_EVENTS <- 5
fit_one <- function(metric, adjust) {
term <- paste0("z_", metric)
d <- cox_df[!is.na(cox_df[[term]]), ]
out <- tibble(metric = metric, adjusted = adjust, n = nrow(d), events = sum(d$event))
if (out$events < MIN_EVENTS) return(out)
f <- as.formula(paste0("Surv(time, event) ~ ", term, if (adjust) " + log_pick" else ""))
m <- tryCatch(coxph(f, data = d, ties = "exact"), error = function(e) NULL)
if (is.null(m)) return(out)
ph <- tryCatch(cox.zph(m)$table[term, "p"], error = function(e) NA_real_)
tidy(m, exponentiate = TRUE, conf.int = TRUE) |>
filter(term == !!term) |>
bind_cols(select(out, -metric)) |>
mutate(metric = metric, ph_p = ph)
}
cox_results <- bind_rows(lapply(cox_metrics, fit_one, adjust = FALSE),
lapply(cox_metrics, fit_one, adjust = TRUE)) |>
group_by(adjusted) |>
mutate(p_fdr = p.adjust(p.value, method = "BH")) |>
ungroup()
forest_df <- cox_results |>
filter(!is.na(estimate)) |>
mutate(model = ifelse(adjusted, "After adjusting for draft pick", "On its own"),
label = pretty_metric[metric],
label = reorder(label, estimate),
text = sprintf("%s (%s)<br>HR %.2f (95%% CI %.2f–%.2f)<br>p = %.3f",
label, tolower(model), estimate, conf.low, conf.high, p.value))
p_forest <- ggplot(forest_df, aes(estimate, label, color = model, text = text)) +
geom_vline(xintercept = 1, linetype = "dashed") +
geom_pointrange(aes(xmin = conf.low, xmax = conf.high),
position = position_dodge(width = 0.5)) +
scale_x_log10() +
scale_color_manual(values = c("#E67E22", "#2C3E50")) +
labs(x = "Hazard ratio per 1 SD better (log scale)", y = NULL, color = NULL,
title = "How each combine measure relates to WR success") +
theme_minimal()
ggplotly(p_forest, tooltip = "text") |> layout(legend = list(orientation = "h", y = -0.15))
How to read this chart: each dot is a hazard ratio and each line is its 95% confidence interval. Dots to the right of the dashed line mean a better result goes with more success; dots to the left mean less success. If a line crosses the dashed line, that result is not statistically significant. Compare the dark (on its own) and orange (after adjusting for draft pick) dots for each measure to see how much of its value was already priced into the draft.
cox_results |>
filter(!is.na(estimate)) |>
mutate(fmt = sprintf("%.2f (%.2f–%.2f)", estimate, conf.low, conf.high)) |>
select(metric, adjusted, n, fmt, p.value, p_fdr) |>
pivot_wider(names_from = adjusted, values_from = c(fmt, p.value, p_fdr)) |>
arrange(p.value_TRUE) |>
mutate(Verdict = case_when(
p_fdr_TRUE < 0.05 ~ "Significant",
p.value_TRUE < 0.05 & p.value_TRUE > 0 ~ ifelse(grepl("^0", fmt_TRUE), "Hint: worse", "Hint: better"),
p.value_TRUE < 0.10 ~ ifelse(grepl("^0", fmt_TRUE), "Weak hint: worse", "Weak hint: better"),
TRUE ~ "No clear effect")) |>
transmute(Measure = pretty_metric[metric], Receivers = n,
`HR on its own` = fmt_FALSE, `p (own)` = round(p.value_FALSE, 3),
`HR after draft pick` = fmt_TRUE, `p (adjusted)` = round(p.value_TRUE, 3),
`FDR p` = round(p_fdr_TRUE, 3), Verdict) |>
kable(align = "lrrrrrrl",
caption = "Hazard ratios per 1 SD better (95% CI), sorted by the draft-adjusted p-value")
| Measure | Receivers | HR on its own | p (own) | HR after draft pick | p (adjusted) | FDR p | Verdict |
|---|---|---|---|---|---|---|---|
| Broad jump | 94 | 0.92 (0.49–1.72) | 0.791 | 0.27 (0.08–0.87) | 0.028 | 0.216 | Hint: worse |
| Vertical jump | 95 | 1.26 (0.65–2.46) | 0.498 | 0.43 (0.17–1.07) | 0.069 | 0.216 | Weak hint: worse |
| Arm length | 106 | 0.73 (0.40–1.35) | 0.317 | 0.49 (0.21–1.11) | 0.085 | 0.216 | Weak hint: worse |
| 10-yard split | 96 | 1.83 (0.91–3.68) | 0.088 | 2.56 (0.86–7.67) | 0.092 | 0.216 | Weak hint: better |
| Wingspan | 106 | 0.74 (0.42–1.32) | 0.307 | 0.54 (0.26–1.14) | 0.108 | 0.216 | No clear effect |
| Height | 106 | 0.94 (0.52–1.71) | 0.843 | 0.63 (0.28–1.44) | 0.273 | 0.433 | No clear effect |
| NGS athleticism score | 106 | 1.30 (0.65–2.57) | 0.456 | 0.64 (0.26–1.59) | 0.336 | 0.433 | No clear effect |
| Weight | 106 | 0.98 (0.54–1.78) | 0.937 | 0.74 (0.38–1.45) | 0.378 | 0.433 | No clear effect |
| Hand size | 106 | 0.79 (0.44–1.43) | 0.437 | 0.74 (0.37–1.48) | 0.390 | 0.433 | No clear effect |
| 40-yard dash | 96 | 1.46 (0.68–3.17) | 0.335 | 0.98 (0.37–2.54) | 0.961 | 0.961 | No clear effect |
What it says:
Why better jumpers might do worse, and why to be careful: draft position already reflects everything teams know, including film and college production. If two receivers went at the same pick and one is a much better jumper, the other likely had stronger tape to be drafted there. So standout explosiveness may mark receivers who were drafted on traits rather than production. However, the broad-jump result does not survive the correction for testing 13 measures (FDR p = 0.22), so it is a lead, not a proven finding.
cox_multi <- coxph(Surv(time, event) ~ z_forty + z_broad_jump + z_combine_weight + log_pick,
data = cox_df, ties = "exact")
tidy(cox_multi, exponentiate = TRUE, conf.int = TRUE) |>
transmute(Measure = label_terms[term],
`HR (95% CI)` = sprintf("%.2f (%.2f–%.2f)", estimate, conf.low, conf.high),
p = round(p.value, 3)) |>
kable(align = "lrr",
caption = sprintf("Speed, explosiveness, size and draft position together (%d receivers, %d successes)",
cox_multi$n, cox_multi$nevent))
| Measure | HR (95% CI) | p |
|---|---|---|
| 40-yard dash | 1.15 (0.35–3.75) | 0.817 |
| Broad jump | 0.28 (0.07–1.19) | 0.084 |
| Weight | 0.90 (0.38–2.13) | 0.814 |
| Draft pick (log) | 0.04 (0.01–0.27) | 0.001 |
What it says: with speed, broad jump and weight held fixed, draft position is still the only clearly significant predictor (HR 0.039, p = 0.001). Doubling the pick number cuts the chance of success by about 90%. Broad jump keeps its negative direction (HR 0.28, p = 0.08).
Treat this model as a rough check. It has four predictors but only 8 successes, below the usual guideline of about 10 successes per predictor, so it partly fits noise. Its concordance of 0.965 looks impressive but overstates real predictive accuracy.
Most receivers skipped the 3-cone drill, short shuttle and bench press, leaving only 2 to 4 successes among those who did them. Standard Cox models often fail with so few successes, so these drills use Firth’s penalized Cox model, which stays stable with small samples. A Wilcoxon test, which simply compares the drill scores of successful and unsuccessful receivers, serves as a backup check.
sparse <- c("three_cone", "short_shuttle", "bench_reps")
lapply(sparse, function(metric) {
term <- paste0("z_", metric)
d <- cox_df[!is.na(cox_df[[term]]), ]
fit <- coxphf(as.formula(paste0("Surv(time, event) ~ ", term)), data = d)
tibble(Drill = pretty_metric[metric], Receivers = nrow(d), Successes = sum(d$event),
`HR (95% CI)` = sprintf("%.2f (%.2f–%.2f)", exp(coef(fit)), fit$ci.lower, fit$ci.upper),
p = round(fit$prob, 3),
`Wilcoxon p` = round(wilcox.test(d[[term]] ~ d$event, exact = FALSE)$p.value, 3))
}) |>
bind_rows() |>
kable(align = "lrrrrr", caption = "Hazard ratios per 1 SD better")
| Drill | Receivers | Successes | HR (95% CI) | p | Wilcoxon p |
|---|---|---|---|---|---|
| 3-cone drill | 32 | 2 | 2.63 (0.84–11.17) | 0.095 | 0.173 |
| Short shuttle | 38 | 4 | 2.55 (1.01–7.24) | 0.047 | 0.190 |
| Bench press | 28 | 3 | 0.73 (0.20–2.17) | 0.569 | 0.575 |
What it says: the short shuttle hints at the same story as the 10-yard split (HR 2.55, p = 0.047). Jaxon Smith-Njigba and Rome Odunze ranked first and third in shuttle among the 38 receivers who ran it. Both the shuttle and the 10-yard split measure quickness over short distances, so the two results support each other. The 3-cone and bench press show nothing usable.
Why this is only a hint: the shuttle result rests on just 4 successes, the Wilcoxon backup test is not significant (p = 0.19), and it is one of 13 measures tested.
lapply(cox_metrics, function(metric) {
skipped <- is.na(cox_df[[paste0("z_", metric)]])
if (!any(skipped) || all(skipped)) return(NULL)
tibble(Drill = pretty_metric[metric],
`Ran it` = sum(!skipped), Skipped = sum(skipped),
`Success rate if ran it` = scales::percent(mean(cox_df$event[!skipped]), 1),
`Success rate if skipped` = scales::percent(mean(cox_df$event[skipped]), 1))
}) |>
bind_rows() |>
kable(align = "lrrrr")
| Drill | Ran it | Skipped | Success rate if ran it | Success rate if skipped |
|---|---|---|---|---|
| 40-yard dash | 96 | 10 | 8% | 30% |
| 10-yard split | 96 | 10 | 8% | 30% |
| Vertical jump | 95 | 11 | 11% | 9% |
| Broad jump | 94 | 12 | 11% | 8% |
| 3-cone drill | 32 | 74 | 6% | 12% |
| Short shuttle | 38 | 68 | 11% | 10% |
| Bench press | 28 | 78 | 11% | 10% |
What it says: the best prospects often skip the 40. Receivers who skipped the 40-yard dash succeeded 30% of the time, compared with 8% of those who ran it. Top prospects often sit out because they have nothing left to prove. That means the receivers with recorded 40 and 10-yard split times are missing several of the best players, which likely makes those measures look weaker than they really are.
zph <- cox.zph(cox_multi)
as.data.frame(zph$table) |>
tibble::rownames_to_column("Term") |>
mutate(Term = ifelse(Term %in% names(label_terms), label_terms[Term], Term),
Term = ifelse(Term == "GLOBAL", "Whole model (global)", Term)) |>
select(Term, `Chi-square` = chisq, p) |>
kable(digits = 3, align = "lrr")
| Term | Chi-square | p |
|---|---|---|
| 40-yard dash | 0.792 | 0.373 |
| Broad jump | 1.181 | 0.277 |
| Weight | 2.443 | 0.118 |
| Draft pick (log) | 3.305 | 0.069 |
| Whole model (global) | 11.839 | 0.019 |
How to read this test: the Cox model assumes each measure’s effect stays the same from season to season. This test checks that assumption. A small p-value (below 0.05) means the assumption is violated.
What it says: no single measure clearly violates the assumption, but the whole-model test does (p = 0.019), with draft position the closest to violating it (p = 0.07). So the combined model’s hazard ratios should be read as effects averaged over three seasons, not constant effects. The likely reason: high picks get snaps and targets right away, so draft position probably matters most as a rookie and less afterward.
The main findings are not affected. The one-measure-at-a-time Cox models in Step 4, which carry this report’s key results, all pass this check (every p > 0.19).
Combine results tell teams less about wide receiver success than draft position does, and most of what they tell is already priced in. Receivers drafted in the first two rounds succeeded at many times the rate of everyone else, and the 40-yard dash, the combine’s headline event, adds nothing once draft position is known.
Two patterns stand out as worth following up:
| Combine measure | On its own | After accounting for draft position | Bottom line |
|---|---|---|---|
| 10-yard split | Weak positive | Stronger positive (p = 0.09) | Most promising signal |
| Short shuttle | Positive (p = 0.047) | Not tested (too few successes) | Supports the 10-yard split |
| 40-yard dash | Weak positive | No effect | Already priced into the draft |
| Broad jump | No effect | Negative (p = 0.03) | Possibly overvalued |
| Vertical, arm length, wingspan | No effect | Weak negative | Possibly overvalued |
| Height, weight, hands, bench, 3-cone | No effect | No effect | No signal |
Neither pattern is conclusive with 11 successes across three seasons. The strength of the survival approach is that it uses every receiver, including the 2024 and 2025 classes whose careers are just starting, so these results will sharpen as more seasons are added.
Data: NFL Big Data Bowl 2027 (players and combine
results) and nflverse data through the nflreadr R package,
including snap counts sourced from Pro Football Reference.