library(dplyr)
library(tibble)
library(stringr)
library(stringdist)
library(quanteda)
library(Matrix)
library(ggplot2)
source("R/permutation-null.R")
within_tolerance <- function(observed, expected, tolerance = 1e-3) {
abs(observed - expected) < tolerance
}
name_matches <- tibble(
recorded = c("Jonson", "Jonson", "Martha", "Martha", "Stevenson", "Stevenson"),
candidate = c("Johnson", "Jansen", "Marta", "Marsha", "Stephenson", "Steven")
) |>
mutate(
levenshtein = stringdist(recorded, candidate, method = "lv"),
jaro_winkler = stringdist(recorded, candidate, method = "jw", p = 0.1),
levenshtein_rank = rank(levenshtein, ties.method = "first"),
jaro_winkler_rank = rank(jaro_winkler, ties.method = "first")
)
stevenson_rows <- name_matches |>
filter(recorded == "Stevenson")Measuring distance between texts
Compare string distance with vector distance
A library volunteer receives two boxes of index cards. One box has typed names with small spelling errors, and the other has paragraphs from speeches.
The word “similar” is doing two jobs. Matching a mistyped name is not the same task as comparing the vocabulary of two paragraphs.
A distance measure turns a comparison into a number. Small distance means close. The hard part is choosing a distance that matches the question.
This lesson shows how to:
- compare edit distance and Jaro-Winkler distance for names;
- build paragraph vectors from word counts;
- compare cosine, Euclidean, and Jaccard distance;
- test whether a distance mostly tracks text length; and
- explain why the same data can give different rankings.
Compare strings directly
String distance compares characters. Levenshtein distance counts edits: insertions, deletions, and substitutions. Jaro-Winkler distance gives extra credit to strings that share an early prefix, which often helps with names.
| Recorded | Candidate | Levenshtein | Jaro-Winkler | Levenshtein rank | Jaro-Winkler rank |
|---|---|---|---|---|---|
| Jonson | Johnson | 1 | 0.0381 | 1 | 2 |
| Jonson | Jansen | 2 | 0.2000 | 4 | 6 |
| Martha | Marta | 1 | 0.0333 | 2 | 1 |
| Martha | Marsha | 1 | 0.0778 | 3 | 5 |
| Stevenson | Stephenson | 2 | 0.0726 | 5 | 4 |
| Stevenson | Steven | 3 | 0.0667 | 6 | 3 |
For Stevenson, Levenshtein picks Stephenson because it needs fewer edits. Jaro-Winkler picks Steven because the shared opening is strong. A name-matching project has to choose the error pattern it cares about.
Build paragraph vectors
A vector is a row of numbers. In a document-feature matrix, each row is a paragraph and each column is a word feature. The entry says how many times that word appears in that paragraph.
source("R/inaugural-corpus.R")
paragraphs <- inaugural_paragraphs()
paragraph_tokens <- tokens(
paragraphs$paragraph,
remove_punct = TRUE,
remove_numbers = TRUE
) |>
tokens_tolower()
paragraph_dfm <- dfm(paragraph_tokens)
docnames(paragraph_dfm) <- paragraphs$paragraph_id
count_matrix <- as(paragraph_dfm, "dgCMatrix")
token_lengths <- Matrix::rowSums(count_matrix)
squared_lengths <- Matrix::rowSums(count_matrix^2)
dot_products <- as.matrix(Matrix::tcrossprod(count_matrix))
euclidean_squared <- outer(squared_lengths, squared_lengths, "+") - 2 * dot_products
euclidean_squared[euclidean_squared < 0 & euclidean_squared > -1e-8] <- 0
euclidean_distance <- sqrt(euclidean_squared)
vector_norms <- sqrt(squared_lengths)
cosine_similarity <- dot_products / outer(vector_norms, vector_norms)
cosine_distance <- 1 - cosine_similarity
binary_matrix <- Matrix::drop0(count_matrix)
binary_matrix@x <- rep(1, length(binary_matrix@x))
feature_counts <- Matrix::rowSums(binary_matrix)
intersections <- as.matrix(Matrix::tcrossprod(binary_matrix))
unions <- outer(feature_counts, feature_counts, "+") - intersections
jaccard_distance <- 1 - intersections / unions
pair_index <- which(upper.tri(euclidean_distance), arr.ind = TRUE)
pair_distances <- tibble(
first = pair_index[, 1],
second = pair_index[, 2],
length_difference = abs(token_lengths[first] - token_lengths[second]),
euclidean = euclidean_distance[pair_index],
cosine = cosine_distance[pair_index],
jaccard = jaccard_distance[pair_index],
first_speech = paragraphs$speech_id[first],
second_speech = paragraphs$speech_id[second]
)| Item | Value |
|---|---|
| Paragraphs | 1377 |
| Speeches | 60 |
| People | 40 |
| Features | 9327 |
| Paragraph pairs | 947376 |
The matrix has 1,377 paragraph rows and 9,327 word columns. Each paragraph becomes a point in a high-dimensional space.
Compare three vector distances
Euclidean distance measures straight-line distance between count vectors. Cosine distance compares direction, so multiplying a paragraph vector by the same constant leaves its cosine direction unchanged. Jaccard distance uses word presence and ignores repeated counts.
length_correlations <- pair_distances |>
summarise(
`Euclidean distance` = cor(euclidean, length_difference),
`Cosine distance` = cor(cosine, length_difference),
`Jaccard distance` = cor(jaccard, length_difference)
) |>
tidyr::pivot_longer(
everything(),
names_to = "distance",
values_to = "correlation_with_length_difference"
)
same_speech_examples <- pair_distances |>
mutate(
first_paragraph = paragraphs$paragraph_id[first],
second_paragraph = paragraphs$paragraph_id[second],
first_length = as.integer(token_lengths[first]),
second_length = as.integer(token_lengths[second])
) |>
filter(first_speech == second_speech) |>
arrange(desc(length_difference), euclidean) |>
slice_head(n = 3) |>
transmute(
first_paragraph,
second_paragraph,
speech = first_speech,
first_length,
second_length,
length_difference = as.integer(length_difference),
euclidean = round(euclidean, 2),
cosine = round(cosine, 3),
jaccard = round(jaccard, 3)
)| Distance | Correlation with length difference |
|---|---|
| Euclidean distance | 0.951 |
| Cosine distance | -0.268 |
| Jaccard distance | 0.275 |
| First paragraph | Second paragraph | Speech | First length | Second length | Length difference | Euclidean | Cosine | Jaccard |
|---|---|---|---|---|---|---|---|---|
| 1841-Harrison-p10 | 1841-Harrison-p22 | 1841-Harrison | 984 | 69 | 915 | 147.46 | 0.330 | 0.936 |
| 1841-Harrison-p10 | 1841-Harrison-p25 | 1841-Harrison | 984 | 80 | 904 | 145.87 | 0.304 | 0.942 |
| 1841-Harrison-p10 | 1841-Harrison-p11 | 1841-Harrison | 984 | 94 | 890 | 142.01 | 0.229 | 0.924 |
The Euclidean correlation with length difference is 0.951, a strong warning that raw-count distance is mostly measuring paragraph size. The cosine correlation is negative, -0.268, because longer paragraphs share many ordinary words with other long paragraphs. That matters for this corpus: nineteenth-century paragraphs are longer than later paragraphs, so a length-shaped method can echo the era pattern seen in the corpus lessons.
same_speech_observed <- pair_distances |>
filter(first_speech == second_speech) |>
summarise(mean_cosine_distance = mean(cosine)) |>
pull(mean_cosine_distance)
speech_labels <- paragraphs$speech_id
same_speech_null <- permutation_null(
observed = same_speech_observed,
replicate_fn = function() {
shuffled_labels <- sample(speech_labels)
pair_distances |>
filter(shuffled_labels[first] == shuffled_labels[second]) |>
summarise(mean_cosine_distance = mean(cosine)) |>
pull(mean_cosine_distance)
},
replicates = 1000L,
seed = 5902L,
alternative = "less"
)| Observed | Null mean | Null 5th pct | Null 95th pct | p-value | Replicates | Alternative |
|---|---|---|---|---|---|---|
| 0.569 | 0.577 | 0.573 | 0.581 | 0.003 | 1000 | less |
A distance formula will fill a matrix for any paragraphs it receives. The permutation check asks whether paragraphs from the same speech are closer than a label shuffle would produce. Here they are, but the raw-count Euclidean result is still dominated by length.
Show the plotting code
source("R/lesson-figures.R")
set.seed(5901)
plot_pairs <- pair_distances |>
slice_sample(n = 3000) |>
tidyr::pivot_longer(
c(euclidean, cosine, jaccard),
names_to = "distance",
values_to = "value"
) |>
mutate(
distance = factor(
distance,
levels = c("euclidean", "cosine", "jaccard"),
labels = c("Euclidean", "Cosine", "Jaccard")
)
)
ggplot(plot_pairs, aes(x = length_difference, y = value)) +
geom_point(alpha = 0.15, size = 0.7, colour = lesson_colours[["accent"]]) +
facet_wrap(vars(distance), scales = "free_y") +
labs(
title = "Raw Euclidean distance mostly follows length",
subtitle = "3,000 sampled paragraph pairs; each panel has its own vertical scale",
x = "Difference in paragraph length, in tokens",
y = "Distance"
) +
theme_lesson(grid = "both")
The plot turns the correlation into a visible warning. A long paragraph and a short paragraph can look far apart under raw-count Euclidean distance even when they come from the same speech.
What to remember
- String distance compares character sequences.
- Vector distance compares numeric representations of text.
- Levenshtein and Jaro-Winkler can rank the same name candidates differently.
- On these paragraph counts, Euclidean distance correlates 0.951 with length difference.
- Cosine distance compares direction, which reduces the length problem for raw counts.
The volunteer keeps two labels on the boxes: spelling repair for names, representation choice for paragraphs.