3 Exploring your data

Exploratory data analysis is the habit of looking before modeling. It is where you learn what the data contains, what shape it has, what appears unusual, and what questions are worth asking next.

This chapter uses the data introduced in Chapter 2, especially FOSBAAS::season_data. The goal is not to build a final model. The goal is to develop a repeatable first-pass workflow:

  1. Check the structure of the data.
  2. Summarize important fields.
  3. Visualize distributions and relationships.
  4. Look for missingness, sparsity, and strange values.
  5. Use simple statistical tools only after the data has been inspected.

Exploration is part of analysis, but it is also part of communication. A good chart can show a manager where a pattern is obvious, where the data is thin, or where a proposed explanation does not hold up.

3.1 Start With Structure

Before building a graph, inspect the table. Ask basic questions: How many rows are there? What does each row represent? Which fields are numeric, categorical, dates, or logical flags?

Table 3.1: Schedule and sales data
gameNumber team date dayOfWeek month weekEnd schoolInOut daysSinceLastGame
1 SF 2022-03-27 Sun Mar FALSE FALSE 50
2 SF 2022-03-28 Mon Mar FALSE FALSE 1
3 SF 2022-03-29 Tue Mar FALSE FALSE 1
4 BAL 2022-04-05 Tue Apr FALSE FALSE 7
5 BAL 2022-04-06 Wed Apr FALSE FALSE 1
6 BAL 2022-04-07 Thu Apr FALSE FALSE 1

season_data is a game-level table. Each row represents one home game. The table includes opponent, date, day of week, month, promotion, and simulated ticket sales.

A quick structural summary helps orient the rest of the chapter.

season_summary <- tibble::tibble(
  rows = nrow(season_data),
  columns = ncol(season_data),
  seasons = n_distinct(season_data$season),
  games_per_season = nrow(season_data) / n_distinct(season_data$season),
  min_sales = min(season_data$ticketSales),
  median_sales = median(season_data$ticketSales),
  mean_sales = mean(season_data$ticketSales),
  max_sales = max(season_data$ticketSales)
)

