DATA 607 Project 2: U.S. Average Tuition — Data Tidying and Analysis

Dataset 2 — Tuition Table 5

Author

Chanice McKenzie

Overview

This report preserves the original wide-format U.S. average tuition Table 5 file, documents its structure, converts repeated measurement columns into tidy rows, and summarizes tuition values and differences across available groups and periods. The analysis is descriptive, not causal. The report displays the original schema and documents how measurement columns are identified.

1. Data source and raw data construction (Step 3.1)

Immediate data source: U.S. average tuition (Table 5), CSV, hosted in the DATA 607 Project 2 repository. The hosting repository is the immediate source; the original statistical publisher, measurement units, reference years, and table notes must be verified against the CSV’s source documentation before any substantive claims are made.

Raw-data preservation: The following import reads the local file preferentially, falling back to the repository-hosted file only if needed. No transformation is applied to the saved raw CSV.

filename <- "us_avg_tuition(Table 5).csv"
local_paths <- c(file.path("data", filename), filename)
found <- local_paths[file.exists(local_paths)]
source_url <- paste0(
  "https://chanicemcken.github.io/Data-607-Project-2/",
  "us_avg_tuition%28Table%205%29.csv"
)
source_path <- if (length(found)) found[[1]] else source_url

# Preserve original column headings; do not overwrite the source file.
tuition_raw <- read_csv(source_path, name_repair = "minimal",
                        show_col_types = FALSE, col_types = cols(.default = col_character()))
stopifnot(nrow(tuition_raw) > 0, ncol(tuition_raw) > 1)

2. Structure before tidying

The original wide dataset has 50 rows and 13 columns. Its original column headings and first six rows appear below.

kable(data.frame(position = seq_along(names(tuition_raw)),
                 original_column_name = names(tuition_raw)),
      caption = "Original column names in the raw CSV")
Original column names in the raw CSV
position original_column_name
1 State
2 2004-05
3 2005-06
4 2006-07
5 2007-08
6 2008-09
7 2009-10
8 2010-11
9 2011-12
10 2012-13
11 2013-14
12 2014-15
13 2015-16
kable(head(tuition_raw, 6), caption = "Representative sample of the original wide data")
Representative sample of the original wide data
State 2004-05 2005-06 2006-07 2007-08 2008-09 2009-10 2010-11 2011-12 2012-13 2013-14 2014-15 2015-16
Alabama $5,683 $5,841 $5,753 $6,008 $6,475 $7,189 $8,071 $8,452 $9,098 $9,359 $9,496 $9,751
Alaska $4,328 $4,633 $4,919 $5,070 $5,075 $5,455 $5,759 $5,762 $6,026 $6,012 $6,149 $6,571
Arizona $5,138 $5,416 $5,481 $5,682 $6,058 $7,263 $8,840 $9,967 $10,134 $10,296 $10,414 $10,646
Arkansas $5,772 $6,082 $6,232 $6,415 $6,417 $6,627 $6,901 $7,029 $7,287 $7,408 $7,606 $7,867
California $5,286 $5,528 $5,335 $5,672 $5,898 $7,259 $8,194 $9,436 $9,361 $9,274 $9,187 $9,270
Colorado $4,704 $5,407 $5,596 $6,227 $6,284 $6,948 $7,748 $8,316 $8,793 $9,293 $9,299 $9,748

3. Reproducible import and tidying (Step 3.2)

3.1 Normalize headings and identify the wide columns

Column names are converted to lowercase snake_case, while unique suffixes prevent accidental duplicate names. Numeric-like columns are identified by their nonmissing values; this prevents an identifying text column from being pivoted as a measurement. Columns with year or academic-year headings are preferentially selected as repeated measures. If no year columns exist, numeric-like columns are selected instead. This is a documented, generic fallback; review the printed selections against the original Table 5 headings before submitting.

clean_names <- function(x) {
  x <- str_to_lower(str_trim(x))
  x <- str_replace_all(x, "[^a-z0-9]+", "_")
  x <- str_replace_all(x, "^_+|_+$", "")
  x[x == ""] <- "unnamed"
  make.unique(x, sep = "_")
}

tuition_named <- tuition_raw |>
  rename_with(clean_names) |>
  mutate(across(everything(), ~ na_if(str_trim(as.character(.x)), "")))

