Zum Hauptinhalt springen

Tidy-Sentimentanalyse in R

Lerne, wie du eine Tidy-Sentimentanalyse in R an Princes Songs durchführst: Sentiment über die Zeit, Song-Ebene, der Einfluss von Bigrammen und vieles mehr!
· 15 Min. lesen

Entdecke KI

Begib dich auf eine emotionale Reise durch Leben und Werk von Prince, The Artist — Teil Zwei-A einer dreiteiligen Tutorialserie, in der wir mithilfe von Sentimentanalyse mit R Einblicke in die Karriere und den gesellschaftlichen Einfluss des Künstlers gewinnen. Die drei Tutorials behandeln:

Wenn du mehr über Sentimentanalyse lernen möchtest, wirf einen Blick auf unseren Kurs Sentiment Analysis in R: The Tidy Way.

Wusstest du, dass Prince angeblich schon drei Jahre vor 9/11 auf der Bühne darauf anspielte? Vielleicht sagte er auch seinen eigenen Tod voraus. Stimmt das? Wäre es nicht spannend, seine Songtexte zu untersuchen und dir selbst ein fundiertes Urteil zu bilden? Am 21. April 1985 nahm er "Sometimes It Snows In April" auf. Genau 31 Jahre später, am 21. April 2016, starb er. Schau dir seine Wortwahl in der Sentimentanalyse dieses Songs an:

Sometimes It Snows In April
Sentimentanalyse von "Sometimes It Snows In April"

Das gibt Predictive Analytics eine ganz neue Bedeutung!

Aufbau

Auf jeden Codeabschnitt folgt eine — meist subjektive — Interpretation. Du kannst gerne zwischen den Abschnitten springen und eigene Annahmen treffen! Ich empfehle die folgende Einführung zu lesen. Wenn du direkt in den Code einsteigen möchtest, klicke hier. Wenn dir Lexika bereits vertraut sind, nimm die Abkürzung direkt zum Abschnitt mit der detaillierten Analyse.

Einführung

Triplets: Drei-Teile-Zusammenfassung

Ich habe gerade zwei Kätzchen und einen Welpen gerettet, daher denke ich jetzt in Dreiergruppen. Außerdem besagt die Regel der Drei, dass Dinge in Dreierpackung oft witziger, befriedigender oder wirkungsvoller sind — deshalb habe ich das Tutorial in drei Teile aufgeteilt. In Teil Eins hast du Text Mining und explorative Analysen anhand eines Datensatzes mit Hunderten Songtexten des legendären Prince kennengelernt. In Teil Zwei fokussierst du Sentimentanalyse (Zwei-A) und Topic Modeling (Zwei-B) mit NLP. Teil Drei schließt mit weiteren Predictive-Analytics-Aufgaben via Machine Learning: etwa, ob sich das Veröffentlichungsjahrzehnt eines Songs bestimmen lässt oder — spannender — ob sich anhand der Lyrics der Einzug in die Billboard-Charts vorhersagen lässt. In allen Tutorials nutzt du unterschiedliche Machine-Learning-Algorithmen — im folgenden Schaubild jeweils rot markiert.

Machine Leaning Techniques
Adaptiert nach der Grafik hier. Beachte: Aspekte des Modellierens und der ML-Verfahren lassen sich nicht immer eindeutig einer Box zuordnen. Die Grafik dient der Orientierung.

Trennung fällt schwer

Ja, ich habe drei Teile versprochen. Aber Teil Zwei habe ich in Zwei-A (Sentimentanalyse) und Zwei-B (Topic Modeling – folgt in Kürze) gesplittet, also sind es am Ende vier Tutorials. Es gibt viele starke Artikel und Kurse zur Methodik der Sentimentanalyse. Ziel von Teil Zwei-A ist daher, den Fokus auf Erkenntnisse aus der Analyse zu legen — nicht nur auf den Code. Wir nutzen Prince’ Lyrics als Beispiel, aber die Schritte lassen sich auf jede:n Lieblingskünstler:in übertragen. Die Serie soll Neugier auf die Möglichkeiten der Lyrikanalyse wecken — und nebenbei in Natural Language Processing (NLP) und Machine Learning einführen.

Voraussetzungen

Für Teil Zwei-A brauchst du Grundkenntnisse zu tidy Data — insbesondere Pakete wie dplyr für Transformationen, ggplot2 für Visualisierungen und den %>%-Pipe-Operator (ursprünglich aus magrittr). Du wirst sehen, dass mehrere Schritte oft per %>% kompakt und wiederverwendbar verknüpft werden.

Da dieses Tutorial auch eine Fallstudie ist, ist es reich an Visualisierungen über die üblichen Diagramme hinaus. Du solltest daher in deiner Umgebung neue Pakete installieren können. Um den Fokus auf Erkenntnisse zu legen, habe ich Code beigefügt, den du auf eigene Datensätze anwenden kannst; die Detailerklärung erfordert stellenweise eigene Recherche. Zu jedem Paket gibt es unterstützende Links. So lernst du neue Tools kennen und konzentrierst dich gleichzeitig auf deren Anwendung.

Tipp: Lies zuerst Teil Eins, um eine Grundlage für die Serie und einen Einblick in das dichte und vielfältige Vokabular von Prince’ Lyrics zu bekommen.

Überblick Sentimentanalyse

  • Methoden: Sentimentanalyse ist eine Form des Text Minings, die Meinung und Subjektivität eines Textes bestimmen will. Bei Lyrics spiegeln die Ergebnisse nicht nur die Haltung der Künstler:innen, sondern auch kulturelle Strömungen. Es gibt verschiedene Ansätze: Modelle auf Basis trainierter Datensätze, regelbasierte Klassifikatoren und vordefinierte lexikalische Wörterbücher (Lexika). In diesem Tutorial verwenden wir den lexikonbasierten Ansatz — ich empfehle dir aber, auch die anderen Methoden und ihre Trade-offs zu erkunden.

  • Ebenen: Neben verschiedenen Methoden gibt es Analyse-Ebenen abhängig vom Text: Dokument, Satz und Wort. Bei Lyrics kann ein Dokument etwa das Sentiment pro Jahrzehnt, Jahr, Chart-Level oder Song sein. Die Satzebene ist bei Lyrics selten sinnvoll, da Interpunktion zugunsten von Reim und Rhythmus weggelassen wird. Die Wortebene liefert Details und dient als Grundlage für fortgeschrittene Methoden wie Topic Modeling.

Formuliere deine Fragen!

Jedes Data-Science-Projekt braucht Leitfragen. Behalte beim Durcharbeiten im Hinterkopf: Lässt sich ein Programm schreiben, das die Stimmung eines Liedtexts erkennt? Reichen vordefinierte Lexika aus? Wie viel Datenvorbereitung ist nötig? Lassen sich Ergebnisse mit realen Ereignissen verknüpfen? Ändert sich Sentiment über die Zeit? Sind Hits positiver oder negativer als Nicht-Chart-Songs? Welche Wörter stechen in dem Jahr hervor, in dem Prince 9/11 vorhergesagt haben soll? Hat er seinen eigenen Tod vorhergesagt?

Tipp: Lyrikanalyse ist anders — und oft komplexer — als Reden oder Bücher. Einsichten sind schwerer zu greifen. Sei also vorsichtig mit Annahmen! Ich stelle in diesem Tutorial mehr Fragen als Antworten — denk außerhalb des Quadrats!

Vorbereitung

Libraries und Funktionen

Um mit der Analyse von Prince’ Lyrics zu starten, lade die folgenden Libraries. Das wirkt viel, aber die meisten sind für Grafiken. Da wir viele Visuals nutzen, ist eine einheitliche Farbpalette hilfreich. Ich habe eine Liste mit speziellen ANSI-Farbcodes erstellt. Die theme()-Funktion aus ggplot2 erlaubt individuelles Styling, daher erstellen wir eine eigene Funktion theme_lyrics(), die Default-Einstellungen anpasst. knitr ist ein Motor für dynamische Berichte mit R. Zusammen mit kableExtra und formattable erzeugst du ansprechende farbige Tabellen. Erstelle zudem my_kable_styling(), um die Ausgabe dieser Libraries zu standardisieren. Weitere Pakete erkläre ich, wenn wir sie einsetzen.

library(dplyr) #Data manipulation (also included in the tidyverse package)
library(tidytext) #Text mining
library(tidyr) #Spread, separate, unite, text mining (also included in the tidyverse package)
library(widyr) #Use for pairwise correlation

