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 size and geographic reach, 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 and marketing 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 possible struggle with attracting new students. This could be due to poor visibility online, broken public trust, and lost student enrollment. This analysis provides CSU marketing and admissions leadership with an evidence base for prioritizing digital investments and messaging improvements.
To establish a baseline for this analysis, we began with primary research inside our own classroom. A twelve-question survey was administered to the CSUCI MSBA cohort in July 2026, yielding twelve complete responses covering how students searched for programs, which sources they trusted, and what ultimately drove their enrollment decision. The results were decisive: nine of twelve respondents named either proximity to home or cost as their single biggest reason for choosing CSUCI, and the same two factors topped the importance rankings, with tuition averaging 4.42 and location 4.33 on a five-point scale. Social media presence finished last among twelve factors at 2.42, while the university website was the most trusted information source, cited by half the cohort — twice as often as any alternative. Yet the survey also surfaced a tension we could not ignore: despite ranking social platforms lowest, seven of twelve respondents acknowledged that online posts or reviews had changed their opinion of a university, and ten of twelve reported that positive discussion would make them more likely to apply. Stated importance, in other words, understates actual influence. The remainder of our analysis looks into two channels rather than one — the structural web presence and organic search visibility that determine whether a campus is found, and the public sentiment, negative sentiment especially, that shapes what a prospective student concludes once they find it.
Data was collected from five independent sources using web scraping, public APIs, and JSON data retrieval:
| Source | Method | Coverage |
|---|---|---|
| Class survey | Google Form → CSV | CSUCI MSBA cohort, n = 12, July 2026 |
| CSU homepage SEO | rvest web scraper |
22 of 23 campuses (Sonoma State blocked scraper) |
| Reddit discussions | Python scraper → JSON | 25,836 posts/comments, all 23 campuses |
| YouTube video stats | YouTube Data API v3 → 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 | What is the most important deciding factor for the MSBA cohort to join CSUCI? | Class survey (n = 12) |
| 2 | How do CSU campuses compare in enrollment size and geographic distribution? | calstate.edu (scraped) |
| 3 | How discoverable are CSU campus websites — and how do their SEO signals compare? | SEO scraper (title tags, meta descriptions, heading structure) |
| 4 | What are students saying about CSU campuses in public forums and on YouTube? | Reddit API; YouTube Data API v3 |
| 5 | Are students searching for CSU campuses — and is interest growing or declining? | Google Trends API |
| 6 | Can a campus’s digital presence and public sentiment predict its enrollment performance? | Regression, clustering, and logistic models |
| Priority | Recommendation | Impact | Difficulty |
|---|---|---|---|
| High | Improve homepage metadata | High | Low |
| High | Optimize title tags | High | Low |
| Medium | Monitor Reddit sentiment monthly | Medium | Medium |
| Medium | Expand YouTube strategy | Medium | Medium |
| Long Term | Build predictive enrollment dashboard | High | High |
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)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 |
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", weight = "bold")),
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())
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())
p_map_ucFall 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% |
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 @ Ref. Yr", "CSU Total Enrollment",
"Data Type", "Data Confidence"),
escape = FALSE)| Campus | Year Est. | Reference Year | Enrollment at Reference | % of CSU Total | Campuses @ Ref. Yr | 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 |
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 6 & 7.)
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 |
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.
Reddit data loaded from csu_clean.json — 25,836
posts/comments across all 23 CSU campuses.
# 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 3, 4 & 5)",
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 5)",
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 |
Below showcases the amount of posts each CSU has versus the comments and is looking at the sentiment behind each in Reddit.
library(kableExtra)
post_type_tbl <- reddit_json_sentiment |>
group_by(campus_std, item_type) |>
summarise(
n = n(),
avg_sentiment = round(mean(sentiment_score, na.rm = TRUE), 3),
.groups = "drop"
)
campus_order <- unique(post_type_tbl$campus_std)
row_bg <- ifelse(match(post_type_tbl$campus_std, campus_order) %% 2 == 0,
"#fde8e8", "white")
post_type_tbl |>
kable(caption = "Sentiment by Post Type (post vs. comment)",
col.names = c("Campus", "Post Type", "Count", "Avg Sentiment"),
escape = FALSE) |>
kable_styling(bootstrap_options = c("condensed"), full_width = TRUE) |>
column_spec(1:4, background = row_bg)| 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 video data provides a third public signal alongside Reddit and Google Trends. Video metadata — including titles, descriptions, views, likes, and comment counts — was collected via the YouTube Data API v3 (code shown below for reference). Sentiment is derived from video title and description text using the Bing lexicon; engagement metrics are summarized at the campus level.
Analysis — loaded from collected data:
library(readr)
# Video stats — used for engagement metrics
yt_videos <- read_csv("csu_video_stats.csv", show_col_types = FALSE) |>
filter(!is.na(csu_campus)) |>
mutate(
views = suppressWarnings(as.numeric(views)),
likes = suppressWarnings(as.numeric(likes)),
comments = suppressWarnings(as.numeric(comments))
)
invisible(nrow(yt_videos))
# Comments — used for word count and sentiment analysis
yt_raw <- read_csv("csu_youtube_comments.csv", show_col_types = FALSE)
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 != "")
invisible(nrow(long_comments_df))junk_words <- c(
"video", "channel", "youtube", "subscribe",
"university", "school", "college", "cal poly",
"it's", "bro", "campus", "i'm", "ahh", "are'nt", "waw", "hey"
)
csu_acronyms <- c(
"cpslo", "cph", "cpp",
"csub", "csuci", "csuc",
"csudh", "csueb", "csuf",
"csulb", "csula", "csumb",
"csun", "csus", "csusb",
"csusm", "csustan",
"sdsu", "sfsu", "sjsu",
"ssu", "csuma", "csum", "csu"
)
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(junk_words, csu_acronyms))
campus_yt_sentiment <- tidy_yt |>
inner_join(get_sentiments("bing"), by = "word", relationship = "many-to-many") |>
count(csu_campus, sentiment) |>
complete(csu_campus = unique(long_comments_df$csu_campus), sentiment, fill = list(n = 0)) |>
mutate(n_plot = ifelse(sentiment == "negative", -n, n))
p_yt_sent <- ggplot(campus_yt_sentiment, aes(x = n_plot, y = csu_campus, fill = sentiment)) +
geom_col() +
scale_fill_manual(values = c("positive" = "steelblue", "negative" = "firebrick")) +
labs(
title = "Positive vs. Negative Sentiment in\nYouTube Comments by CSU Campus",
x = "Word Count (Negative ← | → Positive)",
y = NULL,
fill = "Sentiment"
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0),
axis.text.y = element_text(face = "bold")
)
p_yt_sentlibrary(tidytext)
top_words_per_campus <- tidy_yt |>
count(csu_campus, word, sort = TRUE) |>
group_by(csu_campus) |>
slice_max(n, n = 5, 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 Words in YouTube Video Comments by CSU Campus",
x = "Word Frequency",
y = NULL) +
theme_minimal() +
theme(strip.text = element_text(size = 8, face = "bold"))
csu_bar_plotsyt_engagement <- yt_videos |>
group_by(csu_campus) |>
summarise(
Videos = n(),
Total_Views = sum(views, na.rm = TRUE),
Avg_Views = round(mean(views, na.rm = TRUE)),
Total_Likes = sum(likes, na.rm = TRUE),
.groups = "drop"
) |>
arrange(desc(Total_Views)) |>
mutate(Total_Views = formatC(Total_Views, format = "d", big.mark = ","),
Avg_Views = formatC(Avg_Views, format = "d", big.mark = ","),
Total_Likes = formatC(Total_Likes, format = "d", big.mark = ","))
yt_engagement |>
kable(
caption = "YouTube Engagement Metrics by CSU Campus",
col.names = c("Campus", "Videos", "Total Views", "Avg Views / Video", "Total Likes")
)| Campus | Videos | Total Views | Avg Views / Video | Total Likes |
|---|---|---|---|---|
| San Jose State University | 5 | 645,218 | 129,044 | 10,146 |
| California State University Long Beach | 5 | 367,036 | 73,407 | 3,064 |
| Sonoma State University | 5 | 260,981 | 52,196 | 250 |
| San Diego State University | 5 | 243,135 | 48,627 | 2,650 |
| California State University Northridge | 5 | 182,953 | 36,591 | 1,559 |
| California State University Sacramento | 5 | 146,999 | 29,400 | 1,549 |
| California State University Fullerton | 5 | 135,562 | 27,112 | 1,362 |
| California State University Chico | 5 | 128,635 | 25,727 | 966 |
| Cal Poly San Luis Obispo | 5 | 120,587 | 24,117 | 1,304 |
| Cal Poly Pomona | 5 | 111,540 | 22,308 | 916 |
| California State University Los Angeles | 5 | 108,148 | 21,630 | 1,090 |
| California State University San Marcos | 5 | 99,429 | 19,886 | 489 |
| California State University San Bernardino | 5 | 81,776 | 16,355 | 719 |
| California State University East Bay | 5 | 81,345 | 16,269 | 688 |
| California State University Fresno | 5 | 71,560 | 14,312 | 644 |
| San Francisco State University | 5 | 60,814 | 12,163 | 1,367 |
| California State University Dominguez Hills | 5 | 58,281 | 11,656 | 549 |
| California State University Maritime Academy | 5 | 56,778 | 11,356 | 300 |
| California State University Bakersfield | 5 | 56,521 | 11,304 | 458 |
| California State University Monterey Bay | 5 | 48,727 | 9,745 | 459 |
| California State University Channel Islands | 5 | 46,091 | 9,218 | 397 |
| Cal Poly Humboldt | 5 | 36,788 | 7,358 | 452 |
| California State University Stanislaus | 5 | 16,021 | 3,204 | 182 |
Search interest for each campus over the past 5 years, CSU 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 (CSU, 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.5 | 80 | 2022-08-28 |
| CSU Fresno | 25.4 | 100 | 2021-09-19 |
| San Diego State | 19.5 | 100 | 2023-04-02 |
| Cal Poly Pomona | 14.0 | 26 | 2023-01-29 |
| San Jose State | 13.8 | 30 | 2023-08-27 |
| CSU Sacramento | 13.3 | 24 | 2022-07-17 |
| Cal Poly San Luis Obispo | 10.7 | 38 | 2024-03-10 |
| San Francisco State | 9.9 | 16 | 2022-03-13 |
| CSU Fullerton | 7.6 | 15 | 2022-03-13 |
| CSU Los Angeles | 7.3 | 17 | 2022-08-21 |
| Sonoma State | 6.6 | 21 | 2025-01-19 |
| CSU Long Beach | 3.9 | 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 Bakersfield | 1.0 | 2 | 2022-11-27 |
| CSU Monterey Bay | 1.0 | 3 | 2025-08-03 |
| CSU San Bernardino | 1.0 | 1 | 2021-08-01 |
| CSU San Marcos | 1.0 | 2 | 2022-01-02 |
| CSU Stanislaus | 1.0 | 1 | 2021-08-01 |
| CSU Channel Islands | 0.8 | 1 | 2021-08-01 |
| CSU Dominguez Hills | 0.8 | 2 | 2023-11-26 |
library(plotly)
library(crosstalk)
library(htmltools)
shared_trends <- SharedData$new(trends_data)
campus_names <- sort(unique(trends_data$campus))
plotly_cycle <- c('#636EFA','#EF553B','#00CC96','#AB63FA','#FFA15A',
'#19D3F3','#FF6692','#B6E880','#FF97FF','#FECB52')
campus_colors <- setNames(rep(plotly_cycle, length.out = length(campus_names)), campus_names)
campus_colors["CSU Channel Islands"] <- "black"
campus_checkboxes <- filter_checkbox(
id = "campus_check",
label = NULL,
sharedData = shared_trends,
group = ~campus,
inline = FALSE
)
controls <- tags$div(
style = "padding: 6px 0;",
tags$p(
style = "font-weight: bold; font-size: 13px; margin: 0 0 2px 0;",
"Select campuses:"
),
tags$p(
style = "font-size: 11px; color: #666; margin: 0 0 6px 0;",
"Leave all unchecked to show all"
),
tags$div(
style = "display: flex; gap: 6px; margin-bottom: 6px;",
tags$button(
"Select All",
style = "font-size: 11px; padding: 3px 8px; cursor: pointer;",
onclick = "document.querySelectorAll('#campus_check input[type=checkbox]').forEach(function(cb){ cb.checked = true; cb.dispatchEvent(new Event('change', {bubbles:true})); });"
),
tags$button(
"Deselect All",
style = "font-size: 11px; padding: 3px 8px; cursor: pointer;",
onclick = "document.querySelectorAll('#campus_check input[type=checkbox]').forEach(function(cb){ cb.checked = false; cb.dispatchEvent(new Event('change', {bubbles:true})); });"
)
),
tags$div(
style = "max-height: 220px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; padding: 4px 8px;",
campus_checkboxes
)
)
trends_plot <- plot_ly(
shared_trends,
x = ~date,
y = ~hits,
color = ~campus,
colors = campus_colors,
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 — CSU Campuses",
font = list(color = "#CC0000", size = 14, weight = "bold")
),
xaxis = list(title = "Date"),
yaxis = list(title = "Search Interest (0–100)", rangemode = "tozero"),
legend = list(title = list(text = "Campus"), font = list(size = 9)),
hovermode = "closest"
) |>
htmlwidgets::onRender("
function(el) {
var minTop = 2;
var busy = false;
el.on('plotly_afterplot', function() {
if (busy) return;
// Max y value across all currently visible traces
var maxY = 0;
(el._fullData || []).forEach(function(t) {
if (t.visible !== false && Array.isArray(t.y)) {
t.y.forEach(function(v) { if (+v > maxY) maxY = +v; });
}
});
var yr = el._fullLayout && el._fullLayout.yaxis && el._fullLayout.yaxis.range;
var cur = yr ? yr[1] : null;
if (maxY > 0 && maxY < minTop && (cur === null || cur < minTop)) {
// Visible data is below the floor — lock axis to minTop
busy = true;
Plotly.relayout(el, {'yaxis.range[0]': 0, 'yaxis.range[1]': minTop})
.then(function() { busy = false; });
} else if (maxY >= minTop && cur !== null && cur <= minTop + 0.01) {
// Data now exceeds minTop but axis is still locked — release to autorange
busy = true;
Plotly.relayout(el, {'yaxis.autorange': true})
.then(function() { busy = false; });
}
});
}
")
bscols(
widths = c(3, 9),
controls,
trends_plot
)Select campuses:
Leave all unchecked to show all
Can SEO strength and public sentiment predict enrollment? We join campus-level data from four sources — SEO audit, Reddit sentiment, YouTube comment sentiment, and Google Trends search interest — 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 (same formula as seo-score chunk) ---
seo_model <- 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
) |>
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)))
invisible(nrow(model_data))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")
par(col.main = "#CC0000", font.main = 2)
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 & Google Trends",
mar = c(0, 0, 2, 0))png("output_correlation_matrix.png", width = 800, height = 700, res = 150)
par(col.main = "#CC0000", font.main = 2)
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 & Google Trends",
mar = c(0, 0, 2, 0))
dev.off()library(broom)
tidy(reg_model) |>
mutate(
term = recode(term, "avg_interest" = "google_trends"),
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) | 6907.4959 | 5367.648 | 1.2869 | 0.2144 |
| seo_score | 3836.0709 | 1918.353 | 1.9997 | 0.0609 |
| google_trends | 505.1861 | 224.374 | 2.2515 | 0.0371 |
| reddit_avg_sentiment | -34052.6919 | 21616.008 | -1.5753 | 0.1326 |
| yt_net_sentiment | 15986.9874 | 12561.001 | 1.2727 | 0.2193 |
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")),
size_tier = case_when(
total_enrollment >= 30000 ~ "Large (30k+)",
total_enrollment >= 15000 ~ "Medium (15k–30k)",
TRUE ~ "Small (<15k)"
),
size_tier = factor(size_tier, levels = c("Large (30k+)", "Medium (15k–30k)", "Small (<15k)"))
)
p_clust <- ggplot(model_data_clust,
aes(x = reddit_avg_sentiment, y = seo_score,
color = cluster, shape = size_tier, label = campus)) +
geom_point(size = 4, alpha = 0.85) +
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_shape_manual(values = c("Large (30k+)" = 17, "Medium (15k–30k)" = 16, "Small (<15k)" = 15)) +
labs(
title = "Campus Clusters — SEO Score vs. Reddit Sentiment",
x = "Reddit Avg Sentiment (−1 to +1)",
y = "SEO Score (0–4)",
color = "Cluster",
shape = "Enrollment Size"
) +
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 San Bernardino | 20,622 | 2 | -0.100 | 0.000 |
| Cluster 1 | CSU San Marcos | 16,862 | 1 | 0.043 | 0.000 |
| Cluster 1 | CSU Dominguez Hills | 16,484 | 1 | -0.022 | 0.000 |
| Cluster 1 | CSU Stanislaus | 11,103 | 2 | 0.030 | 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 | CSU Fullerton | 40,421 | 4 | -0.079 | 0.000 |
| Cluster 2 | CSU Northridge | 37,701 | 4 | 0.031 | 0.000 |
| Cluster 2 | San Diego State | 37,014 | 3 | -0.072 | 0.000 |
| Cluster 2 | San Jose State | 35,915 | 3 | -0.200 | 0.000 |
| Cluster 2 | CSU Sacramento | 31,536 | 3 | -0.039 | 0.000 |
| Cluster 2 | CSU Los Angeles | 27,311 | 3 | -0.107 | 0.000 |
| Cluster 2 | CSU Fresno | 25,215 | 3 | -0.063 | 0.000 |
| Cluster 2 | Cal Poly Humboldt | 6,699 | 3 | -0.082 | -0.429 |
| Cluster 3 | CSU Long Beach | 45,064 | 4 | 0.050 | 0.000 |
| Cluster 3 | Cal Poly Pomona | 27,782 | 2 | 0.148 | 0.333 |
| Cluster 3 | San Francisco State | 25,758 | 3 | 0.086 | 0.000 |
| Cluster 3 | Cal Poly San Luis Obispo | 22,108 | 3 | 0.039 | 0.600 |
| Cluster 3 | CSU Chico | 16,549 | 3 | 0.212 | 0.000 |
| Cluster 3 | CSU East Bay | 14,710 | 4 | 0.061 | 0.000 |
| Cluster 3 | CSU Bakersfield | 11,201 | 3 | 0.074 | 0.000 |
| Cluster 3 | CSU Monterey Bay | 8,196 | 3 | -0.002 | 0.000 |
| Cluster 3 | CSU Channel Islands | 7,104 | 4 | 0.013 | 0.000 |
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.
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) | -14.5497 | 8.6707 | -1.6780 | 0.0933 | 0.000 |
| seo_score | 3.6042 | 2.2989 | 1.5678 | 0.1169 | 36.752 |
| avg_interest | 0.1414 | 0.1175 | 1.2038 | 0.2287 | 1.152 |
| reddit_avg_sentiment | -18.2719 | 14.5124 | -1.2591 | 0.2080 | 0.000 |
| yt_net_sentiment | 0.7954 | 5.1581 | 0.1542 | 0.8775 | 2.215 |
# 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.543 | 0 = null model, >0.2 = good fit |
| Accuracy | 0.870 | % of campuses correctly classified |
| Precision | 0.800 | Of predicted Large, % actually Large |
| Recall (Sensitivity) | 0.667 | Of actual Large campuses, % correctly identified |
| F1 Score | 0.727 | 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.6, max.overlaps = Inf, box.padding = 0.5,
point.padding = 0.3, force = 3, min.segment.length = 0,
segment.color = "grey60", 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_logitCSUCI is compared here against its closest enrollment-tier peers: Sonoma State, CSU Monterey Bay, CSU Stanislaus, and Cal Poly Humboldt.
peer_campuses <- c(
"CSU Channel Islands",
"Sonoma State",
"CSU Monterey Bay",
"CSU Stanislaus",
"Cal Poly Humboldt"
)
model_data |>
filter(campus %in% peer_campuses) |>
select(campus, total_enrollment, seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment) |>
mutate(
total_enrollment = formatC(total_enrollment, format = "d", big.mark = ","),
across(c(seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment), ~round(., 3))
) |>
arrange(desc(total_enrollment)) |>
kable(
caption = "CSUCI vs. Enrollment-Tier Peers — Digital Presence Comparison",
col.names = c("Campus", "Enrollment", "SEO Score", "Google Trends Interest",
"Reddit Sentiment", "YouTube Sentiment")
)| Campus | Enrollment | SEO Score | Google Trends Interest | Reddit Sentiment | YouTube Sentiment |
|---|---|---|---|---|---|
| CSU Monterey Bay | 8,196 | 3 | 1.0 | -0.002 | 0.000 |
| CSU Channel Islands | 7,104 | 4 | 0.8 | 0.013 | 0.000 |
| Sonoma State | 6,912 | 0 | 6.6 | -0.031 | 0.000 |
| Cal Poly Humboldt | 6,699 | 3 | 3.5 | -0.082 | -0.429 |
| CSU Stanislaus | 11,103 | 2 | 1.0 | 0.030 | 0.000 |
Explore CSU enrollment trends, SEO scores, and sentiment data interactively. Use the filters to compare campuses side by side.
Launch Interactive Dashboard →
| Priority | Recommendation | Impact | Difficulty |
|---|---|---|---|
| 🔴 High | Add missing meta descriptions to 6 campuses | High | Low |
| 🔴 High | Fix title tags outside the 50–60 character range | High | Low |
| 🟡 Medium | Monitor Reddit sentiment monthly by campus | Medium | Medium |
| 🟡 Medium | Expand CSUCI YouTube content strategy | Medium | Medium |
| 🟡 Medium | Launch branded keyword campaigns for declining-trend campuses | Medium | High |
| 🟢 Long Term | Build an interactive Shiny enrollment dashboard | High | High |
Based on the enrollment analysis, SEO audit, sentiment analysis, and predictive modeling, we offer the following actionable recommendations to CSU marketing and admissions leadership.
Two campuses scored just 1 out of 4 on the SEO composite index: CSU Dominguez Hills (missing H1, meta description exceeding 160 characters) and CSU San Marcos (title tag exceeding the 60-character limit, no meta description). Both also lacked H3 tags, failing the heading hierarchy check. Sonoma State could not be evaluated — its website blocked the web scraper, preventing data collection entirely; a manual SEO audit is warranted, and the campus should investigate whether its bot-blocking configuration is also impeding legitimate search engine indexing by Googlebot. Recommendation: Conduct a structured SEO remediation sprint at all campuses scoring below 2 out of 4, targeting title tag length (10–60 characters), meta description completeness and length (50–160 characters), H1 uniqueness, and heading hierarchy.
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.
Upcoming institutional change: CSU Maritime Academy and Cal Poly San Luis Obispo are scheduled to merge in Fall 2026. Analyses treating these as separate institutions reflect the current structure and will require updating once the merger is complete. Findings related to CSU Maritime Academy should be interpreted with this transition in mind.
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)",
"YouTube Video Stats",
"Primary Survey — MSBA Cohort"
),
Rows = c(
sum(seo_data$status == "ok", na.rm = TRUE),
nrow(website_history),
nrow(enrollment_clean),
nrow(enrollment_trends),
nrow(kpis_data),
nrow(trends_data),
nrow(trends_summary),
nrow(yt_videos),
nrow(survey)
),
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",
"csu_video_stats.csv",
"customer_feedback.csv"
)
) |>
kable(caption = "All Output Files")| Dataset | Rows | File |
|---|---|---|
| SEO Data | 22 | 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) | 11 | kpis.csv |
| Google Trends (time series) | 6026 | csu_trends_timeseries.csv |
| Google Trends (summary) | 23 | csu_trends_summary.csv |
| YouTube Video Stats | 115 | csu_video_stats.csv |
| Primary Survey — MSBA Cohort | 12 | customer_feedback.csv |
CSU Annual Facts PDFs: 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023
Primary sources (official Google documentation):
Supporting industry sources: