library(tidyverse)
library(tidytext)
library(janeaustenr)
library(syuzhet)
library(sentimentr)
library(knitr)
accent <- "#2a78d6"
accent_2 <- "#eb6834"
theme_sa <- theme_minimal(base_size = 12) +
theme(panel.grid.minor = element_blank(), plot.title.position = "plot")Sentiment Analysis: Reproducing and Extending Text Mining with R
Jane Austen, five gothic novels, and four lexicons that disagree
1 Overview
This report has two halves. The first reproduces the sentiment analysis example from Chapter 2 of Text Mining with R. The second extends it with a different corpus — five gothic novels — and two additional lexicons, then asks how much the choice of lexicon changes the answer.
The short version of the finding: it changes it completely. On the gothic novels, one lexicon says the books are negative and three say they are positive. The texts did not change; only the dictionary did.
Source of the original example: Silge, J. and Robinson, D. (2017), Text Mining with R: A Tidy Approach, O’Reilly Media. Chapter 2, “Sentiment analysis with tidy data”. The code in Section 3 follows that chapter closely; any changes are noted where they occur.
1.1 A note on where the lexicons come from
The chapter calls tidytext::get_sentiments(), which for afinn and nrc downloads the data through the textdata package and asks the user to accept each lexicon’s licence first. That prompt cannot be answered in a document that renders automatically, so this report takes the same three lexicons from the syuzhet package, which ships its own copies. bing still comes from tidytext directly, exactly as in the book.
bing <- get_sentiments("bing")
afinn <- as_tibble(get_sentiment_dictionary("afinn"))
nrc <- as_tibble(get_sentiment_dictionary("nrc")) |>
filter(lang == "english") |>
select(word, sentiment) |>
distinct()
tibble(
lexicon = c("bing", "afinn", "nrc"),
source = c("tidytext", "syuzhet", "syuzhet"),
entries = c(nrow(bing), nrow(afinn), nrow(nrc)),
form = c("positive / negative", "score from -5 to +5", "positive / negative + 8 emotions")
) |>
kable()| lexicon | source | entries | form |
|---|---|---|---|
| bing | tidytext | 6786 | positive / negative |
| afinn | syuzhet | 3382 | score from -5 to +5 |
| nrc | syuzhet | 13901 | positive / negative + 8 emotions |
2 Reproducing the Book’s Example
2.1 Tidying the Austen novels
This is the chapter’s code: one word per row, with line and chapter numbers kept so sentiment can be tracked through each novel.
tidy_books <- austen_books() |>
group_by(book) |>
mutate(
linenumber = row_number(),
chapter = cumsum(str_detect(text, regex("^chapter [\\divxlc]", ignore_case = TRUE)))
) |>
ungroup() |>
unnest_tokens(word, text)
tibble(words = nrow(tidy_books), books = n_distinct(tidy_books$book)) |> kable()| words | books |
|---|---|
| 725055 | 6 |
2.2 The joy words in Emma
The chapter’s first result: the most common words in Emma that NRC labels as joy.
nrc_joy <- nrc |> filter(sentiment == "joy")
tidy_books |>
filter(book == "Emma") |>
inner_join(nrc_joy, by = "word", relationship = "many-to-many") |>
count(word, sort = TRUE) |>
head(10) |>
kable()| word | n |
|---|---|
| good | 359 |
| young | 192 |
| friend | 166 |
| hope | 143 |
| happy | 125 |
| love | 117 |
| deal | 92 |
| found | 92 |
| present | 89 |
| kind | 82 |
This matches the book exactly: good, young, friend, hope, happy, love. It also shows the method’s weakness, which the authors point out — “young” is counted as joy, and “found” appears because the lexicon cannot tell found the verb from found meaning established.
2.3 Comparing the three lexicons on Pride and Prejudice
pride_prejudice <- tidy_books |> filter(book == "Pride & Prejudice")
afinn_pp <- pride_prejudice |>
inner_join(afinn, by = "word") |>
group_by(index = linenumber %/% 80) |>
summarise(sentiment = sum(value), .groups = "drop") |>
mutate(method = "AFINN")
bing_and_nrc <- bind_rows(
pride_prejudice |>
inner_join(bing, by = "word", relationship = "many-to-many") |>
mutate(method = "Bing et al."),
pride_prejudice |>
inner_join(nrc |> filter(sentiment %in% c("positive", "negative")),
by = "word", relationship = "many-to-many") |>
mutate(method = "NRC")
) |>
count(method, index = linenumber %/% 80, sentiment) |>
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) |>
mutate(sentiment = positive - negative)
comparison <- bind_rows(afinn_pp, bing_and_nrc |> select(index, sentiment, method))
comparison |>
group_by(method) |>
summarise(
chunks = n(),
mean = round(mean(sentiment), 1),
lowest = min(sentiment),
highest = max(sentiment),
pct_positive = round(100 * mean(sentiment > 0)),
.groups = "drop"
) |>
kable(col.names = c("Method", "Chunks", "Mean", "Lowest", "Highest", "% chunks positive"))| Method | Chunks | Mean | Lowest | Highest | % chunks positive |
|---|---|---|---|---|---|
| AFINN | 163 | 29.0 | -38 | 92 | 84 |
| Bing et al. | 163 | 8.6 | -30 | 39 | 72 |
| NRC | 163 | 23.2 | -8 | 54 | 98 |
ggplot(comparison, aes(index, sentiment, fill = method)) +
geom_col(show.legend = FALSE) +
facet_wrap(~method, ncol = 1, scales = "free_y") +
scale_fill_manual(values = c("AFINN" = accent, "Bing et al." = accent_2, "NRC" = "#1baf7a")) +
labs(title = "The same novel, three lexicons, three different pictures",
x = "Index (80 lines of text per chunk)", y = "Net sentiment") +
theme_saThe reproduction holds up. The three lines move together — they find the same peaks and troughs in the same places — but they sit at different heights. NRC is the most positive, calling 98% of chunks positive, against 72% for Bing. The book attributes this to the lexicons’ own balance, and that is exactly what the counts show:
bind_rows(
bing |> count(sentiment) |> mutate(lexicon = "bing"),
nrc |> filter(sentiment %in% c("positive", "negative")) |> count(sentiment) |> mutate(lexicon = "nrc"),
afinn |> mutate(sentiment = if_else(value > 0, "positive", "negative")) |> count(sentiment) |>
mutate(lexicon = "afinn")
) |>
pivot_wider(names_from = sentiment, values_from = n) |>
mutate(pct_positive = round(100 * positive / (positive + negative), 1)) |>
select(lexicon, negative, positive, pct_positive) |>
kable(col.names = c("Lexicon", "Negative words", "Positive words", "% positive"))| Lexicon | Negative words | Positive words | % positive |
|---|---|---|---|
| bing | 4781 | 2005 | 29.5 |
| nrc | 3324 | 2312 | 41.0 |
| afinn | 2206 | 1176 | 34.8 |
NRC is 41% positive while Bing is only 29.5%, so NRC scores any text higher. Nothing about the novel causes that difference.
2.4 Words that drive the score
tidy_books |>
inner_join(bing, by = "word", relationship = "many-to-many") |>
count(word, sentiment, sort = TRUE) |>
group_by(sentiment) |>
slice_max(n, n = 10) |>
ungroup() |>
mutate(word = reorder(word, n)) |>
ggplot(aes(n, word, fill = sentiment)) +
geom_col(show.legend = FALSE) +
facet_wrap(~sentiment, scales = "free_y") +
scale_fill_manual(values = c(negative = accent_2, positive = accent)) +
labs(title = "Top sentiment words in the Austen novels", x = "Occurrences", y = NULL) +
theme_saThe chapter uses this plot to make a point about error-checking, and the point survives: “miss” is counted as negative 1,855 times, but in Austen it is almost always a title for an unmarried woman, not a failure. In the book the authors add it to a custom stop-word list. I leave it in here so the problem stays visible, and return to it in the conclusions.
3 Extension 1: A Different Corpus
3.1 The gothic novels
I kept the method and changed the material: five gothic and horror novels from Project Gutenberg, chosen because the genre should sit at the opposite emotional end from Austen’s social comedies.
gothic <- read_csv(
"https://raw.githubusercontent.com/AnissSahraoui/DATA607/main/Week10A/data/gothic_books.csv",
show_col_types = FALSE
)
tidy_gothic <- gothic |>
group_by(title) |>
mutate(linenumber = row_number()) |>
ungroup() |>
unnest_tokens(word, text)
tidy_gothic |>
count(title, name = "words") |>
kable(col.names = c("Novel", "Words"))| Novel | Words |
|---|---|
| Dracula | 164925 |
| Frankenstein; or, the modern prometheus | 76204 |
| The Picture of Dorian Gray | 81366 |
| The Turn of the Screw | 43710 |
| The strange case of Dr. Jekyll and Mr. Hyde | 26170 |
The corpus was downloaded once with gutenbergr and saved to my repository, so this document does not depend on a live download. (One title, an older edition of Frankenstein, had been archived on the mirror and was replaced with Gutenberg ID 84.)
3.2 Does the method separate the genres?
chunk_sentiment <- function(tokens, group_col) {
tokens |>
inner_join(bing, by = "word", relationship = "many-to-many") |>
count(.data[[group_col]], index = linenumber %/% 80, sentiment) |>
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) |>
mutate(net = positive - negative)
}
austen_lines <- austen_books() |>
group_by(book) |>
mutate(linenumber = row_number()) |>
ungroup() |>
unnest_tokens(word, text)
gothic_chunks <- chunk_sentiment(tidy_gothic, "title")
austen_chunks <- chunk_sentiment(austen_lines, "book")
bind_rows(
austen_chunks |> group_by(work = as.character(book)) |>
summarise(corpus = "Austen", .groups = "drop_last",
chunks = n(), mean_net = round(mean(net), 1),
pct_positive = round(100 * mean(net > 0))) |> ungroup(),
gothic_chunks |> group_by(work = title) |>
summarise(corpus = "Gothic", .groups = "drop_last",
chunks = n(), mean_net = round(mean(net), 1),
pct_positive = round(100 * mean(net > 0))) |> ungroup()
) |>
select(corpus, work, chunks, mean_net, pct_positive) |>
kable(col.names = c("Corpus", "Work", "Chunks", "Mean net sentiment", "% chunks positive"))| Corpus | Work | Chunks | Mean net sentiment | % chunks positive |
|---|---|---|---|---|
| Austen | Emma | 203 | 11.6 | 80 |
| Austen | Mansfield Park | 192 | 10.0 | 77 |
| Austen | Northanger Abbey | 99 | 7.3 | 71 |
| Austen | Persuasion | 105 | 12.1 | 86 |
| Austen | Pride & Prejudice | 163 | 8.6 | 72 |
| Austen | Sense & Sensibility | 158 | 8.0 | 72 |
| Gothic | Dracula | 194 | -4.5 | 35 |
| Gothic | Frankenstein; or, the modern prometheus | 92 | -8.2 | 35 |
| Gothic | The Picture of Dorian Gray | 107 | -3.9 | 37 |
| Gothic | The Turn of the Screw | 57 | -1.6 | 42 |
| Gothic | The strange case of Dr. Jekyll and Mr. Hyde | 32 | -7.5 | 16 |
bind_rows(
austen_chunks |> transmute(corpus = "Austen", net),
gothic_chunks |> transmute(corpus = "Gothic", net)
) |>
ggplot(aes(x = net, fill = corpus)) +
geom_vline(xintercept = 0, color = "grey50") +
geom_density(alpha = 0.55, color = NA) +
scale_fill_manual(values = c(Austen = accent, Gothic = accent_2), name = NULL) +
labs(title = "Bing separates the two genres cleanly",
x = "Net sentiment per 80-line chunk", y = "Density") +
theme_sa +
theme(legend.position = "top")Using Bing, the separation is complete: all six Austen novels average positive (+7.3 to +12.1) and all five gothic novels average negative (−1.6 to −8.3). Dr Jekyll and Mr Hyde is the darkest, with only 16% of its chunks positive, against 86% for Persuasion.
4 Extension 2: Additional Lexicons
I added two more, each chosen for a different reason.
The syuzhet lexicon is a fourth word-level dictionary, developed by Matthew Jockers for narrative analysis and bundled with the syuzhet package. It is the largest of the four, so it tests whether a bigger dictionary changes the answer.
sentimentr is not a word list but a different method. It scores whole sentences and applies valence shifters: negators, amplifiers and de-amplifiers. This matters because every lexicon used so far is blind to the word “not”.
tibble(sentence = c("This is very good.", "This is not good at all.")) |>
mutate(sentimentr_score = map_dbl(sentence, ~ sentiment_by(get_sentences(.x))$ave_sentiment),
bing_words = c("good = positive", "good = positive")) |>
kable(col.names = c("Sentence", "sentimentr", "What a word lexicon sees"))| Sentence | sentimentr | What a word lexicon sees |
|---|---|---|
| This is very good. | 0.6750000 | good = positive |
| This is not good at all. | -0.3061862 | good = positive |
4.1 How often does negation actually occur?
Before assuming negation matters, it is worth measuring.
negators <- c("not", "no", "never", "without", "none", "cannot", "nor", "neither", "nothing")
bigrams <- gothic |>
group_by(title) |>
summarise(text = paste(text, collapse = " "), .groups = "drop") |>
unnest_tokens(bigram, text, token = "ngrams", n = 2) |>
separate(bigram, c("word1", "word2"), sep = " ", fill = "right")
all_matches <- bigrams |> inner_join(bing, by = c("word2" = "word"), relationship = "many-to-many")
negated <- all_matches |> filter(word1 %in% negators)
n_matches <- nrow(all_matches)
n_negated <- nrow(negated)
tibble(
sentiment_words = n_matches,
negated_words = n_negated,
pct = round(100 * n_negated / n_matches, 2)
) |>
kable(col.names = c("Sentiment words", "Directly preceded by a negator", "%"))| Sentiment words | Directly preceded by a negator | % |
|---|---|---|
| 27310 | 591 | 2.16 |
negated |>
filter(sentiment == "positive") |>
count(word1, word2, sort = TRUE) |>
head(8) |>
mutate(counted_as = "positive", should_be = "negative") |>
kable(col.names = c("Negator", "Word", "Times", "Counted as", "Should be"))| Negator | Word | Times | Counted as | Should be |
|---|---|---|---|---|
| not | like | 25 | positive | negative |
| no | right | 6 | positive | negative |
| not | free | 6 | positive | negative |
| not | sufficient | 6 | positive | negative |
| no | good | 5 | positive | negative |
| not | good | 5 | positive | negative |
| not | love | 5 | positive | negative |
| no | wonder | 4 | positive | negative |
About 2% of sentiment words in the gothic corpus are directly negated, and “not like”, “no good” and “not love” are all scored as positive by every word-level lexicon here. It is a real error, but a small one — too small to explain the differences that follow.
4.2 All four lexicons, both corpora
syuzhet_lex <- as_tibble(get_sentiment_dictionary("syuzhet"))
both <- bind_rows(
tidy_gothic |> transmute(corpus = "Gothic", word),
austen_lines |> transmute(corpus = "Austen", word)
)
lexicon_scores <- bind_rows(
both |> inner_join(bing, by = "word", relationship = "many-to-many") |>
group_by(corpus) |> summarise(lexicon = "bing",
score = mean(if_else(sentiment == "positive", 1, -1)), matched = n(), .groups = "drop"),
both |> inner_join(afinn, by = "word") |>
group_by(corpus) |> summarise(lexicon = "afinn", score = mean(value), matched = n(), .groups = "drop"),
both |> inner_join(nrc |> filter(sentiment %in% c("positive", "negative")),
by = "word", relationship = "many-to-many") |>
group_by(corpus) |> summarise(lexicon = "nrc",
score = mean(if_else(sentiment == "positive", 1, -1)), matched = n(), .groups = "drop"),
both |> inner_join(syuzhet_lex, by = "word") |>
group_by(corpus) |> summarise(lexicon = "syuzhet", score = mean(value), matched = n(), .groups = "drop")
)
lexicon_scores |>
select(lexicon, corpus, score) |>
pivot_wider(names_from = corpus, values_from = score) |>
mutate(across(where(is.numeric), ~ round(.x, 3)),
gothic_sign = if_else(Gothic < 0, "negative", "positive")) |>
kable(col.names = c("Lexicon", "Austen", "Gothic", "Gothic verdict"))| Lexicon | Austen | Gothic | Gothic verdict |
|---|---|---|---|
| bing | 0.171 | -0.087 | negative |
| afinn | 0.540 | 0.054 | positive |
| nrc | 0.338 | 0.105 | positive |
| syuzhet | 0.202 | 0.036 | positive |
lexicon_scores |>
ggplot(aes(x = score, y = lexicon, fill = corpus)) +
geom_vline(xintercept = 0, color = "grey40") +
geom_col(position = position_dodge(width = 0.7), width = 0.6) +
geom_text(aes(label = sprintf("%.2f", score)), position = position_dodge(width = 0.7),
hjust = ifelse(lexicon_scores$score < 0, 1.2, -0.2), size = 3.2) +
scale_fill_manual(values = c(Austen = accent, Gothic = accent_2), name = NULL) +
scale_x_continuous(expand = expansion(mult = 0.18)) +
labs(title = "Three of four lexicons call the gothic novels positive",
x = "Mean sentiment per matched word", y = NULL) +
theme_sa +
theme(legend.position = "top", panel.grid.major.y = element_blank())This is the main result of the extension. On the same five novels, bing returns −0.087, a negative verdict, while nrc returns +0.105, afinn +0.054 and syuzhet +0.036, all positive. The earlier chunk-based chart made the gothic novels look decisively dark, but that conclusion came from one lexicon, and three others disagree with it.
Why? Because bing is the harshest dictionary, only 29.5% positive, while NRC is 41% positive. A lexicon’s own balance sets a baseline, and texts are scored relative to it.
4.3 Coverage: how much of the text each lexicon can even see
coverage <- function(dictionary_words, tokens, label) {
tibble(lexicon = label,
matched = sum(tokens$word %in% dictionary_words),
pct = round(100 * mean(tokens$word %in% dictionary_words), 1))
}
bind_rows(
coverage(bing$word, tidy_gothic, "bing"),
coverage(afinn$word, tidy_gothic, "afinn"),
coverage(unique(nrc$word), tidy_gothic, "nrc"),
coverage(syuzhet_lex$word, tidy_gothic, "syuzhet")
) |>
kable(col.names = c("Lexicon", "Words matched", "% of the corpus scored"))| Lexicon | Words matched | % of the corpus scored |
|---|---|---|
| bing | 27309 | 7.0 |
| afinn | 27144 | 6.9 |
| nrc | 40256 | 10.3 |
| syuzhet | 44943 | 11.5 |
Even the most generous lexicon scores only about 12% of the words, and Bing and AFINN see about 7%. Every conclusion above therefore rests on roughly one word in ten; the other nine are invisible to the method.
4.4 sentimentr, which reads sentences
sentimentr_scores <- bind_rows(
gothic |> group_by(work = title) |> summarise(text = paste(text, collapse = " "), .groups = "drop") |>
mutate(corpus = "Gothic"),
austen_books() |> group_by(work = as.character(book)) |>
summarise(text = paste(text, collapse = " "), .groups = "drop") |> mutate(corpus = "Austen")
) |>
mutate(sentimentr = map_dbl(text, ~ sentiment_by(get_sentences(.x))$ave_sentiment)) |>
select(corpus, work, sentimentr)
sentimentr_scores |>
mutate(sentimentr = round(sentimentr, 3)) |>
arrange(corpus, sentimentr) |>
kable(col.names = c("Corpus", "Work", "sentimentr score"))| Corpus | Work | sentimentr score |
|---|---|---|
| Austen | Northanger Abbey | 0.086 |
| Austen | Mansfield Park | 0.089 |
| Austen | Sense & Sensibility | 0.090 |
| Austen | Pride & Prejudice | 0.095 |
| Austen | Emma | 0.109 |
| Austen | Persuasion | 0.114 |
| Gothic | The strange case of Dr. Jekyll and Mr. Hyde | -0.020 |
| Gothic | The Picture of Dorian Gray | 0.007 |
| Gothic | Frankenstein; or, the modern prometheus | 0.010 |
| Gothic | Dracula | 0.030 |
| Gothic | The Turn of the Screw | 0.037 |
sentimentr agrees with the majority: the Austen novels score clearly higher (+0.086 to +0.114) than the gothic ones (−0.020 to +0.037), but only Dr Jekyll and Mr Hyde comes out negative. Adding negation handling moved the gothic novels slightly, not decisively — which is consistent with negation affecting only 2% of matches.
5 Conclusions
- The reproduction works. The Chapter 2 code runs as published and gives the same results: the joy words in Emma, the three sentiment trajectories through Pride and Prejudice, and the observation that NRC runs more positive than Bing.
- The lexicons agree on shape but not on level. Across Pride and Prejudice all three find the same peaks and troughs. What differs is where zero sits, and that is set by each dictionary’s own positive/negative balance rather than by the text.
- On a new corpus, that difference becomes decisive. Bing calls the five gothic novels negative (−0.087 per word); NRC, AFINN and syuzhet all call them positive. Austen is positive under every lexicon, so the method works where the signal is strong, and breaks down where it is marginal. If I had used a single lexicon and reported “gothic novels are dark”, I would have been reporting a property of Bing.
- Negation is a real but minor flaw. Only 2.16% of sentiment words are directly negated, and
sentimentr, which handles negation and intensifiers, mostly agrees with the simple word counts. Context errors are a bigger problem than negation: “miss” alone is counted negative 1,855 times in Austen, where it is a form of address. - Coverage is the limitation nobody mentions. These lexicons score 7–12% of the words. A sentiment score is a summary of a small, non-random slice of the text, and comparisons across lexicons are partly comparisons of which slice each one happens to see.
What I would do next. The honest way to use these tools is to run at least two lexicons and report the disagreement rather than picking the one that gives the expected answer. For the gothic corpus specifically, a domain-appropriate dictionary — or a labelled sample of sentences to validate against — would settle the question that four general-purpose lexicons could not.
6 References
- Silge, J. and Robinson, D. (2017), Text Mining with R: A Tidy Approach, O’Reilly Media, Chapter 2, “Sentiment analysis with tidy data”. The base example in Section 3 follows this chapter.
- Hu, M. and Liu, B. (2004), opinion lexicon (“bing”), supplied with
tidytext. - Nielsen, F. Å. (2011), AFINN word list; Mohammad, S. and Turney, P. (2013), NRC Emotion Lexicon; Jockers, M. (2015), syuzhet lexicon — all three accessed through the
syuzhetpackage. - Rinker, T. (2021),
sentimentr: calculating text polarity sentiment at the sentence level with valence shifters. - Corpus: Dracula, Frankenstein, The Picture of Dorian Gray, The Turn of the Screw and The Strange Case of Dr Jekyll and Mr Hyde, from Project Gutenberg via
gutenbergr.