#Visualizations!
library(ggplot2) #Visualizations (also included in the tidyverse package)
library(ggrepel) #`geom_label_repel`
library(gridExtra) #`grid.arrange()` for multi-graphs
library(knitr) #Create nicely formatted output tables
library(kableExtra) #Create nicely formatted output tables
library(formattable) #For the color_tile function
library(circlize) #Visualizations - chord diagram
library(memery) #Memes - images with plots
library(magick) #Memes - images with plots (image_read)
library(yarrr)  #Pirate plot
library(radarchart) #Visualizations
library(igraph) #ngram network diagrams
library(ggraph) #ngram network diagrams

#Define some colors to use throughout
my_colors <- c("#E69F00", "#56B4E9", "#009E73", "#CC79A7", "#D55E00", "#D65E00")

#Customize ggplot2's default theme settings
#This tutorial doesn't actually pass any parameters, but you may use it again in future tutorials so it's nice to have the options
theme_lyrics <- function(aticks = element_blank(),
                         pgminor = element_blank(),
                         lt = element_blank(),
                         lp = "none")
{
  theme(plot.title = element_text(hjust = 0.5), #Center the title
        axis.ticks = aticks, #Set axis ticks to on or off
        panel.grid.minor = pgminor, #Turn the minor grid lines on or off
        legend.title = lt, #Turn the legend title on or off
        legend.position = lp) #Turn the legend on or off
}

#Customize the text tables for consistency using HTML formatting
my_kable_styling <- function(dat, caption) {
  kable(dat, "html", escape = FALSE, caption = caption) %>%
  kable_styling(bootstrap_options = c("striped", "condensed", "bordered"),
                full_width = FALSE)
}

Sushi-Daten

Wie immer startest du damit, die Rohdaten in ein Data Frame einzulesen. In Teil Eins hast du den ursprünglichen Datensatz aufbereitet: Kontraktionen erweitert, Escape-Sequenzen entfernt, Text kleingeschrieben. Den bereinigten Datensatz hast du als CSV gespeichert. Nutze read.csv(), um prince_data zu erstellen — die Lyrics sollen Zeichenketten bleiben, also setze stringsAsFactors = FALSE. Da du keine Zeilennummern brauchst, setze row.names = 1.

Hinweis: Du könntest diese Parameter vermeiden, indem du als modernes Data Frame ein tibble via read_csv() einliest. Aus Konsistenzgründen mit Teil Eins verwenden wir hier aber read.csv().

prince_data <- read.csv('prince_new.csv', stringsAsFactors = FALSE, row.names = 1)

Verschaff dir kurz einen Überblick über die Daten:

 glimpse(prince_data) #Transposed version of `print()`
## Observations: 824
## Variables: 10
## $ lyrics      <chr> "all 7 and we will watch them fall they stand in t...
## $ song        <chr> "7", "319", "1999", "2020", "3121", "7779311", "u"...
## $ year        <int> 1992, NA, 1982, NA, 2006, NA, NA, NA, NA, NA, NA, ...
## $ album       <chr> "Symbol", NA, "1999", "Other Songs", "3121", NA, N...
## $ peak        <int> 3, NA, 2, NA, 1, NA, NA, NA, NA, NA, NA, NA, NA, N...
## $ us_pop      <chr> "7", NA, "12", NA, "1", NA, NA, NA, NA, NA, NA, NA...
## $ us_rnb      <chr> "61", NA, "4", NA, "1", NA, NA, NA, NA, NA, NA, NA...
## $ decade      <chr> "1990s", NA, "1980s", NA, "2000s", NA, NA, NA, NA,...
## $ chart_level <chr> "Top 10", "Uncharted", "Top 10", "Uncharted", "Top...
## $ charted     <chr> "Charted", "Uncharted", "Charted", "Uncharted", "C...

Du siehst: prince_data umfasst 824 Songs und 10 Spalten. Ein Record ist also tatsächlich ein Song!

Sauber aufbereitet: prince_tidy

Ein paar verbleibende Wrangling-Schritte:

  • Unerwünschte Wörter entfernen (manuelle Liste überflüssiger Wörter)
  • Stoppwörter entfernen (sehr häufige Wörter wie „and“, „the“, „a“, „of“ usw.)
  • Wörter mit weniger als drei Zeichen entfernen (in Musik oft nur phonetischer Effekt)
  • Lyrics in einzelne Wörter aufteilen

Um Rohdaten in ein tidy Format zu bringen, nutzt du unnest_tokens() aus tidytext und erstellst prince_tidy — ein Wort pro Zeile inklusive zugehörigem Song. Dann helfen anti_join() und filter() aus dplyr beim weiteren Säubern. (Details findest du in Teil Eins.)

#Created in the first tutorial
undesirable_words <- c("prince", "chorus", "repeat", "lyrics",
                       "theres", "bridge", "fe0f", "yeah", "baby",
                       "alright", "wanna", "gonna", "chorus", "verse",
                       "whoa", "gotta", "make", "miscellaneous", "2",
                       "4", "ooh", "uurh", "pheromone", "poompoom", "3121",
                       "matic", " ai ", " ca ", " la ", "hey", " na ",
                       " da ", " uh ", " tin ", "  ll", "transcription",
                       "repeats", "la", "da", "uh", "ah")

#Create tidy text format: Unnested, Unsummarized, -Undesirables, Stop and Short words
prince_tidy <- prince_data %>%
  unnest_tokens(word, lyrics) %>% #Break the lyrics into individual words
  filter(!word %in% undesirable_words) %>% #Remove undesirables
  filter(!nchar(word) < 3) %>% #Words like "ah" or "oo" used in music
  anti_join(stop_words) #Data provided by the tidytext package
glimpse(prince_tidy) #From `dplyr`, better than `str()`.
 ## Observations: 76,116
## Variables: 10
## $ song        <chr> "7", "7", "7", "7", "7", "7", "7", "7", "7", "7", ...
## $ year        <int> 1992, 1992, 1992, 1992, 1992, 1992, 1992, 1992, 19...
## $ album       <chr> "Symbol", "Symbol", "Symbol", "Symbol", "Symbol", ...
## $ peak        <int> 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,...
## $ us_pop      <chr> "7", "7", "7", "7", "7", "7", "7", "7", "7", "7", ...
## $ us_rnb      <chr> "61", "61", "61", "61", "61", "61", "61", "61", "6...
## $ decade      <chr> "1990s", "1990s", "1990s", "1990s", "1990s", "1990...
## $ chart_level <chr> "Top 10", "Top 10", "Top 10", "Top 10", "Top 10", ...
## $ charted     <chr> "Charted", "Charted", "Charted", "Charted", "Chart...
## $ word        <chr> "watch", "fall", "stand", "love", "smoke", "intell...

prince_tidy liegt nun tokenisiert vor: ein Wort pro Zeile inklusive zugehörigem Song. Das Data Frame umfasst 76.116 Wörter und 10 Spalten.

Deskriptive Statistiken

Falls du Teil Eins nicht gelesen hast, lohnt ein kurzer Blick auf ein paar Übersichtsplots des gesamten Datensatzes. Wir nutzen kreative Grafiken aus ggplot2, circlize und yarrr.

Alles klar Schiff: Wortanzahl pro Song

Ein Pirat sagt shipshape, wenn alles ordentlich und sauber ist. Hier siehst du die aufgeräumten Daten aus einer spannenden Perspektive: die lexikalische Diversität, sprich der Wortschatz der Lyrics über die Zeit. Ein Pirate Plot ist eine fortgeschrittene Darstellung einer stetigen abhängigen Variable (z. B. Wortanzahl) als Funktion einer kategorialen unabhängigen Variable (z. B. Jahrzehnt). Er kombiniert Rohdatenpunkte, deskriptive und inferenzstatistische Informationen in einem Plot. Details zu pirateplot() aus yarrr findest du in diesem Blog.

Erstelle das Data Frame word_summary, das die Anzahl unterschiedlicher Wörter pro Song berechnet. Je vielfältiger die Lyrics, desto größer der Wortschatz. So denkst du dich in die Wortebene ein. Setze für Songs ohne Veröffentlichungsjahr das Feld decade auf "NONE" und vergib mit select() klarere Labels.

word_summary <- prince_tidy %>%
  mutate(decade = ifelse(is.na(decade),"NONE", decade)) %>%
  group_by(decade, song) %>%
  mutate(word_count = n_distinct(word)) %>%
  select(song, Released = decade, Charted = charted, word_count) %>%
  distinct() %>% #To obtain one record per song
  ungroup()

pirateplot(formula =  word_count ~ Released + Charted, #Formula
   data = word_summary, #Data frame
   xlab = NULL, ylab = "Song Distinct Word Count", #Axis labels
   main = "Lexical Diversity Per Decade", #Plot title
   pal = "google", #Color scheme
   point.o = .2, #Points
   avg.line.o = 1, #Turn on the Average/Mean line
   theme = 0, #Theme
   point.pch = 16, #Point `pch` type
   point.cex = 1.5, #Point size
   jitter.val = .1, #Turn on jitter to see the songs better
   cex.lab = .9, cex.names = .7) #Axis label size

lexical diversity per decade

Jeder farbige Punkt steht für einen Song. Der dichte rote Bereich mit dem Wert "NONE" zeigt: Viele Songs im Datensatz haben kein Veröffentlichungsdatum. In den frühen Jahrzehnten ist ein leichter Aufwärtstrend bei der Anzahl einzigartiger Wörter pro Song zu erkennen. Die durchgezogene Linie markiert den Mittelwert pro Jahrzehnt. Das ist wichtig, wenn du das Sentiment über die Zeit analysierst. Princes Wortwahl wird im Verlauf seiner Karriere aussagekräftiger.

Ich empfehle dir, Pirate Plots weiter zu erkunden — wir kratzen hier nur an der Oberfläche!

Rund ums Jahr: Songs pro Jahr

Runde Diagramme eignen sich, um komplexe (oder simple!) Zusammenhänge über viele Kategorien zu zeigen. (Und: Alben waren mal rund!). Der folgende Plot ist ein kreisförmiges Balkendiagramm mit coord_polar() aus ggplot2 und zeigt die relative Anzahl der Songs pro Jahr. Der Code ist umfangreich für eine einfache Grafik — aber es lohnt sich. Ein ähnliches Beispiel findest du detailliert in der R Graph Gallery hier. Wie bei Pirate Plots ist dies nur ein Einstieg in zirkuläre Plots.

Songanzahl pro Jahr
songs_year <- prince_data %>%
  select(song, year) %>%
  group_by(year) %>%
  summarise(song_count = n())

id <- seq_len(nrow(songs_year))
songs_year <- cbind(songs_year, id)
label_data = songs_year
number_of_bar = nrow(label_data) #Calculate the ANGLE of the labels
angle = 90 - 360 * (label_data$id - 0.5) / number_of_bar #Center things
label_data$hjust <- ifelse(angle < -90, 1, 0) #Align label
label_data$angle <- ifelse(angle < -90, angle + 180, angle) #Flip angle
ggplot(songs_year, aes(x = as.factor(id), y = song_count)) +
  geom_bar(stat = "identity", fill = alpha("purple", 0.7)) +
  geom_text(data = label_data, aes(x = id, y = song_count + 10, label = year, hjust = hjust), color = "black", alpha = 0.6, size = 3, angle =  label_data$angle, inherit.aes = FALSE ) +
  coord_polar(start = 0) +
  ylim(-20, 150) + #Size of the circle
  theme_minimal() +
  theme(axis.text = element_blank(),
        axis.title = element_blank(),
        panel.grid = element_blank(),
        plot.margin = unit(rep(-4,4), "in"),
        plot.title = element_text(margin = margin(t = 10, b = -10)))

songs without release dates

Siehst du die Lücke ganz oben? Das deutet — wie beim Pirate Plot — auf die vielen Songs ohne Veröffentlichungsdatum hin. Bei so vielen unveröffentlichten Songs wäre der Plot mit ihnen kaum lesbar. Fehlende Jahre markieren Jahre ohne Releases. Am produktivsten waren 1996 und 1998.

Warum? Lies weiter!

Akkorde: Chart-Erfolge nach Jahrzehnt

Die nächste Grafik zeigt den Zusammenhang zwischen Veröffentlichungsjahrzehnt und Chart-Erfolg in den Billboard-Charts. Ein chordDiagram() für Musik — passt doch! Das Tool stammt aus dem eleganten circlize-Paket von Zuguang Gu. Der Ring ist in zwei Bereiche geteilt: Chart-Status (oben) und Jahrzehnt (unten). Breite Lücken trennen die Kategorien, kleinere die Ausprägungen. Ausführlichere Erläuterungen zum Code findest du hier.

decade_chart <-  prince_data %>%
  filter(decade != "NA") %>% #Remove songs without release dates
  count(decade, charted)  #Get SONG count per chart level per decade. Order determines top or bottom.

circos.clear() #Very important - Reset the circular layout parameters!
grid.col = c("1970s" = my_colors[1], "1980s" = my_colors[2], "1990s" = my_colors[3], "2000s" = my_colors[4], "2010s" = my_colors[5], "Charted" = "grey", "Uncharted" = "grey") #assign chord colors
# Set the global parameters for the circular layout. Specifically the gap size
circos.par(gap.after = c(rep(5, length(unique(decade_chart[[1]])) - 1), 15,
                         rep(5, length(unique(decade_chart[[2]])) - 1), 15))

chordDiagram(decade_chart, grid.col = grid.col, transparency = .2)
title("Relationship Between Chart and Decade")

relationship between chart and decade

Auf den ersten Blick komplex, zeigt der Kreis dennoch klar die Anzahl der Songs je Jahrzehnt und Chart-Level. Prince startete in den 1970ern mit wenigen Releases, von denen einige charteten. Vergleiche die 1980er mit den 1990ern: In den 1990ern gab es mehr Releases, in den 1980ern aber mehr Chart-Erfolge. In den 2000ern waren nur wenige Songs kommerziell erfolgreich, in den 2010ern gab es keine Hits.

Lexika und Lyrics

In diesem Abschnitt wirst du:

  • Sentiment-Lexika und ihre Wortumfänge untersuchen
  • Ermitteln, wie viele Wörter der Lyrics in den Lexika vorkommen
  • Konkrete Wörter und Wortformen betrachten
  • Weitere Datenaufbereitung in Erwägung ziehen

Sentiment-Lexika erkunden

Das Paket tidytext enthält den Datensatz sentiments mit mehreren Lexika — das sind Wörterbücher, die Wörtern Kategorien oder Werte zuordnen. tidytext liefert drei allgemeine Lexika:

  • AFINN: vergibt Scores von -5 bis 5; negative Werte = negativ, positive Werte = positiv
  • Bing: ordnet Wörter den Kategorien positiv/negativ zu
  • NRC: ordnet Wörter einer oder mehreren der zehn Kategorien zu: positive, negative, anger, anticipation, disgust, fear, joy, sadness, surprise, trust

Lege zur Untersuchung ein Data Frame new_sentiments an. Filtere ein Finanzlexikon heraus, erzeuge für AFINN ein binäres (polares) Sentimentfeld, indem du den numerischen Score in positive oder negative überführst, und füge die Anzahl unterschiedlicher Wörter je Lexikon hinzu.

new_sentiments hat eine Spalte mit verschiedenen Sentimentkategorien. Für einen besseren Blick auf Wortzahlen pro Lexikon und Kategorie nutzt du spread() aus tidyr, um die Kategorien zu pivotieren. Verwende die Pakete knitr und kableExtra in deiner Funktion my_kable_styling(). Mit color_tile() und color_bar() aus formattable fügst du Farbe hinzu und erhältst eine ansprechend formatierte Tabelle. Drucke die Tabelle und vergleiche die Lexika.

new_sentiments <- sentiments %>% #From the tidytext package
  filter(lexicon != "loughran") %>% #Remove the finance lexicon
  mutate( sentiment = ifelse(lexicon == "AFINN" & score >= 0, "positive",
                              ifelse(lexicon == "AFINN" & score < 0,
                                     "negative", sentiment))) %>%
  group_by(lexicon) %>%
  mutate(words_in_lexicon = n_distinct(word)) %>%
  ungroup()

new_sentiments %>%
  group_by(lexicon, sentiment, words_in_lexicon) %>%
  summarise(distinct_words = n_distinct(word)) %>%
  ungroup() %>%
  spread(sentiment, distinct_words) %>%
  mutate(lexicon = color_tile("lightblue", "lightblue")(lexicon),
         words_in_lexicon = color_bar("lightpink")(words_in_lexicon)) %>%
  my_kable_styling(caption = "Word Counts Per Lexicon")
Word Counts Per Lexicon
lexiconwords_in_lexiconangeranticipationdisgustfearjoynegativepositivesadnesssurprisetrust
AFINN2476NANANANANA1597879NANANA
bing 6785NANANANANA47822006NANANA
nrc 64681247839105814766893324231211915341231

Die Tabelle vermittelt Größe und Struktur der einzelnen Lexika.

Match: Was überschneidet sich?

Um zu sehen, welches Lexikon besser zu Lyrics passt, betrachtest du den Match-Anteil der Wörter, die in Lexikon und Lyrics vorkommen. Zur Erinnerung: prince_tidy enthält 76.116 Wörter und 7.851 unterschiedliche Wörter.

Wie viele davon sind in den Lexika enthalten?

Führe ein inner_join() zwischen prince_tidy und new_sentiments aus und gruppiere nach Lexikon. Das NRC-Lexikon hat 10 Kategorien, und ein Wort kann in mehreren vorkommen (etwa negativ und traurig). Nutze daher n_distinct() in summarise(), um die Anzahl unterschiedlicher Wörter pro Lexikon zu erhalten.

prince_tidy %>%
  mutate(words_in_lyrics = n_distinct(word)) %>%
  inner_join(new_sentiments) %>%
  group_by(lexicon, words_in_lyrics, words_in_lexicon) %>%
  summarise(lex_match_words = n_distinct(word)) %>%
  ungroup() %>%
  mutate(total_match_words = sum(lex_match_words), #Not used but good to have
         match_ratio = lex_match_words / words_in_lyrics) %>%
  select(lexicon, lex_match_words,  words_in_lyrics, match_ratio) %>%
  mutate(lex_match_words = color_bar("lightpink")(lex_match_words),
         lexicon = color_tile("lightgreen", "lightgreen")(lexicon)) %>%
  my_kable_styling(caption = "Lyrics Found In Lexicons")
Lyrics Found In Lexicons
lexiconlex_match_wordswords_in_lyricsmatch_ratio
AFINN77078510.0980767
bing 118578510.1509362
nrc 167878510.2137307

NRC enthält mehr unterschiedliche Wörter aus den Lyrics als AFINN oder Bing. Beachte, dass die Summe der Match-Ratios gering ist. Kein Lexikon deckt alle Wörter ab — und sollte es auch nicht. Viele Wörter sind neutral und haben kein Sentiment. 2000 etwa ist normalerweise neutral und daher nicht im Lexikon. Dennoch war das Jahr mit Ängsten belegt (Y2K), was im Song mitschwingen kann — typische Lexika erfassen das nicht.

Warum fehlen Wörter in Lexika?

  • Nicht jedes Wort trägt ein Sentiment.
  • Lexika wurden für andere Textsorten erstellt — nicht für Lyrics.
  • Die konkrete Form fehlt: strong kann enthalten sein, strongly nicht. Möglicherweise brauchst du mehr Cleaning! (Dazu später mehr.)

Glaub’s nicht nur aufs Wort

Schau dir ein paar spezifische Wörter aus Prince’ Lyrics an, die das Sentiment beeinflussen könnten. Stehen sie in allen Lexika?

new_sentiments %>%
  filter(word %in% c("dark", "controversy", "gangster",
                     "discouraged", "race")) %>%
  arrange(word) %>% #sort
  select(-score) %>% #remove this field
  mutate(word = color_tile("lightblue", "lightblue")(word),
         words_in_lexicon = color_bar("lightpink")(words_in_lexicon),
         lexicon = color_tile("lightgreen", "lightgreen")(lexicon)) %>%
  my_kable_styling(caption = "Specific Words")
Specific Words
wordsentimentlexiconwords_in_lexicon
controversynegativenrc 6468
controversynegativebing 6785
dark sadnessnrc 6468
dark negativebing 6785
discouragednegativeAFINN2476
gangster negativebing 6785

Controversy und dark erscheinen in NRC und Bing, gangster nur in Bing. Race fehlt komplett — dabei ist es zentral in Prince’ Lyrics. Lässt es sich klar einem Sentiment zuordnen? AFINN ist deutlich kleiner und enthält nur eines dieser Wörter.

Wortformen

Jetzt wird’s kniffliger. Sexualität ist ein häufiges Thema bei Prince. Wie beeinflussen unterschiedliche Wortformen die lexikonbasierte Sentimentanalyse? Hier alle Verwendungen des Wortstamms sex in den Lyrics. Vergleiche mit Bing und NRC und sieh dir die Matches an.

my_word_list <- prince_data %>%
  unnest_tokens(word, lyrics) %>%
  filter(grepl("sex", word)) %>% #Use `grepl()` to find the substring `"sex"`
  count(word) %>%
  select(myword = word, n) %>% #Rename word
  arrange(desc(n))

new_sentiments %>%
  #Right join gets all words in `my_word_list` to show nulls
  right_join(my_word_list, by = c("word" = "myword")) %>%
  filter(word %in% my_word_list$myword) %>%
  mutate(word = color_tile("lightblue", "lightblue")(word),
          instances = color_tile("lightpink", "lightpink")(n),
          lexicon = color_tile("lightgreen", "lightgreen")(lexicon)) %>%
  select(-score, -n) %>% #Remove these fields
  my_kable_styling(caption = "Dependency on Word Form")
Dependency on Word Form
wordsentimentlexiconwords_in_lexiconinstances
sexy positivebing 6785220
sexy positiveAFINN2476220
sex anticipationnrc 6468185
sex joynrc 6468185
sex positivenrc 6468185
sex trustnrc 6468185
superfunkycalifragisexy NANA NA19
lovesexy NANA NA16
sexual NANA NA11
sexuality NANA NA11
sexiness NANA NA2
sexually NANA NA2
sexe NANA NA1
sexed NANA NA1
sexier NANA NA1
superfunkycalifraagisexyNANA NA1
superfunkycalifragisexi NANA NA1

Auffällig: sexy kommt häufig vor, fehlt aber in dieser Form im NRC. sex findet NRC, Bing hingegen nicht. Würde es helfen, Stämme oder Wurzeln zu betrachten? Dein Text kann Vergangenheitsformen, Plurale oder Adverbien eines Wortstamms enthalten, die in keinem Lexikon stehen. Wie gehst du damit um?

Mehr Datenvorbereitung?

Eventuell brauchst du weitere Vorbereitungsschritte. Drei Techniken, die du in Betracht ziehen solltest:

  • Stemming: Entfernen von Suffixen, um den gemeinsamen Ursprung zu erhalten
  • Lemmatisierung: Zurückführen flektierter/abgeleiteter Wörter auf Stamm-, Grund- oder Basisform
  • Wortersatz: Wörter durch häufigere Synonyme ersetzen

Fortgeschritten ist der Einsatz von Synonymen (semantisch ähnlichen Wörtern) und Hypernymen (übergeordneten Begriffen). Diese werden häufiger verwendet als das Wort im Song und tauchen im Lexikon auf — der Match-Anteil steigt. Für dieses Tutorial sprengt das den Rahmen, aber es lohnt sich!

Challenge: Recherchiere, wie Lexika entstehen. Gibt es eines, das sich besser für Lyrics eignet? Wenn dich das Thema packt: Was bräuchtest du, um ein eigenes Lexikon zu bauen? Und worin unterscheidet sich klassifikatorbasierte von lexikonbasierter Sentimentanalyse?

Detaillierte Analyse

Mit dem Grundlagenwissen zu Datensatz und Lexika kannst du beide für Analysen zusammenführen. Die Schritte:

  • Lexikon-spezifische Datensätze erstellen
  • Polares Sentiment über alle Songs betrachten
  • Sentimentverlauf über die Zeit untersuchen
  • Ergebnisse mit Ereignissen aus Prince’ Leben abgleichen
  • Sentiment auf Song-Ebene analysieren
  • Untersuchen, wie Wortpaare Sentiment beeinflussen

Sentiment-Datensätze erstellen

Erzeuge für jedes Lexikon einen Prince-Sentiment-Datensatz, indem du per inner_join() gegen get_sentiments() joinst. Übergib jeweils den Lexikon-Namen. Für diese Übung: Bing für binäre und NRC für kategoriale Sentiments. Da Wörter im NRC in mehreren Kategorien stehen können (z. B. Negative/Fear oder Positive/Joy), erzeugst du zusätzlich einen Subset ohne positive und negative Kategorien.

