Key findings at a glance

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.

  1. Success is rare. Only 11 of 106 receivers (10%) had a successful season in the 2023–2025 window. About 1 in 6 succeed by their third season.
  2. Draft round is by far the strongest predictor. First- and second-round receivers produced 8 of the 11 successes. Late-round and undrafted receivers produced 1 (Puka Nacua).
  3. Speed is already priced in. Faster 40 times go with more success, but the effect disappears once draft position is accounted for.
  4. Short-area quickness is the most promising signal. The 10-yard split and short shuttle are the only measures that point toward success beyond draft position. Neither is conclusive.
  5. Among receivers drafted in the same range, better jumpers and longer-armed receivers did slightly worse. This suggests teams may overvalue explosive traits, but it is a hint, not a proven result.

How to read the numbers in this report

  • Hazard ratio (HR) / odds ratio (OR): how much a measure changes a receiver’s chance of success. Above 1 = better chance, below 1 = worse chance, exactly 1 = no effect. An HR of 1.5 means a 50% higher chance each season.
  • “Per 1 SD better”: every combine measure is converted to standard deviations (SD), and timed drills are flipped so that higher always means a better result. One SD is roughly the gap between an average receiver and a clearly above-average one.
  • 95% confidence interval (CI): the range of plausible values. If the interval includes 1, the result is not statistically significant.
  • p-value: below 0.05 is the usual bar for significance. Between 0.05 and 0.10 is treated here as a weak hint.
  • FDR-adjusted p: a stricter p-value that corrects for testing 13 combine measures at once. It is the fairest test of whether any single measure stands out.

Introduction

The question

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 approach

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?

Key terms

  • Success: a season in which a receiver either finished top 32 among all NFL wide receivers in receiving yards, or played at least 65% of his team’s offensive snaps while drawing at least 18% of its targets. The next section explains how and why these were chosen.
  • Censored: a player who had not succeeded by the end of 2025. We do not know what happens to him next, so he is not counted as a failure.
  • Survival curve, S(t): the share of receivers who have not yet succeeded by season t.
  • Cumulative success, F(t): the share who have succeeded by season t, equal to 1 − S(t).
  • Hazard: the chance of a first success in a given season, among receivers who have not succeeded yet.

The data and how it was built

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.

Sources

  • NFL Big Data Bowl 2027: draft information for every player (players), combine measurements and drill results (combine_results), and career totals including Pro Bowl and All-Pro selections (player_career_successes).
  • nflverse, through the 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.

Loading the analysis files

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"))

Who is included

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.

Step A: Defining success

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.

Step B: Computing the season statistics

Each criterion was computed from the nflverse data, regular season only:

  • Receiving-yard rank: each receiver’s receiving yards were totaled for the season, and all NFL wide receivers were ranked from most to fewest yards.
  • Target share: a receiver’s targets divided by his team’s total targets that season. For receivers traded mid-season, the team where he had the larger share was used.
  • Snap share: a receiver’s offensive snaps divided by his team’s total offensive snaps for the full season. Each team’s snaps per game were taken as the most snaps any of its players logged that game (quarterbacks and linemen play nearly every snap). Using the full season means games missed through injury or inactivity count against a receiver, which fits the idea of a reliable starter.

Step C: Matching players across data sources

The Big Data Bowl and nflverse use different player IDs, so each receiver had to be matched in two stages:

  1. Drafted players were matched on draft year plus overall pick number, which is unique for every drafted player.
  2. Anyone still unmatched, mostly undrafted players, was matched on name plus the year he entered the league.

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.

Step D: Shaping the data for survival analysis

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:

  • If he succeeds, that season is his last row and is marked as the event.
  • If the data ends first (after 2025), he is censored: he contributed his observed seasons but is not counted as a failure.

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")
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.

Step E: Preparing the combine measures and draft position

  • Standardizing: every combine measure was converted to standard deviations (SD) from the average receiver, so measures on different scales (seconds, inches, pounds) can be compared directly.
  • Flipping timed drills: for the 40-yard dash, 10-yard split, 3-cone drill and short shuttle, lower times are better, so their signs were flipped. For every measure in this report, higher means a better result.
  • Height: converted to inches if it was stored as text (for example, “6-1”).
  • Skipped drills: many receivers skip some drills. Instead of dropping those players from the whole analysis, each model only leaves out receivers missing the specific measures in that model.
  • Draft position: measured as the log of the overall pick number. The log reflects that moving from pick 1 to pick 10 matters far more than moving from pick 200 to pick 210. Undrafted receivers were assigned pick 263, one past the last pick in a draft.