numeric_share <- vapply(tuition_named, function(x) {
  nonmissing <- x[!is.na(x) & !str_to_lower(x) %in% c("na", "n/a", "--", "-", "suppressed")]
  if (!length(nonmissing)) return(0)
  mean(!is.na(parse_number(nonmissing)))
}, numeric(1))

# Detect repeated year headings (e.g., 2021, 2021_22, 2021_2022).
year_columns <- names(tuition_named)[str_detect(
  names(tuition_named), "(^|_)(19|20)[0-9]{2}($|_)"
)]

# Exclude obvious identifiers from the fallback numeric-measure selection.
identifier_names <- str_detect(names(tuition_named),
  "(^|_)(id|code|fips|rank|number|no)($|_)")
numeric_columns <- names(tuition_named)[numeric_share >= 0.8 & !identifier_names]
measure_columns <- if (length(year_columns) >= 2) {
  year_columns
} else {
  numeric_columns
}

if (length(measure_columns) < 2) {
  stop("Fewer than two wide measurement columns detected. Inspect the raw table and explicitly set measure_columns to the appropriate original columns.")
}
identifier_columns <- setdiff(names(tuition_named), measure_columns)

kable(data.frame(measure_column = measure_columns),
      caption = "Wide columns selected for pivot_longer()")
Wide columns selected for pivot_longer()
measure_column
2004_05
2005_06
2006_07
2007_08
2008_09
2009_10
2010_11
2011_12
2012_13
2013_14
2014_15
2015_16
kable(data.frame(identifier_column = identifier_columns),
      caption = "Columns retained as identifiers or group attributes")
Columns retained as identifiers or group attributes
identifier_column
state

3.2 Pivot, normalize values, and document missing data

Each selected wide measurement column becomes a value of measure, and its cell contents become tuition_value. Currency symbols and thousands separators are parsed consistently. Missing, blank, or nonnumeric values are retained as NA, not silently replaced with zero. No rows are dropped automatically, so missingness remains auditable. The identifier columns are preserved unchanged except for whitespace normalization.

# Wide -> long; every measure is an observation rather than a separate column.
tuition_tidy <- tuition_named |>
  mutate(source_row = row_number()) |>
  pivot_longer(
    cols = all_of(measure_columns),
    names_to = "measure",
    values_to = "raw_value",
    values_drop_na = FALSE
  ) |>
  mutate(
    raw_value = na_if(str_trim(raw_value), ""),
    tuition_value = parse_number(raw_value,
      na = c("", "NA", "N/A", "-", "--", "suppressed"))
  ) |>
  relocate(source_row, all_of(identifier_columns), measure,
           raw_value, tuition_value)

# Validation: pivot_longer should preserve every original cell.
stopifnot(nrow(tuition_tidy) == nrow(tuition_named) * length(measure_columns))
stopifnot(!anyDuplicated(tuition_tidy[c("source_row", "measure")]))

missing_summary <- tuition_tidy |>
  group_by(measure) |>
  summarise(
    observations = n(),
    missing_values = sum(is.na(tuition_value)),
    missing_pct = 100 * mean(is.na(tuition_value)),
    .groups = "drop"
  )
kable(missing_summary, digits = 1,
      caption = "Missing and unparseable numeric values by measure")
Missing and unparseable numeric values by measure
measure observations missing_values missing_pct
2004_05 50 0 0
2005_06 50 0 0
2006_07 50 0 0
2007_08 50 0 0
2008_09 50 0 0
2009_10 50 0 0
2010_11 50 0 0
2011_12 50 0 0
2012_13 50 0 0
2013_14 50 0 0
2014_15 50 0 0
2015_16 50 0 0
kable(head(tuition_tidy, 10), caption = "Representative sample of tidy data")
Representative sample of tidy data
source_row state measure raw_value tuition_value
1 Alabama 2004_05 $5,683 5683
1 Alabama 2005_06 $5,841 5841
1 Alabama 2006_07 $5,753 5753
1 Alabama 2007_08 $6,008 6008
1 Alabama 2008_09 $6,475 6475
1 Alabama 2009_10 $7,189 7189
1 Alabama 2010_11 $8,071 8071
1 Alabama 2011_12 $8,452 8452
1 Alabama 2012_13 $9,098 9098
1 Alabama 2013_14 $9,359 9359