prince_bing <- prince_tidy %>%
  inner_join(get_sentiments("bing"))
prince_nrc <- prince_tidy %>%
  inner_join(get_sentiments("nrc"))
prince_nrc_sub <- prince_tidy %>%
  inner_join(get_sentiments("nrc")) %>%
  filter(!sentiment %in% c("positive", "negative"))

In the Mood: Gesamtsentiment

Für die Detailanalyse willst du verschiedene Ebenen betrachten: alle Songs, Chart-Level, Jahrzehnt und Wort. Starte mit der NRC-Sentimentanalyse des gesamten Datensatzes.

(Nur zum Spaß: Mit den Paketen memery und magick lassen sich Bilder (Memes) in Plots einbetten.)

nrc_plot <- prince_nrc %>%
  group_by(sentiment) %>%
  summarise(word_count = n()) %>%
  ungroup() %>%
  mutate(sentiment = reorder(sentiment, word_count)) %>%
  #Use `fill = -word_count` to make the larger bars darker
  ggplot(aes(sentiment, word_count, fill = -word_count)) +
  geom_col() +
  guides(fill = FALSE) + #Turn off the legend
  theme_lyrics() +
  labs(x = NULL, y = "Word Count") +
  scale_y_continuous(limits = c(0, 15000)) + #Hard code the axis limit
  ggtitle("Prince NRC Sentiment") +
  coord_flip()

img <- "prince_background2.jpg" #Load the background image
lab <- ""  #Turn off the label
#Overlay the plot on the image and create the meme file
meme(img, lab, "meme_nrc.jpg", inset = nrc_plot)
#Read the file back in and display it!
nrc_meme <- image_read("meme_nrc.jpg")
plot(nrc_meme)

prince nrc sentiment

Bei Prince’ Lyrics überwiegt im NRC offenbar das Positive. Sind alle Wörter mit disgust oder anger zugleich in der Kategorie negative? Ein Blick könnte sich lohnen.

Jetzt das Bing-Gesamtsentiment. Von den 1.185 unterschiedlichen Wörtern aus Prince’ Lyrics, die im Bing-Lexikon auftauchen: Wie viele sind positiv, wie viele negativ?

bing_plot <- prince_bing %>%
  group_by(sentiment) %>%
  summarise(word_count = n()) %>%
  ungroup() %>%
  mutate(sentiment = reorder(sentiment, word_count)) %>%
  ggplot(aes(sentiment, word_count, fill = sentiment)) +
  geom_col() +
  guides(fill = FALSE) +
  theme_lyrics() +
  labs(x = NULL, y = "Word Count") +
  scale_y_continuous(limits = c(0, 8000)) +
  ggtitle("Prince Bing Sentiment") +
  coord_flip()

img1 <- "prince_background1.jpg"
lab1 <- ""
meme(img1, lab1, "meme_bing.jpg", inset = bing_plot)
x <- image_read("meme_bing.jpg")
plot(x)

prince bing sentiment

Im Bing-Lexikon wirkt es fast ausgeglichen zwischen positiv und negativ. Hebelt sich Gesamtsentiment aus, wenn der Datensatz zu groß ist? Schwer zu sagen — betrachte Teilmengen und vergleiche.

In der Akustik gibt es Phasenauslöschung: Treffen zwei identische Wellen phaseninvertiert aufeinander, löschen sie sich aus — totale Stille auf dieser Frequenz! Was bedeutet das für Sentimentanalysen großer Datensätze? Denk mal darüber nach.

Sprachprofessor: „Im Englischen ergibt eine doppelte Verneinung ein Positiv. Im Russischen bleibt sie negativ. Aber es gibt keine Sprache, in der zwei Positive ein Negativ ergeben." Studierender (sarkastisch): „Ja, klar."

Lauter drehen: Chart-Level

Verfeinere die Analyse auf Chart-Level mit dem Bing-Lexikon. Erstelle einen Plot des polaren Sentiments je Chart-Level. Trenne Sentiments via spread() in Spalten und berechne mit mutate() die Polarity (positive - negative) sowie percent_positive ($positive / total * 100$). Für den Polarity-Plot füge per geom_hline() eine yintercept ein. Platziere beide Plots nebeneinander mit grid.arrange().

prince_polarity_chart <- prince_bing %>%
  count(sentiment, chart_level) %>%
  spread(sentiment, n, fill = 0) %>%
  mutate(polarity = positive - negative,
    percent_positive = positive / (positive + negative) * 100)

#Polarity by chart
plot1 <- prince_polarity_chart %>%
  ggplot( aes(chart_level, polarity, fill = chart_level)) +
  geom_col() +
  scale_fill_manual(values = my_colors[3:5]) +
  geom_hline(yintercept = 0, color = "red") +
  theme_lyrics() + theme(plot.title = element_text(size = 11)) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("Polarity By Chart Level")

#Percent positive by chart
plot2 <- prince_polarity_chart %>%
  ggplot( aes(chart_level, percent_positive, fill = chart_level)) +
  geom_col() +
  scale_fill_manual(values = c(my_colors[3:5])) +
  geom_hline(yintercept = 0, color = "red") +
  theme_lyrics() + theme(plot.title = element_text(size = 11)) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("Percent Positive By Chart Level")

grid.arrange(plot1, plot2, ncol = 2)

polarity and percent positive by chart level

Ergibt sich hier, dass gechartete Songs tendenziell positiver sind? Falls ja: Was sagt das darüber aus, was das Publikum hören will? Darf man das so annehmen? Relativ zum Gesamtsentiment wirken gechartete Songs leicht positiver. Interessant — zumal das Bing-Lexikon insgesamt mehr negative als positive Wörter enthält.

Schmelzende Pole: So Blue

Wenn du polares Sentiment betrachtest, willst du wissen, ob es sich über die Zeit wettert — äh, ändert (Geek-Humor). Nutze geom_smooth() mit Methode loess für eine weiche Kurve sowie geom_smooth(method = "lm") für eine lineare.

prince_polarity_year <- prince_bing %>%
  count(sentiment, year) %>%
  spread(sentiment, n, fill = 0) %>%
  mutate(polarity = positive - negative,
    percent_positive = positive / (positive + negative) * 100)

polarity_over_time <- prince_polarity_year %>%
  ggplot(aes(year, polarity, color = ifelse(polarity >= 0,my_colors[5],my_colors[4]))) +
  geom_col() +
  geom_smooth(method = "loess", se = FALSE) +
  geom_smooth(method = "lm", se = FALSE, aes(color = my_colors[1])) +
  theme_lyrics() + theme(plot.title = element_text(size = 11)) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("Polarity Over Time")

relative_polarity_over_time <- prince_polarity_year %>%
  ggplot(aes(year, percent_positive , color = ifelse(polarity >= 0,my_colors[5],my_colors[4]))) +
  geom_col() +
  geom_smooth(method = "loess", se = FALSE) +
  geom_smooth(method = "lm", se = FALSE, aes(color = my_colors[1])) +
  theme_lyrics() + theme(plot.title = element_text(size = 11)) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("Percent Positive Over Time")

grid.arrange(polarity_over_time, relative_polarity_over_time, ncol = 2)

polarity and percent positive over time

Einige Extreme wurden im zweiten Plot angepasst, aber der Gesamtrend des polaren Sentiments ist in beiden Fällen negativ.

Stimmungsring

Mit chordDiagram() untersuchen wir die Beziehungen zwischen NRC-Sentiments und Jahrzehnten. Die Sentimentkategorien erscheinen auf der oberen, die Jahrzehnte auf der unteren Ringhälfte.

grid.col = c("1970s" = my_colors[1], "1980s" = my_colors[2], "1990s" = my_colors[3], "2000s" = my_colors[4], "2010s" = my_colors[5], "anger" = "grey", "anticipation" = "grey", "disgust" = "grey", "fear" = "grey", "joy" = "grey", "sadness" = "grey", "surprise" = "grey", "trust" = "grey")

decade_mood <-  prince_nrc %>%
  filter(decade != "NA" & !sentiment %in% c("positive", "negative")) %>%
  count(sentiment, decade) %>%
  group_by(decade, sentiment) %>%
  summarise(sentiment_sum = sum(n)) %>%
  ungroup()

