library(tidytext)
library(textdata)
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.4     ✔ readr     2.1.5
## ✔ forcats   1.0.0     ✔ stringr   1.5.2
## ✔ ggplot2   4.0.0     ✔ tibble    3.3.0
## ✔ lubridate 1.9.4     ✔ tidyr     1.3.1
## ✔ purrr     1.1.0     
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
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

bing

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

nrc

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

Reading in songs.

fleetwood <- read.csv("fleetwood.csv")

Looking at the lexicon to select a word to pick out.

nrc_anger <- get_sentiments("nrc") %>% 
  filter(sentiment == "anger")
nrc_anger
## # A tibble: 1,245 × 2
##    word        sentiment
##    <chr>       <chr>    
##  1 abandoned   anger    
##  2 abandonment anger    
##  3 abhor       anger    
##  4 abhorrent   anger    
##  5 abolish     anger    
##  6 abomination anger    
##  7 abuse       anger    
##  8 accursed    anger    
##  9 accusation  anger    
## 10 accused     anger    
## # ℹ 1,235 more rows

Tokenization, join with lexicon, and count.

library(stringr)
## we need to make sure that the lyrics are characters
fleetwood$lyric <- as.character(fleetwood$lyric)

tidy_fleetwood <- fleetwood %>%
  group_by(song) %>%
  ungroup() %>%
  unnest_tokens(word,lyric)

tidy_fleetwood %>%
  filter(song == "silverspring")%>%
  inner_join(nrc_anger) %>%
  count(word, sort = TRUE)
## Joining with `by = join_by(word)`
## # A tibble: 0 × 2
## # ℹ 2 variables: word <chr>, n <int>

Now using a different lexicon, bing, which just assigns positive or negative values for each word and sums the value for the line. This can hide some variation.

fleetwood_sentiment <- tidy_fleetwood %>%
  inner_join(get_sentiments("bing")) %>%
  count(song, index = line, sentiment) %>%
  spread(sentiment, n, fill = 0) %>%
  mutate(sentiment = positive - negative)
## Joining with `by = join_by(word)`

Plotting the sums for each line, for each song.

ggplot(fleetwood_sentiment, aes(index, sentiment, fill = song)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~song)

Comparing the different lexicons to see which one will be the best to use.

afinn <- tidy_fleetwood %>% 
  filter(song == "littleslies") %>%
  inner_join(get_sentiments("afinn")) %>% 
  group_by(index = line) %>% 
  summarise(sentiment = sum(value)) %>% 
  mutate(method = "AFINN")
## Joining with `by = join_by(word)`
bing_and_nrc <- bind_rows(tidy_fleetwood %>% 
                            filter(song == "littleslies") %>%
                            inner_join(get_sentiments("bing")) %>%
                            mutate(method = "Bing et al."),
                          tidy_fleetwood %>% 
                            filter(song == "littleslies") %>%
                            inner_join(get_sentiments("nrc") %>% 
                                         filter(sentiment %in% c("positive", 
                                                                 "negative"))) %>%
                            mutate(method = "NRC")) %>%
  count(method, index = line, sentiment) %>%
  pivot_wider(names_from = sentiment,
              values_from = n,
              values_fill = 0) %>%
  mutate(sentiment = positive - negative)
## Joining with `by = join_by(word)`
## Joining with `by = join_by(word)`
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")

bing_word_counts <- tidy_fleetwood %>%
  inner_join(get_sentiments("bing")) %>%
  count(word, sentiment, sort = TRUE) %>%
  ungroup()
## Joining with `by = join_by(word)`
bing_word_counts
## # A tibble: 22 × 3
##    word    sentiment     n
##    <chr>   <chr>     <int>
##  1 lies    negative     26
##  2 well    positive     10
##  3 loved   positive      9
##  4 beauty  positive      8
##  5 love    positive      8
##  6 wonders positive      8
##  7 sweet   positive      7
##  8 enough  positive      3
##  9 afraid  negative      2
## 10 bright  positive      2
## # ℹ 12 more rows
bing_word_counts %>%
  group_by(sentiment) %>%
  top_n(10) %>%
  ungroup() %>%
  mutate(word = reorder(word, n)) %>%
  ggplot(aes(word, n, fill = sentiment)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~sentiment, scales = "free_y") +
  labs(y = "Contribution to sentiment",
       x = NULL) +
  coord_flip()
## Selecting by n

Using a more complex sentiment analysis that looks at the whole sentence.

library(sentimentr)

sent_sentiment <- fleetwood %>%
    get_sentences() %>%
    sentiment_by(by = c('song', 'line'))%>%
  as.data.frame()

ggplot(sent_sentiment, aes(line, ave_sentiment, fill = song)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~song)

Let’s use the NRC lexicon to decide how the song “Silver Springs” is perceived.

fleetwood <- fleetwood %>%
  mutate(lyrics = as.character(lyrics))

silver <- fleetwood %>%
  filter(song == "silverspring")  

silver_tokens <- silver %>%
  unnest_tokens(word, lyrics)

nrc <- get_sentiments("nrc")

