# Load packages used throughout the analysis.
library(tidyverse)
library(tidytext)
library(janeaustenr)
library(syuzhet)Assignment 10A: Sentiment Analysis
Introduction
This assignment reproduces and extends the sentiment analysis example from Chapter 2 of Text Mining with R by Julia Silge and David Robinson. First, I analyze Jane Austen’s novels and compare results using the Bing, AFINN, and NRC sentiment lexicons.
Next, I extend the analysis using A Christmas Carol by Charles Dickens and add the Syuzhet sentiment dictionary. I compare the sentiment patterns, dictionary coverage, and words contributing to the results.
The original example code is available at: https://www.tidytextmining.com/sentiment
Load Packages
These packages support importing text, organizing words into a tidy format, matching words to sentiment dictionaries, and creating visualizations.
Part 1: Reproduce the Chapter 2 Example
Prepare the Jane Austen Text
The janeaustenr package provides the novels used in the original example. I retain the book title, line number, and chapter number, then split the text into individual words. The resulting dataset contains one word per row.
This code is adapted from Chapter 2 of Silge and Robinson’s Text Mining with R.
# Obtain the novels included in the original example.
austen_text <- austen_books()
# Preserve each word's location within its book.
tidy_books <- austen_text %>%
group_by(book) %>%
mutate(
linenumber = row_number(),
chapter = cumsum(
str_detect(
text,
regex("^chapter [\\divxlc]", ignore_case = TRUE)
)
)
) %>%
ungroup() %>%
unnest_tokens(word, text)
# Display the first ten words and their locations.
head(tidy_books, 10)# A tibble: 10 × 4
book linenumber chapter word
<fct> <int> <int> <chr>
1 Sense & Sensibility 1 0 sense
2 Sense & Sensibility 1 0 and
3 Sense & Sensibility 1 0 sensibility
4 Sense & Sensibility 3 0 by
5 Sense & Sensibility 3 0 jane
6 Sense & Sensibility 3 0 austen
7 Sense & Sensibility 5 0 1811
8 Sense & Sensibility 10 1 chapter
9 Sense & Sensibility 10 1 1
10 Sense & Sensibility 13 1 the
Count Words in Each Novel
These counts represent word tokens, including repeated words. They provide an overview of the amount of text available for each novel.
# Count all word tokens in each book.
book_word_counts <- tidy_books %>%
count(book, name = "total_words")
knitr::kable(
book_word_counts,
col.names = c("Book", "Total Words"),
caption = "Word counts for Jane Austen's novels"
)| Book | Total Words |
|---|---|
| Sense & Sensibility | 119957 |
| Pride & Prejudice | 122204 |
| Mansfield Park | 160460 |
| Emma | 160996 |
| Northanger Abbey | 77780 |
| Persuasion | 83658 |
Analyze Sentiment Using the Bing Lexicon
The Bing lexicon classifies words as positive or negative. Following the original example, I match the words in each novel to this dictionary and group the results into sections using the original 80-line indexing method. For each section, I subtract the number of negative words from the number of positive words.
A positive score means more positive words were matched, while a negative score means more negative words were matched.
# Retrieve the Bing sentiment dictionary.
bing_lexicon <- tidytext::get_sentiments("bing")
# Preserve all Bing dictionary matches, including words in both categories.
# Calculate net sentiment using the original chapter's indexing method.
jane_austen_sentiment <- tidy_books %>%
inner_join(
bing_lexicon,
by = "word",
relationship = "many-to-many"
) %>%
count(
book,
index = linenumber %/% 80,
sentiment
) %>%
pivot_wider(
names_from = sentiment,
values_from = n,
values_fill = 0
) %>%
mutate(net_sentiment = positive - negative)
# Preview the calculated scores.
head(jane_austen_sentiment, 10)# A tibble: 10 × 5
book index negative positive net_sentiment
<fct> <dbl> <int> <int> <int>
1 Sense & Sensibility 0 16 32 16
2 Sense & Sensibility 1 19 53 34
3 Sense & Sensibility 2 12 31 19
4 Sense & Sensibility 3 15 31 16
5 Sense & Sensibility 4 16 34 18
6 Sense & Sensibility 5 16 51 35
7 Sense & Sensibility 6 24 40 16
8 Sense & Sensibility 7 23 51 28
9 Sense & Sensibility 8 30 40 10
10 Sense & Sensibility 9 15 19 4
Visualize Sentiment Throughout the Novels
The graph below shows how the balance of positive and negative words changes throughout each novel. Each panel represents one book, and each bar represents a section of text.
ggplot(
jane_austen_sentiment,
aes(x = index, y = net_sentiment, fill = book)
) +
geom_col(show.legend = FALSE) +
geom_hline(
yintercept = 0,
color = "gray40",
linewidth = 0.3
) +
facet_wrap(~book, ncol = 2, scales = "free_x") +
labs(
title = "Sentiment Throughout Jane Austen's Novels",
subtitle = "Bing lexicon: positive word count minus negative word count",
x = "Text section (80-line indexing)",
y = "Net sentiment"
) +
theme_minimal()Identify Joy Words in Emma
The NRC lexicon associates words with emotions, including joy. Following the Chapter 2 example, I select the joy category and identify the most frequently occurring joy words in Emma.
# Retrieve the dictionaries for use in this analysis.
nrc_lexicon <- tidytext::get_sentiments("nrc")
afinn_lexicon <- tidytext::get_sentiments("afinn")
# Select NRC words associated with joy.
nrc_joy <- nrc_lexicon %>%
filter(sentiment == "joy")
# Count occurrences of joy words in Emma.
emma_joy_words <- tidy_books %>%
filter(book == "Emma") %>%
inner_join(nrc_joy, by = "word") %>%
count(word, sort = TRUE)
knitr::kable(
head(emma_joy_words, 10),
col.names = c("Word", "Count"),
caption = "Ten most frequent NRC joy words in Emma"
)| Word | Count |
|---|---|
| good | 359 |
| friend | 166 |
| hope | 143 |
| happy | 125 |
| love | 117 |
| deal | 92 |
| found | 92 |
| present | 89 |
| kind | 82 |
| happiness | 76 |
Compare Bing, AFINN, and NRC
Next, I compare the three sentiment dictionaries using Pride and Prejudice. I use the same text sections for each dictionary.
AFINN assigns numerical scores to words, which I sum within each section. For Bing and NRC, I calculate the number of positive matches minus the number of negative matches. For NRC, I include only the positive and negative categories in this comparison.
# Select the novel used in the original dictionary comparison.
pride_prejudice <- tidy_books %>%
filter(book == "Pride & Prejudice")
# Calculate summed AFINN scores for each section.
afinn_results <- pride_prejudice %>%
inner_join(afinn_lexicon, by = "word") %>%
group_by(index = linenumber %/% 80) %>%
summarise(
net_sentiment = sum(value),
.groups = "drop"
) %>%
mutate(method = "AFINN")
# Calculate Bing positive-minus-negative scores.
bing_results <- pride_prejudice %>%
inner_join(
bing_lexicon,
by = "word",
relationship = "many-to-many"
) %>%
count(index = linenumber %/% 80, sentiment) %>%
pivot_wider(
names_from = sentiment,
values_from = n,
values_fill = 0
) %>%
mutate(
net_sentiment = positive - negative,
method = "Bing"
) %>%
select(index, net_sentiment, method)
# Keep only NRC's positive and negative categories.
nrc_polarity <- nrc_lexicon %>%
filter(sentiment %in% c("positive", "negative"))
# Some NRC words belong to both categories.
# The many-to-many relationship is expected for this join.
nrc_results <- pride_prejudice %>%
inner_join(
nrc_polarity,
by = "word",
relationship = "many-to-many"
) %>%
count(index = linenumber %/% 80, sentiment) %>%
pivot_wider(
names_from = sentiment,
values_from = n,
values_fill = 0
) %>%
mutate(
net_sentiment = positive - negative,
method = "NRC"
) %>%
select(index, net_sentiment, method)
# Combine the results into one table.
sentiment_comparison <- bind_rows(
afinn_results,
bing_results,
nrc_results
)Visualize Differences Between Dictionaries
The panels below show the sentiment pattern produced by each dictionary. Each panel uses its own vertical scale so that changes throughout the story are visible. Bar heights across panels should not be interpreted as equivalent sentiment intensity.
ggplot(
sentiment_comparison,
aes(x = index, y = net_sentiment, fill = method)
) +
geom_col(show.legend = FALSE) +
geom_hline(
yintercept = 0,
color = "gray40",
linewidth = 0.3
) +
facet_wrap(~method, ncol = 1, scales = "free_y") +
labs(
title = "Comparing Sentiment Dictionaries",
subtitle = "Pride and Prejudice",
x = "Text section (80-line indexing)",
y = "Sentiment score (method-specific scale)"
) +
theme_minimal()Most Common Positive and Negative Words
I count how frequently each word matches the Bing sentiment dictionary across the six novels. This helps identify which words contribute most to the sentiment results.
# Count occurrences of words matched to the Bing dictionary.
bing_word_counts <- tidy_books %>%
inner_join(
bing_lexicon,
by = "word",
relationship = "many-to-many"
) %>%
count(word, sentiment, sort = TRUE)
# Select the ten most frequent words in each sentiment category.
top_sentiment_words <- bing_word_counts %>%
group_by(sentiment) %>%
slice_max(order_by = n, n = 10, with_ties = FALSE) %>%
ungroup()
knitr::kable(
top_sentiment_words,
col.names = c("Word", "Sentiment", "Count"),
caption = "Most frequent positive and negative words in Austen's novels"
)| Word | Sentiment | Count |
|---|---|---|
| miss | negative | 1855 |
| poor | negative | 424 |
| doubt | negative | 281 |
| object | negative | 233 |
| sorry | negative | 219 |
| impossible | negative | 215 |
| afraid | negative | 198 |
| bad | negative | 174 |
| scarcely | negative | 174 |
| anxious | negative | 165 |
| well | positive | 1523 |
| good | positive | 1380 |
| great | positive | 981 |
| like | positive | 725 |
| better | positive | 639 |
| enough | positive | 613 |
| happy | positive | 534 |
| love | positive | 495 |
| pleasure | positive | 462 |
| happiness | positive | 369 |
Visualize the Words Contributing to Sentiment
top_sentiment_words %>%
mutate(word = reorder(word, n)) %>%
ggplot(aes(x = n, y = word, fill = sentiment)) +
geom_col(show.legend = FALSE) +
facet_wrap(~sentiment, scales = "free_y") +
scale_fill_manual(
values = c(
"negative" = "#D55E00",
"positive" = "#0072B2"
)
) +
labs(
title = "Words Contributing Most to Sentiment",
subtitle = "Ten most frequent words in each Bing sentiment category",
x = "Word occurrences",
y = NULL
) +
theme_minimal()Why Context Matters
A limitation of this method is that a dictionary assigns sentiment to individual words without interpreting the surrounding sentence. For example, the original chapter explains that Bing classifies “miss” as negative, even though Austen often uses it as a title for a woman.
I retain the original classifications here to reproduce the example. However, this illustrates why a high word count does not necessarily mean that the word accurately represents the emotional tone of the text. The same limitation applies to negation: a positive word can appear in a negative phrase, such as “not happy.”
Part 2: Extend the Analysis to A Christmas Carol
Import the Text
For the first extension, I use A Christmas Carol by Charles Dickens. I import the text directly from Project Gutenberg so that the analysis does not depend on a file stored on my computer.
Source: https://www.gutenberg.org/ebooks/46
# Read the text from its online source.
carol_url <- "https://www.gutenberg.org/cache/epub/46/pg46.txt"
carol_raw <- readr::read_lines(carol_url)Remove Material Outside the Story
I locate the Gutenberg start and end markers to remove the licensing information. I then locate the first chapter heading to exclude the title page, preface, and table of contents.
# Locate the Gutenberg boundaries.
start_marker <- which(
str_detect(carol_raw, "^\\*\\*\\* START OF")
)
end_marker <- which(
str_detect(carol_raw, "^\\*\\*\\* END OF")
)
# Confirm that the expected boundaries were found.
stopifnot(
length(start_marker) == 1,
length(end_marker) == 1,
start_marker < end_marker
)
# Keep the material between the Gutenberg markers.
carol_body <- carol_raw[
seq.int(start_marker + 1, end_marker - 1)
]
# Match the uppercase chapter heading, not the contents entry.
story_start <- which(
str_detect(carol_body, "^STAVE I:")
)[1]
stopifnot(!is.na(story_start))
# Retain the story from the first chapter onward.
carol_lines <- tibble(
text = carol_body[seq.int(story_start, length(carol_body))]
) %>%
mutate(
linenumber = row_number(),
chapter = cumsum(
str_detect(text, "^STAVE\\s+[IVX]+:")
)
)
# Verify that all five chapters were identified.
stopifnot(max(carol_lines$chapter) == 5)
# Display the beginning of the cleaned text.
head(carol_lines, 10)# A tibble: 10 × 3
text linenumber chapter
<chr> <int> <int>
1 "STAVE I: MARLEY'S GHOST" 1 1
2 "" 2 1
3 "MARLEY was dead: to begin with. There is no doubt" 3 1
4 "whatever about that. The register of his burial was" 4 1
5 "signed by the clergyman, the clerk, the undertaker," 5 1
6 "and the chief mourner. Scrooge signed it: and" 6 1
7 "Scrooge's name was good upon 'Change, for anything he" 7 1
8 "chose to put his hand to. Old Marley was as dead as a" 8 1
9 "door-nail." 9 1
10 "" 10 1
Convert the Story to Tidy Text
I split the text into individual words while preserving the chapter and line numbers. I exclude chapter headings from the word counts.
For this extension, I group consecutive word tokens into sections of 500 words. This reduces dependence on how the source file wraps its lines. The final section may contain fewer than 500 words.
# Create one row per word.
tidy_carol <- carol_lines %>%
filter(!str_detect(text, "^STAVE\\s+[IVX]+:")) %>%
unnest_tokens(word, text) %>%
mutate(
word_id = row_number(),
section = (word_id - 1) %/% 500 + 1
)
# Preview the tokenized story.
head(tidy_carol, 10)# A tibble: 10 × 5
linenumber chapter word word_id section
<int> <int> <chr> <int> <dbl>
1 3 1 marley 1 1
2 3 1 was 2 1
3 3 1 dead 3 1
4 3 1 to 4 1
5 3 1 begin 5 1
6 3 1 with 6 1
7 3 1 there 7 1
8 3 1 is 8 1
9 3 1 no 9 1
10 3 1 doubt 10 1
Check the Imported Data
# Count word tokens in each chapter.
carol_chapter_counts <- tidy_carol %>%
count(chapter, name = "total_words")
knitr::kable(
carol_chapter_counts,
col.names = c("Chapter", "Total Words"),
caption = "Word counts by chapter in A Christmas Carol"
)| Chapter | Total Words |
|---|---|
| 1 | 6499 |
| 2 | 6204 |
| 3 | 8304 |
| 4 | 5383 |
| 5 | 2320 |
# Summarize the prepared dataset.
carol_summary <- tidy_carol %>%
summarise(
total_words = n(),
chapters = n_distinct(chapter),
text_sections = n_distinct(section)
)
knitr::kable(
carol_summary,
col.names = c("Total Words", "Chapters", "Text Sections"),
caption = "Overview of the prepared Dickens text"
)| Total Words | Chapters | Text Sections |
|---|---|---|
| 28710 | 5 | 58 |
Part 3: Add the Syuzhet Sentiment Dictionary
Prepare the Dictionaries
For the second extension, I add the Syuzhet dictionary from the syuzhet package. I apply Bing and Syuzhet to the same 500-word sections of A Christmas Carol. This keeps the text and section boundaries consistent while changing the dictionary.
Bing contributes +1 for a positive match and -1 for a negative match. Syuzhet provides numerical word scores. I use the Syuzhet dictionary in a word-level tidy-text analysis; this does not apply sentence-level negation or sarcasm handling.
Some Syuzhet words have multiple distinct scores. After removing exact duplicate entries, I use the mean of the available scores for each word. This is an explicit preprocessing choice for this word-level analysis, not a claim about the package’s default scoring behavior. It prevents repeated dictionary entries from multiplying word occurrences and inflating coverage.
Dictionary documentation: https://search.r-project.org/CRAN/refmans/syuzhet/html/get_sentiment_dictionary.html
# Convert Bing labels to scores, preserving both labels where present.
# A word with both labels contributes +1 - 1 = 0.
bing_scores <- bing_lexicon %>%
distinct(word, sentiment) %>%
mutate(value = if_else(sentiment == "positive", 1, -1)) %>%
group_by(word) %>%
summarise(value = sum(value), .groups = "drop")
# Retrieve the additional dictionary.
syuzhet_entries <- syuzhet::get_sentiment_dictionary("syuzhet") %>%
as_tibble() %>%
select(word, value) %>%
distinct()
# Identify words with multiple distinct scores for transparency.
syuzhet_multiple_scores <- syuzhet_entries %>%
count(word, name = "number_of_scores") %>%
filter(number_of_scores > 1)
# Use one score per word so joins preserve the original token counts.
syuzhet_lexicon <- syuzhet_entries %>%
group_by(word) %>%
summarise(value = mean(value), .groups = "drop")
# Confirm that each dictionary has a single score per word.
stopifnot(
!anyDuplicated(bing_scores$word),
!anyDuplicated(syuzhet_lexicon$word),
is.numeric(syuzhet_lexicon$value)
)
knitr::kable(
head(syuzhet_lexicon, 10),
caption = "Preview of the Syuzhet dictionary"
)| word | value |
|---|---|
| abandon | -0.75 |
| abandoned | -0.50 |
| abandoner | -0.25 |
| abandonment | -0.25 |
| abandons | -1.00 |
| abducted | -1.00 |
| abduction | -0.50 |
| abductions | -1.00 |
| aberrant | -0.60 |
| aberration | -0.80 |
Score the Same Text with Both Dictionaries
I retain every word token, including words that do not match a dictionary. Unmatched words contribute zero to the score, but they are counted separately when measuring dictionary coverage. A lack of a match does not prove that a word is emotionally neutral.
# Join each dictionary separately to avoid multiplying word occurrences.
carol_bing_words <- tidy_carol %>%
left_join(bing_scores, by = "word", relationship = "many-to-one") %>%
mutate(method = "Bing")
carol_syuzhet_words <- tidy_carol %>%
left_join(syuzhet_lexicon, by = "word", relationship = "many-to-one") %>%
mutate(method = "Syuzhet")
carol_scored_words <- bind_rows(carol_bing_words, carol_syuzhet_words)
# Divide by the actual section length to account for the shorter final section.
carol_sentiment <- carol_scored_words %>%
group_by(method, section) %>%
summarise(
total_words = n(),
matched_words = sum(!is.na(value)),
raw_score = sum(value, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(score_per_100_words = 100 * raw_score / total_words)
knitr::kable(
head(carol_sentiment, 12),
digits = 2,
caption = "Sentiment scores for the first twelve method-section combinations"
)| method | section | total_words | matched_words | raw_score | score_per_100_words |
|---|---|---|---|---|---|
| Bing | 1 | 500 | 36 | -12 | -2.4 |
| Bing | 2 | 500 | 33 | -3 | -0.6 |
| Bing | 3 | 500 | 39 | 23 | 4.6 |
| Bing | 4 | 500 | 46 | 2 | 0.4 |
| Bing | 5 | 500 | 39 | -3 | -0.6 |
| Bing | 6 | 500 | 39 | -3 | -0.6 |
| Bing | 7 | 500 | 27 | -11 | -2.2 |
| Bing | 8 | 500 | 26 | 4 | 0.8 |
| Bing | 9 | 500 | 26 | -8 | -1.6 |
| Bing | 10 | 500 | 28 | -14 | -2.8 |
| Bing | 11 | 500 | 34 | -6 | -1.2 |
| Bing | 12 | 500 | 28 | -6 | -1.2 |
Compare Sentiment Patterns
The chart shows sentiment per 100 word tokens. Dividing by text length accounts for unequal section sizes, but it does not make the two dictionaries’ scoring scales equivalent. I therefore focus on the direction and location of changes within each panel. Each panel has its own vertical scale.
ggplot(
carol_sentiment,
aes(x = section, y = score_per_100_words, color = method)
) +
geom_hline(yintercept = 0, color = "gray60", linetype = "dashed") +
geom_line(linewidth = 0.7, show.legend = FALSE) +
geom_point(size = 1.2, show.legend = FALSE) +
facet_wrap(~method, ncol = 1, scales = "free_y") +
scale_color_manual(values = c("Bing" = "#0072B2", "Syuzhet" = "#D55E00")) +
labs(
title = "Sentiment Throughout A Christmas Carol",
subtitle = "Identical 500-word sections; final section may be shorter",
x = "Text section",
y = "Score per 100 words (method-specific scale)"
) +
theme_minimal()Compare Dictionary Coverage
Coverage is the percentage of word occurrences that match a dictionary entry. Repeated words count each time they occur. Greater coverage means that more of the text receives a dictionary score; it does not necessarily mean that the sentiment estimates are more accurate.
carol_coverage <- carol_scored_words %>%
group_by(method) %>%
summarise(
total_words = n(),
matched_words = sum(!is.na(value)),
coverage_percent = 100 * matched_words / total_words,
.groups = "drop"
)
knitr::kable(
carol_coverage,
digits = 2,
col.names = c("Dictionary", "Total Word Tokens", "Matched Tokens", "Coverage (%)"),
caption = "Dictionary coverage for A Christmas Carol"
)| Dictionary | Total Word Tokens | Matched Tokens | Coverage (%) |
|---|---|---|---|
| Bing | 28710 | 1926 | 6.71 |
| Syuzhet | 28710 | 3293 | 11.47 |
Interpret the Dickens Results
In this analysis, Bing matches 1,926 of the 28,710 word tokens (6.71%), while Syuzhet matches 3,293 (11.47%). Syuzhet therefore covers 1,367 additional word occurrences, a difference of about 4.76 percentage points. This wider coverage changes which words contribute to the analysis, but does not by itself establish greater accuracy.
Both graphs show a mixture of positive and negative sections, with a positive rise near the end. This pattern is consistent with the story’s movement toward Scrooge’s changed attitude, although the dictionary scores alone cannot establish a character’s emotions. The ending is not a steady increase: both methods show a dip immediately before the final section.
The dictionaries also disagree on some sections. For example, the plotted Bing score is negative in section 5, while the Syuzhet score is positive. Both methods show a pronounced negative dip around section 42. These examples show that the methods can agree on broad changes while giving different classifications to individual sections. Their score magnitudes remain method-specific, even after division by text length.
Words Contributing Most to the Dickens Scores
To understand these differences, I calculate each word’s total contribution as its number of occurrences multiplied by its dictionary score. I select the five strongest positive and five strongest negative contributions for each method. The rankings are within each dictionary; contribution sizes are not directly comparable across dictionaries.
carol_word_contributions <- carol_scored_words %>%
filter(!is.na(value), value != 0) %>%
group_by(method, word, value) %>%
summarise(occurrences = n(), .groups = "drop") %>%
mutate(
contribution = occurrences * value,
polarity = if_else(contribution > 0, "Positive", "Negative")
)
carol_top_contributions <- carol_word_contributions %>%
group_by(method, polarity) %>%
slice_max(abs(contribution), n = 5, with_ties = FALSE) %>%
ungroup() %>%
arrange(method, polarity, desc(abs(contribution)))
knitr::kable(
carol_top_contributions %>%
select(method, polarity, word, occurrences, value, contribution),
digits = 2,
col.names = c("Dictionary", "Polarity", "Word", "Occurrences", "Word Score", "Contribution"),
caption = "Largest positive and negative word contributions in A Christmas Carol"
)| Dictionary | Polarity | Word | Occurrences | Word Score | Contribution |
|---|---|---|---|---|---|
| Bing | Negative | poor | 30 | -1.00 | -30.00 |
| Bing | Negative | cold | 28 | -1.00 | -28.00 |
| Bing | Negative | dark | 21 | -1.00 | -21.00 |
| Bing | Negative | dead | 18 | -1.00 | -18.00 |
| Bing | Negative | death | 12 | -1.00 | -12.00 |
| Bing | Positive | good | 67 | 1.00 | 67.00 |
| Bing | Positive | like | 61 | 1.00 | 61.00 |
| Bing | Positive | great | 36 | 1.00 | 36.00 |
| Bing | Positive | merry | 34 | 1.00 | 34.00 |
| Bing | Positive | well | 25 | 1.00 | 25.00 |
| Syuzhet | Negative | ghost | 88 | -0.60 | -52.80 |
| Syuzhet | Negative | cried | 52 | -1.00 | -52.00 |
| Syuzhet | Negative | dead | 18 | -1.00 | -18.00 |
| Syuzhet | Negative | poor | 30 | -0.50 | -15.00 |
| Syuzhet | Negative | cold | 28 | -0.50 | -14.00 |
| Syuzhet | Positive | good | 67 | 0.75 | 50.25 |
| Syuzhet | Positive | spirit | 84 | 0.50 | 42.00 |
| Syuzhet | Positive | merry | 34 | 1.00 | 34.00 |
| Syuzhet | Positive | like | 61 | 0.50 | 30.50 |
| Syuzhet | Positive | well | 25 | 0.80 | 20.00 |
# Generate an interpretation from the computed rankings.
leading_words <- carol_word_contributions %>%
group_by(method, polarity) %>%
slice_max(abs(contribution), n = 1, with_ties = FALSE) %>%
ungroup()
bing_positive_word <- leading_words %>%
filter(method == "Bing", polarity == "Positive") %>% pull(word)
bing_negative_word <- leading_words %>%
filter(method == "Bing", polarity == "Negative") %>% pull(word)
syuzhet_positive_word <- leading_words %>%
filter(method == "Syuzhet", polarity == "Positive") %>% pull(word)
syuzhet_negative_word <- leading_words %>%
filter(method == "Syuzhet", polarity == "Negative") %>% pull(word)For Bing, the largest positive contribution comes from good, and the largest negative contribution comes from poor. For Syuzhet, the corresponding words are good and ghost. These are contributions to the dictionary-based totals, not judgments about every use of these words. Reading the surrounding sentences would be necessary to assess whether each classification fits the context.
Part 4: Compare the Extension with the Original Example
Compare the Corpora Using the Same Dictionary
The original analysis includes six Jane Austen novels, while my extension uses one Dickens novella. Raw sentiment totals would be affected by their different lengths. To make a consistent descriptive comparison, I apply the same Bing word scores to both corpora and report net sentiment per 100 word tokens. This table uses the full token count of each text rather than the earlier section sizes.
austen_bing_words <- tidy_books %>%
mutate(book = as.character(book)) %>%
left_join(bing_scores, by = "word", relationship = "many-to-one")
corpus_comparison <- bind_rows(
austen_bing_words %>% select(book, word, value),
carol_bing_words %>%
transmute(book = "A Christmas Carol", word, value)
) %>%
group_by(book) %>%
summarise(
total_words = n(),
matched_tokens = sum(!is.na(value)),
net_score = sum(value, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(
coverage_percent = 100 * matched_tokens / total_words,
net_per_100 = 100 * net_score / total_words
)
knitr::kable(
corpus_comparison %>%
select(book, total_words, coverage_percent, net_per_100),
digits = 2,
col.names = c("Text", "Word Tokens", "Bing Coverage (%)", "Net Bing Score per 100 Words"),
caption = "A consistent Bing comparison of the original and extended corpora"
)| Text | Word Tokens | Bing Coverage (%) | Net Bing Score per 100 Words |
|---|---|---|---|
| A Christmas Carol | 28710 | 6.71 | 0.15 |
| Emma | 160996 | 7.43 | 1.46 |
| Mansfield Park | 160460 | 7.21 | 1.20 |
| Northanger Abbey | 77780 | 7.41 | 0.93 |
| Persuasion | 83658 | 6.78 | 1.52 |
| Pride & Prejudice | 122204 | 7.12 | 1.15 |
| Sense & Sensibility | 119957 | 7.17 | 1.05 |
# Compare Dickens with the pooled Austen corpus, weighting by token count.
austen_pooled_rate <- austen_bing_words %>%
summarise(rate = 100 * sum(value, na.rm = TRUE) / n()) %>%
pull(rate)
carol_bing_rate <- corpus_comparison %>%
filter(book == "A Christmas Carol") %>% pull(net_per_100)
comparison_direction <- if (carol_bing_rate > austen_pooled_rate) {
"higher"
} else if (carol_bing_rate < austen_pooled_rate) {
"lower"
} else {
"equal"
}The Dickens text has a net Bing score of 0.15 per 100 words, compared with 1.23 for the pooled Austen corpus. The Dickens rate is lower. This is a comparison of dictionary matches, not proof that one author’s writing is emotionally happier or sadder. The Austen texts retain the original example’s preparation, while the Dickens text excludes its front matter and chapter headings, so preprocessing is not perfectly identical.
How the Results Differ
In the original Pride and Prejudice comparison, the NRC graph is positive in nearly all sections, while Bing identifies more negative stretches. AFINN also shows many positive sections, but its weighted scores have a different range. In the Dickens extension, both Bing and Syuzhet show repeated changes across zero and a positive ending. Thus, the new text produces its own sentiment pattern, and adding another dictionary changes both the vocabulary coverage and some section-level conclusions.
The Austen word-frequency results also reveal a specific context problem: “miss” appears 1,855 times as a negative match, even though it is often a title. The Dickens contribution table makes the influential words in the new corpus visible in the same way. Examining those words is more informative than assuming that every numerical peak or dip directly represents the story’s emotional tone.
The original plots use 80-line sections and raw scores, whereas the Dickens plots use 500-word sections and scores per 100 words. I therefore do not directly compare their horizontal positions or bar heights. The table above provides a separate comparison with a common dictionary and denominator.
Limitations
- Individual-word dictionaries do not interpret negation, sarcasm, speakers, or narrative context. A phrase such as “not happy” can still contribute a positive match.
- Many tokens do not match either dictionary. Unmatched tokens contribute no score but are not necessarily neutral.
- Greater dictionary coverage does not establish accuracy. I do not have human-labeled sentiment judgments against which to validate these estimates.
- Section boundaries affect the apparent narrative pattern. A 500-word section may cross a chapter boundary, and the final section contains only 210 tokens, making its rate more sensitive to individual words.
- Averaging multiple Syuzhet scores for a word is a documented modeling choice. Another resolution rule could change the results.
- Whole-book averages can hide substantial local changes. Dictionary scores describe textual patterns and should not be treated as direct measurements of character emotions or author intent.
Conclusion
This assignment reproduced the main tidy-text sentiment workflow using Jane Austen’s novels and extended it with a different corpus and an additional dictionary. The Dickens results show that Bing and Syuzhet can identify similar broad changes while disagreeing about individual sections. Syuzhet matched more word occurrences in this text, but that does not establish that it is more accurate.
The main lesson is that sentiment results depend on the text, dictionary, and preprocessing choices. Looking at coverage, influential words, and surrounding context helps explain the scores and prevents overinterpreting a single graph.
References
Silge, J., & Robinson, D. (2017). Text mining with R: A tidy approach. O’Reilly Media. Chapter 2, “Sentiment analysis with tidy data.” https://www.tidytextmining.com/sentiment. The Part 1 tokenization, sentiment joins, section scores, dictionary comparison, and word-frequency analysis are adapted from the example code on this page.
Dickens, C. (1843). A Christmas Carol in Prose; Being a Ghost Story of Christmas. Project Gutenberg, eBook No. 46. https://www.gutenberg.org/ebooks/46. Plain-text data: https://www.gutenberg.org/cache/epub/46/pg46.txt.
Syuzhet package documentation: Sentiment dictionaries. R documentation. https://search.r-project.org/CRAN/refmans/syuzhet/html/get_sentiment_dictionary.html. Source of the additional Syuzhet dictionary used in Part 3.
AI Assistance Disclosure
I used ChatGPT to help draft and revise R code, troubleshoot errors, organize the report. This included assistance with handling duplicate sentiment dictionary entries, and formatting references.
AI Tool Reference
OpenAI. (2026). ChatGPT [Large language model]. https://chatgpt.com/
R Package References
The package references below are generated from the installed versions used for this report.
Silge J, Robinson D (2016). “tidytext: Text Mining and Analysis Using Tidy Data Principles in R.” JOSS, 1(3). doi:10.21105/joss.00037 https://doi.org/10.21105/joss.00037. http://dx.doi.org/10.21105/joss.00037.
Silge J (2022). janeaustenr: Jane Austen’s Complete Novels. doi:10.32614/CRAN.package.janeaustenr https://doi.org/10.32614/CRAN.package.janeaustenr. R package version 1.0.0, https://CRAN.R-project.org/package=janeaustenr.
Hvitfeldt E (2024). textdata: Download and Load Various Text Datasets. doi:10.32614/CRAN.package.textdata https://doi.org/10.32614/CRAN.package.textdata. R package version 0.4.5, https://CRAN.R-project.org/package=textdata.
Jockers ML (2015). Syuzhet: Extract Sentiment and Plot Arcs from Text. https://github.com/mjockers/syuzhet.
Reproducibility
NRC and AFINN may require an initial interactive download through tidytext::get_sentiments() in the R Console before rendering on another computer. The session details below record the R and package versions used.
View R session and package versions
R version 4.6.1 (2026-06-24)
Platform: aarch64-apple-darwin23
Running under: macOS Tahoe 26.3.1
Matrix products: default
BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
time zone: America/New_York
tzcode source: internal
attached base packages:
[1] stats graphics grDevices utils datasets methods base
other attached packages:
[1] syuzhet_1.0.7 janeaustenr_1.0.0 tidytext_0.4.3 lubridate_1.9.5
[5] forcats_1.0.1 stringr_1.6.0 dplyr_1.2.1 purrr_1.2.2
[9] readr_2.2.0 tidyr_1.3.2 tibble_3.3.1 ggplot2_4.0.3
[13] tidyverse_2.0.0
loaded via a namespace (and not attached):
[1] rappdirs_0.3.4 utf8_1.2.6 generics_0.1.4 stringi_1.8.9
[5] lattice_0.22-9 hms_1.1.4 digest_0.6.39 magrittr_2.0.5
[9] evaluate_1.0.5 grid_4.6.1 timechange_0.4.0 RColorBrewer_1.1-3
[13] fastmap_1.2.0 jsonlite_2.0.0 Matrix_1.7-5 scales_1.4.0
[17] cli_3.6.6 crayon_1.5.3 rlang_1.3.0 tokenizers_0.3.0
[21] bit64_4.8.6 withr_3.0.3 yaml_2.3.12 otel_0.2.0
[25] parallel_4.6.1 tools_4.6.1 tzdb_0.5.0 curl_8.0.0
[29] vctrs_0.7.3 R6_2.6.1 lifecycle_1.0.5 bit_4.6.0
[33] fs_2.1.0 htmlwidgets_1.6.4 vroom_1.7.1 pkgconfig_2.0.3
[37] pillar_1.11.1 gtable_0.3.6 textdata_0.4.5 glue_1.8.1
[41] Rcpp_1.1.2 xfun_0.60 tidyselect_1.2.1 rstudioapi_0.19.0
[45] knitr_1.51 farver_2.1.2 htmltools_0.5.9 SnowballC_0.7.1
[49] rmarkdown_2.32 labeling_0.4.3 compiler_4.6.1 S7_0.2.2