This project examines whether better-performing NFL teams have higher home attendance and how attendance varies across teams and seasons. Understanding these patterns can help ticketing, marketing and business operations staff assess attendance alongside team performance.
We use NFL attendance, standings and game records for 2000-2019. Repeated season attendance totals are reduced to one row per team-season and joined to standings. We calculate ties as 16 minus wins minus losses and the proportion of games won as wins/16; ties receive no win credit. Game records provide a check on tied games. For the next stage, we plan to use grouped summaries, tables and plots to compare attendance across years, performance groups and franchises.
We plan to use exploratory data analysis to describe associations, rather than estimate causal effects or forecast attendance. Our research questions are:
The planned outcome is season home attendance. We will compare performance using the proportion of games won, win groups, point differential and postseason outcomes. We also plan to compare each season’s attendance and wins with that franchise’s historical averages. Weekly attendance is inspected during cleaning but is not an outcome in the analysis.
The planned analysis could provide historical benchmarks for comparing attendance with performance and identifying weaker seasons for further investigation. Differences between franchises may also point to the importance of venue and market conditions, although those factors are not measured here. The intended use is to guide further business investigation, rather than predict demand or evaluate marketing campaigns.
tidyverse provides data import, cleaning, joins,
reshaping, grouped summaries and plots. knitr::kable()
formats tables. Package-loading messages and warnings are
suppressed.
library(tidyverse)
The three files come from the TidyTuesday NFL Attendance release, originally sourced from Pro Football Reference. They cover the 2000-2019 seasons and were released on 4 February 2020. The documentation does not specify the original collection dates.
| File | Rows | Variables | Unit and contents |
|---|---|---|---|
attendance.csv |
10,846 | 8 | Team-week: identifiers, weekly attendance and repeated season total, home and away attendance |
standings.csv |
638 | 15 | Team-season: record, scoring, rating measures and postseason outcomes |
games.csv |
5,324 | 19 | Individual game: teams, date/time, scores, yards and turnovers, including postseason games |
These levels must be considered before joining. Missing weekly attendance and tie labels are assessed according to their meaning in section 3.3.
We imported the original CSV files with read_csv() and
checked dimensions, types and missing values. Detailed inspection output
is hidden in the report.
attendance <- read_csv("attendance.csv")
standings <- read_csv("standings.csv")
games <- read_csv("games.csv")
Attendance has 638 missing weekly_attendance values: one
per team-season, consistent with the non-playing week described in the
dictionary. We retain these as missing; the season totals used in the
analysis are complete. Standings has no missing values. The 5,314
missing tie labels in games indicate that no second tied
team is listed; scores are available, so these rows are retained.
Season totals repeat across 17 weekly attendance rows.
select() and distinct() retain each
team-season once, preventing those totals from being counted
repeatedly.
# Keep the season totals once for each team and year
season_attendance <- attendance |>
select(team, team_name, year, total, home, away) |>
distinct()
nrow(season_attendance)
## [1] 638
# Check whether any team-season appears more than once
attendance_duplicates <- season_attendance |>
count(team, team_name, year) |>
filter(n > 1)
nrow(attendance_duplicates)
## [1] 0
standings_duplicates <- standings |>
count(team, team_name, year) |>
filter(n > 1)
nrow(standings_duplicates)
## [1] 0
The prepared attendance table has 638 rows, with 0 duplicated team-city, nickname and year keys. Standings has 0 duplicated keys. City is retained for the join; planned franchise summaries will group by nickname across city changes.
We check unmatched records with anti_join(), then add
standings using left_join() on city, nickname and year. The
left join retains all prepared attendance rows.
# Find attendance rows with no matching standings row
unmatched_standings <- season_attendance |>
anti_join(standings, by = c("team", "team_name", "year"))
# Add performance variables to the attendance data
nfl_data <- season_attendance |>
left_join(standings, by = c("team", "team_name", "year"))
All attendance records match (0 unmatched), and the join retains 638 rows.
Standings has no ties column. Each team-season in these files has 16
regular-season games, so we calculate ties as 16 minus wins minus losses
and win_pct as wins/16. This formula applies to the seasons
covered here.
# Find the number of tied games in each team's season record
nfl_data <- nfl_data |>
mutate(ties = 16 - wins - loss)
# Calculate the proportion of games actually won
nfl_data <- nfl_data |>
mutate(win_pct = wins / 16)
Ties receive no win credit: an 8-7-1 record means 8/16 = 50% of games won. We check the tie total against equal-score games in regular-season weeks 1-17; each tied game involves two teams.
# Keep weeks 1-17 and exclude playoff games
regular_season_games <- games |>
filter(week %in% as.character(1:17))
# Identify games where both teams scored the same number of points
tied_games <- regular_season_games |>
filter(pts_win == pts_loss)
nrow(tied_games)
## [1] 10
sum(nfl_data$ties)
## [1] 20
The 10 tied games correspond to 20 team-season ties. Individual game rows remain separate to avoid repeating season totals.
final_duplicates <- nfl_data |>
count(team, team_name, year) |>
filter(n > 1)
# Look for impossible ties or winning percentages
record_errors <- nfl_data |>
filter(ties < 0 | ties > 16 | win_pct < 0 | win_pct > 1)
attendance_errors <- nfl_data |>
filter(home < 0 | away < 0 | total != home + away)
bye_check <- attendance |>
group_by(team, team_name, year) |>
summarise(
missing_weeks = sum(is.na(weekly_attendance)),
.groups = "drop"
)
# Identify team-seasons without exactly one missing week
bye_errors <- bye_check |>
filter(missing_weeks !=1)
| Final check | Result |
|---|---|
| Team-season observations | 638 |
| Duplicate team-season keys | 0 |
| Missing values in the final table | 0 |
| Invalid ties or win proportions | 0 |
| Negative attendance or total unequal to home plus away | 0 |
| Team-seasons without exactly one missing week | 0 |
The validation checks found no invalid attendance or performance values. Although the 2017 Los Angeles Chargers recorded an unusually low home attendance of 202,687, we retained this observation because there was no evidence of a data error. Stadium capacity data was unavailable, so we could not fully explain the difference.
The final table has 638 team-seasons and 20 variables for 2000-2019. It retains season attendance and standings variables and adds ties and the proportion of games won. A six-row preview follows.
nfl_data |>
arrange(year, team, team_name) |>
select(team, team_name, year, home, wins, loss, ties, win_pct) |>
head(6) |>
knitr::kable(
digits = 2,
caption = "A condensed preview of the cleaned team-season data."
)
| team | team_name | year | home | wins | loss | ties | win_pct |
|---|---|---|---|---|---|---|---|
| Arizona | Cardinals | 2000 | 387475 | 3 | 13 | 0 | 0.19 |
| Atlanta | Falcons | 2000 | 422814 | 4 | 12 | 0 | 0.25 |
| Baltimore | Ravens | 2000 | 551695 | 12 | 4 | 0 | 0.75 |
| Buffalo | Bills | 2000 | 560695 | 8 | 8 | 0 | 0.50 |
| Carolina | Panthers | 2000 | 583489 | 7 | 9 | 0 | 0.44 |
| Chicago | Bears | 2000 | 535552 | 5 | 11 | 0 | 0.31 |
| Variable | Meaning and role |
|---|---|
home |
Primary outcome: a team’s season home attendance; counts visits rather than unique people |
total, away |
Season attendance across all games and at away games; retained for context |
wins, loss,
ties |
Regular-season record; ties are calculated as 16 minus wins minus losses |
win_pct |
Wins/16: the proportion of games actually won; ties receive no win credit |
points_differential |
Points scored minus points allowed; a second performance measure |
playoffs, sb_winner |
End-of-season playoff and championship categories |
team, team_name,
year |
Historical city, team nickname and season identifiers |
# Put the selected variables into a long table, as in Week 6
summary_long <- nfl_data |>
select(home, wins, win_pct, points_differential)
summary_long <- summary_long |>
pivot_longer(home:points_differential,
names_to = "Variable", values_to = "value")
# Calculate a few descriptive statistics for each variable
variable_summary <- summary_long |>
group_by(Variable) |>
summarise(
Minimum = min(value),
Median = median(value),
Mean = mean(value),
Maximum = max(value)
) |>
ungroup()
knitr::kable(variable_summary, digits = 3,
caption = "Selected numeric variables across the 638 team-seasons.")
| Variable | Minimum | Median | Mean | Maximum |
|---|---|---|---|---|
| home | 202687 | 543185.0 | 540455.013 | 741775 |
| points_differential | -261 | 1.5 | 0.000 | 315 |
| win_pct | 0 | 0.5 | 0.499 | 1 |
| wins | 0 | 8.0 | 7.984 | 16 |
Mean season home attendance is 540,455 visits; the median is 543,185. There are 240 playoff team-seasons, including 20 champions. Postseason outcomes describe end-of-season results.
We plan to examine the prepared data from several angles rather than only report overall attendance. First, we will group by year and calculate mean home attendance per team-season. Using a mean will help compare seasons with different numbers of teams. Next, we will compare attendance across teams with 0-5, 6-9 and 10-16 wins, and across three postseason groups: teams missing the playoffs, other playoff teams and Super Bowl winners. Each group will be summarised using its size, mean and median attendance. We have already joined attendance to standings and created wins/16. For the within-franchise comparison, we will calculate each franchise’s average attendance and wins, join those averages to its season records, and compare each season with its averages.
A line plot of mean home attendance by year will show changes over time. Scatterplots will compare attendance with the proportion of games won and point differential, showing whether teams with similar performance have different crowds. A bar chart will compare mean attendance across the three postseason groups. An ordered point plot will compare franchise averages, while a further bar chart will show attendance differences from each franchise’s own average in stronger and weaker seasons.
Small summary tables will accompany the comparisons, reporting group sizes and attendance averages. Medians will help show whether unusually large or small crowds affect the means. Plot titles, axis labels and units will explain what each comparison represents.
We do not yet know how to separate the influence of performance from stadium capacity, ticket prices and market differences. Our grouped comparisons can describe associations, but they cannot show how much attendance changes because of performance alone. Some of those questions would also require data we do not currently have.
We need more practice interpreting seasons relative to a franchise’s own average, particularly when a team has changed cities or stadiums. Before drawing conclusions, we will check those comparisons carefully and explain the remaining limitations.
We do not currently plan to use machine learning, regression or clustering. Our questions focus on describing attendance patterns, so the grouped summaries, joins and plots covered in class are appropriate for the planned analysis. We will not claim that these comparisons predict attendance or prove that winning causes larger crowds. Any later decision to add a model would require further learning and a clear reason for using it.