silver_emotions <- silver_tokens %>%
  inner_join(nrc, by = "word") %>%
  count(sentiment) %>%
  complete(sentiment, fill = list(n = 0)) 
## Warning in inner_join(., nrc, by = "word"): Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 8 of `x` matches multiple rows in `y`.
## ℹ Row 9673 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
##   "many-to-many"` to silence this warning.
silver_emotions
## # A tibble: 9 × 2
##   sentiment        n
##   <chr>        <int>
## 1 anticipation     5
## 2 disgust          1
## 3 fear             2
## 4 joy              7
## 5 negative         6
## 6 positive         8
## 7 sadness          1
## 8 surprise         2
## 9 trust            3

Visualizing the above data.

library(viridis)
## Loading required package: viridisLite
silver_emotions %>%
  ggplot(aes(x = sentiment, y = n, fill = sentiment)) +
  geom_col(show.legend = FALSE) +
  scale_fill_viridis(discrete = TRUE, option = "C") +
  coord_flip() +
  labs(title = "NRC Emotion Profile of 'Silver Spring'",
       x = "Emotion",
       y = "Word Count")

Doing the above for all the other songs.

fleetwood <- fleetwood %>%
  mutate(lyrics = as.character(lyrics))
tokens <- fleetwood %>%
  unnest_tokens(word, lyrics)
nrc <- get_sentiments("nrc")
song_emotions <- tokens %>%
  inner_join(nrc, by = "word") %>%
  count(song, sentiment) %>%
  complete(song, sentiment, fill = list(n = 0)) 
## Warning in inner_join(., nrc, by = "word"): Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 26 of `x` matches multiple rows in `y`.
## ℹ Row 12556 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
##   "many-to-many"` to silence this warning.
#convert to wide format
song_emotions_wide <- song_emotions %>%
  pivot_wider(names_from = sentiment,
              values_from = n,
              values_fill = 0)
#normalize by total words per song (so shorter songs aren't penalized/longer have advantage)
song_emotions_prop <- song_emotions %>%
  group_by(song) %>%
  mutate(prop = n / sum(n))
#plot
song_emotions_prop %>%
  ggplot(aes(x = sentiment, y = prop, fill = song)) +
  geom_col(position = "dodge") +
  coord_flip() +
  scale_fill_viridis(discrete = TRUE, option = "C") +
  labs(title = "NRC Emotion Comparison Across Songs",
       x = "Emotion",
       y = "Proportion of Emotion Words")

Trying to use a radar chart as an alternative way to visualize the above data.

#install.packages("fmsb")
library(fmsb)
#convert to wide format
radar_data <- song_emotions_prop %>%
  select(song, sentiment, prop) %>%
  pivot_wider(names_from = sentiment, values_from = prop, values_fill = 0)

# Set max and min rows (0–1 scale)
max_min <- data.frame(
  anger = 1, anticipation = 1, disgust = 1, fear = 1,
  joy = 1, sadness = 1, surprise = 1, trust = 1,
  negative = 1, positive = 1
)

min_row <- data.frame(
  anger = 0, anticipation = 0, disgust = 0, fear = 0,
  joy = 0, sadness = 0, surprise = 0, trust = 0,
  negative = 0, positive = 0
)

emotion_cols <- colnames(max_min)

radar_ready <- rbind(
  max_min,
  min_row,
  radar_data[, emotion_cols]
)

#radar plot for just one song
song_to_plot <- "silverspring"

song_row <- radar_ready[radar_data$song == song_to_plot, ]
rownames(song_row) <- song_to_plot

# fmsb expects: max row, min row, value row
df <- rbind(max_min, min_row, song_row)
#now plot
radarchart(
  df,
  pcol = "darkblue",
  pfcol = scales::alpha("skyblue", 0.4),
  plwd = 2,
  cglcol = "grey70",
  cglty = 1,
  axislabcol = "grey20",
  vlcex = 0.8,
  title = paste("NRC Emotion Radar —", song_to_plot)
)

Radar chart for all songs together.

# Build full df for all songs
multi_df <- rbind(max_min, min_row, radar_data[, emotion_cols])

colors <- RColorBrewer::brewer.pal(n = nrow(radar_data), "Set2")

radarchart(
  multi_df,
  pcol = colors,
  plwd = 1,
  plty = 1,
  pfcol = NA,
  cglcol = "grey70",
  cglty = 1,
  axislabcol = "grey20",
  vlcex = 0.7,
  title = "NRC Emotion Radar — All Songs"
)

legend("topright", legend = radar_data$song, col = colors, lwd = 2, bty = "n")

I was interested in looking at how well a lexicon could differentiate the emotions of a couple of Fleetwood Mac songs that I really like, because I think a lot of the songs are complex. I used tokenization lexicons, like bing to look at overall sentiment, line by line, in each of the five songs I’d selected and was then able to do the same analysis using sentimentr. It was interesting to compare these two, because they were both accurate in capturing what I thought was the overall feeling of the song. For example, both methods categorized “Landslide” as an overall positive song. When I used the bing lexicon, every single line was categorized as positive. This may be because each word is assigned a positive or negative value and then summed by line to be portrayed on the graph, but it hides some details. The sentimentr method shows some small areas of the song where there is a dip into negatives, perhaps a sad emotion, and also portrays a little more diversity in the amount of positivity is in each line, whereas in the bing method all the lines assigned as positive were at the same level.

