Introduction

Problem Statement

The California State University (CSU) system enrolls over 460,000 students across 23 campuses, making it the largest four-year public university system in the United States. Despite its scale, individual campuses compete for students, funding, and public attention in an increasingly digital landscape. Marketing and admissions offices must understand not only how their campus compares on traditional metrics like enrollment and demographics, but also how they are perceived online — in search engines, on social media, and in public discourse.

This project addresses a core marketing analytics question: Do a campus’s digital presence and public sentiment predict its enrollment performance, and where do gaps exist that admissions teams can close?

Research Objectives

  1. Benchmark all 23 CSU campuses on enrollment size, demographic composition, and geographic distribution.
  2. Evaluate each campus’s web presence through SEO signals (title tags, meta descriptions, heading structure).
  3. Measure public sentiment toward each campus using Reddit discussions and YouTube comments.
  4. Analyze Google Search interest trends to identify rising and declining campus awareness.
  5. Build predictive models linking digital presence and sentiment to enrollment outcomes.
  6. Segment campuses into strategic archetypes to support differentiated marketing recommendations.

Business Relevance

Enrollment directly drives state funding allocations, staffing levels, and program viability. A campus that ranks poorly on SEO or carries negative online sentiment faces a structural disadvantage in student recruitment — one that data-driven marketing can address. This analysis provides CSU marketing and admissions leadership with an evidence base for prioritizing digital investments and messaging improvements.

Data Sources

Data was collected from four independent sources using web scraping, public APIs, and JSON data retrieval:

Source Method Coverage
CSU homepage SEO rvest web scraper All 23 campuses
Reddit discussions Python scraper → JSON 25,836 posts/comments, all 23 campuses
YouTube comments tuber API → CSV Selected campus channels
Google Trends gtrendsR API All 23 campuses, 5-year window
Enrollment & demographics calstate.edu (manual) Fall 2023, all 23 campuses
Systemwide enrollment trends CSU Enrollment Dashboard Fall 2015–2025, systemwide headcount
System KPIs CSU Enrollment Dashboard Fall 2024 & 2025 (CA residents, undergrad, grad %)

Business Questions

This report collects and analyzes data across all 23 California State University campuses to answer the following business questions:

# Business Question Data Source
1 How is our class’s sentiment toward CSUCI? Class survey (separate)
2 What is enrollment across the CSU system? calstate.edu (scraped)
3 What are the demographics of each CSU? calstate.edu (scraped)
4 How are Cal State schools talked about in public? Reddit API
5 What is the sentiment — Positive, Negative, or Neutral? Reddit + AFINN
6 What are the differences in tone among Cal States? Reddit sentiment comparison
7 What are the title tags of the top 5 enrolled schools? SEO scraper + enrollment
8 What are the title tags of the 5 lowest-enrolled schools? SEO scraper + enrollment

Note: Business Question 1 (class sentiment toward CSUCI) is answered via a Google Form survey distributed to the CSUCI MSBA cohort (n = 12). Results are presented in the Business Questions Answered section below.


Campus Reference Table

csu_campuses <- tibble(
  campus = c(
    "Cal Poly Humboldt",
    "Cal Poly Pomona",
    "Cal Poly San Luis Obispo",
    "CSU Bakersfield",
    "CSU Channel Islands",
    "CSU Chico",
    "CSU Dominguez Hills",
    "CSU East Bay",
    "CSU Fresno",
    "CSU Fullerton",
    "CSU Long Beach",
    "CSU Los Angeles",
    "CSU Maritime Academy",
    "CSU Monterey Bay",
    "CSU Northridge",
    "CSU Sacramento",
    "CSU San Bernardino",
    "CSU San Marcos",
    "CSU Stanislaus",
    "San Diego State",
    "San Francisco State",
    "San Jose State",
    "Sonoma State"
  ),
  url = c(
    "https://www.humboldt.edu",
    "https://www.cpp.edu",
    "https://www.calpoly.edu",
    "https://www.csub.edu",
    "https://www.csuci.edu",
    "https://www.csuchico.edu",
    "https://www.csudh.edu",
    "https://www.csueastbay.edu",
    "https://www.fresnostate.edu",
    "https://www.fullerton.edu",
    "https://www.csulb.edu",
    "https://www.calstatela.edu",
    "https://www.csum.edu",
    "https://www.csumb.edu",
    "https://www.csun.edu",
    "https://www.csus.edu",
    "https://www.csusb.edu",
    "https://www.csusm.edu",
    "https://www.csustan.edu",
    "https://www.sdsu.edu",
    "https://www.sfsu.edu",
    "https://www.sjsu.edu",
    "https://www.sonoma.edu"
  ),
  search_term = c(
    "Cal Poly Humboldt",
    "Cal Poly Pomona CPP",
    "Cal Poly SLO",
    "CSU Bakersfield CSUB",
    "CSUCI \"Channel Islands\"",
    "CSU Chico",
    "CSU Dominguez Hills CSUDH",
    "CSU East Bay CSUEB",
    "Fresno State",
    "Cal State Fullerton CSUF",
    "Cal State Long Beach CSULB",
    "Cal State LA CSULA",
    "Cal Maritime CSUM",
    "CSU Monterey Bay CSUMB",
    "CSUN Northridge",
    "Sacramento State CSUS",
    "Cal State San Bernardino CSUSB",
    "Cal State San Marcos CSUSM",
    "CSU Stanislaus",
    "SDSU San Diego State",
    "SFSU San Francisco State",
    "SJSU San Jose State",
    "Sonoma State SSU"
  ),
  year_established = c(
    1913,  # Cal Poly Humboldt
    1938,  # Cal Poly Pomona
    1901,  # Cal Poly San Luis Obispo
    1965,  # CSU Bakersfield
    2002,  # CSU Channel Islands (newest CSU)
    1887,  # CSU Chico (oldest CSU)
    1960,  # CSU Dominguez Hills
    1957,  # CSU East Bay
    1911,  # CSU Fresno
    1957,  # CSU Fullerton
    1949,  # CSU Long Beach
    1947,  # CSU Los Angeles
    1929,  # CSU Maritime Academy
    1994,  # CSU Monterey Bay
    1958,  # CSU Northridge
    1947,  # CSU Sacramento
    1960,  # CSU San Bernardino
    1989,  # CSU San Marcos
    1957,  # CSU Stanislaus
    1897,  # San Diego State
    1899,  # San Francisco State
    1857,  # San Jose State (oldest public university on West Coast)
    1960   # Sonoma State
  )
) |>
  mutate(campus_age = 2025L - year_established)

Part 1: Enrollment & Demographics

Per-campus enrollment figures are sourced from the official CSU system facts page (calstate.edu) and entered directly — the site blocks automated scraping. Systemwide headcount trends (Fall 2015–2025) are loaded from the CSU Enrollment Dashboard (tableau.calstate.edu). Verify or update numbers at: https://www.calstate.edu/csu-system/about-the-csu/facts-about-the-csu/enrollment

(Answers BQ 2 & 3.)

# Source: CSU Facts About the CSU — Fall 2023 headcount enrollment
# Update figures from calstate.edu if a newer year is available
enrollment_clean <- tibble(
  campus = c(
    "CSU Long Beach",
    "CSU Fullerton",
    "CSU Northridge",
    "San Diego State",
    "San Jose State",
    "CSU Sacramento",
    "Cal Poly San Luis Obispo",
    "Cal Poly Pomona",
    "CSU Los Angeles",
    "San Francisco State",
    "CSU Fresno",
    "CSU San Bernardino",
    "CSU Chico",
    "CSU San Marcos",
    "CSU Dominguez Hills",
    "CSU East Bay",
    "CSU Stanislaus",
    "CSU Bakersfield",
    "CSU Monterey Bay",
    "CSU Channel Islands",
    "Sonoma State",
    "Cal Poly Humboldt",
    "CSU Maritime Academy"
  ),
  total_enrollment = c(
    45064, 40421, 37701, 37014, 35915,
    31536, 22108, 27782, 27311, 25758,
    25215, 20622, 16549, 16862, 16484,
    14710, 11103, 11201,  8196,  7104,
     6912,  6699,   912
  )
) |>
  arrange(desc(total_enrollment)) |>
  mutate(
    rank             = row_number(),
    enrollment_tier  = case_when(
      total_enrollment >= 30000 ~ "Large (30k+)",
      total_enrollment >= 15000 ~ "Medium (15k–30k)",
      TRUE                      ~ "Small (<15k)"
    )
  )

write.csv(enrollment_clean, "csu_enrollment.csv", row.names = FALSE)

Campus Master Reference (BQ 2)

enrollment_clean |>
  mutate(pct_of_system = round(total_enrollment / sum(total_enrollment) * 100, 1)) |>
  left_join(csu_campuses |> select(campus, year_established, campus_age, url),
            by = "campus") |>
  select(rank, campus, year_established, campus_age, total_enrollment, pct_of_system, enrollment_tier, url) |>
  mutate(total_enrollment = formatC(total_enrollment, format = "d", big.mark = ","),
         pct_of_system    = paste0(pct_of_system, "%")) |>
  kable(
    caption   = "CSU Campus Master Reference — Fall 2023 Enrollment",
    col.names = c("Rank", "Campus", "Year Est.", "Age", "Enrollment", "% of System", "Tier", "URL"),
    escape    = FALSE
  )
CSU Campus Master Reference — Fall 2023 Enrollment
Rank Campus Year Est. Age Enrollment % of System Tier URL
1 CSU Long Beach 1949 76 45,064 9.1% Large (30k+) https://www.csulb.edu
2 CSU Fullerton 1957 68 40,421 8.2% Large (30k+) https://www.fullerton.edu
3 CSU Northridge 1958 67 37,701 7.6% Large (30k+) https://www.csun.edu
4 San Diego State 1897 128 37,014 7.5% Large (30k+) https://www.sdsu.edu
5 San Jose State 1857 168 35,915 7.3% Large (30k+) https://www.sjsu.edu
6 CSU Sacramento 1947 78 31,536 6.4% Large (30k+) https://www.csus.edu
7 Cal Poly Pomona 1938 87 27,782 5.6% Medium (15k–30k) https://www.cpp.edu
8 CSU Los Angeles 1947 78 27,311 5.5% Medium (15k–30k) https://www.calstatela.edu
9 San Francisco State 1899 126 25,758 5.2% Medium (15k–30k) https://www.sfsu.edu
10 CSU Fresno 1911 114 25,215 5.1% Medium (15k–30k) https://www.fresnostate.edu
11 Cal Poly San Luis Obispo 1901 124 22,108 4.5% Medium (15k–30k) https://www.calpoly.edu
12 CSU San Bernardino 1960 65 20,622 4.2% Medium (15k–30k) https://www.csusb.edu
13 CSU San Marcos 1989 36 16,862 3.4% Medium (15k–30k) https://www.csusm.edu
14 CSU Chico 1887 138 16,549 3.4% Medium (15k–30k) https://www.csuchico.edu
15 CSU Dominguez Hills 1960 65 16,484 3.3% Medium (15k–30k) https://www.csudh.edu
16 CSU East Bay 1957 68 14,710 3% Small (<15k) https://www.csueastbay.edu
17 CSU Bakersfield 1965 60 11,201 2.3% Small (<15k) https://www.csub.edu
18 CSU Stanislaus 1957 68 11,103 2.3% Small (<15k) https://www.csustan.edu
19 CSU Monterey Bay 1994 31 8,196 1.7% Small (<15k) https://www.csumb.edu
20 CSU Channel Islands 2002 23 7,104 1.4% Small (<15k) https://www.csuci.edu
21 Sonoma State 1960 65 6,912 1.4% Small (<15k) https://www.sonoma.edu
22 Cal Poly Humboldt 1913 112 6,699 1.4% Small (<15k) https://www.humboldt.edu
23 CSU Maritime Academy 1929 96 912 0.2% Small (<15k) https://www.csum.edu

Share of CSU System Enrollment (BQ 2)

enroll_pct <- enrollment_clean |>
  mutate(pct_of_system = round(total_enrollment / sum(total_enrollment) * 100, 2))

