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?
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 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 %) |
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.
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)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)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
)| 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 |
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_mapuc_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_ucPer-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
Fall headcount across all 23 CSU campuses combined, 2015–2025, including the pandemic-era dip (2021–2023) and two years of recovery (2024–2025). Source: CSU Enrollment Dashboard (tableau.calstate.edu).
enrollment_trends <- read.csv("enrollment_trends.csv", stringsAsFactors = FALSE)
kpis_data <- read.csv("kpis.csv", stringsAsFactors = FALSE)enrollment_trends |>
mutate(headcount = scales::comma(headcount),
pct_change = ifelse(is.na(pct_change), "—", paste0(pct_change, "%"))) |>
kable(caption = "CSU Systemwide Fall Headcount — Fall 2015–2025",
col.names = c("Year", "Headcount", "YoY % Change"),
escape = FALSE)| Year | Headcount | YoY % Change |
|---|---|---|
| 2015 | 474,571 | — |
| 2016 | 478,638 | 0.9% |
| 2017 | 484,297 | 1.2% |
| 2018 | 481,210 | -0.6% |
| 2019 | 481,929 | 0.1% |
| 2020 | 485,550 | 0.8% |
| 2021 | 477,466 | -1.7% |
| 2022 | 457,992 | -4.1% |
| 2023 | 454,640 | -0.7% |
| 2024 | 461,612 | 1.5% |
| 2025 | 471,451 | 2.1% |
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.
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)| school | url |
|---|---|
| Sonoma State | https://www.sonoma.edu |
8 of 22 CSU campuses (36%) have no meta description.
Missing
Present
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()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()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()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()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)| 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_yearis the year with the most Wayback Machine snapshots — heavy archiving activity usually indicates a major redesign period.estimated_last_overhaulcombines both signals. Campuses withsite_age_years> 3 should be flagged: their current SEO elements predate recent enrollment cycles and weaken any correlation analysis.
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)| 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.
Search interest for each campus over the past 5 years, California
only. Campuses are pulled in batches of 4 with "CSU" as a
normalizing anchor term in every batch.
gt_terms <- tibble(
campus = csu_campuses$campus,
trends_term = c(
"Cal Poly Humboldt",
"Cal Poly Pomona",
"Cal Poly SLO",
"CSU Bakersfield",
"Cal State Channel Islands",
"CSU Chico",
"CSU Dominguez Hills",
"CSU East Bay",
"Fresno State",
"Cal State Fullerton",
"Cal State Long Beach",
"Cal State LA",
"Cal Maritime",
"CSU Monterey Bay",
"CSUN",
"Sacramento State",
"Cal State San Bernardino",
"Cal State San Marcos",
"CSU Stanislaus",
"SDSU",
"San Francisco State",
"San Jose State",
"Sonoma State"
)
)
anchor <- "CSU"
batch_size <- 4
batches <- split(gt_terms$trends_term, ceiling(seq_along(gt_terms$trends_term) / batch_size))
fetch_trends_batch <- function(terms, anchor, batch_num) {
message("Google Trends batch ", batch_num, ": ", paste(terms, collapse = ", "))
Sys.sleep(3)
tryCatch({
result <- gtrends(
keyword = c(terms, anchor),
geo = "US-CA",
time = "today+5-y",
onlyInterest = TRUE
)
result$interest_over_time |>
as_tibble() |>
filter(keyword != anchor) |>
select(date, keyword, hits) |>
mutate(
hits = suppressWarnings(as.numeric(hits)),
date = as.Date(date)
)
}, error = function(e) {
message(" ERROR batch ", batch_num, ": ", e$message)
tibble(date = as.Date(NA), keyword = terms, hits = NA_real_)
})
}
trends_raw <- imap(batches, ~ fetch_trends_batch(.x, anchor, .y)) |>
list_rbind()
trends_data <- trends_raw |>
left_join(gt_terms, by = c("keyword" = "trends_term")) |>
select(campus, keyword, date, hits) |>
arrange(campus, date)
trends_summary <- trends_data |>
group_by(campus) |>
summarise(
avg_interest = round(mean(hits, na.rm = TRUE), 1),
peak_interest = max(hits, na.rm = TRUE),
peak_date = if (any(!is.na(hits))) date[which.max(replace(hits, is.na(hits), -Inf))] else as.Date(NA),
.groups = "drop"
) |>
arrange(desc(avg_interest))
write.csv(trends_data, "csu_trends_timeseries.csv", row.names = FALSE)
write.csv(trends_summary, "csu_trends_summary.csv", row.names = FALSE)
kable(trends_summary, caption = "Google Trends — Avg Search Interest by Campus (CA, last 5 years)",
col.names = c("Campus", "Avg Interest", "Peak Interest", "Peak Date"),
escape = FALSE)| Campus | Avg Interest | Peak Interest | Peak Date |
|---|---|---|---|
| CSU Northridge | 40.1 | 82 | 2022-08-28 |
| CSU Fresno | 26.5 | 100 | 2021-09-19 |
| San Diego State | 18.7 | 100 | 2023-04-02 |
| Cal Poly Pomona | 13.9 | 27 | 2023-01-29 |
| San Jose State | 13.6 | 30 | 2023-08-27 |
| CSU Sacramento | 13.1 | 24 | 2022-07-17 |
| Cal Poly San Luis Obispo | 10.6 | 39 | 2024-03-10 |
| San Francisco State | 9.7 | 16 | 2022-03-13 |
| CSU Fullerton | 7.9 | 16 | 2022-03-13 |
| CSU Los Angeles | 7.6 | 18 | 2022-08-21 |
| Sonoma State | 6.6 | 21 | 2025-01-19 |
| CSU Long Beach | 4.1 | 7 | 2022-02-13 |
| Cal Poly Humboldt | 3.5 | 16 | 2024-04-21 |
| CSU Chico | 2.4 | 5 | 2021-08-22 |
| CSU East Bay | 2.2 | 4 | 2022-08-14 |
| CSU Maritime Academy | 1.2 | 3 | 2024-06-02 |
| CSU Monterey Bay | 1.0 | 3 | 2025-08-03 |
| CSU San Bernardino | 1.0 | 1 | 2021-07-11 |
| CSU San Marcos | 1.0 | 2 | 2022-01-02 |
| CSU Stanislaus | 1.0 | 1 | 2021-08-01 |
| CSU Bakersfield | 0.9 | 2 | 2022-11-27 |
| CSU Channel Islands | 0.9 | 1 | 2021-07-25 |
| CSU Dominguez Hills | 0.8 | 2 | 2023-11-26 |
plot_ly(trends_data, x = ~date, y = ~hits, color = ~campus, type = "scatter", mode = "lines",
hovertemplate = paste("<b>%{fullData.name}</b><br>",
"Date: %{x|%b %Y}<br>",
"Interest: %{y}<extra></extra>")) |>
layout(
title = list(text = "Google Search Interest Over Time — All CSU Campuses",
font = list(color = "#CC0000", size = 14)),
xaxis = list(title = "Date"),
yaxis = list(title = "Search Interest (0–100)"),
legend = list(title = list(text = "Campus"), font = list(size = 9)),
hovermode = "closest"
)Reddit data loaded from csu_clean.json — 25,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 = ", "))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)| 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 |
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)| 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 |
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)| 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 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()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_plotsCan 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
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
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
)| 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_regK-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_clustmodel_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
)| 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 |
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
)| 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)| 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_logitenrollment_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)| 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 |
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)| 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 |
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 | 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 |
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
)| 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)")| Platform | Count |
|---|---|
| University Websites | 10 |
| Google Search | 9 |
| Friends/Family recommendations | 6 |
| College ranking websites | 3 |
| 3 | |
| Other | 3 |
| 2 | |
| YouTube | 2 |
| 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")
)| 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?")| 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?")| 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")
)| 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.
Based on the enrollment analysis, SEO audit, sentiment analysis, and predictive modeling, we offer the following actionable recommendations to CSU marketing and admissions leadership.
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.
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.
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.
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.
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.
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.
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")| 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 |