knitr::kable(
  season_summary,
  caption = "High-level ticket sales summary",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.2: High-level ticket sales summary
rows columns seasons games_per_season min_sales median_sales mean_sales max_sales
243 12 3 81 19920 30956 31411.04 45000

This table gives us a baseline. The average and median are close enough to compare, but the maximum is much larger than the lower quartiles. That tells us to look at the distribution instead of relying only on a mean.

3.2 Distributions

A distribution shows the range and concentration of values. Ticket sales are not equally likely at every level. Some games sell poorly, many cluster near the middle, and a smaller number approach sellout levels.

ggplot(season_data, aes(x = ticketSales, fill = factor(season))) +
  geom_histogram(binwidth = 1000, color = "white", alpha = 0.85) +
  scale_fill_manual("Season", values = plot_palette) +
  scale_x_continuous(labels = scales::comma) +
  scale_y_continuous(labels = scales::comma) +
  labs(
    x = "Ticket sales",
    y = "Games",
    title = "Ticket sales are concentrated in the middle of the range"
  ) +
  book_theme
Distribution of ticket sales

Figure 3.1: Distribution of ticket sales

Histograms are usually the best first graph for a numeric field. They are simple, direct, and easy to explain. The bin width matters. A very small bin width creates noise; a very large bin width hides structure.

Density plots show a smoothed version of the same idea. They can make comparisons easier, but they are more abstract than histograms.

ggplot(season_data, aes(x = ticketSales, fill = factor(season))) +
  geom_density(alpha = 0.35) +
  scale_fill_manual("Season", values = plot_palette) +
  scale_x_continuous(labels = scales::comma) +
  scale_y_continuous(labels = scales::percent) +
  labs(
    x = "Ticket sales",
    y = "Density",
    title = "Density plots compare distribution shape across seasons"
  ) +
  book_theme
Density of ticket sales by season

Figure 3.2: Density of ticket sales by season

The density curve is scaled so the area under the curve is approximately one. That makes it useful for comparing shape, not raw count.

ticket_density <- density(season_data$ticketSales)
bin_width <- ticket_density$x[2] - ticket_density$x[1]
area_above_40000 <- sum(ticket_density$y[ticket_density$x >= 40000]) * bin_width

round(area_above_40000, 3)
## [1] 0.134

The value above is the approximate share of the distribution above 40,000 tickets. This is not a business forecast; it is a way to understand what a density plot represents.

3.3 Comparing Groups

Many business questions are comparisons: weekends versus weekdays, promotion types, opponents, sales territories, customer segments, or renewal outcomes.

Box plots are useful because they show median, spread, and potential outliers compactly.

ggplot(season_data, aes(x = factor(season), y = ticketSales)) +
  geom_boxplot(fill = plot_palette[1], alpha = 0.8, outlier.shape = NA) +
  geom_jitter(width = 0.18, height = 0, alpha = 0.35, color = plot_palette[2]) +
  scale_y_continuous(labels = scales::comma) +
  labs(
    x = "Season",
    y = "Ticket sales",
    title = "Box plots make seasonal differences easier to scan"
  ) +
  book_theme
Ticket sales by season

Figure 3.3: Ticket sales by season

A box plot is usually more useful than a table when the question is about spread. The center line is the median. The box covers the middle half of the observations. The points show the individual games.

Faceting is another useful way to compare groups because each group gets its own panel.

ggplot(season_data, aes(x = ticketSales)) +
  geom_histogram(binwidth = 1000, fill = plot_palette[1], color = "white") +
  facet_wrap(~ season, ncol = 1) +
  scale_x_continuous(labels = scales::comma) +
  scale_y_continuous(labels = scales::comma) +
  labs(
    x = "Ticket sales",
    y = "Games",
    title = "Faceting separates the same distribution by season"
  ) +
  book_theme
Faceted ticket sales histogram

Figure 3.4: Faceted ticket sales histogram

When a plot is already split by panel, color often becomes unnecessary. Use visual encodings only when they clarify the comparison.

3.4 Time and Sequence

Sports demand has a calendar. Opponent, day of week, month, school schedules, weather, promotions, and team performance can all make demand move through time.

A line chart is a natural first view for a sequence.

ggplot(season_data, aes(x = gameNumber, y = ticketSales, color = factor(season))) +
  geom_line(linewidth = 0.8) +
  scale_color_manual("Season", values = plot_palette) +
  scale_y_continuous(labels = scales::comma) +
  labs(
    x = "Game number",
    y = "Ticket sales",
    title = "Ticket sales vary across the home schedule"
  ) +
  book_theme
Ticket sales by game number

Figure 3.5: Ticket sales by game number

Line charts are useful for trend and sequence, but they can imply continuity. A game schedule is ordered, but games are still discrete events. Use the line as a guide to the eye, not as proof that demand changes smoothly.

Tile plots are useful when two categorical dimensions matter at the same time.

sales_by_month_dow <- season_data |>
  group_by(month, dayOfWeek) |>
  summarise(
    median_sales = median(ticketSales),
    games = n(),
    .groups = "drop"
  )

knitr::kable(
  head(sales_by_month_dow),
  caption = "Median sales by month and day of week",
  align = "c",
  format = "markdown",
  padding = 0
)
Table 3.3: Median sales by month and day of week
month dayOfWeek median_sales games
Apr Fri 40011.0 5
Apr Mon 24602.0 5
Apr Sat 34721.0 5
Apr Sun 28089.5 6
Apr Thu 23367.0 5
Apr Tue 24717.0 5
ggplot(sales_by_month_dow, aes(x = dayOfWeek, y = month, fill = median_sales)) +
  geom_tile(color = "white") +
  scale_fill_gradient(
    "Median sales",
    low = "#f2f2f2",
    high = plot_palette[1],
    labels = scales::comma
  ) +
  labs(
    x = "Day of week",
    y = "Month",
    title = "Tile plots reveal schedule patterns across two dimensions"
  ) +
  book_theme
Median ticket sales by month and day of week

Figure 3.6: Median ticket sales by month and day of week

A tile plot can be a good bridge between a table and a chart. It preserves the grid structure of a table but makes high and low values easier to see.

3.5 Relationships

Scatter plots show relationships between two numeric variables. In the season table, daysSinceLastGame is a simple operational variable that can be compared with ticket sales.

ggplot(season_data, aes(x = daysSinceLastGame, y = ticketSales, color = factor(season))) +
  geom_point(alpha = 0.75) +
  geom_smooth(method = "lm", se = FALSE, color = "grey20") +
  scale_color_manual("Season", values = plot_palette) +
  scale_y_continuous(labels = scales::comma) +
  labs(
    x = "Days since last game",
    y = "Ticket sales",
    title = "Scatter plots help test simple relationship ideas"
  ) +
  book_theme
## `geom_smooth()` using formula = 'y ~ x'
Ticket sales and days since last game

Figure 3.7: Ticket sales and days since last game

A scatter plot is not proof of causation. It is a way to see whether a relationship is plausible enough to investigate. If the points are scattered with no clear pattern, a more complicated model may not help much.

3.6 Summarizing Data

Graphs help us see patterns. Tables help us report exact values. Most analysis needs both.

sales_by_day <- season_data |>
  group_by(dayOfWeek) |>
  summarise(
    games = n(),
    mean_sales = mean(ticketSales),
    median_sales = median(ticketSales),
    min_sales = min(ticketSales),
    max_sales = max(ticketSales),
    .groups = "drop"
  ) |>
  arrange(desc(median_sales))

knitr::kable(
  sales_by_day,
  caption = "Ticket sales by day of week",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.4: Ticket sales by day of week
dayOfWeek games mean_sales median_sales min_sales max_sales
Fri 33 38794.03 39456.0 31346 45000
Sat 34 38146.29 37490.0 29163 45000
Sun 37 29690.38 29810.0 20800 42928
Tue 36 28217.72 29152.0 21153 39250
Wed 34 28893.47 28728.5 19920 43654
Mon 37 28243.46 28660.0 21126 41873
Thu 32 28560.53 27029.5 21049 40138

The count column matters. A group with two games should not be interpreted the same way as a group with forty games.

Quantiles are useful because they describe the distribution without assuming it is normal.

sales_quantiles <- quantile(
  season_data$ticketSales,
  probs = c(0, 0.10, 0.25, 0.50, 0.75, 0.90, 1)
)

knitr::kable(
  data.frame(
    quantile = names(sales_quantiles),
    ticket_sales = as.numeric(sales_quantiles)
  ),
  caption = "Ticket sales quantiles",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.5: Ticket sales quantiles
quantile ticket_sales
0% 19920.0
10% 22797.6
25% 26126.5
50% 30956.0
75% 35664.0
90% 40816.6
100% 45000.0

Quantiles are often more useful than means when setting practical thresholds. For example, you might define high-demand games as the top quartile of ticket sales, then study what those games have in common.

3.7 Reshaping Data

Real analysis often requires changing the shape of a table. Wide data has multiple measures spread across columns. Long data stores the measure names and values in rows. ggplot2 usually prefers long data.

team_day_wide <- season_data |>
  filter(team %in% c("SF", "BAL")) |>
  group_by(team, dayOfWeek) |>
  summarise(
    median_sales = median(ticketSales),
    games = n(),
    .groups = "drop"
  ) |>
  tidyr::pivot_wider(
    names_from = team,
    values_from = c(median_sales, games)
  ) |>
  mutate(difference = median_sales_BAL - median_sales_SF)

knitr::kable(
  team_day_wide,
  caption = "Wide comparison of selected opponents",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.6: Wide comparison of selected opponents
dayOfWeek median_sales_BAL median_sales_SF games_BAL games_SF difference
Fri 33734.0 43608.5 3 2 -9874.5
Mon 25658.5 25759.0 2 1 -100.5
Sat 29787.0 43999.0 1 1 -14212.0
Sun 28209.0 42928.0 2 1 -14719.0
Thu 22561.0 35357.5 3 2 -12796.5
Tue 26464.0 26233.0 3 1 231.0
Wed 26301.5 28773.0 4 1 -2471.5
team_day_long <- team_day_wide |>
  dplyr::select(dayOfWeek, median_sales_BAL, median_sales_SF) |>
  tidyr::pivot_longer(
    cols = -dayOfWeek,
    names_to = "team",
    values_to = "median_sales"
  ) |>
  mutate(team = sub("median_sales_", "", team))

knitr::kable(
  team_day_long,
  caption = "Long comparison of selected opponents",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.7: Long comparison of selected opponents
dayOfWeek team median_sales
Fri BAL 33734.0
Fri SF 43608.5
Mon BAL 25658.5
Mon SF 25759.0
Sat BAL 29787.0
Sat SF 43999.0
Sun BAL 28209.0
Sun SF 42928.0
Thu BAL 22561.0
Thu SF 35357.5
Tue BAL 26464.0
Tue SF 26233.0
Wed BAL 26301.5
Wed SF 28773.0

The values are the same. The shape changes because different tasks need different layouts.

3.8 Checking Data Quality

Exploration is not only about finding business insights. It is also about finding data problems. Start with simple checks.

missing_summary <- data.frame(
  data_set = c("season_data", "customer_renewals", "demographic_data"),
  rows = c(nrow(season_data), nrow(customer_renewals), nrow(demographic_data)),
  columns = c(ncol(season_data), ncol(customer_renewals), ncol(demographic_data)),
  missing_values = c(
    sum(is.na(season_data)),
    sum(is.na(customer_renewals)),
    sum(is.na(demographic_data))
  )
)

knitr::kable(
  missing_summary,
  caption = "Simple missingness check",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.8: Simple missingness check
data_set rows columns missing_values
season_data 243 12 0
customer_renewals 13706 10 0
demographic_data 200000 14 4

This teaching data is clean. Real data often is not. A first-pass data quality checklist should include missing values, duplicate keys, impossible dates, negative prices, extreme distances, unexpected categories, and fields that changed meaning over time.

Categorical sparsity is another common issue. Some levels may have too few observations to support a model or a business conclusion.

promotion_counts <- season_data |>
  count(promotion, sort = TRUE)

knitr::kable(
  promotion_counts,
  caption = "Promotion counts",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.9: Promotion counts
promotion n
none 212
bobblehead 16
concert 8
other 7

If a promotion appears only a few times, be careful about claiming it caused a sales difference. The comparison may be dominated by schedule placement, opponent, or a small sample.

3.9 Simple Statistical Checks

Statistical tests can be useful, but they should come after exploration. A test answers a narrow question under assumptions. A graph helps you see whether the question is sensible.

As a simple example, we can compare ticket sales by promotion and day of week using an analysis of variance.

promotion_model <- aov(
  ticketSales ~ promotion + dayOfWeek,
  data = season_data
)

anova_table <- as.data.frame(anova(promotion_model))
anova_table$term <- row.names(anova_table)
anova_table <- anova_table |>
  dplyr::select(term, everything())

knitr::kable(
  anova_table,
  caption = "ANOVA results for ticket sales",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.10: ANOVA results for ticket sales
term Df Sum Sq Mean Sq F value Pr(>F)
promotion promotion 3 865001255 288333752 12.26602 2e-07
dayOfWeek dayOfWeek 6 4130134954 688355826 29.28337 0e+00
Residuals Residuals 233 5477064783 23506716 NA NA

Do not stop at the p-value. Look at group means and sample sizes.

promotion_summary <- season_data |>
  group_by(promotion) |>
  summarise(
    games = n(),
    mean_sales = mean(ticketSales),
    sd_sales = sd(ticketSales),
    se_sales = sd_sales / sqrt(games),
    .groups = "drop"
  ) |>
  arrange(desc(mean_sales))

ggplot(promotion_summary, aes(x = reorder(promotion, mean_sales), y = mean_sales)) +
  geom_col(fill = plot_palette[1], alpha = 0.85) +
  geom_errorbar(aes(ymin = mean_sales - se_sales, ymax = mean_sales + se_sales), width = 0.2) +
  coord_flip() +
  scale_y_continuous(labels = scales::comma) +
  labs(
    x = "Promotion",
    y = "Mean ticket sales",
    title = "Promotion comparisons need uncertainty and sample context"
  ) +
  book_theme
Mean sales and standard error by promotion

Figure 3.8: Mean sales and standard error by promotion

Pairwise comparisons can be useful, but they are still descriptive unless the research design supports a causal claim.

tukey_promotion <- TukeyHSD(promotion_model, "promotion")$promotion

tukey_table <- as.data.frame(tukey_promotion)
tukey_table$comparison <- row.names(tukey_table)
tukey_table <- tukey_table |>
  dplyr::select(comparison, everything())

knitr::kable(
  tukey_table,
  caption = "Tukey comparisons by promotion",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.11: Tukey comparisons by promotion
comparison diff lwr upr p adj
concert-bobblehead concert-bobblehead 4465.5000 -967.0665 9898.067 0.1475821
none-bobblehead none-bobblehead -4442.5448 -7695.2441 -1189.845 0.0027589
other-bobblehead other-bobblehead -3646.7321 -9332.1099 2038.646 0.3474454
none-concert none-concert -8908.0448 -13426.6337 -4389.456 0.0000042
other-concert other-concert -8112.2321 -14605.3911 -1619.073 0.0076108
other-none other-none 795.8127 -4023.7710 5615.396 0.9737610

An interval that crosses zero is not statistically distinguishable at the chosen confidence level. Even when a difference is statistically meaningful, the business question remains: Is the lift large enough to justify the cost and operational effort?

3.10 A First Regression

Regression is introduced more carefully in later chapters. Here, we use it as an exploratory tool to see how well basic schedule fields explain ticket sales.

sales_model <- lm(
  ticketSales ~ team + dayOfWeek + month + daysSinceLastGame + openingDay + promotion,
  data = season_data
)

model_summary <- summary(sales_model)
model_metrics <- tibble::tibble(
  residual_standard_error = unname(model_summary$sigma),
  r_squared = unname(model_summary$r.squared),
  adjusted_r_squared = unname(model_summary$adj.r.squared),
  f_statistic = unname(model_summary$fstatistic[1])
)

knitr::kable(
  model_metrics,
  caption = "First regression summary",
  align = "r",
  format = "markdown",
  padding = 0
)
Table 3.12: First regression summary
residual_standard_error r_squared adjusted_r_squared f_statistic
1980.077 0.925496 0.9093972 57.48835

A high R-squared does not make a model good. This model uses many categorical variables on a small table, so it can easily fit the historical data too closely. For exploration, that is acceptable as long as we do not pretend it is a validated forecast.

model_results <- season_data |>
  mutate(predicted_sales = predict(sales_model, newdata = season_data))

ggplot(model_results, aes(x = ticketSales, y = predicted_sales, color = factor(season))) +
  geom_point(alpha = 0.75) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey25") +
  scale_color_manual("Season", values = plot_palette) +
  scale_x_continuous(labels = scales::comma) +
  scale_y_continuous(labels = scales::comma) +
  labs(
    x = "Actual ticket sales",
    y = "Predicted ticket sales",
    title = "Predicted versus actual values reveal model fit"
  ) +
  book_theme
Actual versus predicted ticket sales

Figure 3.9: Actual versus predicted ticket sales

The dashed line shows perfect prediction. Points far from the line are games the model does not explain well. Those games are often more interesting than the average fitted value because they can reveal missing variables or unusual business conditions.

3.11 Communicating Exploration

Exploration should end with a short list of what you learned and what you still do not know. For the season data, a reasonable first read is:

  • Sales vary meaningfully across games and seasons.
  • Day of week, month, opponent, and promotion are plausible demand drivers.
  • Promotion comparisons are not automatically causal because the schedule is not a randomized experiment.
  • Distribution plots and group summaries are more informative than a single average.
  • Simple models can help structure thinking, but they need validation before guiding decisions.

That last point matters. Exploratory analysis is not the same as proof. It is the work that helps you ask sharper questions.

3.12 Key Concepts and Chapter Summary

Exploring data is a fundamental part of sports business analytics. You are looking for patterns, data quality problems, useful comparisons, and questions that deserve deeper analysis.

This chapter covered the core tools used throughout the book:

  • Histograms and density plots for distributions
  • Box plots and facets for group comparisons
  • Line charts for schedule sequence
  • Tile plots for two-dimensional summaries
  • Scatter plots for relationships
  • Grouped summaries, quantiles, and reshaping
  • Basic missingness and sparsity checks
  • Introductory ANOVA and regression as exploratory tools

The most important habit is to slow down before modeling. Understand the table, inspect the fields, make simple graphs, and check whether the result makes business sense. The later chapters build more specific methods on top of that workflow.