The data were obtained from the course-provided GitHub file containing Media Cloud news headlines mentioning Donald Trump. Outlets were classified as left-leaning or right-leaning using the instructor-provided source lookup. I converted each headline into consecutive two-word combinations (bigrams), removed bigrams containing stop words, and compared frequencies by orientation. I then plotted monthly counts for each orientation’s ten most common bigrams. Monthly counts represent raw occurrences, not rates adjusted for changes in total headline volume.
## [1] "id" "indexed_date" "language" "media_name" "media_url"
## [6] "publish_date" "title" "url" "ideology"
| ideology | bigram | frequency |
|---|---|---|
| Left | white house | 123 |
| Left | supreme court | 79 |
| Left | op ed | 68 |
| Left | donald trump | 66 |
| Left | iran war | 53 |
| Left | trump administration | 44 |
| Left | fox news | 40 |
| Left | natalie harp | 38 |
| Left | abdul el | 34 |
| Left | el sayed | 34 |
| Left | kennedy center | 32 |
| Left | jimmy kimmel | 26 |
| Left | morning mail | 26 |
| Left | guardian view | 24 |
| Left | jd vance | 24 |
| Right | white house | 92 |
| Right | el sayed | 58 |
| Right | supreme court | 46 |
| Right | iran war | 42 |
| Right | donald trump | 40 |
| Right | executive news | 40 |
| Right | news summary | 40 |
| Right | trump admin | 34 |
| Right | president trump | 27 |
| Right | lake america | 25 |
| Right | short cuts | 25 |
| Right | lake ontario | 22 |
| Right | trump administration | 22 |
| Right | data centers | 20 |
| Right | midterm convention | 20 |
The analysis compares repeated two-word phrases in headlines mentioning Donald Trump across news outlets categorized as politically left-leaning or right-leaning. In the comparison chart, the most common left-leaning bigram was white house (123 occurrences). The most common right-leaning bigram was white house (92 occurrences). These rankings show which phrases appeared most often, but do not by themselves establish whether the outlets expressed favorable or unfavorable opinions about Trump.
The two ridgeline plots show that the popularity of particular phrases was not necessarily constant across the observation period. A peak indicates that a phrase appeared in more headlines during that month, possibly because of a major event or developing story. The monthly plots use raw counts, so differences may also reflect changes in the total number of published or collected headlines. Similarly, the number of outlets and headlines may differ between ideological groups, making direct comparisons of raw totals imperfect. The ridgeline plots illustrate changes in bigram frequencies across August, September, and October 2026. The dataset contained 3,119 headlines in August, 3,678 in September, and 347 in October. Because October had substantially fewer headlines, decreases in bigram frequencies during that month should not automatically be interpreted as declining media interest. Instead, these differences may reflect variations in the number of headlines collected. Additionally, the three-month observation period limits the ability to identify longer-term trends in political news coverage.
I used AI to adapt the course’s congressional bigram and ridgeline
workflow to the instructor-provided headlines_lr data. AI
helped organize tokenization, frequency counting, monthly aggregation,
and ggplot2 visualizations in R Markdown. I evaluated the output by
checking the source classifications, dataset columns, bigram rankings,
and whether the plots matched the calculated frequencies. This analysis
describes patterns in headline language rather than proving political
bias or the intent of individual journalists.
The complete R code used to produce the graphs appears below.
url <- "https://github.com/drkblake/Data/raw/refs/heads/main/Trump_story_headlines.rds"if (!file.exists("Trump_story_headlines.rds")) download.file(url, "Trump_story_headlines.rds", mode="wb")headlines <- readRDS("Trump_story_headlines.rds")source_ideology <- tibble::tribble( ~media_name, ~ideology, "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", "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")if (nrow(headlines_lr)==0) stop("No outlets matched the lookup table. Check media_name values.")# Inspect names, then choose the relevant text and time fields.print(names(headlines_lr))text_candidates <- c("title", "headline", "story_title", "story_headline", "name")date_candidates <- c("publish_date", "publish_time", "published_at", "published", "publication_date", "date", "pub_date", "date_published", "publish_date_time", "publication_time")text_col <- intersect(text_candidates, names(headlines_lr))[1]date_col <- intersect(date_candidates, names(headlines_lr))[1]if (is.na(text_col) || is.na(date_col)) { stop(paste("Check actual column names and update text_candidates/date_candidates:", paste(names(headlines_lr), collapse=", ")))}parse_news_date <- function(x) { if (inherits(x, "Date")) return(x) if (inherits(x, "POSIXt")) return(as.Date(x)) if (is.numeric(x)) return(as.Date(as.POSIXct(x, origin="1970-01-01", tz="UTC"))) as.Date(lubridate::parse_date_time(as.character(x), orders=c("ymd HMS", "ymd HM", "ymd", "mdy HMS", "mdy HM", "mdy", "dmy HMS", "dmy HM", "dmy", "ymdTz", "ymdT HMSz"), quiet=TRUE))}clean_headlines <- headlines_lr %>% transmute(ideology, headline=as.character(.data[[text_col]]), published_date=parse_news_date(.data[[date_col]])) %>% filter(!is.na(headline), headline!="", !is.na(published_date))if(nrow(clean_headlines)==0) stop("No valid headlines and dates after parsing. Inspect the date column.")
# Keep meaningful two-word phrases by removing stop words after tokenization.# Names and political terms are retained unless they are ordinary stop words.bigram_data <- clean_headlines %>% tidytext::unnest_tokens(bigram, headline, token="ngrams", n=2) %>% tidyr::separate(bigram, into=c("word1", "word2"), sep=" ", remove=FALSE) %>% filter(!is.na(word1), !is.na(word2), !word1 %in% tidytext::stop_words$word, !word2 %in% tidytext::stop_words$word, str_detect(word1, "[a-z]"), str_detect(word2, "[a-z]")) %>% mutate(month=floor_date(published_date, "month"))bigram_counts <- bigram_data %>% count(ideology, bigram, name="frequency", sort=TRUE)top15 <- bigram_counts %>% group_by(ideology) %>% slice_max(frequency, n=15, with_ties=FALSE) %>% ungroup()top10 <- bigram_counts %>% group_by(ideology) %>% slice_max(frequency, n=10, with_ties=FALSE) %>% ungroup()knitr::kable(top15, caption="Top 15 bigrams by outlet orientation")
ggplot(top15, aes(x=frequency, y=reorder_within(bigram, frequency, ideology), fill=ideology)) + geom_col(show.legend=FALSE) + facet_wrap(~ideology, scales="free_y", ncol=2) + tidytext::scale_y_reordered() + scale_fill_manual(values=c(Left="#3076BA", Right="#C83F46")) + labs(title="Most frequent two-word phrases in Trump-related headlines", subtitle="Top 15 bigrams within each outlet orientation", x="Number of occurrences", y=NULL) + theme_minimal(base_size=12)
monthly_counts <- bigram_data %>% inner_join(top10 %>% select(ideology, bigram), by=c("ideology", "bigram")) %>% count(ideology, bigram, month, name="frequency")# Include zero-count months so missing months do not disappear.all_months <- seq(min(bigram_data$month), max(bigram_data$month), by="month")monthly_counts <- monthly_counts %>% group_by(ideology, bigram) %>% tidyr::complete(month=all_months, fill=list(frequency=0)) %>% ungroup()ridge_plot <- function(side, color) { d <- monthly_counts %>% filter(ideology==side) if(nrow(d)==0) stop(paste("No data for",side)) ggplot(d, aes(x=month, y=bigram, height=frequency, group=bigram)) + ggridges::geom_ridgeline(fill=color, color="white", alpha=.85, scale=ifelse(max(d$frequency)>0, 1.6/max(d$frequency), 1), min_height=0) + scale_x_date(date_labels="%b %Y", date_breaks="3 months") + labs(title=paste(side,"outlets: top bigrams over time"), subtitle="Monthly raw frequency; each ridge shares the same vertical scale", x="Publication month", y=NULL) + theme_minimal(base_size=11) + theme(axis.text.x=element_text(angle=45, hjust=1))}
ridge_plot("Left", "#3076BA")
ridge_plot("Right", "#C83F46")