p_enroll <- ggplot(enroll_pct, aes(x = pct_of_system, y = reorder(campus, pct_of_system),
                                    fill = pct_of_system)) +
  geom_col() +
  geom_text(aes(label = paste0(pct_of_system, "%")), hjust = -0.1, size = 3) +
  scale_fill_gradient(low = "#ffcccc", high = "#CC0000", name = "% of System") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.15)), labels = function(x) paste0(x, "%")) +
  labs(title = "Each Campus as % of Total CSU System Enrollment — Fall 2023",
       x = "% of CSU System Total", y = NULL) +
  theme_minimal() +
  theme(legend.position = "none")

p_enroll

ggsave("output_enrollment_pct.png", plot = p_enroll, width = 10, height = 8, dpi = 150)

plot_ly(enroll_pct,
        x         = ~pct_of_system,
        y         = ~reorder(campus, pct_of_system),
        type      = "bar",
        orientation = "h",
        marker    = list(
          color     = ~pct_of_system,
          colorscale = list(c(0, "#ffcccc"), c(1, "#CC0000")),
          showscale = FALSE
        ),
        hovertemplate = paste("<b>%{y}</b><br>",
                              "Share: %{x}%<extra></extra>")) |>
  layout(
    title  = list(text = "Each Campus as % of Total CSU System Enrollment — Fall 2023",
                  font = list(color = "#CC0000", size = 14)),
    xaxis  = list(title = "% of CSU System Total", ticksuffix = "%"),
    yaxis  = list(title = ""),
    margin = list(l = 160)
  )

Campus Locations Map (BQ 2)

Geographic distribution of all 23 CSU campuses. Marker color reflects enrollment tier (small/medium/large); all markers are the same size.

campus_coords <- tibble(
  campus = c(
    "CSU Long Beach", "CSU Fullerton", "CSU Northridge", "San Diego State",
    "San Jose State", "CSU Sacramento", "Cal Poly San Luis Obispo", "Cal Poly Pomona",
    "CSU Los Angeles", "San Francisco State", "CSU Fresno", "CSU San Bernardino",
    "CSU Chico", "CSU San Marcos", "CSU Dominguez Hills", "CSU East Bay",
    "CSU Stanislaus", "CSU Bakersfield", "CSU Monterey Bay", "CSU Channel Islands",
    "Sonoma State", "Cal Poly Humboldt", "CSU Maritime Academy"
  ),
  lat = c(
    33.7838, 33.8828, 34.2410, 32.7757,
    37.3352, 38.5610, 35.3050, 34.0577,
    34.0669, 37.7241, 36.8125, 34.1832,
    39.7285, 33.1289, 33.8649, 37.6580,
    37.5241, 35.3494, 36.6524, 34.1614,
    38.3399, 40.8749, 38.0713
  ),
  lon = c(
    -118.1141, -117.8853, -118.5289, -117.0719,
    -121.8811, -121.4238, -120.6625, -117.8216,
    -118.1650, -122.4763, -119.7462, -117.3230,
    -121.8375, -117.1584, -118.2551, -122.0587,
    -120.8497, -119.1050, -121.7947, -119.0433,
    -122.6706, -124.0787, -122.2283
  )
)

campus_map_data <- enrollment_clean |>
  left_join(campus_coords, by = "campus") |>
  mutate(
    enrollment_tier = factor(enrollment_tier,
                              levels = c("Small (<15k)", "Medium (15k–30k)", "Large (30k+)")),
    label = paste0(campus, "<br>",
                    formatC(total_enrollment, format = "d", big.mark = ","),
                    " students (", enrollment_tier, ")")
  )

tier_colors <- c(
  "Small (<15k)"     = "#FFCC00",
  "Medium (15k–30k)" = "#FF6600",
  "Large (30k+)"     = "#CC0000"
)

dot_size <- 10

# Choropleth trace draws just the CA state outline (black border); geo$visible = FALSE
# below hides the default US/world basemap so no other states or countries appear.
campus_map <- plot_ly() |>
  add_trace(
    type         = "choropleth",
    locationmode = "USA-states",
    locations    = "CA",
    z            = 1,
    colorscale   = list(c(0, "#f5f5f0"), c(1, "#f5f5f0")),
    showscale    = FALSE,
    marker       = list(line = list(color = "black", width = 2)),
    hoverinfo    = "skip",
    showlegend   = FALSE
  )

for (tier in names(tier_colors)) {
  tier_data <- campus_map_data |> filter(enrollment_tier == tier)
  campus_map <- campus_map |> add_trace(
    type      = "scattergeo",
    mode      = "markers",
    lat       = tier_data$lat,
    lon       = tier_data$lon,
    text      = tier_data$label,
    hoverinfo = "text",
    name      = tier,
    marker    = list(
      size    = dot_size,
      color   = tier_colors[[tier]],
      line    = list(width = 1, color = "#333333"),
      opacity = 0.85
    )
  )
}

campus_map |>
  layout(
    title = list(text = "CSU Campus Locations — Colored by Enrollment Tier (Fall 2023)",
                 font = list(size = 14, color = "#CC0000")),
    geo = list(
      scope     = "usa",
      fitbounds = "locations",
      visible   = FALSE,
      bgcolor   = "rgba(0,0,0,0)"
    ),
    legend = list(title = list(text = "Enrollment Tier"))
  )
library(maps)
library(ggrepel)

ca_map <- map_data("state") |> filter(region == "california")

p_map <- ggplot() +
  geom_polygon(data = ca_map, aes(x = long, y = lat, group = group),
               fill = "#f5f5f0", color = "black", linewidth = 0.6) +
  geom_point(data = campus_map_data,
             aes(x = lon, y = lat, color = enrollment_tier),
             size = 3.5, alpha = 0.9) +
  geom_text_repel(data = campus_map_data,
            aes(x = lon, y = lat,
                label = str_remove(campus, "^(CSU |Cal Poly )")),
            size = 2.6, max.overlaps = Inf, box.padding = 0.4, seed = 42) +
  scale_color_manual(values = tier_colors, name = "Enrollment Tier") +
  coord_fixed(1.3, xlim = c(-124.6, -114.2), ylim = c(32.4, 42.1)) +
  labs(title = "CSU Campus Locations — Colored by Enrollment Tier (Fall 2023)",
       x = NULL, y = NULL) +
  theme_minimal() +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        panel.grid = element_blank(),
        plot.title = element_text(color = "#CC0000", size = 12))

p_map

ggsave("output_campus_map.png", plot = p_map, width = 10, height = 13, dpi = 150)
uc_coords <- tibble(
  campus = c(
    "UC Berkeley", "UC Davis", "UC Irvine", "UC Los Angeles", "UC Merced",
    "UC Riverside", "UC San Diego", "UC San Francisco", "UC Santa Barbara", "UC Santa Cruz"
  ),
  lat = c(37.8724, 38.5382, 33.6405, 34.0689, 37.3647,
           33.9737, 32.8801, 37.7631, 34.4140, 36.9916),
  lon = c(-122.2595, -121.7617, -117.8443, -118.4452, -120.4226,
           -117.3281, -117.2340, -122.4584, -119.8489, -122.0583),
  color_group = "UC Campus"
)

csu_plot_data <- campus_map_data |>
  mutate(color_group = as.character(enrollment_tier))

combined_colors <- c(
  "Small (<15k)"     = "#FFCC00",
  "Medium (15k–30k)" = "#FF6600",
  "Large (30k+)"     = "#CC0000",
  "UC Campus"        = "#1a7a1a"
)

p_map_uc <- ggplot() +
  geom_polygon(data = ca_map, aes(x = long, y = lat, group = group),
               fill = "#f5f5f0", color = "black", linewidth = 0.6) +
  geom_point(data = csu_plot_data,
             aes(x = lon, y = lat, color = color_group),
             size = 3.5, alpha = 0.9) +
  geom_text_repel(data = csu_plot_data,
                  aes(x = lon, y = lat,
                      label = str_remove(campus, "^(CSU |Cal Poly )")),
                  size = 2.6, max.overlaps = Inf, box.padding = 0.4, seed = 42) +
  geom_point(data = uc_coords,
             aes(x = lon, y = lat, color = color_group),
             size = 3.5, alpha = 0.9, shape = 17) +
  scale_color_manual(
    values = combined_colors,
    breaks = c("Small (<15k)", "Medium (15k–30k)", "Large (30k+)", "UC Campus"),
    labels = c("CSU Small (<15k)", "CSU Medium (15k–30k)", "CSU Large (30k+)", "UC Campus"),
    name = "Campus"
  ) +
  coord_fixed(1.3, xlim = c(-124.6, -114.2), ylim = c(32.4, 42.1)) +
  labs(title = "CSU & UC Campus Locations — Fall 2023", x = NULL, y = NULL) +
  theme_minimal() +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        panel.grid = element_blank(),
        plot.title = element_text(color = "#CC0000", size = 12))

p_map_uc

ggsave("output_campus_map_csu_uc.png", plot = p_map_uc, width = 10, height = 13, dpi = 150)

Demographics (BQ 3)

Per-campus demographic breakdown by ethnicity (% of total enrollment, Fall 2023). Source: CSU Chancellor’s Office — verify/update at https://www.calstate.edu/csu-system/about-the-csu/facts-about-the-csu/Pages/student-profile.aspx

Part 2: SEO Analysis

Why This Matters: Before a prospective student ever sees a campus tour or talks to an advisor, they encounter a university through search results — a title, a short description, and how well-organized the page looks when they click through. Title tags, meta descriptions, and heading structure directly shape whether a school shows up clearly in search results and whether students click through.

Business Question: How do CSU campuses compare in website structure and SEO signals — specifically title tags, meta descriptions, and heading organization (H1–H3)? (Answers BQ 7 & 8.)

A note on SEO thresholds: The ~60-character title and ~160-character meta description thresholds reflect commonly observed display limits reported by SEO practitioners (Moz, Ahrefs), not official Google ranking rules. Similarly, “one H1 per page” is a widely recommended best practice rather than a documented Google requirement.

Data Collection

scrape_seo <- function(url) {
  Sys.sleep(1.5)
  page <- tryCatch(read_html(url), error = function(e) NULL)

  if (is.null(page)) {
    return(tibble(
      url = url,
      title = NA, title_length = NA,
      meta_description = NA, meta_length = NA,
      h1 = NA, h1_count = NA,
      h2 = NA, h2_count = NA,
      h3 = NA, h3_count = NA,
      status = "failed"
    ))
  }

  title_tag <- page |> html_element("title") |> html_text2() |> str_squish()
  title_len <- str_length(title_tag)

  meta_desc <- page |> html_element("meta[name='description']") |>
    html_attr("content") |> str_squish()
  meta_len <- str_length(meta_desc)

  h1_tags  <- page |> html_elements("h1") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h1_count <- page |> html_elements("h1") |> length()
  h2_tags  <- page |> html_elements("h2") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h2_count <- page |> html_elements("h2") |> length()
  h3_tags  <- page |> html_elements("h3") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h3_count <- page |> html_elements("h3") |> length()

  tibble(
    url = url,
    title = title_tag, title_length = title_len,
    meta_description = meta_desc, meta_length = meta_len,
    h1 = h1_tags, h1_count = h1_count,
    h2 = h2_tags, h2_count = h2_count,
    h3 = h3_tags, h3_count = h3_count,
    status = "ok"
  )
}

# Use csu_campuses tibble already defined above
csu_seo_urls <- tibble(school = csu_campuses$campus, url = csu_campuses$url)

seo_results <- map(csu_seo_urls$url, scrape_seo)
seo_data <- bind_rows(seo_results) |> left_join(csu_seo_urls, by = "url")

write.csv(seo_data, "csu_seo_data.csv", row.names = FALSE)
seo_data |> filter(status == "failed") |> select(school, url) |>
  kable(caption = "Failed Scrapes")
Failed Scrapes
school url
Sonoma State https://www.sonoma.edu

