Assignment 10A: Sentiment Analysis

Author

Lakeranie Boodram

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.

# Load packages used throughout the analysis.
library(tidyverse)
library(tidytext)
library(janeaustenr)
library(syuzhet)

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"
)
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"
)
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"
)
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"
)
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"
)
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"
)
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"
)
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 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"
)
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"
)
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.

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