circos.clear()
#Set the gap size
circos.par(gap.after = c(rep(5, length(unique(decade_mood[[1]])) - 1), 15,
                         rep(5, length(unique(decade_mood[[2]])) - 1), 15))
chordDiagram(decade_mood, grid.col = grid.col, transparency = .2)
title("Relationship Between Mood and Decade")

relationship between mood and decade

Die Grafik zeigt Wortanzahlen pro NRC-Kategorie und Jahrzehnt. Auf kleiner Fläche viel Information — Beziehungen zwischen Kategorien und Zeit werden sichtbar. Die Diagramme sind extrem anpassbar — von simpel bis tief informativ.

Sentiment in Echtzeit

Nach all den DataCamp-Kursen juckt es dich sicher, Skills in der Praxis anzuwenden. Tun wir das, indem wir unsere Analyse von Prince’ Lyrics über Zeit mit realen Ereignissen verknüpfen. Bitte bleib kritisch: Eine vereinfachte Analyse trifft auf ein komplexes Thema.

Ich habe eine Liste von Lebensereignissen von Prince zusammengestellt (u. a. Rolling Stone, Biography.com). Ich habe öffentlich bekannte Jahre ausgewählt, die in unserem Datensatz mit Release-Daten abgedeckt sind. Lies nun princeEvents.csv ein. Erzeuge mit prince_bing und spread() einen Polaritätswert pro Jahr. Joine auf das Data Frame events und erstelle ein Feld für das Sentiment, damit du die Balken einfärben kannst. Bei langen Textlabels empfiehlt sich coord_flip().

events <- read.csv('princeEvents.csv', stringsAsFactors = FALSE)

year_polarity_bing <- prince_bing %>%
  group_by(year, sentiment) %>%
  count(year, sentiment) %>%
  spread(sentiment, n) %>%
  mutate(polarity = positive - negative,
         ratio = polarity / (positive + negative)) #use polarity ratio in next graph

 events %>%
   #Left join gets event years with no releases
   left_join(year_polarity_bing) %>%
   filter(event != " ") %>% #Account for bad data
   mutate(event = reorder(event, year), #Sort chart by desc year
           sentiment = ifelse(positive > negative,
                              "positive", "negative")) %>%
   ggplot(aes(event, polarity, fill = sentiment)) +
   geom_bar(stat = "identity") +
   theme_minimal() + theme(legend.position = "none") +
   xlab(NULL) +
   ggtitle("Sentiment by Events") +
   coord_flip()

sentiment by events

Tipp: princeEvents.csv findest du hier.

Der Vergleich von Ereignissen und Sentiment ist faszinierend. Subjektiv wirken sie sehr korreliert. Klar, ich habe den Ereignisdatensatz selbst erstellt und könnte ihn (unbewusst) an die Lyrics angepasst haben. Ich habe jedoch pro Jahr mehrere Quellen genutzt und mich auf die meistgenannten Fakten gestützt. Die Ergebnisse motivieren, auf der Wortebene tiefer zu graben — worüber sang Prince konkret?

The Black Album: 1994–1996

1994 veröffentlichte Prince The Black Album — ein Versuch, sein afroamerikanisches Publikum zurückzugewinnen. Da 1994 und die beiden Folgejahre in der Analyse herausstechen, betrachtest du die Top-Wörter, die im Zeitraum 1994–1996 im NRC-Lexikon matchen. Nutze geom_label_repel() aus ggrepel zusammen mit geom_point(). Der Plot ist etwas knifflig — probiere ruhig an den Parametern.

plot_words_94_96 <- prince_nrc %>%
  filter(year %in% c("1994", "1995", "1996")) %>%
  group_by(sentiment) %>%
  count(word, sort = TRUE) %>%
  arrange(desc(n)) %>%
  slice(seq_len(8)) %>% #consider top_n() from dplyr also
  ungroup()

plot_words_94_96 %>%
  #Set `y = 1` to just plot one variable and use word as the label
  ggplot(aes(word, 1, label = word, fill = sentiment )) +
  #You want the words, not the points
  geom_point(color = "transparent") +
  #Make sure the labels don't overlap
  geom_label_repel(force = 1,nudge_y = .5,  
                   direction = "y",
                   box.padding = 0.04,
                   segment.color = "transparent",
                   size = 3) +
  facet_grid(~sentiment) +
  theme_lyrics() +
  theme(axis.text.y = element_blank(), axis.text.x = element_blank(),
        axis.title.x = element_text(size = 6),
        panel.grid = element_blank(), panel.background = element_blank(),
        panel.border = element_rect("lightgray", fill = NA),
        strip.text.x = element_text(size = 9)) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("1994 - 1996 NRC Sentiment") +
  coord_flip()

1994-1996 nrc sentiment

Prince sagte 1994 im Rolling Stone:

„Wenn du einen Mann am Träumen hinderst, wird er zum Sklaven. Genau da war ich."

Er trat mit dem Wort „slave“ deutlich sichtbar im Gesicht auf — offener Protest gegen sein damaliges Label. Zudem änderte er seinen Namen in ein Symbol. Wie gut passen die oben gezeigten Wörter zu diesen Ereignissen? Siehst du „slave“, „emancipation“, „black“ und „change“?

(Das gilt nicht nur für Prince. Du kannst das mit jedem Text tun! Beispiel: Welche trust-Wörter nutzte Präsident Trump in seinem ersten Amtsjahr am häufigsten?)

Dieses Mal ist es persönlich: 1998

Der Plot Sentiment by Events zeigte: Zwischen 1996 und 1998 heiratete Prince, verlor zwei Kinder und soll auf der Bühne 9/11 angedeutet haben. (Suche auf YouTube nach seinem 1998er Konzert in den Niederlanden!) Nutze die gleichen Schritte und betrachte die Top-10-Wörter je NRC-Kategorie für 1998.

plot_words_1998 <- prince_nrc %>%
  filter(year == "1998") %>%
  group_by(sentiment) %>%
  count(word, sort = TRUE) %>%
  arrange(desc(n)) %>%
  slice(seq_len(10)) %>%
  ungroup()

#Same comments as previous graph
plot_words_1998 %>%
  ggplot(aes(word, 1, label = word, fill = sentiment )) +
  geom_point(color = "transparent") +
  geom_label_repel(force = 1,nudge_y = .5,  
                   direction = "y",
                   box.padding = 0.05,
                   segment.color = "transparent",
                   size = 3) +
  facet_grid(~sentiment) +
  theme_lyrics() +
  theme(axis.text.y = element_blank(), axis.text.x = element_blank(),
        axis.title.x = element_text(size = 6),
        panel.grid = element_blank(), panel.background = element_blank(),
        panel.border = element_rect("lightgray", fill = NA),
        strip.text.x = element_text(size = 9)) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("1998 NRC Sentiment") +
  coord_flip()

1998 nrc sentiment

Angesichts persönlicher Verluste und der Bedrohung durch Terror sind Wörter wie „suicide“, „waste“, „mad“, „hurt“ — und viele hier nicht gezeigte — sehr treffend. Sie sind stark und geben echten Einblick in die Stimmung dieser Zeit.

Auf dem Radar: Radar-Charts

Ein weiterer Vergleich über Kategorien gelingt mit Radar- bzw. Spider-Charts. Erstellen kannst du sie mit dem Paket radarchart. Ideal, um ähnliche Ausprägungen oder Ausreißer je Variable zu erkennen.

Wir brechen die Analyse auf drei Ebenen herunter: Jahr, Chart und Jahrzehnt. (Aus Platzgründen zeige ich nur den Code für spezifische Jahre.)

  • Nutze prince_nrc_sub ohne positive und negative, damit die übrigen Kategorien besser sichtbar sind. Berechne zunächst die Wortanzahl je Sentiment und Jahr sowie die Gesamtanzahl und leite den Prozentwert ab ($Sentimentwörter pro Jahr / Gesamt pro Jahr * 100$).
  • Filtere auf die Jahre 1978, 1994, 1995 und entferne Unnötiges mit select().
  • Pivotiere mit spread() Jahr und Prozentwerte in Spalten, sodass je Sentiment eine Zeile und je Jahr eine Spalte entsteht. Erzeuge dann mit chartJSRadar() ein interaktives Widget. Du kannst Labels im Mouseover anzeigen lassen. (FYI: Manchmal kappt radarchart das J und Y von „joy“ — sieht dann wie „iov“ aus.)