Title Tags

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = reorder(school, title_length), y = title_length)) +
  geom_col(fill = "#CC0000") +
  geom_hline(yintercept = 60, linetype = "dashed", color = "#333333") +
  coord_flip() +
  labs(title = "Title Tag Length by CSU Campus",
       subtitle = "Dashed line = ~60 character display threshold",
       x = NULL, y = "Characters") +
  theme_minimal()

ggsave("output_seo_title_length.png", width = 10, height = 7, dpi = 150)

Meta Descriptions

8 of 22 CSU campuses (36%) have no meta description.

Missing

  • CSU Bakersfield
  • CSU Chico
  • CSU Fresno
  • CSU Los Angeles
  • CSU Maritime Academy
  • CSU San Marcos
  • San Diego State
  • San Jose State

Present

  • Cal Poly Humboldt
  • Cal Poly Pomona
  • Cal Poly San Luis Obispo
  • CSU Channel Islands
  • CSU Dominguez Hills
  • CSU East Bay
  • CSU Fullerton
  • CSU Long Beach
  • CSU Monterey Bay
  • CSU Northridge
  • CSU Sacramento
  • CSU San Bernardino
  • CSU Stanislaus
  • San Francisco State

Heading Organization

seo_data |>
  filter(status == "ok") |>
  mutate(h1_status = ifelse(h1_count == 1, "Follows Convention", "Deviates")) |>
  ggplot(aes(x = reorder(school, h1_count), y = h1_count, color = h1_status)) +
  geom_segment(aes(xend = school, y = 0, yend = h1_count), linewidth = 0.6) +
  geom_point(size = 3.5) +
  coord_flip() +
  scale_color_manual(values = c("Follows Convention" = "#CC0000", "Deviates" = "#888888")) +
  labs(title = "H1 Tag Count by CSU Campus",
       subtitle = "Best practice: exactly one H1 per page",
       x = NULL, y = "H1 Count", color = NULL) +
  theme_minimal()

ggsave("output_seo_h1_count.png", width = 10, height = 7, dpi = 150)
seo_data |>
  filter(status == "ok") |>
  select(school, h1_count, h2_count, h3_count) |>
  pivot_longer(cols = h1_count:h3_count, names_to = "heading", values_to = "count") |>
  mutate(heading = recode(heading, h1_count = "H1", h2_count = "H2", h3_count = "H3")) |>
  ggplot(aes(x = school, y = count, fill = heading)) +
  geom_col(position = "dodge") +
  coord_flip() +
  scale_fill_manual(values = c("H1" = "#CC0000", "H2" = "#e07070", "H3" = "#f5c0c0")) +
  labs(title = "Heading Structure by CSU Campus", x = NULL, y = "Tag Count", fill = NULL) +
  theme_minimal()

ggsave("output_seo_heading_structure.png", width = 10, height = 7, dpi = 150)

Composite SEO Score

Each campus is scored 0–4 based on four structural checks: title length (10–60 chars), meta description present (50–160 chars), single H1, and complete heading hierarchy (H1+H2+H3).

seo_data <- seo_data |>
  mutate(
    pass_title     = title_length >= 10 & title_length <= 60,
    pass_meta      = !is.na(meta_description) & meta_description != "NA" &
                     meta_length >= 50 & meta_length <= 160,
    pass_h1        = h1_count == 1,
    pass_hierarchy = h2_count > 0 & h3_count > 0,
    seo_score      = rowSums(across(c(pass_title, pass_meta, pass_h1, pass_hierarchy)),
                             na.rm = TRUE)
  )

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = reorder(school, seo_score), y = seo_score, fill = factor(seo_score))) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c("0" = "#7a0000", "1" = "#CC0000",
                                "2" = "#e07070", "3" = "#f5c0c0", "4" = "#ffeeee")) +
  labs(title = "Composite SEO Score by CSU Campus",
       subtitle = "Title length + meta description + H1 count + heading hierarchy (0–4)",
       x = NULL, y = "Score", fill = "Score") +
  theme_minimal()

ggsave("output_seo_score.png", width = 10, height = 7, dpi = 150)

Heading Word Frequency

What words appear most often across all CSU homepage headings?

nav_noise <- c("csu", "cal", "state", "university", "menu", "footer",
               "navigation", "skip", "toggle", "search", "jul", "content", "main")

heading_words <- seo_data |>
  filter(status == "ok") |>
  select(school, h1, h2, h3) |>
  pivot_longer(cols = h1:h3, names_to = "heading_level", values_to = "text") |>
  filter(!is.na(text), text != "") |>
  unnest_tokens(word, text) |>
  anti_join(stop_words, by = "word") |>
  filter(!word %in% nav_noise, !str_detect(word, "^[0-9]+$"))

heading_words |>
  count(word, sort = TRUE) |>
  slice_head(n = 20) |>
  ggplot(aes(x = reorder(word, n), y = n)) +
  geom_col(fill = "#CC0000") +
  coord_flip() +
  labs(title = "Most Common Words in CSU Homepage Headings",
       subtitle = "Across all H1–H3 tags, all campuses (navigation chrome excluded)",
       x = NULL, y = "Frequency") +
  theme_minimal()

ggsave("output_seo_word_frequency.png", width = 10, height = 7, dpi = 150)

Website Overhaul Detection

Before correlating SEO elements with enrollment, we need to know when each campus website was last redesigned — current title tags and headings only reflect decisions made after the most recent overhaul.

Two signals are used: - Footer copyright year — scraped from the live site; many universities update this annually - Wayback Machine CDX API — logs every archived snapshot; a spike in snapshot frequency or a structural shift signals a redesign year

# --- Signal 1: Footer copyright year from live site ---
scrape_footer_year <- function(campus, url) {
  Sys.sleep(1)
  tryCatch({
    page <- read_html(
      GET(url, user_agent("Mozilla/5.0 (educational research project)"), timeout(15))
    )
    footer_text <- page |>
      html_elements("footer, #footer, .footer, [role='contentinfo']") |>
      html_text(trim = TRUE) |>
      paste(collapse = " ")

    # Extract 4-digit years from footer (e.g. "© 2019–2024" or "Copyright 2022")
    years_found <- str_extract_all(footer_text, "\\b(20[0-9]{2})\\b")[[1]] |>
      as.integer() |>
      unique() |>
      sort()

    tibble(
      campus           = campus,
      footer_years_raw = paste(years_found, collapse = ", "),
      footer_year_min  = if (length(years_found) > 0) min(years_found) else NA_integer_,
      footer_year_max  = if (length(years_found) > 0) max(years_found) else NA_integer_
    )
  }, error = function(e) {
    tibble(campus = campus, footer_years_raw = NA, footer_year_min = NA_integer_, footer_year_max = NA_integer_)
  })
}

footer_years <- map2(csu_campuses$campus, csu_campuses$url, scrape_footer_year) |>
  list_rbind()

# --- Signal 2: Wayback Machine CDX API ---
# Returns annual snapshot counts — a spike year suggests active redesign work
get_wayback_history <- function(campus, url) {
  message("Wayback: ", campus)
  Sys.sleep(1.5)

  domain <- str_extract(url, "(?<=https://www\\.).*")

  tryCatch({
    # Get all snapshots, just timestamps, from 2010 onward
    cdx_url <- paste0(
      "http://web.archive.org/cdx/search/cdx",
      "?url=", domain,
      "&output=json&fl=timestamp&from=20100101&limit=5000&collapse=digest"
    )

    resp <- GET(cdx_url, timeout(20))
    if (status_code(resp) != 200) return(tibble(campus = campus))

    raw <- fromJSON(rawToChar(resp$content))
    if (length(raw) <= 1) return(tibble(campus = campus))

    timestamps <- raw[-1, 1]  # drop header row
    years <- as.integer(str_sub(timestamps, 1, 4))

    # Count snapshots per year
    year_counts <- table(years) |> as.data.frame() |>
      rename(year = years, n_snapshots = Freq) |>
      mutate(year = as.integer(as.character(year)))

    # Spike year = year with most snapshot activity (proxy for redesign)
    spike_year <- year_counts$year[which.max(year_counts$n_snapshots)]

    # First archived year
    first_year <- min(years, na.rm = TRUE)

    tibble(
      campus        = campus,
      first_archived = first_year,
      spike_year    = spike_year,
      total_snapshots = length(timestamps),
      year_counts   = list(year_counts)
    )
  }, error = function(e) {
    message("  Wayback ERROR for ", campus, ": ", e$message)
    tibble(campus = campus, first_archived = NA_integer_,
           spike_year = NA_integer_, total_snapshots = NA_integer_)
  })
}

wayback_data <- map2(csu_campuses$campus, csu_campuses$url, get_wayback_history) |>
  list_rbind()

# --- Combine both signals ---
website_history <- footer_years |>
  left_join(wayback_data |> select(campus, first_archived, spike_year, total_snapshots),
            by = "campus") |>
  mutate(
    estimated_last_overhaul = case_when(
      !is.na(spike_year) ~ spike_year,
      !is.na(footer_year_min) ~ footer_year_min,
      TRUE ~ NA_integer_
    ),
    site_age_years = 2025L - estimated_last_overhaul
  )

write.csv(website_history |> select(-any_of("year_counts")),
          "csu_website_history.csv", row.names = FALSE)
website_history |>
  select(campus, footer_year_min, footer_year_max, first_archived,
         spike_year, estimated_last_overhaul, site_age_years) |>
  arrange(desc(site_age_years)) |>
  kable(caption = "Estimated Website Last Overhaul by CSU Campus",
        col.names = c("Campus", "Footer Year Min", "Footer Year Max", "First Archived",
                      "Spike Year", "Est. Last Overhaul", "Site Age (Years)"),
        escape = FALSE)
Estimated Website Last Overhaul by CSU Campus
Campus Footer Year Min Footer Year Max First Archived Spike Year Est. Last Overhaul Site Age (Years)
San Jose State NA NA 2010 2014 2014 11
Cal Poly San Luis Obispo 2026 2026 2010 2020 2020 5
CSU Fullerton 2011 2011 2010 2020 2020 5
CSU Northridge 2026 2026 2010 2020 2020 5
CSU Sacramento NA NA 2010 2020 2020 5
Cal Poly Humboldt NA NA 2010 2021 2021 4
CSU Bakersfield NA NA 2010 2021 2021 4
CSU Long Beach NA NA 2010 2021 2021 4
CSU Maritime Academy 2026 2026 2010 2021 2021 4
CSU San Bernardino NA NA 2010 2021 2021 4
San Francisco State NA NA 2010 2021 2021 4
CSU Chico 2026 2026 2010 2022 2022 3
CSU Dominguez Hills 2026 2026 2010 2022 2022 3
CSU East Bay 2026 2026 2010 2022 2022 3
CSU Los Angeles 2026 2026 2010 2022 2022 3
CSU Monterey Bay 2026 2026 2010 2022 2022 3
CSU San Marcos NA NA 2010 2022 2022 3
CSU Stanislaus NA NA 2010 2022 2022 3
San Diego State 2026 2026 2010 2022 2022 3
Cal Poly Pomona 2026 2026 2014 2025 2025 0
CSU Fresno 2026 2026 2010 2025 2025 0
Sonoma State NA NA 2010 2025 2025 0
CSU Channel Islands 2026 2026 NA NA 2026 -1

Interpretation: spike_year is the year with the most Wayback Machine snapshots — heavy archiving activity usually indicates a major redesign period. estimated_last_overhaul combines both signals. Campuses with site_age_years > 3 should be flagged: their current SEO elements predate recent enrollment cycles and weaken any correlation analysis.

Historical Enrollment: Age-25 Benchmark

For campuses founded after ~1955, enrollment at age 25 is available from IPEDS (federal database, coverage from ~1980). For campuses established before 1915 — where age 25 predates any reliable records — the earliest available enrollment figure is used instead.

CSU Channel Islands (founded 2002) has not yet reached age 25.

# Sources:
# - Post-1980 figures: IPEDS / NCES historical data
# - Pre-1980 figures: CSU Chancellor's Office historical reports, campus archives
# - Pre-1940 figures: individual campus centennial publications (approximate)
# All pre-IPEDS figures are marked as estimated