3.3 Data quality and tidy export

A numerical value of zero is retained when explicitly present, but blanks and suppressed entries remain missing. No imputation is performed because missing tuition figures cannot be safely inferred from other groups. All source rows are retained, including those with missing measurements. The original wide file is never modified.

quality_checks <- tibble(
  check = c("Raw source rows", "Repeated measure columns",
            "Expected tidy rows", "Actual tidy rows", "Missing numeric values"))
  value = c(nrow(tuition_raw), length(measure_columns),
            nrow(tuition_raw) * length(measure_columns),
            nrow(tuition_tidy), sum(is.na(tuition_tidy$tuition_value)))
kable(quality_checks, caption = "Data validation checks")
Data validation checks
check
Raw source rows
Repeated measure columns
Expected tidy rows
Actual tidy rows
Missing numeric values
# Save the tidy dataset without changing the original source.
dir.create("data", showWarnings = FALSE)
write_csv(tuition_tidy, "data/us_avg_tuition_tidy.csv", na = "")

4. Analysis of the tidy data (Step 3.3)

Analytical methods

For each measurement category, this analysis calculates the number of nonmissing observations, mean, median, minimum, and maximum. It also visualizes the distribution of values across categories. These comparisons use only tuition_tidy, not the wide input. Units are reported as source-table units because the original table’s unit definition (e.g., current dollars, constant dollars, annual tuition and fees) has not been independently confirmed.

measure_summary <- tuition_tidy |>
  group_by(measure) |>
  summarise(
    n_nonmissing = sum(!is.na(tuition_value)),
    mean_source_units = if (all(is.na(tuition_value))) NA_real_ else mean(tuition_value, na.rm = TRUE),
    median_source_units = if (all(is.na(tuition_value))) NA_real_ else median(tuition_value, na.rm = TRUE),
    min_source_units = if (all(is.na(tuition_value))) NA_real_ else min(tuition_value, na.rm = TRUE),
    max_source_units = if (all(is.na(tuition_value))) NA_real_ else max(tuition_value, na.rm = TRUE),
    .groups = "drop"
  )
kable(measure_summary, digits = 2,
      caption = "Descriptive statistics by measurement column (source-table units)")
Descriptive statistics by measurement column (source-table units)
measure n_nonmissing mean_source_units median_source_units min_source_units max_source_units
2004_05 50 6409.60 5875.5 3621 11067
2005_06 50 6654.16 6169.5 3687 11295
2006_07 50 6809.94 6348.0 3888 11473
2007_08 50 7085.84 6616.0 3879 12013
2008_09 50 7156.56 6573.5 3929 12305
2009_10 50 7761.84 7309.5 4129 13316
2010_11 50 8228.80 8104.5 4299 13655
2011_12 50 8539.12 8416.5 4357 14263
2012_13 50 8842.38 8675.0 4456 15224
2013_14 50 8947.84 8745.5 4499 14969
2014_15 50 9037.38 8896.5 4654 14756
2015_16 50 9317.58 9141.5 4891 15160

Figure 1. Average reported values by measure

A column chart makes it straightforward to compare the mean across the wide-table measurement categories. The mean is descriptive and may conceal within-category variation.

ggplot(measure_summary, aes(x = reorder(measure, mean_source_units),
                            y = mean_source_units)) +
  geom_col(fill = "#336699", na.rm = TRUE) +
  coord_flip() +
  scale_y_continuous(labels = label_comma()) +
  labs(title = "Average U.S. tuition-table values by measure",
       x = "Measurement category", y = "Mean (source-table units)")
Figure 1: Mean reported value by measurement category

Figure 2. Distribution of values

A boxplot shows medians, interquartile ranges, and potential outliers, revealing variation that averages alone may obscure.

ggplot(tuition_tidy, aes(x = measure, y = tuition_value)) +
  geom_boxplot(fill = "#80b1d3", na.rm = TRUE) +
  coord_flip() +
  scale_y_continuous(labels = label_comma()) +
  labs(title = "Distribution of tuition-table values",
       x = "Measurement category", y = "Value (source-table units)")