#Get the count of words per sentiment per year
year_sentiment_nrc <- prince_nrc_sub %>%
  group_by(year, sentiment) %>%
  count(year, sentiment) %>%
  select(year, sentiment, sentiment_year_count = n)

#Get the total count of sentiment words per year (not distinct)
total_sentiment_year <- prince_nrc_sub %>%
  count(year) %>%
  select(year, year_total = n)

#Join the two and create a percent field
year_radar_chart <- year_sentiment_nrc %>%
  inner_join(total_sentiment_year, by = "year") %>%
  mutate(percent = sentiment_year_count / year_total * 100 ) %>%
  filter(year %in% c("1978","1994","1995")) %>%
  select(-sentiment_year_count, -year_total) %>%
  spread(year, percent) %>%
  chartJSRadar(showToolTipLabel = TRUE,
               main = "NRC Years Radar")

Du musst etwas scrollen, um die Plots zu vergleichen — dafür bleiben sie gut lesbar. Bemerkenswert: Bei kleineren Datensätzen wie year erkennst du Varianzen pro Sentiment deutlicher. Zudem fällt auf, dass im Prozentvergleich „joy“ überall am höchsten liegt (besonders zu Beginn von Prince’ Karriere 1978).

Sign O' the Times

Spotify nutzt Machine Learning für Empfehlungen an 140 Millionen aktive Nutzer:innen. Lyrik-Inhalte fließen bisher nicht ein. Stell dir vor, sie täten es … Werfen wir einen tieferen Blick in die Stimmung einzelner Songs.

1987 schrieb Prince „Sign O' the Times“. Ein Billboard-Artikel von Kenneth Partridge sagt:

„Er greift Themen wie AIDS, Gangs, Drogen, Naturkatastrophen und sogar die Challenger-Explosion auf, bleibt dabei aber cool und distanziert. 'Sign O' the Times' ist ein Status-Update, kein Aufruf zum Handeln. Seine Lösung ist weder der Marsch auf die Straße noch der Anruf beim Abgeordneten."

Wie würde eine Maschine die Stimmung des Songs interpretieren? Hoch emotional oder eher informativ? Spiegelt er Prince als Person oder die Gesellschaft? Plotte die NRC-Kategorien mit ggplot2.

prince_nrc %>%
  filter(song %in% "sign o the times") %>%
  group_by(sentiment) %>%
  summarise(word_count = n()) %>%
  ungroup() %>%
  mutate(sentiment = reorder(sentiment, word_count)) %>%
  ggplot(aes(sentiment, word_count, fill = -word_count)) +
  geom_col() +
  guides(fill = FALSE) +
  theme_minimal() + theme_lyrics() +
  labs(x = NULL, y = "Word Count") +
  ggtitle("Sign O' The Times NRC Sentiment") +
  coord_flip()

sign o' the times nrc sentiment

Subjektiv, aber die Ergebnisse scheinen Partridge zu bestätigen: Prince „bleibt cool und distanziert“. Dafür spricht, dass anticipation-Wörter häufiger sind als emotionale Kategorien wie sadness, fear und anger. Dieser Artikel ordnet die drei als Emotionen ein, anticipation hingegen als Sentiment. Der Billboard-Autor bezeichnet den Song als „Status-Update, fast ohne Emotion“. Die Maschine scheint zuzustimmen. Siehst du das auch so? Sentimentanalyse ist nicht rein technisch — sie gilt als Schnittstelle, wo KI auf Psychologie trifft.

Mit ggplot2 als leicht veränderter Darstellung betrachten wir die Wörter je Kategorie.

prince_tidy %>%
  filter(song %in% 'sign o the times') %>%
  distinct(word) %>%
  inner_join(get_sentiments("nrc")) %>%
  ggplot(aes(x = word, fill = sentiment)) +
  facet_grid(~sentiment) +
  geom_bar() + #Create a bar for each word per sentiment
  theme_lyrics() +
  theme(panel.grid.major.x = element_blank(),
        axis.text.x = element_blank()) + #Place the words on the y-axis
  xlab(NULL) + ylab(NULL) +
  ggtitle("Sign O' The Times Sentiment Words") +
  coord_flip()

sign o' the times sentiment words

Kannst du die Wörter den Themen aus dem Billboard-Artikel zuordnen? Challenger-Katastrophe → „rocket“, AIDS → „disease“, Drogen → „crack“, Naturkatastrophen → „hurricane“, Gangs → „gang“ usw.

Fast so, als hätte Partridge vorher R-Code geschrieben und diesen Plot erzeugt!

Weitere Songs

Betrachte Sentimentkategorien für einige weitere Songs mit markanten Titeln und prüfe, ob es Korrelationen gibt.

prince_nrc_sub %>%
  filter(song %in% c("so blue", "controversy", "raspberry beret",
                     "when doves cry", "the future", "1999")) %>%
  count(song, sentiment, year) %>%
  mutate(sentiment = reorder(sentiment, n), song = reorder(song, n)) %>%
  ggplot(aes(sentiment, n, fill = sentiment)) +
  geom_col() +
  facet_wrap(year ~ song, scales = "free_x", labeller = label_both) +
  theme_lyrics() +
  theme(panel.grid.major.x = element_blank(),
        axis.text.x = element_blank()) +
  labs(x = NULL, y = NULL) +
  ggtitle("NRC Sentiment Song Analysis") +
  coord_flip()

nrc sentiment song analysis

NRC zeigt hohe anticipation und fear für einen Song über the future sowie zusätzlich hohe trust bei controversy. Ausgeprägt traurige Songs scheinen ihren Titeln zu entsprechen.

Bigrams pro Jahrzehnt

Bisher hast du Unigramme (einzelne Wörter) betrachtet. Aber wenn „love“ häufig ist: Was steht davor? Oder danach? Einzelwörter ohne Kontext können in die Irre führen. Zeit für Bigramme, also Wortpaare.

Praktisch: tidytext kann auch Wortpaare entpacken. Rufe unnest_tokens() mit token = ngrams auf. Da wir Bigramme betrachten, setze n = 2. Speichere die Ergebnisse in prince_bigrams.

Mit tidyr kannst du Bigramme mittels separate() in einzelne Wörter zerlegen. Um Stopp- und unerwünschte Wörter zu entfernen, trennst du das Bigramm, filterst Unerwünschtes heraus und setzt die Paare per unite() wieder zusammen. So visualisierst du die häufigsten Bigramme pro Jahrzehnt. (Teil Eins erklärt slice() und row_number().)

prince_bigrams <- prince_data %>%
  unnest_tokens(bigram, lyrics, token = "ngrams", n = 2)

bigrams_separated <- prince_bigrams %>%
  separate(bigram, c("word1", "word2"), sep = " ")

bigrams_filtered <- bigrams_separated %>%
  filter(!word1 %in% stop_words$word) %>%
  filter(!word2 %in% stop_words$word) %>%
  filter(!word1 %in% undesirable_words) %>%
  filter(!word2 %in% undesirable_words)

#Because there is so much repetition in music, also filter out the cases where the two words are the same
bigram_decade <- bigrams_filtered %>%
  filter(word1 != word2) %>%
  filter(decade != "NA") %>%
  unite(bigram, word1, word2, sep = " ") %>%
  inner_join(prince_data) %>%
  count(bigram, decade, sort = TRUE) %>%
  group_by(decade) %>%
  slice(seq_len(7)) %>%
  ungroup() %>%
  arrange(decade, n) %>%
  mutate(row = row_number())
## Joining, by = c("song", "year", "album", "peak", "us_pop", "us_rnb", "decade", "chart_level", "charted")
bigram_decade %>%
  ggplot(aes(row, n, fill = decade)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~decade, scales = "free_y") +
  xlab(NULL) + ylab(NULL) +
  scale_x_continuous(  # This handles replacement of row
      breaks = bigram_decade$row, # Notice need to reuse data frame
      labels = bigram_decade$bigram) +
  theme_lyrics() +
  theme(panel.grid.major.x = element_blank()) +
  ggtitle("Bigrams Per Decade") +
  coord_flip()

bigrams per decade

Über Bigramme erkennst du einen Wechsel gängiger Phrasen: von Sex, Tanz und Romantik hin zu Religion und (Rainbow) Children. „Rainbow Baby“ nennen Eltern manchmal ein Kind, das nach einer Fehlgeburt geboren wird. Interessanterweise fand ich keine ernsthafte Rezension zu Prince’ „Rainbow Children“-Album, die das erwähnt.

