Domain: Media / news authenticity detection. Phase 1
asked how fake and real news differ, so this analysis
loads both Fake.csv and
True.csv and adds a label column. Using the
fake file alone would make the Phase 1 comparison questions
unanswerable.
fake_path <- file.path(params$data_dir, "Fake.csv")
real_path <- file.path(params$data_dir, "True.csv")
stopifnot("Fake.csv not found - set params$data_dir" = file.exists(fake_path),
"True.csv not found - set params$data_dir" = file.exists(real_path))
# Read everything as character first; types are fixed explicitly during cleaning
read_news <- function(path, lab) {
read_csv(path, col_types = cols(.default = col_character()), show_col_types = FALSE) %>%
mutate(label = lab)
}
raw_df <- bind_rows(read_news(fake_path, "Fake"), read_news(real_path, "Real")) %>%
mutate(id = row_number(), label = factor(label, levels = c("Fake", "Real")))
cat("Rows:", nrow(raw_df), "| Columns:", ncol(raw_df), "\n")
## Rows: 44898 | Columns: 6
raw_df %>% count(label, name = "articles") %>% knitr::kable(caption = "Articles per file")
| label | articles |
|---|---|
| Fake | 23481 |
| Real | 21417 |
# First rows (text truncated for readability)
raw_df %>% mutate(text = str_trunc(text, 100)) %>% head(5) %>% knitr::kable()
| title | text | subject | date | label | id |
|---|---|---|---|---|---|
| Donald Trump Sends Out Embarrassing New Year’s Eve Message; This is Disturbing | Donald Trump just couldn t wish all Americans a Happy New Year and leave it at that. Instead, he … | News | December 31, 2017 | Fake | 1 |
| Drunk Bragging Trump Staffer Started Russian Collusion Investigation | House Intelligence Committee Chairman Devin Nunes is going to have a bad day. He s been under the… | News | December 31, 2017 | Fake | 2 |
| Sheriff David Clarke Becomes An Internet Joke For Threatening To Poke People ‘In The Eye’ | On Friday, it was revealed that former Milwaukee Sheriff David Clarke, who was being considered f… | News | December 30, 2017 | Fake | 3 |
| Trump Is So Obsessed He Even Has Obama’s Name Coded Into His Website (IMAGES) | On Christmas day, Donald Trump announced that he would be back to work the following day, but h… | News | December 29, 2017 | Fake | 4 |
| Pope Francis Just Called Out Donald Trump During His Christmas Speech | Pope Francis used his annual Christmas Day message to rebuke Donald Trump without even mentioning… | News | December 25, 2017 | Fake | 5 |
# Structure and summary
str(raw_df, vec.len = 1, nchar.max = 60)
## tibble [44,898 × 6] (S3: tbl_df/tbl/data.frame)
## $ title : chr [1:44898] "Donald Trump Sends Out Embarrassing New Yea"| __truncated__ ...
## $ text : chr [1:44898] "Donald Trump just couldn t wish all America"| __truncated__ ...
## $ subject: chr [1:44898] "News" ...
## $ date : chr [1:44898] "December 31, 2017" ...
## $ label : Factor w/ 2 levels "Fake","Real": 1 1 ...
## $ id : int [1:44898] 1 2 ...
summary(raw_df)
## title text subject date
## Length :44898 Length :44898 Length :44898 Length :44898
## N.unique :38728 N.unique :38639 N.unique : 8 N.unique : 1685
## N.blank : 0 N.blank : 0 N.blank : 0 N.blank : 0
## Min.nchar: 8 Min.nchar: 5 Min.nchar: 4 Min.nchar: 9
## Max.nchar: 285 Max.nchar:51793 Max.nchar: 15 Max.nchar: 149
## NAs : 631
## label id
## Fake:23481 Min. : 1
## Real:21417 1st Qu.:11225
## Median :22450
## Mean :22450
## 3rd Qu.:33674
## Max. :44898
# Subjects per class: note that the two classes use completely different subject labels
raw_df %>% count(label, subject, sort = TRUE) %>% knitr::kable(caption = "Subject categories")
| label | subject | n |
|---|---|---|
| Real | politicsNews | 11272 |
| Real | worldnews | 10145 |
| Fake | News | 9050 |
| Fake | politics | 6841 |
| Fake | left-news | 4459 |
| Fake | Government News | 1570 |
| Fake | US_News | 783 |
| Fake | Middle-east | 778 |
Observation: the two files share no subject
categories (fake: News, politics,
left-news, …; real: politicsNews,
worldnews). Subject is therefore perfectly confounded with
class, so subject comparisons below are within-source
descriptions, not a fair like-for-like test.
# Treat blank strings as missing, then parse dates. The files mix two formats
# ("December 31, 2017" and "19-Feb-18"); a few fake rows contain URLs instead of dates.
df <- raw_df %>%
mutate(across(c(title, text, subject, date), ~ na_if(str_squish(.x), "")),
date_parsed = as.Date(parse_date_time(date, orders = c("mdY", "dby"), quiet = TRUE)))
# NA report per column and class (before removal)
df %>% group_by(label) %>%
summarise(across(c(title, text, subject, date, date_parsed), ~ sum(is.na(.x)))) %>%
knitr::kable(caption = "Missing values per column (date_parsed counts unparseable dates too)")
| label | title | text | subject | date | date_parsed |
|---|---|---|---|---|---|
| Fake | 0 | 630 | 0 | 0 | 10 |
| Real | 0 | 1 | 0 | 0 | 0 |
# Remove incomplete rows and exact duplicates, logging rows left after each step
count_lab <- function(d) d %>% count(label, .drop = FALSE) %>% pivot_wider(names_from = label, values_from = n)
steps <- list(bind_cols(step = "Raw rows", count_lab(df)))
df <- df %>% filter(!is.na(text))
steps[[2]] <- bind_cols(step = "Remove empty text", count_lab(df))
df <- df %>% filter(!is.na(title), !is.na(subject))
steps[[3]] <- bind_cols(step = "Remove missing title/subject", count_lab(df))
df <- df %>% filter(!is.na(date_parsed))
steps[[4]] <- bind_cols(step = "Remove missing/unparseable date", count_lab(df))
df <- df %>% distinct(title, text, .keep_all = TRUE) # keeps first occurrence
steps[[5]] <- bind_cols(step = "Remove duplicate articles", count_lab(df))
removal_log <- bind_rows(steps) %>% mutate(Total = Fake + Real)
knitr::kable(removal_log, caption = "Rows remaining after each cleaning step")
| step | Fake | Real | Total |
|---|---|---|---|
| Raw rows | 23481 | 21417 | 44898 |
| Remove empty text | 22851 | 21416 | 44267 |
| Remove missing title/subject | 22851 | 21416 | 44267 |
| Remove missing/unparseable date | 22841 | 21416 | 44257 |
| Remove duplicate articles | 17453 | 21194 | 38647 |
rows_removed <- nrow(raw_df) - nrow(df)
cat("Total rows removed:", rows_removed, sprintf("(%.1f%% of the raw data)\n", 100 * rows_removed / nrow(raw_df)))
## Total rows removed: 6251 (13.9% of the raw data)
We remove rather than impute: empty text or an unparseable date cannot be reconstructed meaningfully. Duplicates are removed because repeated articles would over-weight their words and patterns (this affects the fake file far more than the real one).
# Source-leakage check: nearly every genuine article starts with "CITY (Reuters) -".
# That tag would trivially separate the classes, so we strip it from the real text.
df <- df %>%
mutate(had_dateline = str_detect(text, "^[^()]{0,100}\\(Reuters\\)"),
text_base = str_remove(text, "^[^()]{0,100}\\(Reuters\\)\\s*[-\u2013\u2014]?\\s*"))
dateline_share <- df %>% group_by(label) %>% summarise(pct = 100 * mean(had_dateline)) %>% deframe()
# English stopwords (tm) turned into one regular expression
sw <- tm::stopwords("english")
sw_regex <- paste0("\\b(", paste(sw, collapse = "|"), ")\\b")
# Step A: lower-case; drop HTML, URLs, @mentions, numbers and punctuation; squeeze whitespace
clean_basic <- function(x) {
x %>%
str_replace_all("[\u2018\u2019\u201B]", "'") %>% # curly -> straight apostrophe
str_remove_all("<[^>]+>") %>% # HTML tags
str_remove_all("&[a-zA-Z#0-9]+;") %>% # HTML entities
str_remove_all("(https?://|www\\.|pic\\.twitter\\.com/)\\S+") %>% # URLs
str_remove_all("@\\w+") %>% # @mentions
str_to_lower() %>%
str_remove_all("'s\\b") %>% # possessives
str_replace_all("[^a-z'\\s]", " ") %>% # punctuation, digits, symbols
str_replace_all("'{2,}|\\s'|'\\s", " ") %>%
str_squish()
}
# Step B: remove stopwords, then leftover apostrophes
clean_nostop <- function(x) x %>% str_remove_all(sw_regex) %>% str_remove_all("'") %>% str_squish()
df <- df %>%
mutate(title_lc = clean_basic(title),
body_lc = clean_basic(text_base),
combined_lc = clean_basic(paste(title, text_base)), # title + text, stopwords kept (for n-grams)
combined_ns = clean_nostop(combined_lc)) # title + text, stopwords removed (for words)
98.5% of real articles carried a Reuters dateline versus 0.01% of fake ones, a clear source artefact that we neutralised.
# Before / after comparison on three random articles
ba_ids <- sample(df$id, 3)
tibble(Before = str_trunc(raw_df$text[match(ba_ids, raw_df$id)], 250),
After = str_trunc(df$combined_ns[match(ba_ids, df$id)], 250)) %>%
knitr::kable(caption = "Raw text vs. cleaned text (title + body, stopwords removed)")
| Before | After |
|---|---|
| If you wanted to see how low the Trump campaign would go to win, look no further than the stunt they pulled at the second presidential debate. For weeks the Trump campaign had been hinting they would invite Bill Clinton s alleged victims to the … | breaking trump campaign caught paying hillary hating sexual assault victim details wanted see low trump campaign go win look stunt pulled second presidential debate weeks trump campaign hinting invite bill clinton s alleged victims debates teetere… |
| We all know by now that House Speaker Paul Ryan is a spineless weasel. He is clearly uncomfortable with the idea of a President Trump, and yet he endorsed the great orange one anyway, and no matter how outrageously offensive Trump becomes, no matt… | gary johnson paul ryan trump endorsement mistake man admit video know now house speaker paul ryan spineless weasel clearly uncomfortable idea president trump yet endorsed great orange one anyway matter outrageously offensive trump becomes matter m… |
| WASHINGTON (Reuters) - The United States and China failed on Wednesday to agree on major new steps to reduce the U.S. trade deficit with China, casting doubt over President Donald Trump’s economic and security relations with Beijing. The annual e… | u s china fail agree trade issues casting doubt issues united states china failed wednesday agree major new steps reduce u s trade deficit china casting doubt president donald trump economic security relations beijing annual economic dialogue sess… |
# All structural features are computed on the (dateline-stripped) text, *before*
# lower-casing, so capitalisation and punctuation are preserved.
df <- df %>%
mutate(
# date features
year = year(date_parsed),
month = month(date_parsed, label = TRUE),
wday = wday(date_parsed, label = TRUE, week_start = 1),
# text size
char_count = str_length(text_base),
word_count = stringi::stri_count_words(text_base),
avg_word_len = str_count(text_base, "[A-Za-z]") / pmax(word_count, 1),
# punctuation & emphasis
punct_count = str_count(text_base, "[[:punct:]]"),
excl_count = str_count(text_base, "!"),
has_excl = excl_count > 0,
has_q = str_detect(text_base, "\\?"),
upper_ratio = str_count(text_base, "\\b[A-Z]{2,}\\b") / pmax(word_count, 1), # ALL-CAPS words (incl. acronyms)
# title features
title_chars = str_length(title),
title_words = stringi::stri_count_words(title),
title_has_excl = str_detect(title, "!"),
title_upper_ratio = str_count(title, "\\b[A-Z]{2,}\\b") / pmax(title_words, 1)
) %>%
filter(word_count > 0) # drop articles that became empty after dateline removal
feat_cols <- c("char_count", "word_count", "avg_word_len", "punct_count",
"excl_count", "upper_ratio", "title_chars", "title_words")
df %>% select(label, all_of(feat_cols)) %>%
pivot_longer(-label, names_to = "feature") %>%
group_by(label, feature) %>%
summarise(mean = mean(value), median = median(value), sd = sd(value),
min = min(value), max = max(value), .groups = "drop") %>%
mutate(across(where(is.numeric), ~ round(.x, 3))) %>%
knitr::kable(caption = "Summary statistics of engineered features")
| label | feature | mean | median | sd | min | max |
|---|---|---|---|---|---|---|
| Fake | avg_word_len | 4.711 | 4.701 | 0.342 | 2.000 | 13.471 |
| Fake | char_count | 2538.620 | 2224.000 | 2189.887 | 5.000 | 51493.000 |
| Fake | excl_count | 0.742 | 0.000 | 2.005 | 0.000 | 133.000 |
| Fake | punct_count | 59.400 | 48.000 | 77.306 | 0.000 | 6197.000 |
| Fake | title_chars | 90.705 | 87.000 | 24.674 | 15.000 | 279.000 |
| Fake | title_words | 14.618 | 14.000 | 4.159 | 3.000 | 45.000 |
| Fake | upper_ratio | 0.018 | 0.007 | 0.053 | 0.000 | 1.000 |
| Fake | word_count | 431.417 | 382.000 | 362.009 | 1.000 | 8272.000 |
| Real | avg_word_len | 4.948 | 4.941 | 0.259 | 3.373 | 6.192 |
| Real | char_count | 2348.661 | 2186.500 | 1677.332 | 123.000 | 29755.000 |
| Real | excl_count | 0.062 | 0.000 | 0.419 | 0.000 | 12.000 |
| Real | punct_count | 55.427 | 47.000 | 44.789 | 1.000 | 795.000 |
| Real | title_chars | 64.656 | 64.000 | 9.159 | 26.000 | 133.000 |
| Real | title_words | 10.067 | 10.000 | 1.705 | 4.000 | 20.000 |
| Real | upper_ratio | 0.007 | 0.003 | 0.011 | 0.000 | 0.286 |
| Real | word_count | 384.785 | 359.000 | 275.841 | 19.000 | 5159.000 |
df %>% group_by(label) %>%
summarise(`% with "!"` = 100 * mean(has_excl), `% with "?"` = 100 * mean(has_q),
`% titles with "!"` = 100 * mean(title_has_excl)) %>%
mutate(across(where(is.numeric), ~ round(.x, 1))) %>%
knitr::kable(caption = "Presence of exclamation / question marks")
| label | % with “!” | % with “?” | % titles with “!” |
|---|---|---|---|
| Fake | 32.1 | 49.4 | 11.1 |
| Real | 3.8 | 6.3 | 0.1 |
# Token-level analyses (words, n-grams) are run on a stratified random sample to keep
# memory and run-time reasonable; change params$sample_n to use more articles.
set.seed(429)
samp <- df %>% group_by(label) %>% slice_sample(n = min(params$sample_n, min(table(df$label)))) %>% ungroup()
cat("Sample used for token analyses:", nrow(samp), "articles\n")
## Sample used for token analyses: 12000 articles
q99_wc <- quantile(df$word_count, 0.99) # used to trim extreme tails in plots
# Articles per month by class
monthly <- df %>% mutate(ym = floor_date(date_parsed, "month")) %>% count(ym, label)
p <- ggplot(monthly, aes(ym, n, colour = label)) +
geom_line(linewidth = 1) + geom_point(size = 1.5) +
scale_colour_manual(values = pal, name = "Class") +
scale_x_date(date_breaks = "3 months", date_labels = "%b %Y") +
labs(title = "Article frequency over time", x = "Month", y = "Articles published") +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
save_plot(p, "01_articles_over_time")
# Day-of-week pattern (share of each class)
dow <- df %>% count(label, wday) %>% group_by(label) %>% mutate(share = n / sum(n))
p <- ggplot(dow, aes(wday, share, fill = label)) +
geom_col(position = "dodge") +
scale_fill_manual(values = pal, name = "Class") +
scale_y_continuous(labels = percent) +
labs(title = "Publication by day of week", x = "Day of week", y = "Share of the class's articles")
save_plot(p, "02_day_of_week")
# Articles by subject category
subj <- df %>% count(subject, label)
p <- ggplot(subj, aes(reorder(subject, n), n, fill = label)) +
geom_col() + coord_flip() +
geom_text(aes(label = comma(n)), hjust = -0.1, size = 3.2) +
scale_fill_manual(values = pal, name = "Class") +
scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
labs(title = "Articles by subject category", x = "Subject", y = "Articles")
save_plot(p, "03_articles_by_subject")
# Heatmap: subject x year-month
heat <- df %>% mutate(ym = format(floor_date(date_parsed, "month"), "%Y-%m")) %>% count(subject, ym)
p <- ggplot(heat, aes(ym, subject, fill = n)) +
geom_tile(colour = "white") +
scale_fill_viridis_c(option = "magma", direction = -1, trans = "sqrt", name = "Articles") +
scale_x_discrete(breaks = function(x) x[seq(1, length(x), by = 3)]) +
labs(title = "Publication pattern: subject vs. month", x = "Month", y = "Subject") +
theme(axis.text.x = element_text(angle = 60, hjust = 1))
save_plot(p, "04_subject_month_heatmap", h = 5.5)
df %>% group_by(label) %>%
summarise(first = min(date_parsed), last = max(date_parsed), months = n_distinct(floor_date(date_parsed, "month"))) %>%
knitr::kable(caption = "Publication window per class")
| label | first | last | months |
|---|---|---|---|
| Fake | 2015-03-31 | 2018-02-19 | 35 |
| Real | 2016-01-13 | 2017-12-31 | 24 |
Interpretation. The two classes cover different time windows, so volume differences over time partly reflect how the datasets were collected, not real publishing behaviour. Weekday patterns and subject activity are best read as descriptions of each source.
# Word-count histogram + density, and title-length histogram (tails trimmed at the 99th percentile)
p1 <- df %>% filter(word_count <= q99_wc) %>%
ggplot(aes(word_count, fill = label)) +
geom_histogram(aes(y = after_stat(density)), bins = 50, alpha = 0.55, position = "identity") +
geom_density(aes(colour = label), fill = NA, linewidth = 0.9) +
scale_fill_manual(values = pal, name = "Class") + scale_colour_manual(values = pal, guide = "none") +
labs(title = "Distribution of word counts", x = "Words per article", y = "Density")
p2 <- ggplot(df, aes(title_words, fill = label)) +
geom_histogram(aes(y = after_stat(density)), binwidth = 1, alpha = 0.6, position = "identity") +
scale_fill_manual(values = pal, name = "Class") + coord_cartesian(xlim = c(0, 40)) +
labs(title = "Distribution of title lengths", x = "Words per title", y = "Density")
g <- gridExtra::arrangeGrob(p1, p2, ncol = 2)
ggsave("figures/05_wordcount_titlelength_hist.png", g, width = 13, height = 5, dpi = 150)
grid::grid.newpage(); grid::grid.draw(g)
# Word count by subject (boxplot, tail trimmed)
p <- ggplot(df, aes(reorder(subject, word_count, median), word_count, fill = label)) +
geom_boxplot(outlier.alpha = 0.1, outlier.size = 0.6) +
coord_flip(ylim = c(0, q99_wc)) +
scale_fill_manual(values = pal, name = "Class") +
labs(title = "Word count by subject", x = "Subject", y = "Words per article")
save_plot(p, "06_wordcount_by_subject_box")
# Title length vs text length (random subset of points to avoid over-plotting)
set.seed(429)
sc <- df %>% group_by(label) %>% slice_sample(n = 3000) %>% ungroup()
p <- ggplot(sc, aes(title_chars, char_count, colour = label)) +
geom_point(alpha = 0.25, size = 1) +
geom_smooth(method = "lm", se = FALSE, linewidth = 1) +
scale_y_log10(labels = comma) +
scale_colour_manual(values = pal, name = "Class") +
labs(title = "Title length vs. text length", x = "Title length (characters)", y = "Text length (characters, log scale)")
save_plot(p, "07_title_vs_text_scatter")
p <- ggplot(df, aes(char_count, fill = label)) +
geom_density(alpha = 0.5) +
scale_x_log10(labels = comma) +
scale_fill_manual(values = pal, name = "Class") +
labs(title = "Character count distribution", x = "Characters per article (log scale)", y = "Density")
save_plot(p, "08_charcount_density")
Interpretation. Body length overlaps heavily between classes (both are long-form), so length alone is a weak signal. Titles are where the classes visibly diverge (see the title histogram).
# Tokenise the cleaned (stopword-free) combined text of the sample
tokens <- samp %>% select(id, label, subject, combined_ns) %>%
unnest_tokens(word, combined_ns) %>%
filter(str_length(word) > 2)
# Top 20 words overall (term frequency)
top20 <- tokens %>% count(word, sort = TRUE) %>% slice_head(n = 20)
p <- ggplot(top20, aes(reorder(word, n), n)) +
geom_col(fill = "#34495E") + coord_flip() +
scale_y_continuous(labels = comma) +
labs(title = "Top 20 most frequent words", x = NULL, y = "Frequency (sampled articles)")
save_plot(p, "09_top20_words")
# Top 10 words within each subject (term frequency)
top_subj <- tokens %>% count(subject, word) %>%
group_by(subject) %>% slice_max(n, n = 10, with_ties = FALSE) %>% ungroup()
p <- ggplot(top_subj, aes(reorder_within(word, n, subject), n, fill = subject)) +
geom_col(show.legend = FALSE) + coord_flip() +
scale_x_reordered() + facet_wrap(~subject, scales = "free", ncol = 2) +
labs(title = "Top 10 words per subject", x = NULL, y = "Frequency")
save_plot(p, "10_top_words_by_subject", h = 9)
# TF-IDF with each subject treated as one "document": highlights words *specific* to a subject
tfidf <- tokens %>% count(subject, word) %>% bind_tf_idf(word, subject, n)
top_tfidf <- tfidf %>% group_by(subject) %>% slice_max(tf_idf, n = 8, with_ties = FALSE) %>% ungroup()
p <- ggplot(top_tfidf, aes(reorder_within(word, tf_idf, subject), tf_idf, fill = subject)) +
geom_col(show.legend = FALSE) + coord_flip() +
scale_x_reordered() + facet_wrap(~subject, scales = "free", ncol = 2) +
labs(title = "Most distinctive words per subject (TF-IDF)", x = NULL, y = "TF-IDF")
save_plot(p, "11_tfidf_by_subject", h = 9)
# Words that best separate Fake from Real: log2 ratio of relative frequencies
# (add-one smoothing; words with fewer than 50 occurrences are ignored)
word_ratio <- tokens %>% count(word, label) %>%
pivot_wider(names_from = label, values_from = n, values_fill = 0) %>%
filter(Fake + Real >= 50) %>%
mutate(p_fake = (Fake + 1) / (sum(Fake) + 1), p_real = (Real + 1) / (sum(Real) + 1),
log_ratio = log2(p_fake / p_real))
top_ratio <- bind_rows(slice_max(word_ratio, log_ratio, n = 15, with_ties = FALSE),
slice_min(word_ratio, log_ratio, n = 15, with_ties = FALSE)) %>%
mutate(side = ifelse(log_ratio > 0, "Fake", "Real"))
p <- ggplot(top_ratio, aes(reorder(word, log_ratio), log_ratio, fill = side)) +
geom_col() + coord_flip() +
scale_fill_manual(values = pal, name = "More typical of") +
labs(title = "Words that most distinguish fake from real news",
x = NULL, y = "log2(relative frequency in Fake / relative frequency in Real)")
save_plot(p, "12_discriminating_words", h = 7)
# Word cloud: overall word frequency
freq <- tokens %>% count(word, sort = TRUE)
dual_plot("13_wordcloud_overall", {
set.seed(429)
wordcloud(freq$word, freq$n, max.words = 120, random.order = FALSE,
colors = brewer.pal(8, "Dark2"), scale = c(4, 0.6))
})
# Comparison cloud: words characteristic of Fake vs Real
mat_label <- tokens %>% count(word, label) %>% reshape2::acast(word ~ label, value.var = "n", fill = 0)
dual_plot("14_comparison_cloud_label", {
set.seed(429)
comparison.cloud(mat_label, max.words = 120, colors = pal[colnames(mat_label)],
title.size = 1.6, random.order = FALSE)
})
# Comparison cloud across all subjects (words unique to each subject)
mat_subj <- tokens %>% count(word, subject) %>% reshape2::acast(word ~ subject, value.var = "n", fill = 0)
dual_plot("15_comparison_cloud_subject", {
set.seed(429)
comparison.cloud(mat_subj, max.words = 150, colors = brewer.pal(max(3, ncol(mat_subj)), "Dark2"),
title.size = 1.1, random.order = FALSE)
}, w = 8, h = 8)
Interpretation. Frequent words mostly describe US politics for both classes. The log-ratio chart is more informative: it shows the vocabulary and style markers (e.g. reporting verbs, institutions, colloquial or emotive words) that tilt towards one class.
We use the Bing lexicon (included in tidytext,
no download needed). The word “trump” is labelled positive in
that lexicon, which would be a serious error for a corpus about Donald
Trump, so it is removed. The score is
(positive words - negative words) / total words, so
articles of different lengths are comparable.
bing <- get_sentiments("bing") %>% filter(word != "trump")
pos_words <- bing$word[bing$sentiment == "positive"]
neg_words <- bing$word[bing$sentiment == "negative"]
toks <- stringi::stri_extract_all_words(df$combined_ns) # one word vector per article
df$n_pos <- vapply(toks, function(w) sum(w %in% pos_words), integer(1))
df$n_neg <- vapply(toks, function(w) sum(w %in% neg_words), integer(1))
df$n_toks <- vapply(toks, function(w) sum(!is.na(w)), integer(1))
rm(toks)
df <- df %>%
mutate(sent_score = (n_pos - n_neg) / pmax(n_toks, 1),
sent_cat = case_when(sent_score > 0.005 ~ "Positive", # +/-0.5% net share = neutral band
sent_score < -0.005 ~ "Negative",
TRUE ~ "Neutral") %>% factor(levels = c("Negative", "Neutral", "Positive")))
df %>% group_by(label) %>%
summarise(mean = mean(sent_score), median = median(sent_score), sd = sd(sent_score)) %>%
mutate(across(where(is.numeric), ~ round(.x, 4))) %>% knitr::kable(caption = "Sentiment score by class")
| label | mean | median | sd |
|---|---|---|---|
| Fake | -0.0182 | -0.0182 | 0.0434 |
| Real | -0.0112 | -0.0099 | 0.0387 |
p <- ggplot(df, aes(sent_score, fill = label)) +
geom_histogram(aes(y = after_stat(density)), bins = 60, alpha = 0.55, position = "identity") +
scale_fill_manual(values = pal, name = "Class") + coord_cartesian(xlim = c(-0.1, 0.1)) +
labs(title = "Distribution of sentiment scores", x = "Net sentiment (positive - negative share of words)", y = "Density")
save_plot(p, "16_sentiment_hist")
p <- ggplot(df, aes(reorder(subject, sent_score, median), sent_score, fill = label)) +
geom_boxplot(outlier.alpha = 0.1, outlier.size = 0.6) + coord_flip(ylim = c(-0.1, 0.1)) +
scale_fill_manual(values = pal, name = "Class") +
labs(title = "Sentiment by subject", x = "Subject", y = "Net sentiment score")
save_plot(p, "17_sentiment_by_subject")
p <- df %>% count(label, sent_cat) %>% group_by(label) %>% mutate(share = n / sum(n)) %>%
ggplot(aes(sent_cat, share, fill = label)) +
geom_col(position = "dodge") +
geom_text(aes(label = percent(share, accuracy = 0.1)), position = position_dodge(0.9), vjust = -0.4, size = 3.2) +
scale_fill_manual(values = pal, name = "Class") + scale_y_continuous(labels = percent, expand = expansion(mult = c(0, 0.12))) +
labs(title = "Sentiment category counts (share within class)", x = "Sentiment category", y = "Share of articles")
save_plot(p, "18_sentiment_categories")
# Robustness check: compare the lexicon score with sentimentr (sentence-level, handles negation)
# on a small sample. Wrapped in tryCatch so the report still knits if sentimentr fails.
sent_check <- tryCatch({
set.seed(429)
s <- df %>% group_by(label) %>% slice_sample(n = 500) %>% ungroup()
sc <- sentimentr::sentiment_by(sentimentr::get_sentences(s$text_base))
tibble(bing = s$sent_score, sentimentr = sc$ave_sentiment)
}, error = function(e) { message("sentimentr check skipped: ", conditionMessage(e)); NULL })
if (!is.null(sent_check)) {
cat("Spearman correlation, Bing score vs sentimentr (n = 1000):",
round(cor(sent_check$bing, sent_check$sentimentr, method = "spearman"), 3), "\n")
}
## Spearman correlation, Bing score vs sentimentr (n = 1000): 0.733
# Bigrams from the stopword-retaining text, then drop pairs that contain a stopword
bigrams <- samp %>% select(id, label, combined_lc) %>%
unnest_tokens(bigram, combined_lc, token = "ngrams", n = 2) %>%
filter(!is.na(bigram)) %>%
separate(bigram, c("w1", "w2"), sep = " ", remove = FALSE) %>%
filter(!w1 %in% sw, !w2 %in% sw, str_length(w1) > 2, str_length(w2) > 2)
top_bi <- bigrams %>% count(bigram, sort = TRUE) %>% slice_head(n = 15)
p <- ggplot(top_bi, aes(reorder(bigram, n), n)) +
geom_col(fill = "#16A085") + coord_flip() + scale_y_continuous(labels = comma) +
labs(title = "Top 15 bigrams (all sampled articles)", x = NULL, y = "Frequency")
save_plot(p, "19_top15_bigrams")
top_bi_lab <- bigrams %>% count(label, bigram) %>% group_by(label) %>%
slice_max(n, n = 15, with_ties = FALSE) %>% ungroup()
p <- ggplot(top_bi_lab, aes(reorder_within(bigram, n, label), n, fill = label)) +
geom_col(show.legend = FALSE) + coord_flip() + scale_x_reordered() +
scale_fill_manual(values = pal) + facet_wrap(~label, scales = "free") +
labs(title = "Top 15 bigrams by class", x = NULL, y = "Frequency")
save_plot(p, "20_top_bigrams_by_class", w = 10, h = 6.5)
# Trigrams: keep only those with no stopword in any position
trigrams <- samp %>% select(id, label, combined_lc) %>%
unnest_tokens(trigram, combined_lc, token = "ngrams", n = 3) %>%
filter(!is.na(trigram)) %>%
separate(trigram, c("w1", "w2", "w3"), sep = " ", remove = FALSE) %>%
filter(!w1 %in% sw, !w2 %in% sw, !w3 %in% sw, str_length(w1) > 2, str_length(w3) > 2)
top_tri <- trigrams %>% count(label, trigram) %>% group_by(label) %>%
slice_max(n, n = 15, with_ties = FALSE) %>% ungroup()
p <- ggplot(top_tri, aes(reorder_within(trigram, n, label), n, fill = label)) +
geom_col(show.legend = FALSE) + coord_flip() + scale_x_reordered() +
scale_fill_manual(values = pal) + facet_wrap(~label, scales = "free") +
labs(title = "Top 15 trigrams by class", x = NULL, y = "Frequency")
save_plot(p, "21_top_trigrams_by_class", w = 10, h = 6.5)
Flesch Reading Ease and Flesch-Kincaid Grade are computed from word, sentence and syllable counts. Syllables are approximated by counting vowel groups (minus silent final “e”), which is fast on 40,000+ articles but slightly less accurate than a dictionary method, so compare groups rather than trusting individual scores. Articles under 30 words are excluded (unstable), and extreme values are clipped.
df <- df %>%
mutate(
n_sent = pmax(stringi::stri_count_regex(text_base, "[.!?]+(\\s|$)"), 1),
letters_lc = str_to_lower(str_replace_all(text_base, "[^A-Za-z\\s]", " ")),
n_syl = stringi::stri_count_regex(letters_lc, "[aeiouy]+") -
stringi::stri_count_regex(letters_lc, "\\b[a-z]{2,}[^aeiouyl\\s]e\\b"),
words_per_sent = word_count / n_sent,
syl_per_word = pmax(n_syl, 1) / word_count,
flesch = 206.835 - 1.015 * words_per_sent - 84.6 * syl_per_word,
fk_grade = 0.39 * words_per_sent + 11.8 * syl_per_word - 15.59,
flesch = ifelse(word_count < 30, NA, pmin(pmax(flesch, -50), 120)),
fk_grade = ifelse(word_count < 30, NA, pmin(pmax(fk_grade, 0), 30))
) %>% select(-letters_lc)
df %>% group_by(label) %>%
summarise(across(c(flesch, fk_grade), list(mean = ~ mean(.x, na.rm = TRUE), median = ~ median(.x, na.rm = TRUE)))) %>%
mutate(across(where(is.numeric), ~ round(.x, 2))) %>% knitr::kable(caption = "Readability by class")
| label | flesch_mean | flesch_median | fk_grade_mean | fk_grade_median |
|---|---|---|---|---|
| Fake | 41.61 | 45.07 | 15.17 | 14.09 |
| Real | 41.99 | 42.42 | 13.18 | 13.02 |
p1 <- ggplot(df, aes(reorder(subject, flesch, median, na.rm = TRUE), flesch, fill = label)) +
geom_boxplot(outlier.alpha = 0.1, outlier.size = 0.6, na.rm = TRUE) + coord_flip() +
scale_fill_manual(values = pal, name = "Class") +
labs(title = "Flesch Reading Ease by subject", x = "Subject", y = "Flesch Reading Ease (higher = easier)")
save_plot(p1, "22_flesch_by_subject")
p2 <- ggplot(df, aes(reorder(subject, fk_grade, median, na.rm = TRUE), fk_grade, fill = label)) +
geom_boxplot(outlier.alpha = 0.1, outlier.size = 0.6, na.rm = TRUE) + coord_flip() +
scale_fill_manual(values = pal, name = "Class") +
labs(title = "Flesch-Kincaid Grade Level by subject", x = "Subject", y = "US school grade level")
save_plot(p2, "23_fk_grade_by_subject")
set.seed(429)
sc2 <- df %>% filter(!is.na(flesch)) %>% group_by(label) %>% slice_sample(n = 3000) %>% ungroup()
p <- ggplot(sc2, aes(word_count, flesch, colour = label)) +
geom_point(alpha = 0.25, size = 1) +
geom_smooth(method = "loess", se = FALSE, linewidth = 1) +
scale_x_log10(labels = comma) + scale_colour_manual(values = pal, name = "Class") +
labs(title = "Readability vs. article length", x = "Word count (log scale)", y = "Flesch Reading Ease")
save_plot(p, "24_readability_vs_wordcount")
# Spearman correlation (robust to the skewed distributions seen above); is_fake: 1 = fake
cor_vars <- df %>%
transmute(is_fake = as.integer(label == "Fake"), word_count, title_words, avg_word_len,
upper_ratio, excl_count, sentiment = sent_score, flesch, fk_grade)
cm <- cor(cor_vars, use = "pairwise.complete.obs", method = "spearman")
cor_long <- as.data.frame(as.table(cm)) %>% rename(r = Freq)
p <- ggplot(cor_long, aes(Var1, Var2, fill = r)) +
geom_tile(colour = "white") +
geom_text(aes(label = sprintf("%.2f", r)), size = 3.3) +
scale_fill_gradient2(low = "#2471A3", mid = "white", high = "#C0392B", limits = c(-1, 1), name = "Spearman r") +
labs(title = "Correlation heatmap of numerical features", x = NULL, y = NULL) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
save_plot(p, "25_correlation_heatmap", w = 8, h = 6.5)
# Fake vs Real comparison for each feature: medians, Mann-Whitney test, and an effect size
# "prob_fake_higher" = chance that a random fake article scores higher than a random real one
# (0.5 = no difference; with n this large, p-values are always tiny, so judge by the effect size)
effect_vars <- c("word_count", "title_words", "avg_word_len", "upper_ratio",
"excl_count", "sent_score", "flesch", "fk_grade")
effect_tbl <- map_dfr(effect_vars, function(v) {
x <- df[[v]][df$label == "Fake"]; x <- x[!is.na(x)]
y <- df[[v]][df$label == "Real"]; y <- y[!is.na(y)]
w <- suppressWarnings(wilcox.test(x, y))
tibble(feature = v, median_fake = median(x), median_real = median(y),
p_value = w$p.value, prob_fake_higher = unname(w$statistic) / (length(x) * length(y)))
})
effect_tbl %>% mutate(across(c(median_fake, median_real, prob_fake_higher), ~ round(.x, 3)),
p_value = format.pval(p_value, digits = 2)) %>%
knitr::kable(caption = "Fake vs. Real: medians, Mann-Whitney p-value and effect size")
| feature | median_fake | median_real | p_value | prob_fake_higher |
|---|---|---|---|---|
| word_count | 382.000 | 359.000 | <2e-16 | 0.548 |
| title_words | 14.000 | 10.000 | <2e-16 | 0.874 |
| avg_word_len | 4.701 | 4.941 | <2e-16 | 0.261 |
| upper_ratio | 0.007 | 0.003 | <2e-16 | 0.646 |
| excl_count | 0.000 | 0.000 | <2e-16 | 0.643 |
| sent_score | -0.018 | -0.010 | <2e-16 | 0.442 |
| flesch | 45.075 | 42.418 | <2e-16 | 0.545 |
| fk_grade | 14.087 | 13.016 | <2e-16 | 0.592 |
RQ1. What linguistic and structural patterns distinguish fake
from real news? Structure rather than length separates the
classes: longer and more emphatic headlines (more words, more ALL-CAPS
words, far more “!”), and more “!” and “?” inside the body (Sections 3
and 4.2). The effect-size table in Section 5 quantifies this; values far
from 0.5 in prob_fake_higher mark the strongest
signals.
RQ2. How do publication patterns (frequency, timing, topics)
differ? Publication frequency differs mainly because the two
datasets cover different periods (Section 4.1). Weekday profiles are
described in Figure 02, and topics are source-specific: the fake file is
split into News, politics,
left-news, Government News,
US_News and Middle-east, while real news is
politicsNews and worldnews. Because those
labels never overlap, subject cannot be treated as an independent
predictor.
RQ3. Which textual features (word frequency, sentiment, readability) are characteristic of fake news? The log-ratio word chart (Figure 12) and the n-gram charts (Figures 19-21) show distinctive vocabulary and recurring phrases; sentiment and readability differences are listed in 6.1 and quantified in the effect-size table. Sentiment and readability differences are real but modest relative to headline style, so they work best as supporting features in a model.
params$sample_n per class); rerun with a larger
value to check stability.After cleaning the combined corpus (duplicates, missing dates and
source tags removed), the strongest and most consistent differences
between fake and real news were stylistic and concentrated in the
headline: length, ALL-CAPS usage and exclamation marks, followed by body
punctuation and vocabulary. Article length is similar across classes;
sentiment and readability differ only modestly. These engineered
features give a sound, interpretable starting point for the predictive
modelling phase, provided source and period confounding are controlled
for during evaluation. All figures are saved in the
figures/ folder.
sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=English_United States.utf8
## [2] LC_CTYPE=English_United States.utf8
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C
## [5] LC_TIME=C
##
## time zone: Africa/Nairobi
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] scales_1.4.0 wordcloud_2.6 RColorBrewer_1.1-3 tidytext_0.4.3
## [5] lubridate_1.9.5 forcats_1.0.1 stringr_1.6.0 dplyr_1.2.1
## [9] purrr_1.2.2 readr_2.2.0 tidyr_1.3.2 tibble_3.3.1
## [13] ggplot2_4.0.3 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] textshape_1.7.5 gtable_0.3.6 xfun_0.62
## [4] bslib_0.12.0 lattice_0.22-9 tzdb_0.5.0
## [7] vctrs_0.7.3 tools_4.6.1 generics_0.1.4
## [10] parallel_4.6.1 janeaustenr_1.0.0 pkgconfig_2.0.3
## [13] tokenizers_0.3.0 Matrix_1.7-5 data.table_1.18.6.1
## [16] S7_0.2.2 lifecycle_1.0.5 compiler_4.6.1
## [19] farver_2.1.2 textshaping_1.0.5 qdapRegex_0.7.10
## [22] SnowballC_0.7.1 htmltools_0.5.9 sass_0.4.10
## [25] yaml_2.3.12 pillar_1.11.1 crayon_1.5.3
## [28] jquerylib_0.1.4 cachem_1.1.0 lexicon_1.3.2
## [31] nlme_3.1-169 sentimentr_2.9.0 tidyselect_1.2.1
## [34] digest_0.6.39 stringi_1.8.9 slam_0.1-56
## [37] reshape2_1.4.5 splines_4.6.1 labeling_0.4.3
## [40] fastmap_1.2.0 grid_4.6.1 cli_3.6.6
## [43] magrittr_2.0.5 textclean_0.9.7 withr_3.0.3
## [46] bit64_4.8.6 timechange_0.4.0 rmarkdown_2.32
## [49] bit_4.6.0 otel_0.2.0 gridExtra_2.3.1
## [52] ragg_1.5.2 hms_1.1.4 NLP_0.3-3
## [55] evaluate_1.0.5 knitr_1.52 viridisLite_0.4.3
## [58] tm_0.7-20 mgcv_1.9-4 rlang_1.3.0
## [61] syuzhet_1.0.7 Rcpp_1.1.2 glue_1.8.1
## [64] xml2_1.6.0 rstudioapi_0.19.0 vroom_1.7.1
## [67] jsonlite_2.0.0 plyr_1.8.9 R6_2.6.1
## [70] systemfonts_1.3.2