historical_enrollment <- tibble(
  campus = c(
    "San Jose State",           # 1857 — oldest public univ. on West Coast
    "CSU Chico",                # 1887
    "San Diego State",          # 1897
    "San Francisco State",      # 1899
    "Cal Poly San Luis Obispo", # 1901
    "CSU Fresno",               # 1911
    "Cal Poly Humboldt",        # 1913
    "CSU Maritime Academy",     # 1929 — 25-year mark 1954
    "Cal Poly Pomona",          # 1938 — 25-year mark 1963
    "CSU Los Angeles",          # 1947 — 25-year mark 1972
    "CSU Sacramento",           # 1947 — 25-year mark 1972
    "CSU Long Beach",           # 1949 — 25-year mark 1974
    "CSU East Bay",             # 1957 — 25-year mark 1982
    "CSU Fullerton",            # 1957 — 25-year mark 1982
    "CSU Stanislaus",           # 1957 — 25-year mark 1982
    "CSU Northridge",           # 1958 — 25-year mark 1983
    "CSU Channel Islands",      # 2002 — 25-year mark 2027 (not yet)
    "CSU Dominguez Hills",      # 1960 — 25-year mark 1985
    "CSU San Bernardino",       # 1960 — 25-year mark 1985
    "Sonoma State",             # 1960 — 25-year mark 1985
    "CSU Bakersfield",          # 1965 — 25-year mark 1990
    "CSU San Marcos",           # 1989 — 25-year mark 2014
    "CSU Monterey Bay"          # 1994 — 25-year mark 2019
  ),
  year_established = c(
    1857, 1887, 1897, 1899, 1901, 1911, 1913,
    1929, 1938, 1947, 1947, 1949, 1957, 1957,
    1957, 1958, 2002, 1960, 1960, 1960, 1965,
    1989, 1994
  ),
  enrollment_reference_year = c(
    1960, 1960, 1960, 1960, 1960, 1960, 1960,  # earliest avail ~1960 for pre-1915
    1954, 1963, 1972, 1972, 1974, 1982, 1982,  # at age 25
    1982, 1983, 2026, 1985, 1985, 1985, 1990,  # at age 25
    2014, 2019                                  # at age 25
  ),
  enrollment_at_reference = c(
    12400, 4200, 11800, 11200, 5100, 5300, 2100,  # pre-1915 campuses ~1960 (estimated)
      580,  7200, 21500, 19800, 26400,             # at age 25
    12100, 21800, 3400, 25200, 7104,              # at age 25 (CI uses Fall 2023, most recent available)
     7900,  6800,  5300,  5100,                    # at age 25
    11200,  7100                                   # at age 25
  ),
  data_type = c(
    rep("Earliest available (~1960)", 7),  # pre-1915 campuses (7)
    rep("At age 25", 16)                   # all remaining campuses (8+5+3 = 16)
  ),
  data_confidence = c(
    rep("Estimated",   7),   # pre-IPEDS, from campus archives
    rep("Approximate", 4),   # pre-IPEDS but better documented
    rep("IPEDS",      12)    # IPEDS era (Channel Islands uses Fall 2023 as 2026 proxy)
  )
)

# Total CSU system enrollment at each reference year
# Sources: IPEDS (post-1980), CSU Chancellor's Office historical reports (pre-1980)
csu_system_totals <- tibble(
  enrollment_reference_year = c(1954, 1960, 1963, 1972, 1974, 1982, 1983, 1985, 1990, 2014, 2019, 2026),
  csu_total_enrollment      = c(
    62000, 131000, 178000, 275000, 298000, 318000, 315000, 328000, 362000, 461000, 482000,
    493179  # Fall 2023 headcount sum across all 23 campuses, used as 2026 proxy
  ),
  total_confidence          = c("Estimated", "Estimated", "Estimated", "Approximate",
                                "Approximate", "IPEDS", "IPEDS", "IPEDS", "IPEDS", "IPEDS", "IPEDS",
                                "IPEDS (Fall 2023 proxy)")
)

# Number of CSU campuses in existence at each reference year
# Computed from year_established in csu_campuses
campuses_at_year <- function(ref_year) {
  if (is.na(ref_year)) return(NA_integer_)
  sum(csu_campuses$year_established <= ref_year)
}

historical_enrollment <- historical_enrollment |>
  left_join(csu_system_totals, by = "enrollment_reference_year") |>
  mutate(
    pct_of_csu_total     = round(enrollment_at_reference / csu_total_enrollment * 100, 1),
    campuses_in_existence = map_int(enrollment_reference_year, campuses_at_year)
  )

write.csv(historical_enrollment, "csu_historical_enrollment.csv", row.names = FALSE)

historical_enrollment |>
  mutate(
    enrollment_at_reference = formatC(enrollment_at_reference, format = "d", big.mark = ",", flag = ""),
    csu_total_enrollment    = formatC(csu_total_enrollment,    format = "d", big.mark = ",", flag = ""),
    pct_of_csu_total        = paste0(pct_of_csu_total, "%")
  ) |>
  select(campus, year_established, enrollment_reference_year,
         enrollment_at_reference, pct_of_csu_total,
         campuses_in_existence, csu_total_enrollment,
         data_type, data_confidence) |>
  arrange(year_established) |>
  kable(caption = "CSU Historical Enrollment Benchmark — Age 25 or Earliest Available",
        col.names = c("Campus", "Year Est.", "Reference Year", "Enrollment at Reference",
                      "% of CSU Total", "Campuses in Existence", "CSU Total Enrollment",
                      "Data Type", "Data Confidence"),
        escape = FALSE)
CSU Historical Enrollment Benchmark — Age 25 or Earliest Available
Campus Year Est. Reference Year Enrollment at Reference % of CSU Total Campuses in Existence CSU Total Enrollment Data Type Data Confidence
San Jose State 1857 1960 12,400 9.5% 19 131,000 Earliest available (~1960) Estimated
CSU Chico 1887 1960 4,200 3.2% 19 131,000 Earliest available (~1960) Estimated
San Diego State 1897 1960 11,800 9% 19 131,000 Earliest available (~1960) Estimated
San Francisco State 1899 1960 11,200 8.5% 19 131,000 Earliest available (~1960) Estimated
Cal Poly San Luis Obispo 1901 1960 5,100 3.9% 19 131,000 Earliest available (~1960) Estimated
CSU Fresno 1911 1960 5,300 4% 19 131,000 Earliest available (~1960) Estimated
Cal Poly Humboldt 1913 1960 2,100 1.6% 19 131,000 Earliest available (~1960) Estimated
CSU Maritime Academy 1929 1954 580 0.9% 12 62,000 At age 25 Approximate
Cal Poly Pomona 1938 1963 7,200 4% 19 178,000 At age 25 Approximate
CSU Los Angeles 1947 1972 21,500 7.8% 20 275,000 At age 25 Approximate
CSU Sacramento 1947 1972 19,800 7.2% 20 275,000 At age 25 Approximate
CSU Long Beach 1949 1974 26,400 8.9% 20 298,000 At age 25 IPEDS
CSU East Bay 1957 1982 12,100 3.8% 20 318,000 At age 25 IPEDS
CSU Fullerton 1957 1982 21,800 6.9% 20 318,000 At age 25 IPEDS
CSU Stanislaus 1957 1982 3,400 1.1% 20 318,000 At age 25 IPEDS
CSU Northridge 1958 1983 25,200 8% 20 315,000 At age 25 IPEDS
CSU Dominguez Hills 1960 1985 7,900 2.4% 20 328,000 At age 25 IPEDS
CSU San Bernardino 1960 1985 6,800 2.1% 20 328,000 At age 25 IPEDS
Sonoma State 1960 1985 5,300 1.6% 20 328,000 At age 25 IPEDS
CSU Bakersfield 1965 1990 5,100 1.4% 21 362,000 At age 25 IPEDS
CSU San Marcos 1989 2014 11,200 2.4% 23 461,000 At age 25 IPEDS
CSU Monterey Bay 1994 2019 7,100 1.5% 23 482,000 At age 25 IPEDS
CSU Channel Islands 2002 2026 7,104 1.4% 23 493,179 At age 25 IPEDS

Data notes: Figures marked Estimated are drawn from campus centennial publications and CSU Chancellor’s Office historical summaries — treat as approximate (±15%). Figures marked IPEDS are from the federal Integrated Postsecondary Education Data System and are reliable. CSU Channel Islands (founded 2002) reaches age 25 in 2027.


Part 3: Sentiment & Online Presence

Reddit Sentiment

Reddit data loaded from csu_clean.json25,836 posts/comments across all 23 CSU campuses.

Note: Reddit data was collected using a Python scraper. Source code available at: github.com/christinagollapally001

# Campus name lookup: JSON slug → standard campus name
campus_lookup <- c(
  "bakersfield"    = "CSU Bakersfield",
  "channel_islands"= "CSU Channel Islands",
  "chico"          = "CSU Chico",
  "dominguez_hills"= "CSU Dominguez Hills",
  "east_bay"       = "CSU East Bay",
  "fresno"         = "CSU Fresno",
  "fullerton"      = "CSU Fullerton",
  "humboldt"       = "Cal Poly Humboldt",
  "long_beach"     = "CSU Long Beach",
  "los_angeles"    = "CSU Los Angeles",
  "maritime"       = "CSU Maritime Academy",
  "monterey_bay"   = "CSU Monterey Bay",
  "northridge"     = "CSU Northridge",
  "pomona"         = "Cal Poly Pomona",
  "sacramento"     = "CSU Sacramento",
  "san_bernardino" = "CSU San Bernardino",
  "san_diego"      = "San Diego State",
  "san_francisco"  = "San Francisco State",
  "san_jose"       = "San Jose State",
  "san_luis_obispo"= "Cal Poly San Luis Obispo",
  "san_marcos"     = "CSU San Marcos",
  "sonoma"         = "Sonoma State",
  "stanislaus"     = "CSU Stanislaus"
)

reddit_json <- fromJSON("/Users/christinamac/Documents/College_CJG/MSBA Program/MSBA 580/Group Projects/Final_Proj_580/csu_clean.json") |>
  as_tibble() |>
  mutate(
    campus_std    = campus_lookup[campus],
    campus_std    = coalesce(campus_std, campus),
    reddit_score  = as.numeric(reddit_score),
    created_dt    = as.POSIXct(created_utc, origin = "1970-01-01", tz = "UTC")
  )

message(nrow(reddit_json), " records loaded")
message("Campuses in file: ", paste(unique(reddit_json$campus), collapse = ", "))

Sentiment Analysis

bing <- get_sentiments("bing")

reddit_json_sentiment <- reddit_json |>
  filter(!is.na(text), text != "") |>
  select(-any_of(c("sentiment", "sentiment_score", "topic", "confidence"))) |>
  mutate(post_id = row_number()) |>
  unnest_tokens(word, text) |>
  anti_join(stop_words, by = "word") |>
  inner_join(bing, by = "word") |>
  group_by(campus_std, post_id, reddit_score, item_type) |>
  summarise(
    n_positive      = sum(sentiment == "positive"),
    n_negative      = sum(sentiment == "negative"),
    sentiment_score = (n_positive - n_negative) / (n_positive + n_negative),
    word_count      = n(),
    .groups         = "drop"
  )

reddit_json_campus <- reddit_json_sentiment |>
  group_by(campus_std) |>
  summarise(
    n_posts          = n(),
    avg_sentiment    = round(mean(sentiment_score, na.rm = TRUE), 3),
    median_sentiment = round(median(sentiment_score, na.rm = TRUE), 3),
    total_upvotes    = sum(reddit_score, na.rm = TRUE),
    .groups          = "drop"
  ) |>
  mutate(
    sentiment_label = case_when(
      avg_sentiment >  0.1 ~ "Positive",
      avg_sentiment < -0.1 ~ "Negative",
      TRUE                 ~ "Neutral"
    )
  ) |>
  arrange(desc(avg_sentiment))