Figure 2: Distribution of reported values across measurement categories

Figure 3. Missingness by measure

This chart documents where observations are unavailable. Missing values are excluded from numerical summaries but counted in this figure.

ggplot(missing_summary, aes(x = reorder(measure, missing_pct), y = missing_pct)) +
  geom_col(fill = "#7a7a7a") +
  coord_flip() +
  scale_y_continuous(limits = c(0, 100), labels = label_percent(scale = 1)) +
  labs(title = "Missing values by measurement category",
       x = "Measurement category", y = "Missing observations (%)")
Figure 3: Percentage of missing or unparseable entries by measure

5. Conclusions and recommendations

Findings based on the tidy data

available <- measure_summary |>
  filter(!is.na(mean_source_units)) |>
  arrange(desc(mean_source_units))

if (nrow(available) >= 2) {
  highest <- available[1, ]
  lowest <- available[nrow(available), ]
  difference <- highest$mean_source_units - lowest$mean_source_units
  cat(paste0(
    "The highest numerical category mean was **", highest$measure,
    "** (", comma(round(highest$mean_source_units, 2)),
    " source-table units). The lowest was **", lowest$measure,
    "** (", comma(round(lowest$mean_source_units, 2)),
    " source-table units), a numerical difference of **",
    comma(round(difference, 2)), "** units. This difference should be",
    " interpreted only if both categories represent comparable tuition",
    " measures on the same scale.\n\n"
  ))
} else if (nrow(available) == 1) {
  cat(paste0("The only category with usable numerical data was **",
             available$measure[1], "**, with a mean of **",
             comma(round(available$mean_source_units[1], 2)),
             "** source-table units.\n\n"))
} else {
  cat("No numerical values were available for a quantitative comparison.\n\n")
}

The highest numerical category mean was 2015_16 (9,318 source-table units). The lowest was 2004_05 (6,410 source-table units), a numerical difference of 2,908 units. This difference should be interpreted only if both categories represent comparable tuition measures on the same scale.

missing_n <- sum(is.na(tuition_tidy$tuition_value))
missing_pct_all <- 100 * mean(is.na(tuition_tidy$tuition_value))
cat(paste0(
  "The tidy dataset contains **", comma(nrow(tuition_tidy)),
  "** long-format records. Of these, **", comma(missing_n),
  "** (", round(missing_pct_all, 1),
  "%) are missing or unparseable. Missing entries were retained",
  " as NA rather than replaced with zero.\n\n"
))

The tidy dataset contains 600 long-format records. Of these, 0 (0%) are missing or unparseable. Missing entries were retained as NA rather than replaced with zero.

The analysis is descriptive rather than causal. A larger mean does not by itself show that a type of institution is less affordable, because tuition may be defined differently across categories or years. The figures show numerical patterns in the source table; the underlying units, population, time period, and any weighting must be checked before making substantive comparisons. Boxplots are meaningful only where each measurement category has multiple comparable observations.

Recommendations

  1. Verify definitions and units before reporting tuition differences. Consult the original Table 5 notes to determine whether values are annual tuition, tuition and fees, current dollars, constant dollars, or another measure. Only compare values measured on a consistent basis.
  2. Investigate missing or suppressed entries. Use the missingness table to identify affected categories. Keep missing values as NA unless a documented and defensible imputation strategy is available.
  3. Prioritize like-for-like comparisons. If the dataset includes institution sector, degree level, geography, or year, compare within the same grouping and use percent changes for time comparisons when appropriate. Do not combine fundamentally different measures into one average.
  4. Extend the analysis with context. Where available, compare tuition with household income, financial aid, or inflation-adjusted costs to evaluate affordability rather than nominal tuition alone.
  5. Preserve reproducibility. Keep the original CSV unchanged, commit it separately to GitHub, and render the Quarto report from the repository root before submission.

These are analytical recommendations, not evidence that any particular policy or institution caused the observed differences.

6. Reproducibility and submission notes

This report uses only local, repository-relative file paths when the raw CSV is committed to data/. The file data/us_avg_tuition_tidy.csv is generated automatically. The seed is fixed at 123 although the analyses do not use randomness. Commit the untouched raw CSV first, followed by this .qmd and the resulting output. The original source table and its notes remain accessible in the repository.