## Picking joint bandwidth of 6.23
## Picking joint bandwidth of 4.85
Left and right leaning outlets shared the same four leading bi grams: “white house,” “Supreme Court,” “trump administration,” and “Iran war.” Beyond the core, the two sides separated. Left leaning headlines focused on people and controversies around Trump, including his aide Natalie Harp, the Kennedy Center, Jimmy Kimmel, JD Vance and Fox News. Along with that, includes policy fights over mail in voting and the trade war. Right leaning headlines leaned toward campaign and political opposition stories such as the RNC’s convention, Senate races, and weirdly streamer Hasan Piker, plus data centers and Trump’s proposal to rename Lake Ontario to “Lake America”.
The ridge line plots show that the shared bi grams appeared steadily across the two months, while most side specific bi grams were tied to short news cycles. On the left “Natalie harp” peaked in mid August, “trade war” and “mail-in voting” in late August, and “Jimmy Kimmel” and “Kennedy center” in early to mid September. On the right the Lake Ontario story spiked in late August and the midterm convention in early September.
These results need a caution warning on them though. Only 12 of the 41 outlets in the look up table matched the data, because the data lists many outlets by their domain rather than by name. The Guardian alone supplies more than 40% of left leaning headlines. Bi grams can also hide coverage based on what the outlets called someone. For example the right leaning outlets often called Abdul El-Sayed simply El-Sayed so his name never forms a bi gram.
I used AI to adapt the module’s congressional bi gram and ridge line scripts to the headlines _lr data frame. It also flagged the outlet mismatch, split hyphenated names and tied counts, which i then checked against the actual headlines.
# ============================================================
# 1. Load packages ----
# ============================================================
if (!require(tidyverse)) {
install.packages("tidyverse")
library(tidyverse)
}
if (!require(tidytext)) {
install.packages("tidytext")
library(tidytext)
}
if (!require(ggridges)) {
install.packages("ggridges")
library(ggridges)
}
# ============================================================
# 2. Download, filter and code the headlines (instructor script) ----
# ============================================================
download.file(
url = "https://github.com/drkblake/Data/raw/refs/heads/main/Trump_story_headlines.rds",
destfile = "Trump_story_headlines.rds",
mode = "wb"
)
headlines <- readRDS("Trump_story_headlines.rds")
source_ideology <- tribble(
~media_name, ~ideology,
# Left
"Talking Points Memo", "Left",
"Daily Kos", "Left",
"The New Replublic", "Left",
"Mother Jones", "Left",
"thenation.com", "Left",
"Raw Story", "Left",
"salon.com", "Left",
"Jezebel", "Left",
"slate.com", "Left",
"Truthout", "Left",
"colorlines.com", "Left",
"newsone.com", "Left",
"National Memo", "Left",
"TheRealNews.com", "Left",
"huffpost.com", "Left",
"AlterNet", "Left",
"vox.com", "Left",
"MintPress News", "Left",
"Mic", "Left",
"theintercept.com", "Left",
"theguardian.com", "Left",
"The Root", "Left",
# Right
"Hot Air", "Right",
"RedState", "Right",
"foxnews.com", "Right",
"National Review", "Right",
"NewsBusters", "Right",
"Daily Caller", "Right",
"PJ Media", "Right",
"breitbart.com", "Right",
"Townhall", "Right",
"CNS News", "Right",
"American Spectator", "Right",
"TheBlaze", "Right",
"newsmax.com", "Right",
"patriotpost.us", "Right",
"Daily Signal", "Right",
"One America News Network", "Right",
"Fox Business", "Right",
"shop.americanfreepress.net", "Right",
"National Catholic Register", "Right"
)
headlines_lr <- headlines %>%
inner_join(
source_ideology,
by = "media_name"
)
# Quick audit: which outlets actually matched?
outlet_counts <- headlines_lr %>%
count(ideology, media_name, sort = TRUE)
unmatched_outlets <- setdiff(source_ideology$media_name,
unique(headlines$media_name))
# ============================================================
# 3. Clean headline text ----
# ============================================================
headlines_clean <- headlines_lr %>%
# Drop the same headline repeated by the same outlet
distinct(media_name, title, .keep_all = TRUE) %>%
mutate(
title = title %>%
# Standardize curly apostrophes so stop words like "trump's" match
str_replace_all("[\u2018\u2019]", "'") %>%
# Keep hyphenated names/terms together (e.g., El-Sayed, op-ed)
str_replace_all("(\\p{L})-(\\p{L})", "\\1_\\2") %>%
# Treat "Trump admin" and "Trump administration" as the same phrase
str_replace_all(regex("\\badmin\\b", ignore_case = TRUE),
"administration")
)
# ============================================================
# 4. Tokenize into bigrams and remove stop words ----
# ============================================================
# Bigrams that only reflect how the corpus was built (every story
# mentions Trump) or an outlet's recurring column/format labels
custom_exclude <- c(
"donald trump", "president trump", # selection term
"op-ed", "guardian view", "morning mail", # column labels
"politics live", "watch live", "live updates" # format labels
)
bigrams <- headlines_clean %>%
unnest_tokens(bigram, title, token = "ngrams", n = 2) %>%
filter(!is.na(bigram)) %>%
separate(bigram, into = c("word1", "word2"), sep = " ") %>%
filter(
!word1 %in% stop_words$word,
!word2 %in% stop_words$word,
!str_detect(word1, "^[0-9]+$"),
!str_detect(word2, "^[0-9]+$")
) %>%
unite(bigram, word1, word2, sep = " ") %>%
mutate(bigram = str_replace_all(bigram, "_", "-")) %>%
filter(!bigram %in% custom_exclude)
# ============================================================
# 5. Top bigrams by outlet ideology ----
# ============================================================
top_n_bigrams <- 10
bigram_counts <- bigrams %>%
count(ideology, bigram, sort = TRUE)
top_bigrams <- bigram_counts %>%
group_by(ideology) %>%
# Keep ties at the cutoff rather than dropping them arbitrarily
slice_max(n, n = top_n_bigrams, with_ties = TRUE) %>%
ungroup()
party_colors <- c("Left" = "#2166AC", "Right" = "#B2182B")
bigram_graph <- top_bigrams %>%
mutate(bigram = reorder_within(bigram, n, ideology)) %>%
ggplot(aes(x = n, y = bigram, fill = ideology)) +
geom_col(show.legend = FALSE) +
facet_wrap(~ ideology, scales = "free_y",
labeller = as_labeller(c(Left = "Left-leaning outlets",
Right = "Right-leaning outlets"))) +
scale_y_reordered() +
scale_fill_manual(values = party_colors) +
labs(
title = "Top bigrams in Trump-related headlines, by outlet ideology",
subtitle = paste0("Top ", top_n_bigrams,
" bigrams (ties included) after removing stop words; Aug. 3 - Oct. 4, 2026"),
x = "Number of occurrences",
y = NULL,
caption = "Source: MediaCloud.org headlines mentioning \"Donald Trump\""
) +
theme_minimal(base_size = 12) +
theme(strip.text = element_text(face = "bold", size = 12),
panel.grid.major.y = element_blank())
# ============================================================
# 6. Ridgeline plots: top bigrams over time ----
# ============================================================
date_range <- range(headlines_clean$publish_date)
make_ridgeline <- function(side) {
side_top <- top_bigrams %>%
filter(ideology == side)
bigrams %>%
filter(ideology == side, bigram %in% side_top$bigram) %>%
mutate(bigram = factor(bigram,
levels = rev(side_top$bigram))) %>%
ggplot(aes(x = publish_date, y = bigram)) +
geom_density_ridges(
fill = party_colors[[side]],
color = "white",
alpha = 0.85,
scale = 1.6,
rel_min_height = 0.01,
# Keep density curves inside the actual data window
from = as.numeric(date_range[1]),
to = as.numeric(date_range[2])
) +
scale_x_date(limits = date_range, date_breaks = "2 weeks",
date_labels = "%b %d", expand = c(0.01, 0)) +
labs(
title = paste0("Top bigrams over time: ", side, "-leaning outlets"),
subtitle = "Distribution of each bigram's appearances by publish date (most frequent at top)",
x = "Publish date",
y = NULL,
caption = "Source: MediaCloud.org headlines mentioning \"Donald Trump\""
) +
theme_ridges(font_size = 12, grid = TRUE) +
theme(plot.title.position = "plot",
axis.title.x = element_text(hjust = 0.5))
}
ridge_left <- make_ridgeline("Left")
ridge_right <- make_ridgeline("Right")
# ============================================================
# 7. Display ----
# ============================================================
bigram_graph
ridge_left
ridge_right
top_bigrams
outlet_counts