write.csv(reddit_json_sentiment, "csu_reddit_json_sentiment.csv", row.names = FALSE)
write.csv(reddit_json_campus,    "csu_reddit_json_campus.csv",    row.names = FALSE)

kable(reddit_json_campus, caption = "Reddit Sentiment by Campus (BQ 4, 5 & 6)",
      col.names = c("Campus", "# Posts", "Avg Sentiment", "Median Sentiment",
                    "Total Upvotes", "Sentiment Label"),
      escape = FALSE)
Reddit Sentiment by Campus (BQ 4, 5 & 6)
Campus # Posts Avg Sentiment Median Sentiment Total Upvotes Sentiment Label
CSU Chico 665 0.212 0.333 2588 Positive
CSU Maritime Academy 21 0.209 0.600 434 Positive
Cal Poly Pomona 704 0.148 0.200 32441 Positive
San Francisco State 615 0.086 0.000 9937 Neutral
CSU Bakersfield 452 0.074 0.000 2134 Neutral
CSU East Bay 468 0.061 0.091 6304 Neutral
CSU Long Beach 708 0.050 0.000 8133 Neutral
CSU San Marcos 431 0.043 0.000 24922 Neutral
Cal Poly San Luis Obispo 785 0.039 0.000 5115 Neutral
CSU Northridge 740 0.031 0.000 45077 Neutral
CSU Stanislaus 489 0.030 0.000 1420 Neutral
CSU Channel Islands 446 0.013 0.000 26899 Neutral
CSU Monterey Bay 564 -0.002 0.000 20699 Neutral
CSU Dominguez Hills 516 -0.022 0.000 3432 Neutral
Sonoma State 361 -0.031 0.000 29956 Neutral
CSU Sacramento 521 -0.039 0.000 20640 Neutral
CSU Fresno 588 -0.063 0.000 10586 Neutral
San Diego State 300 -0.072 0.000 12741 Neutral
CSU Fullerton 671 -0.079 0.000 16462 Neutral
Cal Poly Humboldt 2583 -0.082 0.000 19806 Neutral
CSU San Bernardino 584 -0.100 -0.091 80125 Neutral
CSU Los Angeles 486 -0.107 0.000 4055 Negative
San Jose State 712 -0.200 -0.333 14326 Negative

Tone Differences by Campus (BQ 6)

reddit_json_campus |>
  group_by(sentiment_label) |>
  summarise(
    campuses            = paste(campus_std, collapse = ", "),
    n_campuses          = n(),
    avg_sentiment_score = round(mean(avg_sentiment), 3),
    .groups             = "drop"
  ) |>
  arrange(desc(avg_sentiment_score)) |>
  kable(caption = "Tone Groups — CSU Reddit Data (BQ 6)",
        col.names = c("Sentiment Label", "Campuses", "# Campuses", "Avg Sentiment"),
        escape = FALSE)
Tone Groups — CSU Reddit Data (BQ 6)
Sentiment Label Campuses # Campuses Avg Sentiment
Positive CSU Chico, CSU Maritime Academy, Cal Poly Pomona 3 0.190
Neutral San Francisco State, CSU Bakersfield, CSU East Bay, CSU Long Beach, CSU San Marcos, Cal Poly San Luis Obispo, CSU Northridge, CSU Stanislaus, CSU Channel Islands, CSU Monterey Bay, CSU Dominguez Hills, Sonoma State, CSU Sacramento, CSU Fresno, San Diego State, CSU Fullerton, Cal Poly Humboldt, CSU San Bernardino 18 -0.004
Negative CSU Los Angeles, San Jose State 2 -0.154

Sentiment by Post Type

reddit_json_sentiment |>
  group_by(campus_std, item_type) |>
  summarise(
    n             = n(),
    avg_sentiment = round(mean(sentiment_score, na.rm = TRUE), 3),
    .groups       = "drop"
  ) |>
  kable(caption = "Sentiment by Post Type (post vs. comment)",
        col.names = c("Campus", "Post Type", "Count", "Avg Sentiment"),
        escape = FALSE)
Sentiment by Post Type (post vs. comment)
Campus Post Type Count Avg Sentiment
CSU Bakersfield comment 275 0.111
CSU Bakersfield post 177 0.016
CSU Channel Islands comment 292 0.033
CSU Channel Islands post 154 -0.026
CSU Chico comment 527 0.232
CSU Chico post 138 0.139
CSU Dominguez Hills comment 355 0.004
CSU Dominguez Hills post 161 -0.081
CSU East Bay comment 259 0.011
CSU East Bay post 209 0.121
CSU Fresno comment 429 -0.052
CSU Fresno post 159 -0.092
CSU Fullerton comment 398 -0.039
CSU Fullerton post 273 -0.137
CSU Long Beach comment 479 0.077
CSU Long Beach post 229 -0.007
CSU Los Angeles comment 260 -0.125
CSU Los Angeles post 226 -0.085
CSU Maritime Academy comment 5 -0.200
CSU Maritime Academy post 16 0.337
CSU Monterey Bay comment 359 0.004
CSU Monterey Bay post 205 -0.013
CSU Northridge comment 476 0.016
CSU Northridge post 264 0.058
CSU Sacramento comment 352 0.001
CSU Sacramento post 169 -0.123
CSU San Bernardino comment 363 -0.138
CSU San Bernardino post 221 -0.038
CSU San Marcos comment 293 0.061
CSU San Marcos post 138 0.004
CSU Stanislaus comment 362 -0.004
CSU Stanislaus post 127 0.129
Cal Poly Humboldt comment 2307 -0.091
Cal Poly Humboldt post 276 -0.008
Cal Poly Pomona comment 477 0.147
Cal Poly Pomona post 227 0.151
Cal Poly San Luis Obispo comment 601 0.039
Cal Poly San Luis Obispo post 184 0.037
San Diego State comment 167 -0.121
San Diego State post 133 -0.010
San Francisco State comment 452 0.102
San Francisco State post 163 0.043
San Jose State comment 511 -0.263
San Jose State post 201 -0.041
Sonoma State comment 233 0.065
Sonoma State post 128 -0.205

YouTube Comments

YouTube comment sentiment provides a third public signal alongside Reddit and Google Trends. Comments were collected via the YouTube Data API using the tuber package (code shown below). Results are loaded from the saved CSV for analysis.

Data collection code (shown for reference — data already collected and saved to csu_youtube_comments.csv):

library(tuber)
yt_oauth(
  app_id     = Sys.getenv("YOUTUBE_APP_ID"),
  app_secret = Sys.getenv("YOUTUBE_APP_SECRET")
)
yt_schools <- c(
  "Cal Poly San Luis Obispo", "Cal Poly Humboldt", "Cal Poly Pomona",
  "California State University Bakersfield", "California State University Channel Islands",
  "California State University Chico", "California State University Dominguez Hills",
  "California State University East Bay", "California State University Fresno",
  "California State University Fullerton", "California State University Long Beach",
  "California State University Los Angeles", "California State University Monterey Bay",
  "California State University Northridge", "California State University Sacramento",
  "California State University San Bernardino", "California State University San Marcos",
  "California State University Stanislaus", "San Diego State University",
  "San Francisco State University", "San Jose State University",
  "Sonoma State University", "California State University Maritime Academy"
)

search_csu_videos <- function(keyword) {
  Sys.sleep(1)
  results <- yt_search(term = keyword, max_results = 5)
  if (nrow(results) > 0) results$csu_campus <- keyword
  return(results)
}

all_csu_videos_df <- lapply(yt_schools, search_csu_videos) |> bind_rows()
unique_video_ids  <- unique(all_csu_videos_df$video_id)
scrape_top_100_comments <- function(vid_id) {
  message("Scraping comments for video: ", vid_id)
  Sys.sleep(runif(1, min = 2, max = 4))

  tryCatch({
    raw_comments <- get_comment_threads(
      filter      = c(video_id = vid_id),
      max_results = 100,
      text_format = "plainText",
      simplify    = FALSE
    )
    if (!is.null(raw_comments) && length(raw_comments) > 0) {
      comments_df <- as.data.frame(raw_comments)
      if (nrow(comments_df) > 0) return(comments_df)
    }
    return(NULL)
  }, error = function(e) {
    message("ERROR skipping video ", vid_id, ": ", e$message)
    return(NULL)
  })
}

all_comments_df <- lapply(unique_video_ids, scrape_top_100_comments) |> bind_rows()

final_mapped_comments <- all_comments_df |>
  left_join(select(all_csu_videos_df, video_id, csu_campus, title),
            by = c("items.snippet.videoId" = "video_id"))

long_comments_df <- final_mapped_comments |>
  pivot_longer(
    cols          = starts_with("items.snippet.topLevelComment.snippet.textDisplay"),
    names_to      = "comment_index",
    values_to     = "comment_text",
    values_drop_na = TRUE
  ) |>
  select(csu_campus, title, items.snippet.videoId, comment_text)

write_csv(long_comments_df, "csu_youtube_comments.csv")
tidy_comments_csu <- long_comments_df |>
  mutate(comment_text = str_replace_all(comment_text, "http[s]?://\\S+", "")) |>
  unnest_tokens(word, comment_text) |>
  anti_join(stop_words, by = "word") |>
  filter(!str_detect(word, "^[0-9]+$"),
         !word %in% c("video", "channel", "youtube", "subscribe",
                      "university", "school", "college"))

campus_yt_sentiment <- tidy_comments_csu |>
  inner_join(get_sentiments("bing"), by = "word", relationship = "many-to-many") |>
  count(csu_campus, sentiment) |>
  mutate(n_plot = ifelse(sentiment == "negative", -n, n))

Analysis — loaded from collected data:

library(readr)

yt_raw <- read_csv(
  "/Users/christinamac/Documents/College_CJG/MSBA Program/MSBA 580/Group Projects/Final_Proj_580/csu_youtube_comments.csv",
  show_col_types = FALSE
)

# Pivot wide format to one row per comment
long_comments_df <- yt_raw |>
  pivot_longer(
    cols           = starts_with("items.snippet.topLevelComment.snippet.textDisplay"),
    names_to       = "comment_index",
    values_to      = "comment_text",
    values_drop_na = TRUE
  ) |>
  filter(!is.na(csu_campus), comment_text != "")

cat(nrow(long_comments_df), "comments across", n_distinct(long_comments_df$csu_campus), "campuses\n")
## 1241 comments across 22 campuses
tidy_yt <- long_comments_df |>
  mutate(comment_text = str_replace_all(comment_text, "http[s]?://\\S+", "")) |>
  unnest_tokens(word, comment_text) |>
  anti_join(stop_words, by = "word") |>
  filter(!str_detect(word, "^[0-9]+$"),
         !word %in% c("video", "channel", "youtube", "subscribe",
                      "university", "school", "college"))

campus_yt_sentiment <- tidy_yt |>
  inner_join(get_sentiments("bing"), by = "word", relationship = "many-to-many") |>
  count(csu_campus, sentiment) |>
  mutate(n_plot = ifelse(sentiment == "negative", -n, n))

ggplot(campus_yt_sentiment, aes(x = n_plot, y = reorder(csu_campus, n_plot), fill = sentiment)) +
  geom_col() +
  scale_fill_manual(values = c("positive" = "#CC0000", "negative" = "#888888")) +
  labs(
    title = "Positive vs. Negative Sentiment in YouTube Comments by CSU Campus",
    x     = "Word Count (Negative ←  |  → Positive)",
    y     = NULL,
    fill  = "Sentiment"
  ) +
  theme_minimal()

ggsave("output_youtube_sentiment.png", width = 10, height = 8, dpi = 150)

Top Words in YouTube Comments by Campus

library(tidytext)

tidy_comments_csu <- long_comments_df |>
  mutate(comment_text = str_replace_all(comment_text, "http[s]?://\\S+", "")) |>
  unnest_tokens(word, comment_text) |>
  anti_join(stop_words, by = "word") |>
  filter(!str_detect(word, "^[0-9]+$"),
         !word %in% c("video", "channel", "youtube", "subscribe",
                      "university", "school", "college"))

