library(dplyr)
library(tibble)
library(purrr)
library(stringr)
library(quanteda)
library(quanteda.textstats)
source("R/inaugural-corpus.R")
source("R/permutation-null.R")
within_tolerance <- function(observed, expected, tolerance = 1e-3) {
abs(observed - expected) < tolerance
}
paragraphs <- inaugural_paragraphs()
original_speeches <- tibble(
speech_id = names(quanteda::data_corpus_inaugural),
year = as.integer(quanteda::data_corpus_inaugural$Year),
surname = as.character(quanteda::data_corpus_inaugural$President),
first_name = as.character(quanteda::data_corpus_inaugural$FirstName),
party = as.character(quanteda::data_corpus_inaugural$Party),
text = as.character(quanteda::data_corpus_inaugural)
) |>
mutate(
president = str_squish(paste(first_name, surname)),
era = factor(
if_else(year < 1900, "before 1900", "1900 or later"),
levels = c("before 1900", "1900 or later")
),
original_words = str_count(text, "\\S+")
)
retained_by_speech <- paragraphs |>
group_by(speech_id) |>
summarise(retained_words = sum(paragraph_words), .groups = "drop")
paragraph_era_summary <- paragraphs |>
group_by(era) |>
summarise(median_kept_paragraph_words = median(paragraph_words), .groups = "drop")
retention_by_era <- original_speeches |>
select(speech_id, era, original_words) |>
left_join(retained_by_speech, by = "speech_id") |>
mutate(retained_words = coalesce(retained_words, 0L)) |>
group_by(era) |>
summarise(
speeches = n(),
original_words = sum(original_words),
retained_words = sum(retained_words),
share_retained = retained_words / original_words,
.groups = "drop"
) |>
left_join(paragraph_era_summary, by = "era")
speeches <- paragraphs |>
group_by(speech_id, year, president, surname, party, era) |>
summarise(
text = paste(paragraph, collapse = "\n"),
speech_tokens = sum(paragraph_words),
.groups = "drop"
)
make_speech_dfm <- function(remove_stops = FALSE, tfidf = FALSE) {
speech_tokens <- tokens(
speeches$text,
remove_punct = TRUE,
remove_numbers = TRUE
) |>
tokens_tolower()
if (remove_stops) {
speech_tokens <- tokens_remove(speech_tokens, stopwords("en"))
}
speech_dfm <- dfm(speech_tokens)
docnames(speech_dfm) <- speeches$speech_id
if (tfidf) {
speech_dfm <- dfm_tfidf(speech_dfm)
}
speech_dfm
}
raw_dfm <- make_speech_dfm(remove_stops = FALSE, tfidf = FALSE)
tfidf_dfm <- make_speech_dfm(remove_stops = FALSE, tfidf = TRUE)
tfidf_no_stop_dfm <- make_speech_dfm(remove_stops = TRUE, tfidf = TRUE)
representation_table <- tibble(
representation = c("raw counts", "tf-idf", "tf-idf with stopwords removed"),
documents = c(ndoc(raw_dfm), ndoc(tfidf_dfm), ndoc(tfidf_no_stop_dfm)),
features = c(nfeat(raw_dfm), nfeat(tfidf_dfm), nfeat(tfidf_no_stop_dfm))
)
surname_collisions <- speeches |>
distinct(president, surname) |>
count(surname, name = "people") |>
filter(people > 1L) |>
arrange(surname)
identity_table <- tibble(
item = c("speeches", "people", "surnames", "surnames used by two people"),
value = c(nrow(speeches), n_distinct(speeches$president), n_distinct(speeches$surname), nrow(surname_collisions))
)Finding similar documents
Nearest neighbours depend on the representation
A reading group wants a companion speech similar to a selected address. A similarity method can compare word counts, rare-word weights, or features with common words removed. Each representation gives a different comparison.
Document similarity is a score that compares the features of two documents. The score is meaningful only if those features address the research question.
This lesson shows how to:
- build speech-level document-feature matrices
- compute cosine similarity across 60 speeches
- list the most and least similar pairs under tf-idf
- show that nearest neighbours change when features change
- compare an authorship pattern with shuffled labels
- notice when document length is part of the signal.
Build speech vectors
Tf-idf stands for term frequency times inverse document frequency. It retains words from each speech. It gives more weight to words that are less common across the full set of speeches.
| Representation | Documents | Features |
|---|---|---|
| raw counts | 60 | 9327 |
| tf-idf | 60 | 9327 |
| tf-idf with stopwords removed | 60 | 9193 |
| Era | Speeches | Original words | Retained words | Share retained | Median kept paragraph words |
|---|---|---|---|---|---|
| before 1900 | 28 | 71961 | 71800 | 0.998 | 112 |
| 1900 or later | 32 | 69032 | 63106 | 0.914 | 54 |
| Item | Count |
|---|---|
| speeches | 60 |
| people | 40 |
| surnames | 36 |
| surnames used by two people | 4 |
The helper rebuilds each speech by joining only paragraphs with at least 25 words. The βspeechesβ below therefore contain only retained paragraphs. The filter keeps nearly all words before 1900 and a smaller share from 1900 or later. The president field is the full name: 60 speeches come from 40 people, while four surnames each refer to two people.
Comparing every speech with every other speech gives 1,770 pairs, because 60 * 59 / 2 = 1,770.
Rank all speech pairs
Cosine similarity ranges from 0 to 1 for these nonnegative vectors. Larger values mean the two speech vectors have more similar directions.
cosine_matrix <- function(speech_dfm) {
similarity_matrix <- as.matrix(
textstat_simil(speech_dfm, method = "cosine", margin = "documents")
)
diag(similarity_matrix) <- NA_real_
similarity_matrix
}
cosine_pairs <- function(speech_dfm) {
similarity_matrix <- cosine_matrix(speech_dfm)
pair_index <- which(upper.tri(similarity_matrix), arr.ind = TRUE)
tibble(
first = rownames(similarity_matrix)[pair_index[, 1]],
second = colnames(similarity_matrix)[pair_index[, 2]],
similarity = similarity_matrix[pair_index]
) |>
left_join(
speeches |> select(first = speech_id, first_year = year, first_president = president, first_tokens = speech_tokens),
by = "first"
) |>
left_join(
speeches |> select(second = speech_id, second_year = year, second_president = president, second_tokens = speech_tokens),
by = "second"
)
}
tfidf_pairs <- cosine_pairs(tfidf_dfm) |>
arrange(desc(similarity), first, second)
most_similar_pairs <- tfidf_pairs |>
slice_head(n = 5) |>
mutate(group = "Most similar")
least_similar_pairs <- tfidf_pairs |>
arrange(similarity, first, second) |>
slice_head(n = 5) |>
mutate(group = "Least similar")
pair_table <- bind_rows(most_similar_pairs, least_similar_pairs) |>
transmute(
group,
first,
second,
first_president,
second_president,
first_tokens,
second_tokens,
similarity = round(similarity, 4)
)
least_with_short_washington <- least_similar_pairs |>
filter(first == "1793-Washington" | second == "1793-Washington") |>
nrow()| Group | First speech | Second speech | First president | Second president | First tokens | Second tokens | Cosine similarity |
|---|---|---|---|---|---|---|---|
| Most similar | 1817-Monroe | 1821-Monroe | James Monroe | James Monroe | 3373 | 4470 | 0.2903 |
| Most similar | 1837-VanBuren | 1841-Harrison | Martin Van Buren | William Henry Harrison | 3846 | 8465 | 0.2471 |
| Most similar | 1897-McKinley | 1909-Taft | William McKinley | William Howard Taft | 3973 | 5430 | 0.2454 |
| Most similar | 1841-Harrison | 1845-Polk | William Henry Harrison | James Knox Polk | 8465 | 4810 | 0.2438 |
| Most similar | 1825-Adams | 1845-Polk | John Quincy Adams | James Knox Polk | 2920 | 4810 | 0.2365 |
| Least similar | 1793-Washington | 1905-Roosevelt | George Washington | Theodore Roosevelt | 135 | 984 | 0.0066 |
| Least similar | 1793-Washington | 2021-Biden | George Washington | Joseph R. Biden | 135 | 412 | 0.0073 |
| Least similar | 1793-Washington | 1941-Roosevelt | George Washington | Franklin D. Roosevelt | 135 | 1143 | 0.0074 |
| Least similar | 1829-Jackson | 2021-Biden | Andrew Jackson | Joseph R. Biden | 1130 | 412 | 0.0075 |
| Least similar | 1793-Washington | 1913-Wilson | George Washington | Woodrow Wilson | 135 | 1699 | 0.0095 |
The strongest pair under this representation is the two Monroe speeches, with cosine similarity about 0.2903. Four of the five weakest rows include the short 1793 Washington speech, which has 135 retained tokens. The speechβs short length explains its low ranking. The ranking does not establish a difference in subject matter.
tfidf_similarity_matrix <- cosine_matrix(tfidf_dfm)
mean_tfidf_similarity <- tibble(
speech_id = rownames(tfidf_similarity_matrix),
mean_similarity = rowMeans(tfidf_similarity_matrix, na.rm = TRUE)
) |>
left_join(speeches |> select(speech_id, speech_tokens), by = "speech_id")
length_similarity_table <- mean_tfidf_similarity |>
summarise(
measure = "Mean tf-idf similarity and speech token count",
correlation = cor(mean_similarity, speech_tokens)
)| Measure | Correlation |
|---|---|
| Mean tf-idf similarity and speech token count | 0.861 |
Mean tf-idf similarity still correlates strongly with speech length. Tf-idf changes the representation, but it does not remove the relationship with length from this corpus.
Change the representation
A nearest neighbour is the highest-scoring other document. The code below compares raw counts, tf-idf, and tf-idf without stopwords for the 1793 Washington speech. Stopwords are common words that an analysis excludes.
nearest_for <- function(speech_dfm, target) {
similarity_matrix <- cosine_matrix(speech_dfm)
target_scores <- similarity_matrix[target, ]
target_scores[target] <- NA_real_
tibble(
neighbour = names(target_scores),
similarity = as.numeric(target_scores)
) |>
arrange(desc(similarity), neighbour) |>
slice_head(n = 1)
}
target_speech <- "1793-Washington"
nearest_table <- bind_rows(
nearest_for(raw_dfm, target_speech) |>
mutate(representation = "raw counts"),
nearest_for(tfidf_dfm, target_speech) |>
mutate(representation = "tf-idf"),
nearest_for(tfidf_no_stop_dfm, target_speech) |>
mutate(representation = "tf-idf, stopwords removed")
) |>
left_join(
speeches |> select(neighbour = speech_id, year, president, speech_tokens),
by = "neighbour"
) |>
transmute(
target = target_speech,
representation,
neighbour,
year,
president,
speech_tokens,
similarity = round(similarity, 4)
)| Target | Representation | Nearest neighbour | Year | President | Neighbour tokens | Cosine similarity |
|---|---|---|---|---|---|---|
| 1793-Washington | raw counts | 1841-Harrison | 1841 | William Henry Harrison | 8465 | 0.8474 |
| 1793-Washington | tf-idf | 1861-Lincoln | 1861 | Abraham Lincoln | 3617 | 0.0552 |
| 1793-Washington | tf-idf, stopwords removed | 1885-Cleveland | 1885 | Grover Cleveland | 1687 | 0.0512 |
The three answers differ. The raw-count neighbour for the shortest speech is the longest retained speech, and the score is about 0.8474. Shared high-frequency words and length dominate the raw-count comparison. Do not interpret the raw-count score as evidence of matching topics.
nearest_all <- function(speech_dfm) {
similarity_matrix <- cosine_matrix(speech_dfm)
map_dfr(
rownames(similarity_matrix),
function(target) {
scores <- similarity_matrix[target, ]
tibble(
target,
neighbour = names(scores)[which.max(scores)],
similarity = max(scores, na.rm = TRUE)
)
}
) |>
left_join(
speeches |> select(target = speech_id, target_president = president, target_surname = surname),
by = "target"
) |>
left_join(
speeches |> select(neighbour = speech_id, neighbour_president = president, neighbour_surname = surname),
by = "neighbour"
) |>
mutate(
same_president = target_president == neighbour_president,
same_surname = target_surname == neighbour_surname
)
}
raw_neighbours <- nearest_all(raw_dfm)
raw_hub_table <- raw_neighbours |>
count(neighbour, name = "nearest_neighbour_count", sort = TRUE) |>
slice_head(n = 10)
harrison_hub_count <- raw_neighbours |>
filter(neighbour == "1841-Harrison") |>
nrow()| Raw-count nearest neighbour | Number of target speeches |
|---|---|
| 1985-Reagan | 7 |
| 1925-Coolidge | 6 |
| 1897-McKinley | 4 |
| 1953-Eisenhower | 4 |
| 1845-Polk | 3 |
| 1853-Pierce | 3 |
| 1877-Hayes | 3 |
| 1889-Harrison | 3 |
| 1909-Taft | 3 |
| 1817-Monroe | 2 |
The raw-count comparison does not show that 1841-Harrison is a corpus-wide hub. A hub is the nearest neighbour for many other documents. 1841-Harrison is the nearest neighbour for two speeches. Several other speeches are nearest neighbours for many targets under raw counts. This pattern also indicates that shared common words dominate the comparison, rather than distinctive vocabulary.
What to remember
- Document similarity compares representations, not documents in the abstract.
- Speech-level tf-idf gives 1,770 pairwise comparisons for 60 speeches.
- The nearest neighbour of one speech changed across raw counts, tf-idf, and stopword removal.
- Same-person neighbours can show authorship or style rather than subject matter.
- Similarity functions return a ranking even when length or labels explain it.