Data and methods

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"
Top 15 bigrams by outlet orientation
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

Most frequent bigrams by political orientation

How left-leaning bigrams changed over time

How right-leaning bigrams changed over time

Interpretation and use of AI

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.

Reproducible R script

The complete R code used to produce the graphs appears below.

load-and-classify

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.")

tokenize

# 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")

comparison-chart

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)

prepare-monthly

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))}

left-ridges

ridge_plot("Left", "#3076BA")

right-ridges

ridge_plot("Right", "#C83F46")