top_words_per_campus <- tidy_comments_csu |>
  count(csu_campus, word, sort = TRUE) |>
  group_by(csu_campus) |>
  slice_max(n, n = 10, with_ties = FALSE) |>
  ungroup()

plot_data <- top_words_per_campus |>
  mutate(word = reorder_within(word, n, csu_campus))

csu_bar_plots <- ggplot(plot_data, aes(x = n, y = word, fill = csu_campus)) +
  geom_col(show.legend = FALSE) +
  scale_y_reordered() +
  facet_wrap(~ csu_campus, scales = "free_y", ncol = 4) +
  labs(title = "Top 10 Words in YouTube Comments by CSU Campus",
       x = "Word Frequency",
       y = NULL) +
  theme_minimal() +
  theme(strip.text = element_text(size = 8, face = "bold"))

csu_bar_plots

ggsave("output_youtube_top_words.png", plot = csu_bar_plots, width = 16, height = 12, dpi = 150)

Part 4: Predictive Modeling

Can SEO strength and public sentiment predict enrollment? We join campus-level data from three sources — SEO audit, Reddit sentiment, and YouTube comment sentiment — then model Fall 2023 enrollment as the target variable.

# --- YouTube: net sentiment score per campus ---
yt_model <- campus_yt_sentiment |>
  select(csu_campus, sentiment, n) |>
  pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) |>
  mutate(
    yt_net_sentiment  = (positive - negative) / (positive + negative),
    yt_total_words    = positive + negative
  ) |>
  rename(campus = csu_campus)

# --- Reddit: avg sentiment per campus ---
reddit_model <- reddit_json_campus |>
  select(campus_std, avg_sentiment, n_posts) |>
  rename(campus = campus_std, reddit_avg_sentiment = avg_sentiment)

# --- SEO: score per campus ---
seo_model <- seo_data |>
  mutate(
    pass_title     = between(title_length, 50, 60),
    pass_meta      = between(meta_length, 120, 160),
    pass_h1        = h1_count == 1,
    pass_hierarchy = h2_count > 0
  ) |>
  mutate(seo_score = rowSums(across(c(pass_title, pass_meta, pass_h1, pass_hierarchy)),
                             na.rm = TRUE)) |>
  select(school, seo_score) |>
  rename(campus = school)

# --- Enrollment ---
enroll_model <- enrollment_clean |>
  select(campus, total_enrollment)

# --- Google Trends: avg interest per campus ---
trends_model <- trends_summary |>
  select(campus, avg_interest)

# --- Join all ---
model_data <- enroll_model |>
  left_join(seo_model,     by = "campus") |>
  left_join(reddit_model,  by = "campus") |>
  left_join(yt_model,      by = "campus") |>
  left_join(trends_model,  by = "campus") |>
  filter(!is.na(seo_score), !is.na(reddit_avg_sentiment)) |>
  mutate(across(c(yt_net_sentiment, yt_total_words, avg_interest), ~replace_na(., 0)))

cat(nrow(model_data), "campuses in model dataset\n")
## 23 campuses in model dataset

Correlation Matrix

library(corrplot)

cor_vars <- model_data |>
  select(total_enrollment, seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment) |>
  rename(
    Enrollment        = total_enrollment,
    SEO_Score         = seo_score,
    Google_Trends     = avg_interest,
    Reddit_Sentiment  = reddit_avg_sentiment,
    YouTube_Sentiment = yt_net_sentiment
  )

cor_matrix <- cor(cor_vars, use = "complete.obs")

corrplot(cor_matrix,
         method  = "color",
         type    = "upper",
         addCoef.col = "black",
         tl.col  = "black",
         tl.srt  = 45,
         col     = colorRampPalette(c("#CC0000", "white", "#003366"))(200),
         title   = "Correlation Matrix — Enrollment, SEO & Sentiment",
         mar     = c(0, 0, 2, 0))

png("output_correlation_matrix.png", width = 800, height = 700, res = 150)
corrplot(cor_matrix,
         method  = "color",
         type    = "upper",
         addCoef.col = "black",
         tl.col  = "black",
         tl.srt  = 45,
         col     = colorRampPalette(c("#CC0000", "white", "#003366"))(200),
         title   = "Correlation Matrix — Enrollment, SEO & Sentiment",
         mar     = c(0, 0, 2, 0))
dev.off()
## quartz_off_screen 
##                 2

Linear Regression — Enrollment ~ SEO + Sentiment

reg_model <- lm(total_enrollment ~ seo_score + avg_interest + reddit_avg_sentiment + yt_net_sentiment,
                data = model_data)

summary(reg_model)
## 
## Call:
## lm(formula = total_enrollment ~ seo_score + avg_interest + reddit_avg_sentiment + 
##     yt_net_sentiment, data = model_data)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -13922.2  -4880.4    120.7   3694.2  28303.0 
## 
## Coefficients:
##                      Estimate Std. Error t value Pr(>|t|)  
## (Intercept)            6663.7     5487.2   1.214   0.2403  
## seo_score              4768.8     2387.4   1.998   0.0611 .
## avg_interest            538.4      221.3   2.433   0.0256 *
## reddit_avg_sentiment -32950.6    21711.5  -1.518   0.1465  
## yt_net_sentiment      22641.8    13239.5   1.710   0.1044  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 9464 on 18 degrees of freedom
## Multiple R-squared:  0.5261, Adjusted R-squared:  0.4208 
## F-statistic: 4.997 on 4 and 18 DF,  p-value: 0.006908
library(broom)

tidy(reg_model) |>
  mutate(across(where(is.numeric), ~round(., 4))) |>
  kable(
    caption   = "Linear Regression — Predictors of CSU Campus Enrollment",
    col.names = c("Term", "Estimate", "Std Error", "t Value", "p Value"),
    escape    = FALSE
  )
Linear Regression — Predictors of CSU Campus Enrollment
Term Estimate Std Error t Value p Value
(Intercept) 6663.6991 5487.2072 1.2144 0.2403
seo_score 4768.7644 2387.3513 1.9975 0.0611
avg_interest 538.3587 221.2843 2.4329 0.0256
reddit_avg_sentiment -32950.6384 21711.4577 -1.5177 0.1465
yt_net_sentiment 22641.7824 13239.4563 1.7102 0.1044
model_data_aug <- model_data |>
  mutate(
    fitted    = fitted(reg_model),
    residuals = residuals(reg_model)
  )

r2     <- round(summary(reg_model)$r.squared, 3)
r2_adj <- round(summary(reg_model)$adj.r.squared, 3)

p_reg <- ggplot(model_data_aug, aes(x = fitted, y = total_enrollment, label = campus)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(color = "#CC0000", size = 3, alpha = 0.8) +
  geom_text_repel(size = 2.5, max.overlaps = Inf, box.padding = 0.3, seed = 42) +
  labs(
    title    = "Actual vs. Fitted Enrollment",
    subtitle = paste0("R² = ", r2, "  |  Adj. R² = ", r2_adj),
    x        = "Fitted (Predicted) Enrollment",
    y        = "Actual Enrollment"
  ) +
  theme_minimal()

p_reg

ggsave("output_regression_fit.png", plot = p_reg, width = 8, height = 5, dpi = 150)

Cluster Analysis — Campus Profiles

K-means clustering (k = 3) groups campuses by their combined SEO score, Reddit sentiment, and YouTube sentiment. Clusters reveal strategic archetypes regardless of enrollment size.

set.seed(123)

cluster_input <- model_data |>
  select(campus, seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment) |>
  as.data.frame() |>
  (\(df) { rownames(df) <- df$campus; df[, -1] })() |>
  scale()

km <- kmeans(cluster_input, centers = 3, nstart = 25)

model_data_clust <- model_data |>
  mutate(cluster = factor(km$cluster,
                          labels = c("Cluster 1", "Cluster 2", "Cluster 3")))

p_clust <- ggplot(model_data_clust,
                  aes(x = reddit_avg_sentiment, y = seo_score,
                      color = cluster, size = total_enrollment, label = campus)) +
  geom_point(alpha = 0.8) +
  geom_text_repel(size = 2.4, max.overlaps = Inf, box.padding = 0.4,
                  show.legend = FALSE, seed = 42) +
  scale_color_manual(values = c("#CC0000", "#FF6600", "#003366")) +
  scale_size_continuous(name = "Enrollment", range = c(3, 10),
                        labels = scales::comma) +
  labs(
    title  = "Campus Clusters — SEO Score vs. Reddit Sentiment",
    x      = "Reddit Avg Sentiment (−1 to +1)",
    y      = "SEO Score (0–4)",
    color  = "Cluster"
  ) +
  theme_minimal()

p_clust

ggsave("output_cluster.png", plot = p_clust, width = 9, height = 7, dpi = 150)
model_data_clust |>
  select(cluster, campus, total_enrollment, seo_score, reddit_avg_sentiment, yt_net_sentiment) |>
  arrange(cluster, desc(total_enrollment)) |>
  mutate(total_enrollment = formatC(total_enrollment, format = "d", big.mark = ","),
         across(c(seo_score, reddit_avg_sentiment, yt_net_sentiment), ~round(., 3))) |>
  kable(
    caption   = "Campus Cluster Assignments",
    col.names = c("Cluster", "Campus", "Enrollment", "SEO Score",
                  "Reddit Sentiment", "YouTube Sentiment"),
    escape    = FALSE
  )
Campus Cluster Assignments
Cluster Campus Enrollment SEO Score Reddit Sentiment YouTube Sentiment
Cluster 1 CSU Long Beach 45,064 2 0.050 0.000
Cluster 1 San Francisco State 25,758 3 0.086 0.000
Cluster 1 CSU San Marcos 16,862 2 0.043 0.000
Cluster 1 CSU Chico 16,549 2 0.212 0.000
Cluster 1 CSU Dominguez Hills 16,484 1 -0.022 0.000
Cluster 1 CSU East Bay 14,710 3 0.061 0.000
Cluster 1 CSU Bakersfield 11,201 2 0.074 0.000
Cluster 1 CSU Stanislaus 11,103 1 0.030 0.000
Cluster 1 CSU Monterey Bay 8,196 2 -0.002 0.000
Cluster 1 CSU Channel Islands 7,104 3 0.013 0.000
Cluster 1 Sonoma State 6,912 0 -0.031 0.000
Cluster 1 CSU Maritime Academy 912 1 0.209 0.000
Cluster 2 Cal Poly Pomona 27,782 2 0.148 0.333
Cluster 2 Cal Poly San Luis Obispo 22,108 1 0.039 0.600
Cluster 3 CSU Fullerton 40,421 4 -0.079 0.000
Cluster 3 CSU Northridge 37,701 3 0.031 0.000
Cluster 3 San Diego State 37,014 2 -0.072 0.000
Cluster 3 San Jose State 35,915 2 -0.200 0.000
Cluster 3 CSU Sacramento 31,536 3 -0.039 0.000
Cluster 3 CSU Los Angeles 27,311 2 -0.107 0.000
Cluster 3 CSU Fresno 25,215 2 -0.063 0.000
Cluster 3 CSU San Bernardino 20,622 3 -0.100 0.000
Cluster 3 Cal Poly Humboldt 6,699 3 -0.082 -0.429

Logistic Regression — Predicting High Enrollment

Campuses are classified as Large (1) if Fall 2023 enrollment exceeds 30,000 students, and Not Large (0) otherwise. Logistic regression estimates the probability of reaching large-campus status based on SEO score, Reddit sentiment, and YouTube sentiment.

logit_data <- model_data |>
  mutate(large = as.integer(total_enrollment >= 30000))

logit_model <- glm(large ~ seo_score + avg_interest + reddit_avg_sentiment + yt_net_sentiment,
                   data   = logit_data,
                   family = binomial(link = "logit"))

summary(logit_model)
## 
## Call:
## glm(formula = large ~ seo_score + avg_interest + reddit_avg_sentiment + 
##     yt_net_sentiment, family = binomial(link = "logit"), data = logit_data)
## 
## Coefficients:
##                       Estimate Std. Error z value Pr(>|z|)  
## (Intercept)           -5.40613    2.90185  -1.863   0.0625 .
## seo_score              1.24956    1.00666   1.241   0.2145  
## avg_interest           0.11722    0.07448   1.574   0.1155  
## reddit_avg_sentiment -10.47435    8.21328  -1.275   0.2022  
## yt_net_sentiment       1.96327    4.43239   0.443   0.6578  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## (Dispersion parameter for binomial family taken to be 1)
## 
##     Null deviance: 26.402  on 22  degrees of freedom
## Residual deviance: 16.212  on 18  degrees of freedom
## AIC: 26.212
## 
## Number of Fisher Scoring iterations: 6
tidy(logit_model) |>
  mutate(
    odds_ratio = round(exp(estimate), 3),
    across(where(is.numeric), ~round(., 4))
  ) |>
  kable(
    caption   = "Logistic Regression — Predictors of Large-Campus Enrollment (30k+)",
    col.names = c("Term", "Estimate (log-odds)", "Std Error", "z Value", "p Value", "Odds Ratio"),
    escape    = FALSE
  )
Logistic Regression — Predictors of Large-Campus Enrollment (30k+)
Term Estimate (log-odds) Std Error z Value p Value Odds Ratio
(Intercept) -5.4061 2.9018 -1.8630 0.0625 0.004
seo_score 1.2496 1.0067 1.2413 0.2145 3.489
avg_interest 0.1172 0.0745 1.5739 0.1155 1.124
reddit_avg_sentiment -10.4744 8.2133 -1.2753 0.2022 0.000
yt_net_sentiment 1.9633 4.4324 0.4429 0.6578 7.123
# McFadden's pseudo-R²
null_ll  <- logLik(glm(large ~ 1, data = logit_data, family = binomial))
full_ll  <- logLik(logit_model)
mcfadden <- round(1 - as.numeric(full_ll) / as.numeric(null_ll), 3)

# Confusion matrix metrics (threshold = 0.5)
predicted_class <- as.integer(fitted(logit_model) >= 0.5)
actual          <- logit_data$large

tp <- sum(predicted_class == 1 & actual == 1)
tn <- sum(predicted_class == 0 & actual == 0)
fp <- sum(predicted_class == 1 & actual == 0)
fn <- sum(predicted_class == 0 & actual == 1)

accuracy  <- round((tp + tn) / length(actual), 3)
precision <- round(tp / (tp + fp), 3)
recall    <- round(tp / (tp + fn), 3)
f1        <- round(2 * precision * recall / (precision + recall), 3)

tibble(
  Metric = c("McFadden Pseudo-R²", "Accuracy", "Precision", "Recall (Sensitivity)", "F1 Score"),
  Value  = c(mcfadden, accuracy, precision, recall, f1),
  Notes  = c(
    "0 = null model, >0.2 = good fit",
    "% of campuses correctly classified",
    "Of predicted Large, % actually Large",
    "Of actual Large campuses, % correctly identified",
    "Harmonic mean of Precision & Recall"
  )
) |>
  kable(caption = "Logistic Regression — Model Fit Statistics", escape = FALSE)
Logistic Regression — Model Fit Statistics
Metric Value Notes
McFadden Pseudo-R² 0.386 0 = null model, >0.2 = good fit
Accuracy 0.913 % of campuses correctly classified
Precision 0.833 Of predicted Large, % actually Large
Recall (Sensitivity) 0.833 Of actual Large campuses, % correctly identified
F1 Score 0.833 Harmonic mean of Precision & Recall
logit_data_aug <- logit_data |>
  mutate(prob_large = fitted(logit_model),
         outcome    = factor(large, labels = c("Not Large", "Large (30k+)")))

p_logit <- ggplot(logit_data_aug,
                  aes(x = seo_score, y = prob_large, color = outcome, label = campus)) +
  geom_point(size = 3, alpha = 0.85) +
  geom_text_repel(size = 2.4, max.overlaps = Inf, box.padding = 0.35, seed = 42) +
  scale_color_manual(values = c("Not Large" = "#888888", "Large (30k+)" = "#CC0000")) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1), limits = c(0, 1)) +
  labs(
    title    = "Predicted Probability of Large-Campus Status by SEO Score",
    subtitle = "Logistic regression — color shows actual enrollment outcome",
    x        = "SEO Score (0–4)",
    y        = "Predicted Probability of Large (30k+)",
    color    = "Actual Outcome"
  ) +
  theme_minimal()

