A Map of the Eras
What Taylor Swift sings about, feels through, and changes over time
Gopi Nath Vajpai
May 26, 2026
# Run once before knitting this report:
install.packages(c(
"taylor", "tidyverse", "tidytext", "textdata", "topicmodels",
"broom", "DT", "scales", "lubridate", "ggrepel"
))
# The NRC emotion lexicon is downloaded once and cached locally.
# This may ask you to accept its license interactively.
textdata::lexicon_nrc()From diary pages to world-building
This analysis listens computationally to 261 songs across 16 studio-album releases: not to replace close reading, but to find the patterns that make close reading more interesting.
Taylor Swift’s songs are often read one confession, character, or bridge at a time. This blog takes another path: it asks what becomes visible when the discography is treated as a longitudinal text corpus. Three questions guide the journey:
- What themes are prevalent in the lyrics?
- What emotions recur across songs and release years?
- Does the writing change over time—in emotion, vocabulary, or point of view?
Data choice. The analysis uses
taylor::taylor_album_songs, which includes lyric data and
song metadata for official studio-album tracks. By default, a track that
appears on both an original album and a Taylor’s Version is retained at
its earliest public release, avoiding a false “new lyric” spike in
rerecording years. Previously unreleased vault tracks remain assigned to
their first public-release year. Set params$include_rerecordings:
true in the YAML header to analyze releases exactly as
distributed instead.
DT::datatable(
corpus_snapshot |>
mutate(Value = scales::comma(Value)),
rownames = FALSE,
options = list(dom = "t", pageLength = 5),
caption = "The lyrical corpus used in this report"
)album_summary <- lyric_lines |>
distinct(song_id, album_name, release_year, track_name) |>
count(album_name, release_year, name = "songs") |>
left_join(
content_tokens |>
count(album_name, release_year, name = "content_tokens"),
by = c("album_name", "release_year")
) |>
arrange(release_year)
DT::datatable(
album_summary |>
mutate(content_tokens = scales::comma(content_tokens)),
rownames = FALSE,
options = list(dom = "tip", pageLength = 20),
caption = "Album-level coverage after the rerecording rule is applied"
)1. The rooms in the lyrical house: themes
A theme is more than a frequent word. To discover clusters of words that repeatedly appear together across songs, I use Latent Dirichlet Allocation (LDA). Think of each song as a house containing several rooms: heartbreak might occupy one room, memory another, escape another. LDA estimates how much of each room appears in each song.
term_frequency <- content_tokens |>
count(word, name = "total_n")
terms_to_keep <- term_frequency |>
filter(total_n >= params$min_term_frequency) |>
pull(word)
song_term_counts <- content_tokens |>
filter(word %in% terms_to_keep) |>
count(song_id, word, name = "n")
dtm <- song_term_counts |>
cast_dtm(song_id, word, n)
set.seed(params$seed)
lda_fit <- topicmodels::LDA(
dtm,
k = params$k_topics,
control = list(seed = params$seed)
)
topic_terms <- broom::tidy(lda_fit, matrix = "beta")
song_topics <- broom::tidy(lda_fit, matrix = "gamma") |>
rename(song_id = document)
top_terms <- topic_terms |>
group_by(topic) |>
slice_max(beta, n = 10, with_ties = FALSE) |>
ungroup()
topic_labels <- top_terms |>
group_by(topic) |>
slice_max(beta, n = 4, with_ties = FALSE) |>
summarise(anchor_words = paste(term, collapse = " · "), .groups = "drop") |>
mutate(topic_label = paste0("Room ", topic, ": ", anchor_words))top_terms |>
left_join(topic_labels, by = "topic") |>
mutate(term = reorder_within(term, beta, topic_label)) |>
ggplot(aes(x = beta, y = term, fill = beta)) +
geom_col(show.legend = FALSE) +
facet_wrap(~topic_label, scales = "free_y", ncol = 2) +
scale_y_reordered() +
scale_fill_taylor_c(album = "Midnights") +
labs(
title = "Six lyrical rooms, discovered from word co-occurrence",
subtitle = "The bars show words most strongly associated with each topic; room labels are deliberately data-led.",
x = "Probability of word within topic (beta)",
y = NULL,
caption = "Model: LDA at the song level; common stop words and vocal fillers removed."
)The labels above are not imposed beforehand: they are built from each topic’s four strongest anchor words. This keeps interpretation honest—theme naming follows the textual evidence rather than leading it.
Which eras visit which rooms?
topic_by_album <- song_topics |>
left_join(songs |> select(song_id, album_name, release_year), by = "song_id") |>
group_by(album_name, release_year, topic) |>
summarise(topic_share = mean(gamma), .groups = "drop") |>
left_join(topic_labels, by = "topic") |>
mutate(
album_name = factor(album_name, levels = album_order),
topic_label = factor(topic_label, levels = topic_labels$topic_label)
)
ggplot(topic_by_album, aes(x = topic_label, y = album_name, fill = topic_share)) +
geom_tile(color = "white", linewidth = 0.7) +
scale_fill_taylor_c(album = "THE TORTURED POETS DEPARTMENT", labels = scales::percent) +
labs(
title = "Each album has its own floor plan",
subtitle = "Average share of each discovered topic within songs on each album",
x = NULL,
y = NULL,
fill = "Topic share"
) +
theme(axis.text.x = element_text(angle = 30, hjust = 1))The heatmap is useful because it shifts the question from what is Taylor Swift usually about? to how does each album rebalance recurring concerns? Some records concentrate heavily in a few rooms, suggesting a tighter conceptual identity, while others spread themselves across several rooms, indicating broader emotional or narrative range. In practice, the most interesting albums are often not the ones with brand-new topics, but the ones that redistribute familiar ones in a new proportion.
theme_examples <- song_topics |>
group_by(topic) |>
slice_max(gamma, n = 3, with_ties = FALSE) |>
ungroup() |>
left_join(topic_labels, by = "topic") |>
left_join(songs |> select(song_id, track_name, album_name, release_year), by = "song_id") |>
transmute(
Theme = topic_label,
Song = track_name,
Album = as.character(album_name),
Year = release_year,
`Theme share` = scales::percent(gamma, accuracy = 0.1)
)
DT::datatable(
theme_examples,
rownames = FALSE,
options = list(pageLength = params$k_topics * 3, dom = "t"),
caption = "Songs most closely associated with each discovered lyrical room"
)2. The emotional weather of the songs
Themes tell us what songs circle around; emotion lexicons offer a modest way of asking how the language feels. The NRC lexicon assigns words to eight emotions: anger, anticipation, disgust, fear, joy, sadness, surprise, and trust. I report emotion hits per 100 lyric tokens so that longer songs do not automatically look more emotional.
emotion_levels <- c(
"anger", "anticipation", "disgust", "fear",
"joy", "sadness", "surprise", "trust"
)
nrc <- tryCatch(
tidytext::get_sentiments("nrc") |>
filter(sentiment %in% emotion_levels),
error = function(e) {
stop(
"The NRC lexicon is not yet available locally. Run textdata::lexicon_nrc() ",
"interactively once, accept its license, and knit again."
)
}
)
song_lengths <- all_tokens |>
count(song_id, name = "all_words") |>
filter(is.finite(all_words), all_words > 0)
# A song without usable lyric tokens cannot have a valid normalized emotion rate.
# Excluding it here prevents NA rates from propagating through all eight summaries.
songs_with_lyrics <- songs |>
semi_join(song_lengths, by = "song_id")
omitted_lyric_tracks <- songs |>
anti_join(song_lengths, by = "song_id") |>
select(track_name, album_name, release_year)
emotion_hits <- all_tokens |>
inner_join(nrc, by = "word") |>
count(song_id, sentiment, name = "hits")
song_emotions <- tidyr::crossing(
song_id = songs_with_lyrics$song_id,
sentiment = emotion_levels
) |>
left_join(emotion_hits, by = c("song_id", "sentiment")) |>
mutate(hits = replace_na(hits, 0L)) |>
left_join(song_lengths, by = "song_id") |>
left_join(songs_with_lyrics |> select(song_id, album_name, release_year, track_name), by = "song_id") |>
mutate(
rate_per_100 = 100 * hits / all_words,
sentiment = factor(sentiment, levels = emotion_levels)
) |>
filter(is.finite(rate_per_100))
emotion_summary <- song_emotions |>
group_by(sentiment) |>
summarise(
mean_hits_per_100 = mean(rate_per_100, na.rm = TRUE),
median_hits_per_100 = median(rate_per_100, na.rm = TRUE),
songs_with_emotion = sum(hits > 0, na.rm = TRUE),
share_of_songs = mean(hits > 0, na.rm = TRUE),
.groups = "drop"
) |>
filter(is.finite(mean_hits_per_100)) |>
arrange(desc(mean_hits_per_100))
top_emotion <- emotion_summary |>
slice_max(mean_hits_per_100, n = 1, with_ties = FALSE) |>
pull(sentiment) |>
as.character()# Avoid fct_reorder() here: missing normalized rates in a denominator-less song
# can cause a mismatch between available summaries and the preset emotion levels.
emotion_summary_plot <- emotion_summary |>
filter(is.finite(mean_hits_per_100)) |>
arrange(mean_hits_per_100) |>
mutate(
sentiment = factor(as.character(sentiment), levels = as.character(sentiment)),
label = sprintf("%.2f", mean_hits_per_100)
)
if (nrow(emotion_summary_plot) == 0 || max(emotion_summary_plot$mean_hits_per_100, na.rm = TRUE) <= 0) {
stop(
"The mean emotion plot has no valid non-zero values. Confirm that the NRC lexicon loaded ",
"correctly and that the lyric tokens contain matching NRC terms."
)
}
emotion_colours <- setNames(
as.character(taylor::color_palette(taylor::album_palettes$lover, n = length(emotion_levels))),
emotion_levels
)
ggplot(emotion_summary_plot, aes(x = mean_hits_per_100, y = sentiment)) +
geom_col(fill = "#B56576", width = 0.72) +
geom_text(
aes(label = label),
hjust = -0.12,
size = 3.5
) +
scale_x_continuous(expand = expansion(mult = c(0, .14))) +
labs(
title = "Mean emotion hits per 100 words",
subtitle = "Average NRC-coded emotion vocabulary per song, normalized for song length",
x = "Mean emotion hits per 100 words",
y = NULL
)Across songs, Joy appears most consistently in the vocabulary. Analytically, that matters because it suggests the emotional center of the catalog is not just intense feeling, but a repeated mode of framing experience. If trust or anticipation ranks highly, Swift’s writing leans toward expectation, attachment, and relational orientation; if sadness or fear rises, the songs are more often organized around rupture, uncertainty, or loss. The chart should therefore be read as a map of recurring emotional language, not as a simple mood score.
Normalization excludes 6 track(s) without usable lyric tokens; this prevents missing denominators from turning every emotion summary into an NA value.
Emotion across release years
For the yearly weather map, I pool emotion-word hits and lyric tokens within each release year before normalizing. This makes each tile a transparent measure of the proportion of the year’s lyric vocabulary assigned to an NRC emotion, rather than allowing a short song to carry the same numerical weight as a much longer one.
# A weighted, complete year-by-emotion panel prevents missing cells or all-NA
# fills from silently producing an apparently blank heatmap.
yearly_emotions <- song_emotions |>
group_by(release_year, sentiment) |>
summarise(
hits = sum(hits, na.rm = TRUE),
all_words = sum(all_words, na.rm = TRUE),
songs = n_distinct(song_id),
.groups = "drop"
) |>
mutate(
mean_rate = if_else(all_words > 0, 100 * hits / all_words, 0),
sentiment = factor(as.character(sentiment), levels = emotion_levels)
)
emotion_by_year <- tidyr::crossing(
release_year = sort(unique(songs$release_year)),
sentiment = factor(emotion_levels, levels = emotion_levels)
) |>
left_join(yearly_emotions, by = c("release_year", "sentiment")) |>
mutate(
hits = replace_na(hits, 0L),
all_words = replace_na(all_words, 0L),
songs = replace_na(songs, 0L),
mean_rate = replace_na(mean_rate, 0)
)
if (nrow(emotion_by_year) == 0 ||
!any(is.finite(emotion_by_year$mean_rate)) ||
max(emotion_by_year$mean_rate, na.rm = TRUE) <= 0) {
stop(
"No non-zero NRC emotion rates were computed. Confirm that lyrics were ",
"tokenized and that textdata::lexicon_nrc() has been downloaded."
)
}
weather_max <- max(emotion_by_year$mean_rate, na.rm = TRUE)
weather_text_cut <- 0.58 * weather_max
# A high-contrast Red-inspired sequential palette is used here rather than
# relying on a very pale/interpolated scale that can disappear in HTML output.
weather_colours <- c("#FBF7F3", "#EBCDC7", "#D48F91", "#9C4552", "#461722")
peak_weather <- emotion_by_year |>
slice_max(mean_rate, n = 1, with_ties = FALSE)
ggplot(
emotion_by_year,
aes(x = factor(release_year), y = fct_rev(sentiment), fill = mean_rate)
) +
geom_tile(color = "white", linewidth = .65, width = .98, height = .98) +
geom_text(
aes(
label = sprintf("%.1f", mean_rate),
color = mean_rate >= weather_text_cut
),
size = 3.05,
show.legend = FALSE
) +
scale_fill_gradientn(
colours = weather_colours,
limits = c(0, weather_max),
oob = scales::squish,
labels = function(x) sprintf("%.1f", x)
) +
scale_color_manual(values = c(`TRUE` = "white", `FALSE` = "#302028")) +
labs(
title = "An emotional weather map across release years",
subtitle = "Each labelled tile reports NRC emotion hits per 100 lyric words in that release year",
x = "Release year",
y = NULL,
fill = "Hits per\n100 words"
) +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
panel.grid = element_blank(),
legend.position = "right",
plot.title.position = "plot"
)The densest emotional cell in this map is Joy in 2019, at 3.67 NRC emotion hits per 100 lyric words. Because dictionary-based emotion analysis detects associated vocabulary rather than a song’s whole emotional meaning, this cell is best read as a prompt for close reading rather than a verdict on the era.
Taken analytically, the weather map shows whether Swift’s emotional language changes by substitution or by layering. A year dominated by one or two emotions suggests a sharper lyrical climate; a more even spread implies emotional complexity, where longing, trust, fear, and joy coexist rather than replace one another. That distinction helps explain why two albums can both feel deeply emotional while still producing very different listening experiences.
ggplot(emotion_by_year, aes(x = release_year, y = mean_rate, color = sentiment)) +
geom_line(linewidth = .7, alpha = .85) +
geom_point(size = 2) +
facet_wrap(~ sentiment, scales = "free_y", ncol = 2) +
scale_color_manual(values = emotion_colours, guide = "none") +
labs(
title = "Some feelings persist; others arrive in waves",
subtitle = "Descriptive trajectories only: sparse release years make smooth causal claims inappropriate.",
x = "Release year",
y = "Emotion hits per 100 words"
)Does lyrical emotion resemble sonic mood?
The taylor data also supplies valence,
an audio-based indicator of musical positiveness. Comparing it to lyric
emotion is useful precisely because upbeat sound and painful words do
not always move together.
lyric_mood <- song_emotions |>
select(song_id, sentiment, rate_per_100) |>
pivot_wider(names_from = sentiment, values_from = rate_per_100) |>
mutate(lyrical_balance = joy - sadness) |>
left_join(
songs |> select(song_id, track_name, album_name, release_year, valence),
by = "song_id"
) |>
filter(!is.na(valence))
mood_cor <- cor(lyric_mood$lyrical_balance, lyric_mood$valence, use = "complete.obs")
ggplot(lyric_mood, aes(x = lyrical_balance, y = valence, color = album_name)) +
geom_hline(yintercept = median(lyric_mood$valence, na.rm = TRUE), linetype = "dotted", color = "grey65") +
geom_vline(xintercept = 0, linetype = "dotted", color = "grey65") +
geom_point(alpha = .8, size = 2.2) +
geom_smooth(method = "lm", se = TRUE, color = "grey30", linewidth = .7) +
scale_color_albums() +
labs(
title = "When the words and the music disagree",
subtitle = paste0("Lyric balance = joy rate − sadness rate; correlation with audio valence: r = ", round(mood_cor, 2)),
x = "Lyrical joy minus sadness (hits per 100 words)",
y = "Audio valence",
color = "Album"
)This comparison speaks to one of Swift’s signature strengths: emotional counterpoint. When songs sit in the quadrant with high valence but sadness-heavy lyrics, the writing and the production are pulling in different directions, creating emotional ambivalence rather than a single-tone feeling. That tension is part of what makes the catalog analytically rich: the words often complicate what the sonics initially promise.
3. The years leave fingerprints: word choice and voice
Lyrics can change even when their topics remain familiar. Here I look for three signals: vocabulary breadth, the balance of first- and second-person address, and words disproportionately associated with early versus later albums.
Has the vocabulary broadened?
A simple count of unique words rewards albums with more songs. Instead, I repeatedly draw the same number of content-word tokens from each album and count how many distinct words appear. This rarefied vocabulary makes albums more comparable.
album_words <- content_tokens |>
group_by(album_name, release_year) |>
summarise(words = list(word), token_count = n(), .groups = "drop") |>
arrange(release_year)
sample_size <- min(1000L, min(album_words$token_count))
set.seed(params$seed)
album_lexical <- album_words |>
mutate(
rarefied_vocabulary = map_dbl(
words,
~ mean(replicate(250, n_distinct(sample(.x, size = sample_size, replace = FALSE))))
),
mean_word_length = map_dbl(words, ~ mean(str_length(.x))),
album_name = factor(album_name, levels = album_order)
)
broadest_album <- album_lexical |>
slice_max(rarefied_vocabulary, n = 1, with_ties = FALSE) |>
pull(album_name) |>
as.character()ggplot(album_lexical, aes(x = release_year, y = rarefied_vocabulary, color = album_name)) +
geom_line(color = "grey70", linewidth = .7) +
geom_point(size = 3) +
ggrepel::geom_text_repel(aes(label = album_name), show.legend = FALSE, size = 3, max.overlaps = 20) +
scale_color_albums(guide = "none") +
labs(
title = "Vocabulary breadth, compared on equal lyrical footing",
subtitle = paste0("Average unique content words in repeated random samples of ", scales::comma(sample_size), " tokens per album"),
x = "Release year",
y = "Rarefied vocabulary size"
)By this sampling-based comparison, THE TORTURED POETS DEPARTMENT has the broadest vocabulary among the included album releases. Analytically, vocabulary breadth is most interesting when read as a sign of world-building rather than sophistication alone. Broader vocabulary often accompanies a larger cast of images, settings, and narrative situations; narrower vocabulary can reflect aesthetic concentration, where repetition is being used to intensify a mood or persona rather than to signal limitation.
From “you” to “I”? Tracking narrative orientation
voice_words <- tibble(
word = c("i", "me", "my", "mine", "myself", "you", "your", "yours", "yourself", "yourselves"),
voice = c(rep("Self: I / me / my", 5), rep("Addressee: you / your", 5))
)
album_word_totals <- all_tokens |>
count(album_name, release_year, name = "all_words")
voice_by_album <- all_tokens |>
inner_join(voice_words, by = "word") |>
count(album_name, release_year, voice, name = "hits") |>
left_join(album_word_totals, by = c("album_name", "release_year")) |>
mutate(
per_100 = 100 * hits / all_words,
album_name = factor(album_name, levels = album_order)
)
ggplot(voice_by_album, aes(x = release_year, y = per_100, color = voice)) +
geom_line(linewidth = 1) +
geom_point(size = 2.6) +
scale_color_taylor_d(album = "Lover") +
labs(
title = "Who is speaking, and who is being addressed?",
subtitle = "First-person and second-person pronouns per 100 lyric words",
x = "Release year",
y = "Pronouns per 100 words",
color = NULL
)Pronoun balance helps distinguish confessional writing from relational writing. A rise in I/me/my signals inward narration and self-positioning; a rise in you/your suggests address, confrontation, longing, or dialogue. The most revealing pattern is not which side wins once, but whether the catalog gradually moves from talking to someone toward narrating a more self-contained interior world.
The vocabulary that distinguishes early and later eras
This comparison sets two public-release periods against one another: early albums (2006–2012) and folklore-forward releases (2020 onward). The bridge years are omitted to sharpen the contrast. A smoothed log-odds statistic identifies words disproportionately characteristic of either period; it does not say that a word is absent elsewhere.
period_tokens <- content_tokens |>
mutate(
period = case_when(
release_year <= 2012 ~ "Early: 2006–2012",
release_year >= 2020 ~ "Later: 2020 onward",
TRUE ~ NA_character_
)
) |>
filter(!is.na(period))
contrast_counts <- period_tokens |>
count(period, word, name = "n") |>
pivot_wider(names_from = period, values_from = n, values_fill = 0) |>
rename(
early = `Early: 2006–2012`,
later = `Later: 2020 onward`
) |>
mutate(total = early + later) |>
filter(total >= 8)
alpha <- 0.5
vocab_size <- nrow(contrast_counts)
N_early <- sum(contrast_counts$early)
N_later <- sum(contrast_counts$later)
word_shift <- contrast_counts |>
mutate(
log_odds_later = log((later + alpha) / (N_later + alpha * vocab_size)) -
log((early + alpha) / (N_early + alpha * vocab_size)),
era = if_else(log_odds_later > 0, "Later: 2020 onward", "Early: 2006–2012")
)
contrast_words <- bind_rows(
word_shift |> slice_min(log_odds_later, n = 15),
word_shift |> slice_max(log_odds_later, n = 15)
) |>
mutate(word = fct_reorder(word, log_odds_later))ggplot(contrast_words, aes(x = log_odds_later, y = word, fill = era)) +
geom_col(width = .75) +
geom_vline(xintercept = 0, linewidth = .5, color = "grey40") +
scale_fill_taylor_d(album = "evermore") +
labs(
title = "Words that tilt toward two ends of the timeline",
subtitle = "Smoothed log odds: positive values are more characteristic of 2020 onward",
x = "Log odds toward later releases",
y = NULL,
fill = NULL
)The early-versus-later contrast is where stylistic evolution becomes easiest to see. If the early-era words tilt toward concrete romance scripts and the later-era words toward memory, atmosphere, or abstraction, that suggests a shift from event-centered storytelling to reflective scene-building. In other words, the evolution is not merely about becoming more mature; it is about changing the unit of narration itself.
DT::datatable(
word_shift |>
arrange(desc(abs(log_odds_later))) |>
slice_head(n = 40) |>
transmute(
Word = word,
`Early count` = early,
`Later count` = later,
`Log odds toward later era` = round(log_odds_later, 3),
`More characteristic of` = era
),
rownames = FALSE,
options = list(pageLength = 10),
caption = "Most differentiating content words in the early-versus-later comparison"
)Closing: the story the numbers are telling
Taken together, the analyses point to a coherent story. Taylor Swift’s catalog is not simply a sequence of breakup songs or autobiographical snapshots. It is a writing project built on recurrence with reinvention: a stable set of emotional and thematic concerns—attachment, anticipation, trust, loss, memory, address—reappears across eras, but the way those concerns are voiced changes over time.
The topic model suggests that the core lyrical rooms remain surprisingly durable. What changes is the floor plan: albums differ in how much space they give to confession, retrospection, fantasy, intimacy, or fracture. The emotion results add another layer: rather than moving in a straight line from sadness to joy or from fear to confidence, the catalog often accumulates emotions, producing albums whose power comes from emotional layering rather than emotional uniformity. And the language-change measures suggest that later work often feels different not because it abandons the old concerns, but because it handles them with a wider vocabulary, a different narrative distance, and a more deliberate relationship between speaker and addressee.
In that sense, the discography reads like an expanding narrative universe. Early songs often sharpen feeling through direct address and recognizably immediate situations; later songs more often build atmosphere, memory, and perspective. The emotional world does not become less intense—it becomes more architected. That may be the broadest takeaway from the whole analysis: Swift’s evolution is not best understood as a move away from confession, but as a move toward crafting larger lyrical worlds out of familiar emotional materials.
The eras change, but the deeper story is continuity under transformation: the same emotional materials return, while the language around them grows more spacious, layered, and self-aware.