The build script

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")

The finished dataset

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")
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.

Step 1: How quickly do receivers succeed?

The method

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.

Results

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.

Step 2: Does draft round matter?

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)))
Log-rank test: chi-square = 32.2, p = 1e-07
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.

Step 3: Do key combine measures predict success?

The method

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.")
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:

  • Combine measures alone barely predict success. No measure is significant in the combine-only model. A faster 40 points the right way (OR 1.83, p = 0.22), but the evidence is weak.
  • Adding draft position dramatically improves the model (likelihood-ratio test p = 2.3e-08). Draft position explains far more than all four combine measures together.
  • Speed is already priced in. Once draft position is included, the 40’s odds ratio drops from 1.83 to 0.63 (p = 0.55). Fast receivers succeed more because they are drafted higher, not because speed tells teams anything extra.

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.

Step 4: Testing every combine measure

The method

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()

Visualization: which measures matter?

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.

Results table

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")
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:

  • On its own, no combine measure clearly predicts success. Every interval crosses 1, and no FDR-adjusted p-value comes close to significance.
  • The 10-yard split is the most promising positive signal. It leans positive on its own (HR 1.83, p = 0.09) and is the only measure that gets stronger after adjusting for draft position (HR 2.56, p = 0.09). The first 10 yards capture acceleration, which helps receivers beat press coverage and separate on short routes. Teams may be undervaluing it.
  • The 40-yard dash adds nothing once draft position is known (HR 1.46 on its own, 0.98 after adjusting). Teams already reward speed with higher picks.
  • After adjusting for draft position, better jumpers and longer-armed receivers did slightly worse. Broad jump (HR 0.27, p = 0.03), vertical (0.43), arm length (0.49) and wingspan (0.54) all point below 1.

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.

A combined model

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))
Speed, explosiveness, size and draft position together (88 receivers, 8 successes)
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.

Step 5: The drills most receivers skipped

Penalized models for sparse drills

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")
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.

Who skips the drills?

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.

Model checks

Proportional hazards

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).

Other checks

  • Small number of successes. Eleven successes is the main limit on this analysis. The one-measure models stay within safe limits, but the combined models do not, which shows up as warnings, an unrealistic season-3 odds ratio of about 46 in Step 3 and an inflated concordance in Step 4. The one-measure Cox results are the most trustworthy in this report.
  • Player matching. 97 of 106 receivers matched to nflverse statistics and 98 to snap counts. Eight of the nine unmatched receivers were undrafted and one was a seventh-round pick. They are counted as unsuccessful, which is unlikely to change the results given their draft position.
  • Pro Bowl and All-Pro selections. Season-by-season selections were not available, so success is defined by yards and role only. Every receiver with a career Pro Bowl or All-Pro selection was captured anyway, except Marvin Mims and Chimere Dike, whose honors appear to be for kick and punt returning rather than receiving.

Conclusion

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:

  • Short-area quickness may be undervalued. The 10-yard split and the short shuttle are the only measures that point toward success beyond draft position.
  • Explosive traits may be overvalued. Among receivers drafted in the same range, better jumpers and longer-armed receivers tended to do worse.
Combine scorecard for NFL wide receiver success
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.

Assumptions and limitations

  • Short window. Only three seasons are observed, and the 2025 class has just one. Many receivers develop after year three, so this is a study of early success.
  • Few successes. Eleven successes limit statistical power and make combined models unstable.
  • Opportunity bias. Snap share and target share depend on coaching decisions, and high picks get more chances regardless of ability. Part of the draft-position effect reflects teams investing in their own picks.
  • Definition of success. The top-32 yards and 65% snap / 18% target thresholds are reasonable but arbitrary. Different cutoffs would change who counts as successful.
  • Drill skipping. Top prospects often skip drills, so measures like the 40 come from a non-random group of receivers.
  • Multiple testing. Thirteen measures were tested. Results that do not survive the FDR correction should be treated as leads.
  • Matching. Nine receivers, mostly undrafted, could not be matched to external statistics and were treated as unsuccessful.

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.