p_logit

ggsave("output_logistic.png", plot = p_logit, width = 8, height = 5, dpi = 150)

Business Questions Answered

BQ 2: Enrollment Across the CSU System

enrollment_clean |>
  mutate(
    rank             = row_number(),
    total_enrollment = formatC(total_enrollment, format = "d", big.mark = ",")
  ) |>
  select(rank, campus, total_enrollment) |>
  kable(caption = "CSU Enrollment — Ranked Largest to Smallest",
        col.names = c("Rank", "Campus", "Total Enrollment"),
        escape = FALSE)
CSU Enrollment — Ranked Largest to Smallest
Rank Campus Total Enrollment
1 CSU Long Beach 45,064
2 CSU Fullerton 40,421
3 CSU Northridge 37,701
4 San Diego State 37,014
5 San Jose State 35,915
6 CSU Sacramento 31,536
7 Cal Poly Pomona 27,782
8 CSU Los Angeles 27,311
9 San Francisco State 25,758
10 CSU Fresno 25,215
11 Cal Poly San Luis Obispo 22,108
12 CSU San Bernardino 20,622
13 CSU San Marcos 16,862
14 CSU Chico 16,549
15 CSU Dominguez Hills 16,484
16 CSU East Bay 14,710
17 CSU Bakersfield 11,201
18 CSU Stanislaus 11,103
19 CSU Monterey Bay 8,196
20 CSU Channel Islands 7,104
21 Sonoma State 6,912
22 Cal Poly Humboldt 6,699
23 CSU Maritime Academy 912

BQ 4–5: How Are Cal States Talked About? What is the Sentiment?

reddit_json_campus |>
  select(campus_std, n_posts, avg_sentiment, median_sentiment, sentiment_label) |>
  arrange(desc(avg_sentiment)) |>
  kable(caption = "Reddit Public Sentiment by Campus (BQ 4 & 5)",
        col.names = c("Campus", "# Posts", "Avg Sentiment", "Median Sentiment", "Tone"),
        escape = FALSE)
Reddit Public Sentiment by Campus (BQ 4 & 5)
Campus # Posts Avg Sentiment Median Sentiment Tone
CSU Chico 665 0.212 0.333 Positive
CSU Maritime Academy 21 0.209 0.600 Positive
Cal Poly Pomona 704 0.148 0.200 Positive
San Francisco State 615 0.086 0.000 Neutral
CSU Bakersfield 452 0.074 0.000 Neutral
CSU East Bay 468 0.061 0.091 Neutral
CSU Long Beach 708 0.050 0.000 Neutral
CSU San Marcos 431 0.043 0.000 Neutral
Cal Poly San Luis Obispo 785 0.039 0.000 Neutral
CSU Northridge 740 0.031 0.000 Neutral
CSU Stanislaus 489 0.030 0.000 Neutral
CSU Channel Islands 446 0.013 0.000 Neutral
CSU Monterey Bay 564 -0.002 0.000 Neutral
CSU Dominguez Hills 516 -0.022 0.000 Neutral
Sonoma State 361 -0.031 0.000 Neutral
CSU Sacramento 521 -0.039 0.000 Neutral
CSU Fresno 588 -0.063 0.000 Neutral
San Diego State 300 -0.072 0.000 Neutral
CSU Fullerton 671 -0.079 0.000 Neutral
Cal Poly Humboldt 2583 -0.082 0.000 Neutral
CSU San Bernardino 584 -0.100 -0.091 Neutral
CSU Los Angeles 486 -0.107 0.000 Negative
San Jose State 712 -0.200 -0.333 Negative

BQ 6: Differences in Tone Among Cal States

reddit_json_campus |>
  group_by(sentiment_label) |>
  summarise(
    campuses  = paste(campus_std, collapse = ", "),
    n         = n(),
    avg_score = round(mean(avg_sentiment), 3),
    .groups   = "drop"
  ) |>
  arrange(desc(avg_score)) |>
  kable(caption = "Tone Groups Across CSU Campuses (BQ 6)",
        col.names = c("Tone", "Campuses", "# Campuses", "Avg Sentiment"),
        escape = FALSE)
Tone Groups Across CSU Campuses (BQ 6)
Tone Campuses # Campuses Avg Sentiment
Positive CSU Chico, CSU Maritime Academy, Cal Poly Pomona 3 0.190
Neutral San Francisco State, CSU Bakersfield, CSU East Bay, CSU Long Beach, CSU San Marcos, Cal Poly San Luis Obispo, CSU Northridge, CSU Stanislaus, CSU Channel Islands, CSU Monterey Bay, CSU Dominguez Hills, Sonoma State, CSU Sacramento, CSU Fresno, San Diego State, CSU Fullerton, Cal Poly Humboldt, CSU San Bernardino 18 -0.004
Negative CSU Los Angeles, San Jose State 2 -0.154

BQ 7: Title Tags of the Top 5 Enrolled Schools

top5 <- enrollment_clean |>
  slice_max(total_enrollment, n = 5) |>
  left_join(seo_data |> select(school, title, meta_description), by = c("campus" = "school"))

top5 |>
  mutate(total_enrollment = formatC(total_enrollment, format = "d", big.mark = ",")) |>
  select(campus, total_enrollment, title, meta_description) |>
  kable(caption = "Title Tags — Top 5 Enrolled CSU Campuses (BQ 7)",
        col.names = c("Campus", "Total Enrollment", "Title Tag", "Meta Description"),
        escape = FALSE)
Title Tags — Top 5 Enrolled CSU Campuses (BQ 7)
Campus Total Enrollment Title Tag Meta Description
CSU Long Beach 45,064 California State University, Long Beach CSULB is a large, urban, comprehensive university in the 23-campus California State University system.
CSU Fullerton 40,421 Achieve Greatness: California State University, Fullerton Launch your career at CSUF, a top public Southern California university. 110 affordable degree programs. Large, diverse, supportive CSU campus.
CSU Northridge 37,701 California State University, Northridge CSUN stands out among Southern California universities with its dedication to student success, diversity, and community involvement. Get started now!
San Diego State 37,014 Home | San Diego State University NA
San Jose State 35,915 San José State University NA

BQ 8: Title Tags of the 5 Lowest-Enrolled Schools

bottom5 <- enrollment_clean |>
  slice_min(total_enrollment, n = 5) |>
  left_join(seo_data |> select(school, title, meta_description), by = c("campus" = "school"))

bottom5 |>
  mutate(total_enrollment = formatC(total_enrollment, format = "d", big.mark = ",")) |>
  select(campus, total_enrollment, title, meta_description) |>
  kable(caption = "Title Tags — 5 Lowest-Enrolled CSU Campuses (BQ 8)",
        col.names = c("Campus", "Total Enrollment", "Title Tag", "Meta Description"),
        escape = FALSE)
Title Tags — 5 Lowest-Enrolled CSU Campuses (BQ 8)
Campus Total Enrollment Title Tag Meta Description
CSU Maritime Academy 912 Home | Cal Poly Maritime Academy NA
Cal Poly Humboldt 6,699 Cal Poly Humboldt | California State Polytechnic University, Humboldt Explore Cal Poly Humboldt’s innovative, hands-on academic programs amplified by the incredible learning environment of California’s North Coast.
Sonoma State 6,912 NA NA
CSU Channel Islands 7,104 CSU Channel Islands Welcome to California State University Channel Islands (CSUCI) in Ventura County. Explore our diverse academic programs, campus life, and community engagement.
CSU Monterey Bay 8,196 Home | California State University Monterey Bay California State University, Monterey Bay (CSUMB) offers exceptional academic programs, hands-on learning, and a vibrant community in a stunning coastal setting. Discover your future at CSUMB., Cal State Monterey Bay offers an affordable, accessible education guided by mentorship and inspired by our coastal location.

