library(tidytext)
library(janeaustenr)
library(dplyr)
library(stringr)
library(tidyr)
library(ggplot2)
library(wordcloud)
library(reshape2)
library(schrute)
library(lexicon)Sentiment Analysis: From Jane Austen to The Office
This report has two parts. Part 1 reproduces the sentiment analysis from Chapter 2 of Text Mining with R (Silge and Robinson, 2017), which scores the novels of Jane Austen with three sentiment lexicons. Part 2 applies the same approach to a very different text, the complete scripts of the TV show The Office, and adds a fourth lexicon, Jockers-Rinker from the lexicon package.
Setup
The AFINN and NRC lexicons are downloaded through the textdata package. Each one asks you to accept its license the first time, so I ran get_sentiments("afinn") and get_sentiments("nrc") once in the console before rendering.
Part 1: Reproducing the Chapter 2 example
All code in Part 1 comes from Chapter 2, “Sentiment analysis with tidy data,” of Silge, J., & Robinson, D. (2017). Text Mining with R: A Tidy Approach. O’Reilly Media. The chapter is available at https://www.tidytextmining.com/sentiment.html, and the book’s source code is at https://github.com/dgrtwo/tidy-text-mining.
The sentiment lexicons
get_sentiments("afinn")# A tibble: 2,477 × 2
word value
<chr> <dbl>
1 abandon -2
2 abandoned -2
3 abandons -2
4 abducted -2
5 abduction -2
6 abductions -2
7 abhor -3
8 abhorred -3
9 abhorrent -3
10 abhors -3
# ℹ 2,467 more rows
get_sentiments("bing")# A tibble: 6,786 × 2
word sentiment
<chr> <chr>
1 2-faces negative
2 abnormal negative
3 abolish negative
4 abominable negative
5 abominably negative
6 abominate negative
7 abomination negative
8 abort negative
9 aborted negative
10 aborts negative
# ℹ 6,776 more rows
get_sentiments("nrc")# A tibble: 13,872 × 2
word sentiment
<chr> <chr>
1 abacus trust
2 abandon fear
3 abandon negative
4 abandon sadness
5 abandoned anger
6 abandoned fear
7 abandoned negative
8 abandoned sadness
9 abandonment anger
10 abandonment fear
# ℹ 13,862 more rows
Sentiment analysis with inner join
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)
nrc_joy <- get_sentiments("nrc") %>%
filter(sentiment == "joy")
emma_joy <- tidy_books %>%
filter(book == "Emma") %>%
inner_join(nrc_joy) %>%
count(word, sort = TRUE)
emma_joy# A tibble: 301 × 2
word n
<chr> <int>
1 good 359
2 friend 166
3 hope 143
4 happy 125
5 love 117
6 deal 92
7 found 92
8 present 89
9 kind 82
10 happiness 76
# ℹ 291 more rows
jane_austen_sentiment <- tidy_books %>%
inner_join(get_sentiments("bing")) %>%
count(book, index = linenumber %/% 80, sentiment) %>%
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) %>%
mutate(sentiment = positive - negative)
ggplot(jane_austen_sentiment, aes(index, sentiment, fill = book)) +
geom_col(show.legend = FALSE) +
facet_wrap(~book, ncol = 2, scales = "free_x")Comparing the three sentiment dictionaries
pride_prejudice <- tidy_books %>%
filter(book == "Pride & Prejudice")
afinn <- pride_prejudice %>%
inner_join(get_sentiments("afinn")) %>%
group_by(index = linenumber %/% 80) %>%
summarise(sentiment = sum(value)) %>%
mutate(method = "AFINN")
bing_and_nrc <- bind_rows(
pride_prejudice %>%
inner_join(get_sentiments("bing")) %>%
mutate(method = "Bing et al."),
pride_prejudice %>%
inner_join(get_sentiments("nrc") %>%
filter(sentiment %in% c("positive",
"negative"))
) %>%
mutate(method = "NRC")) %>%
count(method, index = linenumber %/% 80, sentiment) %>%
pivot_wider(names_from = sentiment,
values_from = n,
values_fill = 0) %>%
mutate(sentiment = positive - negative)
bind_rows(afinn,
bing_and_nrc) %>%
ggplot(aes(index, sentiment, fill = method)) +
geom_col(show.legend = FALSE) +
facet_wrap(~method, ncol = 1, scales = "free_y")get_sentiments("nrc") %>%
filter(sentiment %in% c("positive", "negative")) %>%
count(sentiment)# A tibble: 2 × 2
sentiment n
<chr> <int>
1 negative 3316
2 positive 2308
get_sentiments("bing") %>%
count(sentiment)# A tibble: 2 × 2
sentiment n
<chr> <int>
1 negative 4781
2 positive 2005
Most common positive and negative words
bing_word_counts <- tidy_books %>%
inner_join(get_sentiments("bing")) %>%
count(word, sentiment, sort = TRUE) %>%
ungroup()
bing_word_counts# A tibble: 2,585 × 3
word sentiment n
<chr> <chr> <int>
1 miss negative 1855
2 well positive 1523
3 good positive 1380
4 great positive 981
5 like positive 725
6 better positive 639
7 enough positive 613
8 happy positive 534
9 love positive 495
10 pleasure positive 462
# ℹ 2,575 more rows
bing_word_counts %>%
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") +
labs(x = "Contribution to sentiment",
y = NULL)custom_stop_words <- bind_rows(tibble(word = c("miss"),
lexicon = c("custom")),
stop_words)
custom_stop_words# A tibble: 1,150 × 2
word lexicon
<chr> <chr>
1 miss custom
2 a SMART
3 a's SMART
4 able SMART
5 about SMART
6 above SMART
7 according SMART
8 accordingly SMART
9 across SMART
10 actually SMART
# ℹ 1,140 more rows
Wordclouds
tidy_books %>%
anti_join(stop_words) %>%
count(word) %>%
with(wordcloud(word, n, max.words = 100))tidy_books %>%
inner_join(get_sentiments("bing")) %>%
count(word, sentiment, sort = TRUE) %>%
acast(word ~ sentiment, value.var = "n", fill = 0) %>%
comparison.cloud(colors = c("gray20", "gray80"),
max.words = 100)Looking at units beyond just words
p_and_p_sentences <- tibble(text = prideprejudice) %>%
unnest_tokens(sentence, text, token = "sentences")
p_and_p_sentences$sentence[2][1] "by jane austen"
austen_chapters <- austen_books() %>%
group_by(book) %>%
unnest_tokens(chapter, text, token = "regex",
pattern = "Chapter|CHAPTER [\\dIVXLC]") %>%
ungroup()
austen_chapters %>%
group_by(book) %>%
summarise(chapters = n())# A tibble: 6 × 2
book chapters
<fct> <int>
1 Sense & Sensibility 51
2 Pride & Prejudice 62
3 Mansfield Park 49
4 Emma 56
5 Northanger Abbey 32
6 Persuasion 25
bingnegative <- get_sentiments("bing") %>%
filter(sentiment == "negative")
wordcounts <- tidy_books %>%
group_by(book, chapter) %>%
summarize(words = n())
tidy_books %>%
semi_join(bingnegative) %>%
group_by(book, chapter) %>%
summarize(negativewords = n()) %>%
left_join(wordcounts, by = c("book", "chapter")) %>%
mutate(ratio = negativewords/words) %>%
filter(chapter != 0) %>%
slice_max(ratio, n = 1) %>%
ungroup()# A tibble: 6 × 5
book chapter negativewords words ratio
<fct> <int> <int> <int> <dbl>
1 Sense & Sensibility 43 161 3405 0.0473
2 Pride & Prejudice 34 111 2104 0.0528
3 Mansfield Park 46 173 3685 0.0469
4 Emma 15 151 3340 0.0452
5 Northanger Abbey 21 149 2982 0.0500
6 Persuasion 4 62 1807 0.0343
The most common joy words in Emma are “good,” “friend,” and “hope.” The three lexicons follow a similar path through Pride and Prejudice, even though their values are on different scales. The top negative word across the novels is “miss.” As the book points out, Bing reads “miss” as negative, but Austen mostly uses it as a title.
Part 2: Extending the analysis to The Office
A new corpus: The Office scripts
The schrute package (Lindblad) contains the complete scripts of The Office, with one row per line of dialogue. Each row records the season, the episode, and the character speaking. Spoken comedy is a useful contrast to Austen. The language is casual and full of filler words, and a lot of what is said is sarcastic.
office <- schrute::theoffice %>%
mutate(season = as.integer(season),
episode = as.integer(episode),
character = str_trim(character)) %>%
arrange(season, episode, index) %>%
mutate(ep_num = dense_rank(season * 100 + episode))
tidy_office <- office %>%
select(season, episode, ep_num, episode_name, character, text) %>%
unnest_tokens(word, text)
office %>%
summarise(lines = n(), episodes = n_distinct(ep_num),
seasons = n_distinct(season))# A tibble: 1 × 3
lines episodes seasons
<int> <int> <int>
1 55130 186 9
nrow(tidy_office)[1] 571612
Here every episode is one unit of text, the way the book used 80-line sections of each novel.
A fourth lexicon: Jockers-Rinker
The lexicon package (Rinker) includes hash_sentiment_jockers_rinker. This lexicon combines the Syuzhet lexicon (Jockers, 2017) with the Hu and Liu (2004) word list, which is the same source as Bing. Each word gets a graded score instead of just positive or negative, like AFINN, but the list is much larger. A few entries are two-word phrases. Because the text is split into single words, those can’t match, so I removed them.
jockers_rinker <- as_tibble(lexicon::hash_sentiment_jockers_rinker)
names(jockers_rinker) <- c("word", "value")
jockers_rinker <- jockers_rinker %>%
filter(!str_detect(word, " "))
nrow(jockers_rinker)[1] 11423
range(jockers_rinker$value)[1] -2 1
After removing the phrases, Jockers-Rinker has 11,423 words, with scores from -2 to 1. AFINN has 2,477 words, with scores from -5 to 5.
How each lexicon scores a text
This function scores every unit of a text with all four lexicons. AFINN and Jockers-Rinker add up word scores. Bing and NRC count positive words minus negative words, the same way the book does. Each score is divided by the number of words in the unit and multiplied by 1,000, so long and short episodes can be compared fairly.
score_units <- function(tidy_text, unit) {
words <- tidy_text %>% count({{ unit }}, name = "words")
scored <- bind_rows(
tidy_text %>%
inner_join(get_sentiments("afinn"), by = "word") %>%
group_by({{ unit }}) %>%
summarise(score = sum(value)) %>%
mutate(method = "AFINN"),
tidy_text %>%
inner_join(jockers_rinker, by = "word") %>%
group_by({{ unit }}) %>%
summarise(score = sum(value)) %>%
mutate(method = "Jockers-Rinker"),
tidy_text %>%
inner_join(get_sentiments("bing"), by = "word") %>%
count({{ unit }}, sentiment) %>%
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) %>%
mutate(score = positive - negative, method = "Bing et al.") %>%
select(-positive, -negative),
tidy_text %>%
inner_join(get_sentiments("nrc") %>%
filter(sentiment %in% c("positive", "negative")),
by = "word") %>%
count({{ unit }}, sentiment) %>%
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) %>%
mutate(score = positive - negative, method = "NRC") %>%
select(-positive, -negative)
)
scored %>%
left_join(words) %>%
mutate(per_1000 = score / words * 1000)
}
office_scores <- score_units(tidy_office, ep_num) %>%
left_join(distinct(office, ep_num, season, episode), by = "ep_num")Sentiment across the series
office_scores %>%
filter(method == "Bing et al.") %>%
ggplot(aes(episode, per_1000, fill = per_1000 > 0)) +
geom_col(show.legend = FALSE) +
scale_fill_manual(values = c("TRUE" = "#2a78d6", "FALSE" = "#eb6834")) +
scale_x_continuous(breaks = seq(0, 30, by = 5)) +
facet_wrap(~season, ncol = 3, scales = "free_x",
labeller = labeller(season = \(s) paste("Season", s))) +
labs(title = "Net sentiment in each episode of The Office (Bing)",
x = "Episode", y = "Net sentiment per 1,000 words") +
theme_minimal()season_avg <- office_scores %>%
filter(method == "Bing et al.") %>%
group_by(season) %>%
summarise(avg_per_1000 = mean(per_1000), episodes = n())
knitr::kable(season_avg, digits = 1)| season | avg_per_1000 | episodes |
|---|---|---|
| 1 | 27.8 | 6 |
| 2 | 23.7 | 22 |
| 3 | 20.9 | 23 |
| 4 | 22.8 | 14 |
| 5 | 20.7 | 26 |
| 6 | 19.3 | 24 |
| 7 | 22.0 | 24 |
| 8 | 21.7 | 24 |
| 9 | 19.4 | 23 |
best_season <- season_avg$season[which.max(season_avg$avg_per_1000)]
worst_season <- season_avg$season[which.min(season_avg$avg_per_1000)]
bing_episodes <- office_scores %>% filter(method == "Bing et al.")
pct_positive_eps <- mean(bing_episodes$per_1000 > 0)
austen_chunks <- jane_austen_sentiment
pct_positive_austen <- mean(austen_chunks$sentiment > 0)
shape <- if (pct_positive_eps > pct_positive_austen) {
"The Office is more consistently positive. A sitcom resets every week instead of building one long story arc with dark stretches."
} else {
"The Office is not more consistently positive than Austen, even though it is a comedy, which may come from words like \"funny\" and \"joke\" being scored as negative (see below)."
}By the Bing lexicon, Season 1 is the most positive season on average and Season 6 is the least positive. 99% of episodes come out net positive. For comparison, 76% of the 80-line sections of the Austen novels are net positive.
Comparing the four lexicons
office_scores %>%
ggplot(aes(ep_num, per_1000, fill = method)) +
geom_col(show.legend = FALSE) +
facet_wrap(~method, ncol = 1, scales = "free_y") +
labs(title = "Four lexicons scoring every episode of The Office",
x = "Episode (in series order)", y = "Sentiment per 1,000 words") +
theme_minimal()To see how closely the lexicons agree, I correlated their episode scores. I did the same for the 80-line sections of Pride and Prejudice, so the two texts can be compared directly.
lexicon_cor <- function(scores, unit) {
scores %>%
select({{ unit }}, method, per_1000) %>%
pivot_wider(names_from = method, values_from = per_1000) %>%
select(-{{ unit }}) %>%
cor(use = "pairwise.complete.obs")
}
pp_scores <- score_units(pride_prejudice %>% mutate(index = linenumber %/% 80), index)
office_cor <- lexicon_cor(office_scores, ep_num)
pp_cor <- lexicon_cor(pp_scores, index)
knitr::kable(office_cor, digits = 2, caption = "The Office: correlation between lexicons (by episode)")| AFINN | Jockers-Rinker | Bing et al. | NRC | |
|---|---|---|---|---|
| AFINN | 1.00 | 0.81 | 0.79 | 0.60 |
| Jockers-Rinker | 0.81 | 1.00 | 0.84 | 0.74 |
| Bing et al. | 0.79 | 0.84 | 1.00 | 0.61 |
| NRC | 0.60 | 0.74 | 0.61 | 1.00 |
knitr::kable(pp_cor, digits = 2, caption = "Pride and Prejudice: correlation between lexicons (by 80-line section)")| AFINN | Jockers-Rinker | Bing et al. | NRC | |
|---|---|---|---|---|
| AFINN | 1.00 | 0.84 | 0.81 | 0.72 |
| Jockers-Rinker | 0.84 | 1.00 | 0.90 | 0.85 |
| Bing et al. | 0.81 | 0.90 | 1.00 | 0.73 |
| NRC | 0.72 | 0.85 | 0.73 | 1.00 |
avg_cor <- function(m) mean(m[upper.tri(m)])
agreement <- if (avg_cor(office_cor) >= 0.5) {
"The book's point still holds for The Office: the lexicons give different sizes of sentiment but mostly agree on which episodes are more positive or more negative."
} else {
"Unlike in the book, the lexicons often disagree about which episodes are more positive, which suggests that word lists built for other kinds of writing fit casual dialogue less well."
}The average correlation between lexicons is 0.73 for The Office and 0.81 for Pride and Prejudice.
How much of each text the lexicons can score
A lexicon can only score words it contains. This table shows the share of all words in each text that each lexicon matched.
coverage <- function(tidy_text, corpus) {
tibble(
corpus = corpus,
AFINN = mean(tidy_text$word %in% get_sentiments("afinn")$word),
`Bing et al.` = mean(tidy_text$word %in% get_sentiments("bing")$word),
NRC = mean(tidy_text$word %in% (get_sentiments("nrc") %>%
filter(sentiment %in% c("positive", "negative")))$word),
`Jockers-Rinker` = mean(tidy_text$word %in% jockers_rinker$word)
)
}
coverage_table <- bind_rows(
coverage(tidy_books, "Jane Austen (all six novels)"),
coverage(tidy_office, "The Office (all episodes)")
)
coverage_table %>%
mutate(across(-corpus, \(x) scales::percent(x, accuracy = 0.1))) %>%
knitr::kable()| corpus | AFINN | Bing et al. | NRC | Jockers-Rinker |
|---|---|---|---|---|
| Jane Austen (all six novels) | 6.5% | 7.2% | 8.6% | 12.2% |
| The Office (all episodes) | 8.4% | 7.0% | 6.3% | 11.0% |
Most common positive and negative words
office_bing_counts <- tidy_office %>%
inner_join(get_sentiments("bing"), by = "word") %>%
count(word, sentiment, sort = TRUE)
office_bing_counts %>%
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) +
scale_fill_manual(values = c(positive = "#2a78d6", negative = "#eb6834")) +
facet_wrap(~sentiment, scales = "free_y") +
labs(title = "Words that drive sentiment in The Office (Bing)",
x = "Contribution to sentiment", y = NULL) +
theme_minimal()top_pos <- office_bing_counts %>% filter(sentiment == "positive") %>% slice_head(n = 3)
top_neg <- office_bing_counts %>% filter(sentiment == "negative") %>% slice_head(n = 3)The top positive words in The Office are “like,” “right,” and “well,” and the top negative words are “sorry,” “bad,” and “wrong.”
Fixing words that don’t fit the show
The book found that “miss” is a problem for Austen. The Office has the same kind of problem with different words.
- Filler words scored as positive: Bing scores “like,” “well,” “right,” and “pretty” as positive. In conversation they are mostly filler (“it’s, like, fine”) or hedges (“pretty bad”).
- Comedy words scored as negative: Bing scores “funny” and “joke” as negative, because it reads “funny” as meaning strange. In a comedy, both words are usually neutral or positive.
office_stop_words <- tibble(word = c("like", "well", "right", "pretty", "funny", "joke"))
office_stop_words %>%
inner_join(office_bing_counts, by = "word") %>%
knitr::kable()| word | sentiment | n |
|---|---|---|
| like | positive | 2986 |
| well | positive | 2284 |
| right | positive | 2419 |
| pretty | positive | 314 |
| funny | negative | 203 |
| joke | negative | 119 |
before_after <- bind_rows(
tidy_office %>% mutate(version = "All words"),
tidy_office %>% anti_join(office_stop_words, by = "word") %>%
mutate(version = "Filler and comedy words removed")
) %>%
inner_join(get_sentiments("bing"), by = "word") %>%
count(version, sentiment) %>%
pivot_wider(names_from = sentiment, values_from = n) %>%
mutate(positive_share = positive / (positive + negative))
knitr::kable(before_after, digits = 3)| version | negative | positive | positive_share |
|---|---|---|---|
| All words | 13872 | 26120 | 0.653 |
| Filler and comedy words removed | 13550 | 18117 | 0.572 |
Removing these six words changes the positive share of Bing matches from 65.3% to 57.2%.
The most positive characters
main_characters <- tidy_office %>%
count(character, name = "words") %>%
filter(words >= 10000)
character_sentiment <- tidy_office %>%
semi_join(main_characters, by = "character") %>%
inner_join(jockers_rinker, by = "word") %>%
group_by(character) %>%
summarise(score = sum(value)) %>%
left_join(main_characters, by = "character") %>%
mutate(per_1000 = score / words * 1000) %>%
arrange(desc(per_1000))
ggplot(character_sentiment, aes(per_1000, reorder(character, per_1000))) +
geom_col(fill = "#2a78d6", width = 0.7) +
labs(title = "Sentiment by character (Jockers-Rinker)",
subtitle = "Characters with at least 10,000 spoken words",
x = "Sentiment per 1,000 words", y = NULL) +
theme_minimal()Among the characters with at least 10,000 spoken words, Jim is the most positive by the Jockers-Rinker lexicon and Dwight is the least positive.
How the results differ from the original example
- Shape over time: Austen’s novels move between positive and negative as the plot develops, and the book’s plots show dips at the dramatic moments. In The Office, 99% of episodes are net positive, compared with 76% of the Austen sections. The Office is more consistently positive. A sitcom resets every week instead of building one long story arc with dark stretches.
- Coverage: Jockers-Rinker matched 11.0% of the words in The Office, compared with 8.4% for AFINN and 7.0% for Bing. For Austen the same three numbers are 12.2%, 6.5%, and 7.2%.
- Agreement between lexicons: The average correlation between lexicons is 0.73 for The Office and 0.81 for Pride and Prejudice. The book’s point still holds for The Office: the lexicons give different sizes of sentiment but mostly agree on which episodes are more positive or more negative.
- Domain words: For Austen, the problem word was “miss.” For The Office, filler words (“like,” “well,” “right”) and comedy words (“funny,” “joke”) are scored in ways that don’t match how they are used. Removing them changed the positive share of Bing matches from 65.3% to 57.2%.
- Limits: Every lexicon here scores single words. Sarcasm, which The Office relies on, and phrases like “not good” are scored as if they were sincere. A sentence-level tool like
sentimentrwould handle negation better.
References
- Silge, J., & Robinson, D. (2017). Text Mining with R: A Tidy Approach. O’Reilly Media. https://www.tidytextmining.com/sentiment.html. Code: https://github.com/dgrtwo/tidy-text-mining.
- Hu, M., & Liu, B. (2004). Mining and summarizing customer reviews. Proceedings of the ACM SIGKDD International Conference on Knowledge Discovery and Data Mining. (Bing lexicon)
- Nielsen, F. Å. (2011). A new ANEW: Evaluation of a word list for sentiment analysis in microblogs. (AFINN lexicon)
- Mohammad, S. M., & Turney, P. D. (2013). Crowdsourcing a word-emotion association lexicon. Computational Intelligence, 29(3). (NRC lexicon)
- Jockers, M. L. (2017). Syuzhet: Extract Sentiment and Plot Arcs from Text. R package. https://CRAN.R-project.org/package=syuzhet
- Rinker, T. W. lexicon: Lexicon Data. R package. https://CRAN.R-project.org/package=lexicon
- Lindblad, B. schrute: The Entire Transcript from the Office in Tidy Format. R package. https://CRAN.R-project.org/package=schrute