I also wanted to look at what the main emotion of the songs was, not just whether the overall feeling was positive or negative, so I used the nrc lexicon to run all the lyrics through and tally the number of words that correlated with each emotion. I normalized this with the total number of words per song, so it’s a proportion that can be compared. I did this with “Silver Springs” individually, but then continued to apply the same method to all five songs, showing the results in bar graph format, but also a radar. The nrc lexicon selected “positive” as the most powerful emotion, which is funny because it’s a breakup song! The saddest song was “Landslide”, but it also had high values in anticipation, fear, and positive, which I think is an accurate representation. I think the radar plots are a good idea, but they’re hard to read because of overlap. Overall, I think these plots and the various lexicons I used do a good job at showing the complexity of the lyrics and that Fleetwood Mac’s songs are never just happy or sad.

  1. Here’s the prompt I gave the UMich AI chatbot:

System: You are a rater that classifies short statements (song lyrics) using a fixed schema. Follow the instructions exactly and return valid JSON only. User: Classify the following lyrics. Schema (JSON): { “label”: “anger | sadness | joy | fear | surprise | trust | neutral”, “rationale”: “brief reason citing key phrases”, “evidence_spans”: [“exact substrings in the lyrics that triggered the label”] } Definitions: anger = expresses frustration, irritation, or rage sadness = expresses sorrow, loss, or melancholy joy = expresses happiness, love, or delight fear = expresses anxiety, worry, or dread surprise = expresses shock or unexpected events trust = expresses confidence, safety, or reassurance neutral = descriptive or narrative without clear emotion Examples: “I can’t stop crying over you.” → {“label”:“sadness”,“rationale”:“explicit expression of sorrow”,“evidence_spans”:[“can’t stop crying”]} “Your love lifts me up so high.” → {“label”:“joy”,“rationale”:“expresses happiness and uplift”,“evidence_spans”:[“lifts me up so high”]} “I don’t know what’s coming next.” → {“label”:“fear”,“rationale”:“expresses uncertainty and anxiety”,“evidence_spans”:[“don’t know what’s coming next”]}

I was able to save the output as a CSV and tried to plot it using a similar method to what I did with the nrc lexicon.

library(dplyr)
ai <- read.csv("silver_springs_sentiment.csv")
ai_counts <- ai %>%
  group_by(label) %>%
  summarise(count = n()) %>%
  ungroup()
#plotting
library(ggplot2)

ggplot(ai_counts, aes(x = reorder(label, -count), y = count, fill = label)) +
  geom_col(show.legend = FALSE) +
  scale_fill_viridis(discrete = TRUE, option = "C") +
  labs(
    title = "Sentiment Analysis of 'Silver Springs'",
    x = "Emotion",
    y = "Number of Lines"
  ) +
  theme_minimal(base_size = 14) +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    plot.title = element_text(face = "bold", size = 16)
  )

One of the reasons why I chose this group and these songs to analyze is because of the nuance of feelings communicated in the lyrics. I think it’s part of why people enjoy their music so much; it talks about anger, sadness, loss, arguably “ugly” things, in such beautiful words that the meaning is sometimes hidden. That is what I think at least partially occurred using the nrc lexicon in R on “Silver Springs”. It ranked the three most common emotions as positive, joy, and anticipation. Comparing this to the graph I generated using the jSON spit out by the UMich chatbot, the three most common are sadness, trust, and joy. One thing to note when comparing is that the R graph shows the emotions by word count, while the AI output shows the emotions by the number of lines with that “total” emotion, using a more similar method of sentiment analysis to the sentimentr package. I think that the AI output generated a more representative sentiment analysis of the song and I want to highlight a few lyrics, where they fit in, and why the tokenization or sentimentr lexicons may have missed them. Perhaps one of the most iconic lyrics of the song is “You’ll never get away from the sound; Of the woman that loved you”. These are two separate lines, but the second one contains the word “love”, representing positivity/joy. However, in the context of the previous line, you can tell that it’s not being said in a joyful way, but instead in an angry, almost vengeful way. Even when using sentimentr (which can read additive text), it’s still likely to read this as an overall positive because it’s not taking into context the previous lyrics. I think something similar happened with this lyric: “I know I could have loved you; But you would not let me”. It again talks about love, but how it didn’t work out in the next lyric. In the lyric “And can you tell me was it worth it? Baby, I don’t wanna know”, the word “baby” is used almost in a sarcastic way, but I think that the R lexicons probably ready it as positive or neutral, while the AI version got more into the sadness conveyed by calling someone you’re no longer with this. In some of past comparisons between R and AI, R always seemed to come out on top, so I’m shocked to say that I think AI’s sentiment analysis is a more accurate representation in this case. I think it’s a little more adept at reading context and getting at metaphors. It would be interesting to take a closer look at the CSV and see exactly which examples were cited for each of the above emotions.