Sentiment mit Bigrammen

Wie beeinflussen Bigramme das Sentiment? Nutze das AFINN-Lexikon, um Sentiment bei Wortpaaren zu analysieren — speziell wenn Sentiment-Wörter durch „not“ oder andere Negationen relativiert werden.

AFINN <- get_sentiments("afinn")

not_words <- bigrams_separated %>%
  filter(word1 == "not") %>%
  inner_join(AFINN, by = c(word2 = "word")) %>%
  count(word2, score, sort = TRUE) %>%
  ungroup()

not_words %>%
  mutate(contribution = n * score) %>%
  arrange(desc(abs(contribution))) %>%
  head(20) %>%
  mutate(word2 = reorder(word2, contribution)) %>%
  ggplot(aes(word2, n * score, fill = n * score > 0)) +
  geom_col(show.legend = FALSE) +
  theme_lyrics() +
  xlab("Words preceded by \"not\"") +
  ylab("Sentiment score * Number of Occurrences") +
  ggtitle("Polar Sentiment of Words Preceded by Not") +
  coord_flip()

polar sentiment of words preceded by not

In der ersten Zeile erhält care ein false positive, weil das „not“ bei Unigramm-Analyse ignoriert wird. Heben sich falsche Positive und falsche Negative gegenseitig auf?

Auch das bleibt eine offene Frage — ein gutes Thema für weitere Untersuchungen. Tipp: Mit token = "trigrams" und n = 3 in unnest_tokens() kannst du drei aufeinanderfolgende Wörter analysieren!

Weitere Negationswörter solltest du ebenfalls betrachten. Erstelle nun ein Netzwerkdiagramm mit ggraph und igraph. Ordne die Wörter als verbundene Knoten an, mit den Negationswörtern im Zentrum. Erzeuge das erste Objekt aus dem tidy Datensatz mit graph_from_data_frame() und plottet mit ggraph(). geom_edge_density() hebt wichtige Knoten hervor. Ein ähnliches Beispiel findest du im Buch von Julia Silge und David Robinson zu Tidy Text Mining.

negation_words <- c("not", "no", "never", "without")

negation_bigrams <- bigrams_separated %>%
  filter(word1 %in% negation_words) %>%
  inner_join(AFINN, by = c(word2 = "word")) %>%
  count(word1, word2, score, sort = TRUE) %>%
  mutate(contribution = n * score) %>%
  arrange(desc(abs(contribution))) %>%
  group_by(word1) %>%
  slice(seq_len(20)) %>%
  arrange(word1,desc(contribution)) %>%
  ungroup()

bigram_graph <- negation_bigrams %>%
  graph_from_data_frame() #From `igraph`

set.seed(123)

a <- grid::arrow(type = "closed", length = unit(.15, "inches"))

ggraph(bigram_graph, layout = "fr") +
  geom_edge_link(alpha = .25) +
  geom_edge_density(aes(fill = score)) +
  geom_node_point(color = "purple1", size = 1) + #Purple for Prince!
  geom_node_text(aes(label = name),  repel = TRUE) +
  theme_void() + theme(legend.position = "none",
                       plot.title = element_text(hjust = 0.5)) +
  ggtitle("Negation Bigram Network")

negation bigram network

Hier siehst du Wortpaare rund um Negationswörter. Wenn in einer Unigramm-Analyse „alone“ negativ wirkt, kehrt das Bigramm „not alone“ den Effekt um. Manche Wörter verbinden mehrere Knoten — gut sichtbar: „never hurt“ und „not hurt“.

Paarweise Vergleiche

Nachdem du n-Gramme gesehen hast, wirf einen Blick auf Korrelationen zwischen Wörtern. Welche Wörter sind am stärksten korreliert? Nutze pairwise_count() aus widyr für Co-Occurrence-Counts — also: Wie oft treten Wortpaare gemeinsam in einem Song auf? widyr arbeitet mit tidy Datensätzen, weitet sie vorübergehend und gibt sie tidy zurück.

Zur Vereinfachung habe ich vier markante Wörter aus Prince’ Lyrics gewählt.

pwc <- prince_tidy %>%
  filter(n() >= 20) %>%  #High counts
  pairwise_count(word, song, sort = TRUE) %>%
  filter(item1 %in% c("love", "peace", "gangster", "hate")) %>%
  group_by(item1) %>%
  slice(seq_len(7)) %>%
  ungroup() %>%
  mutate(row = -row_number()) #Descending order

pwc %>%
  ggplot(aes(row, n, fill = item1)) +
  geom_bar(stat = "identity", show.legend = FALSE) +
  facet_wrap(~item1, scales = "free") +
  scale_x_continuous(  #This handles replacement of row
      breaks = pwc$row, #Notice need to reuse data frame
      labels = pwc$item2) +
  theme_lyrics() + theme(panel.grid.major.x = element_blank()) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("Pairwise Counts") +
  coord_flip()

pairwise counts

Im Vergleich dazu die paarweise Korrelation — wie oft Wörter gemeinsam auftreten relativ zu ihrem Einzelauftreten. pairwise_cor() bestimmt Korrelationen basierend auf Co-Occurrence in denselben Songs.

prince_tidy %>%
  group_by(word) %>%
  filter(n() >= 20) %>%
  pairwise_cor(word, song, sort = TRUE) %>%
  filter(item1 %in% c("love", "peace", "gangster", "hate")) %>%
  group_by(item1) %>%
  top_n(7) %>%
  ungroup() %>%
  mutate(item2 = reorder(item2, correlation)) %>%
  ggplot(aes(item2, correlation, fill = item1)) +
  geom_bar(stat = 'identity', show.legend = FALSE) +
  facet_wrap(~item1, scales = 'free') +
  theme_lyrics() + theme(panel.grid.major.x = element_blank()) +
  xlab(NULL) + ylab(NULL) +
  ggtitle("Pairwise Correlation") +
  coord_flip()

pairwise correlation

Schon diese vier Wörter liefern spannende Einblicke! Es zeichnen sich Themen ab — perfekte Überleitung zum nächsten Tutorial über Topic Modeling.

Fazit

In diesem Tutorial hast du ein tidy Data Set erstellt und Basisinformationen analysiert: lexikalische Diversität, Songanzahl pro Veröffentlichungsjahr und den Zusammenhang zwischen Jahrzehnt und Charterfolg. Du hast Sentiment-Lexika untersucht und ihre Passung zu den Lyrics bewertet. Danach folgten Sentimentanalysen über alle Songs, über die Zeit, auf Song-Ebene und mit Blick auf Bigramme — visualisiert mit vielfältigen, aussagekräftigen Plots.

Ist es möglich, ein Programm zu schreiben, das die Stimmung in Lyrics erkennt? Ja! Wie verlässlich sind die Ergebnisse? Das hängt von vielen Faktoren ab: Datenvorbereitung, Lexikonwahl, Methode, Datenqualität usw. Der Abgleich mit realen Ereignissen — persönlich und gesellschaftlich — kann die Stimmung eines Liedtexts erhellen. Princes polares Sentiment scheint über die Zeit leicht zu sinken, dennoch sticht joy insgesamt hervor. Gechartete Songs wirken positiver als ungechartete. Die Behauptungen rund um 9/11 und seinen Tod scheinen auf unheimliche Weise zu seinen Worten zu passen.

Aber Lyrics sind komplex, und zu viele Annahmen sind gefährlich. Wenn du die hier gewonnenen Erkenntnisse in das nächste Tutorial, Teil Zwei-B zu Topic Modeling, mitnimmst, bist du auf dem besten Weg, Themen und Motive herauszuarbeiten. Wie viel NLP ist bei Lyrics ohne Interpunktion oder Satzstruktur möglich? Wovon hat Prince wirklich gesungen? Jetzt, da du die Stimmung kennst, bist du bereit, potenzielle Themen zu untersuchen. Und schließlich: Lassen sich Jahrzehnt oder Chart-Level eines Songs vorhersagen? Finde es in den nächsten Tutorials heraus!

Ich hoffe, dir hat die Reise bisher gefallen. In Teil Zwei-B feiern wir „wie 1999“!

Themen

Ähnlich

Tutorial

OLS-Regression: Die wichtigsten Ideen erklärt

Tutorial

Loop-Schleifen in Python-Tutorial