BQ 1: How Is Our Class’s Sentiment Toward CSUCI?

Primary Data — CSUCI MSBA Cohort Survey (n = 12). A Google Form survey was distributed to students in the CSUCI MSBA program to capture firsthand perspectives on university search behavior, decision factors, and the role of digital platforms. Because respondents are current graduate students reflecting on their own enrollment journey, results represent insider student experience rather than a broad prospective-student sample. Given the small sample size, findings are treated as exploratory and directional.

survey_raw <- read.csv("customer_feedback.csv", check.names = FALSE)

survey <- survey_raw |>
  rename(
    timestamp        = 1,
    univ_types       = 2,
    search_freq      = 3,
    platforms        = 4,
    imp_tuition      = 5,
    imp_finaid       = 6,
    imp_reputation   = 7,
    imp_location     = 8,
    imp_online       = 9,
    imp_campus_env   = 10,
    imp_career       = 11,
    imp_faculty      = 12,
    imp_applic       = 13,
    imp_social_media = 14,
    imp_website      = 15,
    imp_support      = 16,
    likely_apply     = 17,
    reviews_changed  = 18,
    trusted_source   = 19,
    info_session     = 20,
    social_csu       = 21,
    reason_csuci     = 22
  )
importance_means <- survey |>
  summarise(across(starts_with("imp_"), \(x) mean(x, na.rm = TRUE))) |>
  pivot_longer(everything(), names_to = "factor", values_to = "mean_score") |>
  mutate(factor = dplyr::recode(factor,
    imp_tuition      = "Tuition / Cost",
    imp_finaid       = "Financial Aid",
    imp_reputation   = "Program Reputation",
    imp_location     = "Location",
    imp_online       = "Online / Flexible Options",
    imp_campus_env   = "Campus Environment",
    imp_career       = "Career Outcomes",
    imp_faculty      = "Faculty Reputation",
    imp_applic       = "Application Requirements",
    imp_social_media = "Social Media Presence",
    imp_website      = "Website Quality",
    imp_support      = "Support Programs"
  )) |>
  arrange(desc(mean_score))

importance_means |>
  kable(
    caption = "Mean Importance Ratings — CSUCI MSBA Cohort Survey (1 = Low, 5 = High)",
    col.names = c("Decision Factor", "Mean Score (1–5)"),
    digits = 2
  )
Mean Importance Ratings — CSUCI MSBA Cohort Survey (1 = Low, 5 = High)
Decision Factor Mean Score (1–5)
Tuition / Cost 4.42
Location 4.33
Program Reputation 4.17
Career Outcomes 4.08
Financial Aid 3.75
Application Requirements 3.75
Faculty Reputation 3.58
Website Quality 3.50
Campus Environment 3.42
Online / Flexible Options 3.33
Support Programs 3.00
Social Media Presence 2.42
p_importance <- ggplot(importance_means,
       aes(x = mean_score, y = reorder(factor, mean_score))) +
  geom_col(fill = "#C8102E", width = 0.65) +
  geom_text(aes(label = round(mean_score, 2)), hjust = -0.2, size = 3.2) +
  scale_x_continuous(limits = c(0, 5.4), breaks = 1:5) +
  labs(
    title  = "What Matters Most When Choosing a University?",
    subtitle = "CSUCI MSBA Cohort Survey — Mean Importance (1–5 scale, n = 12)",
    x = "Mean Score", y = NULL
  ) +
  theme_minimal(base_size = 12) +
  theme(panel.grid.major.y = element_blank())

print(p_importance)

ggsave("output_survey_importance.png", p_importance,
       width = 7, height = 5, dpi = 150, bg = "white")
platform_counts <- survey |>
  pull(platforms) |>
  strsplit(";") |>
  unlist() |>
  trimws() |>
  table() |>
  as.data.frame() |>
  rename(Platform = Var1, Count = Freq) |>
  arrange(desc(Count))

platform_counts |>
  kable(caption = "Research Platforms Used by MSBA Cohort (Multiple Selections Allowed)")
Research Platforms Used by MSBA Cohort (Multiple Selections Allowed)
Platform Count
University Websites 10
Google Search 9
Friends/Family recommendations 6
College ranking websites 3
Instagram 3
Other 3
LinkedIn 2
YouTube 2
Reddit 1
TikTok 1
survey |>
  count(trusted_source, name = "Respondents") |>
  arrange(desc(Respondents)) |>
  kable(
    caption = "Most Trusted Online Source When Researching a University",
    col.names = c("Trusted Source", "Respondents")
  )
Most Trusted Online Source When Researching a University
Trusted Source Respondents
University Website 6
Current Students/Alumni 3
AI Tools (ChatGPT, Gemini, Claude) 2
Google Search results 1
social_pct <- survey |>
  count(social_csu) |>
  mutate(Pct = paste0(round(n / sum(n) * 100), "%")) |>
  rename(Response = social_csu, Count = n)

reviews_pct <- survey |>
  count(reviews_changed) |>
  mutate(Pct = paste0(round(n / sum(n) * 100), "%")) |>
  rename(Response = reviews_changed, Count = n)

social_pct |>
  kable(caption = "Did social media influence your perception of any CSU campus?")
Did social media influence your perception of any CSU campus?
Response Count Pct
No 10 83%
Yes 2 17%
reviews_pct |>
  kable(caption = "Have online reviews or Reddit posts changed your opinion about a university?")
Have online reviews or Reddit posts changed your opinion about a university?
Response Count Pct
No 5 42%
Yes 7 58%
survey |>
  count(reason_csuci, name = "Count") |>
  arrange(desc(Count)) |>
  kable(
    caption = "Primary Reason for Choosing CSUCI — MSBA Cohort",
    col.names = c("Reason", "Count")
  )
Primary Reason for Choosing CSUCI — MSBA Cohort
Reason Count
Close to Home 5
Cost 4
Other 2
Brand Loyalty 1

Key Findings: Tuition/Cost, Program Reputation, Career Outcomes, and Location consistently ranked as the highest-importance factors — all areas where university websites and SEO visibility directly influence discoverability. Social media influenced at least some CSU campus perception for roughly half of respondents, and University Websites emerged as the most trusted research source, reinforcing the SEO findings in Part 2. The dominance of “Cost” and “Close to Home” as CSUCI enrollment drivers suggests that proximity and affordability messaging should anchor CSUCI’s digital content strategy.


Managerial Recommendations

Based on the enrollment analysis, SEO audit, sentiment analysis, and predictive modeling, we offer the following actionable recommendations to CSU marketing and admissions leadership.

1. Prioritize SEO Fixes at Under-Enrolled Campuses

Several small campuses — including CSU Maritime Academy, Sonoma State, and CSU Channel Islands — scored lowest on the SEO composite index. Title tags outside the 50–60 character optimal range and missing or duplicate H1 tags reduce search visibility precisely at campuses that most need to attract students. Recommendation: Conduct a structured SEO remediation sprint at all campuses scoring below 2 out of 4, targeting title tag length, meta description completeness, and H1 uniqueness.

2. Address Negative Sentiment Campuses with Proactive Messaging

Reddit and YouTube sentiment analysis identified campuses with consistently negative public discourse. Negative online sentiment, left unaddressed, compounds over time as prospective students rely on social media to evaluate campus choices. Recommendation: Marketing teams at campuses with below-average sentiment scores should audit the most-discussed topics in Reddit threads and YouTube comments to identify recurring pain points (housing, financial aid, campus culture) and respond with targeted content campaigns.

3. Leverage High-Sentiment Campuses as Messaging Templates

Campuses with strong positive sentiment — particularly among larger enrollments — demonstrate that scale and positive perception are not mutually exclusive. Recommendation: Study the messaging, social media strategy, and website content of high-sentiment, high-enrollment campuses (e.g., San Diego State, San Jose State) and use them as templates for mid-tier campuses seeking enrollment growth.

4. Invest in Search Trend Recovery for Declining Campuses

Google Trends data reveals that several campuses have experienced declining search interest over the five-year window. Declining search volume is an early indicator of weakening brand awareness among prospective students. Recommendation: Campuses showing a downward trend in Google search interest should increase investment in branded keyword campaigns, press releases, and social media content to rebuild search visibility before enrollment impacts materialize.

5. Use Cluster Segments for Differentiated Marketing Strategy

The k-means cluster analysis identified three distinct campus archetypes — differentiated by SEO strength and sentiment profile. Rather than a one-size-fits-all system-wide campaign, the CSU Chancellor’s Office marketing team should develop three differentiated playbooks aligned to each cluster: one for high-SEO/high-sentiment leaders, one for mid-tier campuses with improvement potential, and one for campuses requiring foundational digital investment.


Limitations and Future Research

Limitations

Sample size: With only 23 campuses, regression models have limited statistical power. Coefficients should be interpreted directionally rather than as precise predictors. A p-value threshold of 0.10 (rather than the conventional 0.05) is appropriate given the small N.

Enrollment data: Per-campus enrollment figures reflect Fall 2023 and were entered manually from the CSU Chancellor’s Office facts page, as the site blocks automated scraping. Systemwide headcount trends are available through Fall 2025 via the CSU Enrollment Dashboard.

Reddit and YouTube coverage: Not all 23 campuses have equal Reddit or YouTube representation. Smaller campuses (e.g., CSU Maritime Academy) have very few posts, making their sentiment scores less reliable than those for larger campuses with thousands of posts.

SEO snapshot: The SEO scrape represents a single point in time. Websites are updated frequently, and title tags or meta descriptions may have changed since data collection.

Sentiment lexicon: The Bing lexicon used for sentiment scoring is a general-purpose word list not trained on higher-education language. Domain-specific terms (e.g., “party school,” “commuter campus”) may be misclassified.

Google Trends normalization: Trends data is indexed relative to peak search volume within each batch query, not as absolute search counts. Comparisons across batches (which were run separately due to API limits) are approximate.

Future Research

  • Expand primary survey (BQ 1) beyond the MSBA cohort to prospective undergraduate students; a larger sample (n ≥ 100) would support conjoint analysis or EFA to quantify the relative weight of each decision factor.
  • Expand sentiment sources to include Bluesky, X (Twitter), and RateMyProfessors to reduce platform dependency.
  • Longitudinal modeling: Collect enrollment and sentiment data over multiple years to test whether sentiment changes predict enrollment changes with a lag.
  • Page speed and mobile usability: Incorporate Google PageSpeed Insights scores as additional SEO predictors.
  • Shiny dashboard: Deploy an interactive version of this report as a Shiny application to allow CSU administrators to filter and explore campus-specific findings.

Data Summary

tibble(
  Dataset = c(
    "SEO Data",
    "Website Overhaul History",
    "Enrollment (campus-level, Fall 2023)",
    "Enrollment (systemwide trends, 2015–2025)",
    "System KPIs (2024–2025)",
    "Google Trends (time series)",
    "Google Trends (summary)"
  ),
  Rows = c(
    nrow(seo_data),
    nrow(website_history),
    nrow(enrollment_clean),
    nrow(enrollment_trends),
    nrow(kpis_data),
    nrow(trends_data),
    nrow(trends_summary)
  ),
  File = c(
    "csu_seo_data.csv",
    "csu_website_history.csv",
    "csu_enrollment.csv",
    "enrollment_trends.csv",
    "kpis.csv",
    "csu_trends_timeseries.csv",
    "csu_trends_summary.csv"
  )
) |>
  kable(caption = "All Output Files")
All Output Files
Dataset Rows File
SEO Data 23 csu_seo_data.csv
Website Overhaul History 23 csu_website_history.csv
Enrollment (campus-level, Fall 2023) 23 csu_enrollment.csv
Enrollment (systemwide trends, 2015–2025) 11 enrollment_trends.csv
System KPIs (2024–2025) 10 kpis.csv
Google Trends (time series) 6026 csu_trends_timeseries.csv
Google Trends (summary) 23 csu_trends_summary.csv