Grundlagen des Textminings mit R - Teil 2

In dieser Übung benötigte R-Pakete:

library(tidyverse)  # Datenjudo
library(stringr)  # Textverarbeitung
library(tidytext)  # Textmining
library(lsa)  # Stopwörter 
library(SnowballC)  # Wörter trunkieren
library(wordcloud)  # Wordcloud anzeigen
library(skimr)  # Überblicksstatistiken

Bitte installieren Sie rechtzeitig alle Pakete, z.B. in RStudio über den Reiter Packages … Install.

Aus dem letzten Post

Daten einlesen:

osf_link <- paste0("https://osf.io/b35r7/?action=download")
afd <- read_csv(osf_link)
## Rows: 96 Columns: 2
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (1): content
## dbl (1): page
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.

Aus breit mach lang:

afd %>% 
  unnest_tokens(output = token, input = content) %>% 
  dplyr::filter(str_detect(token, "[a-z]")) -> afd_long

Stopwörter entfernen:

data(stopwords_de, package = "lsa")

stopwords_de <- data_frame(word = stopwords_de)
## Warning: `data_frame()` was deprecated in tibble 1.1.0.
## ℹ Please use `tibble()` instead.
# Für das Joinen werden gleiche Spaltennamen benötigt
stopwords_de <- stopwords_de %>% 
  rename(token = word)  

afd_long %>% 
  anti_join(stopwords_de) -> afd_no_stop
## Joining, by = "token"

Wörter zählen:

afd_no_stop %>% 
  count(token, sort = TRUE) -> afd_count

Wörter trunkieren:

afd_no_stop %>% 
  mutate(token_stem = wordStem(.$token, language = "de")) %>% 
  count(token_stem, sort = TRUE) -> afd_count_stemmed

Regulärausdrücke

Das "[a-z]" in der Syntax oben steht für “alle Buchstaben von a-z”. Diese flexible Art von “String-Verarbeitung mit Jokern” nennt man Regulärausdrücke (regular expressions; regex). Es gibt eine ganze Reihe von diesen Regulärausdrücken, die die Verarbeitung von Texten erleichert. Mit dem Paket stringr geht das - mit etwas Übung - gut von der Hand. Nehmen wir als Beispiel den Text eines Tweets:

string <-"Correlation of unemployment and #AfD votes at #btw17: ***r = 0.18***\n\nhttps://t.co/YHyqTguVWx"  

Möchte man Ziffern identifizieren, so hilft der Reulärausdruck [:digit:]:

“Gibt es mindestens eine Ziffer in dem String?”

str_detect(string, "[:digit:]")
## [1] TRUE

“Finde die Position der ersten Ziffer! Welche Ziffer ist es?”

str_locate(string, "[:digit:]")
##      start end
## [1,]    51  51
str_extract(string, "[:digit:]")
## [1] "1"

“Finde alle Ziffern!”

str_extract_all(string, "[:digit:]")
## [[1]]
## [1] "1" "7" "0" "1" "8"

“Finde alle Stellen an denen genau 2 Ziffern hintereinander folgen!”

str_extract_all(string, "[:digit:]{2}")
## [[1]]
## [1] "17" "18"

Der Quantitätsoperator {n} findet alle Stellen, in der der der gesuchte Ausdruck genau \(n\) mal auftaucht.

“Gebe die Hashtags zurück!”

str_extract_all(string, "#[:alnum:]+")
## [[1]]
## [1] "#AfD"   "#btw17"

Der Operator [:alnum:] steht für “alphanumerischer Charakter” - also eine Ziffer oder ein Buchstabe; synonym hätte man auch \\w schreiben können (w wie word). Warum werden zwei Backslashes gebraucht? Mit \\w wird signalisiert, dass nicht der Buchstabe w, sondern etwas Besonderes, eben der Regex-Operator \w gesucht wird.

“Gebe URLs zurück!”

str_extract_all(string, "https?://[:graph:]+")
## [[1]]
## [1] "https://t.co/YHyqTguVWx"

Das Fragezeichen ? ist eine Quantitätsoperator, der einen Treffer liefert, wenn das vorherige Zeichen (hier s) null oder einmal gefunden wird. [:graph:] ist die Summe von [:alpha:] (Buchstaben, groß und klein), [:digit:] (Ziffern) und [:punct:] (Satzzeichen u.ä.).

“Zähle die Wörter im String!”

str_count(string, boundary("word"))
## [1] 13

“Liefere nur Buchstabenfolgen zurück, lösche alles übrige”

str_extract_all(string, "[:alpha:]+")
## [[1]]
##  [1] "Correlation"  "of"           "unemployment" "and"          "AfD"         
##  [6] "votes"        "at"           "btw"          "r"            "https"       
## [11] "t"            "co"           "YHyqTguVWx"

Der Quantitätsoperator + liefert alle Stellen zurück, in denen der gesuchte Ausdruck einmal oder häufiger vorkommt. Die Ergebnisse werden als Vektor von Wörtern zurückgegeben. Ein anderer Quantitätsoperator ist *, der für 0 oder mehr Treffer steht. Möchte man einen Vektor, der aus Stringen-Elementen besteht zu einem Strring zusammenfüngen, hilft paste(string) oder str_c(string, collapse = " ").

str_replace_all(string, "[^[:alpha:]+]", "")
## [1] "CorrelationofunemploymentandAfDvotesatbtwrhttpstcoYHyqTguVWx"

Mit dem Negationsoperator [^x] wird der Regulärausrck x negiert; die Syntax oben heißt also “ersetze in string alles außer Buchstaben durch Nichts”. Mit “Nichts” sind hier Strings der Länge Null gemeint; ersetzt man einen belieibgen String durch einen String der Länge Null, so hat man den String gelöscht.

Das Cheatsheet zur Strings bzw zu stringr von RStudio gibt einen guten Überblick über Regex; im Internet finden sich viele Beispiele.

Sentiment-Analyse

Eine weitere interessante Analyse ist, die “Stimmung” oder “Emotionen” (Sentiments) eines Textes auszulesen. Die Anführungszeichen deuten an, dass hier ein Maß an Verständnis suggeriert wird, welches nicht (unbedingt) von der Analyse eingehalten wird. Jedenfalls ist das Prinzip der Sentiment-Analyse im einfachsten Fall so:

Schau dir jeden Token aus dem Text an.
Prüfe, ob sich das Wort im Lexikon der Sentiments wiederfindet.
Wenn ja, dann addiere den Sentimentswert dieses Tokens zum bestehenden Sentiments-Wert.
Wenn nein, dann gehe weiter zum nächsten Wort.
Liefere zum Schluss die Summenwerte pro Sentiment zurück.

Es gibt Sentiment-Lexika, die lediglich einen Punkt für “positive Konnotation” bzw. “negative Konnotation” geben; andere Lexiko weisen differenzierte Gefühlskonnotationen auf. Wir nutzen hier das Sentimentlexikon sentiws. Sie können es hier herunterladen:

sentiws <- read_csv("https://osf.io/x89wq/?action=download")
## Rows: 3468 Columns: 4
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (3): neg_pos, word, inflections
## dbl (1): value
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.

Alternativ können Sie die Daten aus dem Paket pradadata laden. Allerdings müssen Sie dieses Paket von Github installieren:

install.packages("devtools", dep = TRUE)
devtools::install_github("sebastiansauer/pradadata")
data(sentiws, package = "pradadata")

Tabelle @ref(tab:afd_count) zeigt einen Ausschnitt aus dem Sentiment-Lexikon SentiWS.

Table 1: Auszug aus SentiwS
neg_pos word value inflections
neg Abbau -0.0580 Abbaus,Abbaues,Abbauen,Abbaue
neg Abbruch -0.0048 Abbruches,Abbrüche,Abbruchs,Abbrüchen
neg Abdankung -0.0048 Abdankungen
neg Abdämpfung -0.0048 Abdämpfungen
neg Abfall -0.0048 Abfalles,Abfälle,Abfalls,Abfällen
neg Abfuhr -0.3367 Abfuhren

Ungewichtete Sentiment-Analyse

Nun können wir jedes Token des Textes mit dem Sentiment-Lexikon abgleichen; dabei zählen wir die Treffer für positive bzw. negative Terme. Zuvor müssen wir aber noch die Daten (afd_long) mit dem Sentimentlexikon zusammenführen (joinen). Das geht nach bewährter Manier mit inner_join; “inner” sorgt dabei dafür, dass nur Zeilen behalten werden, die in beiden Dataframes vorkommen. Tabelle @ref(tab:afd_senti_tab) zeigt Summe, Anzahl und Anteil der Emotionswerte.

afd_long %>% 
  inner_join(sentiws, by = c("token" = "word")) %>% 
  select(-inflections) -> afd_senti  # die Spalte brauchen wir nicht

afd_senti %>% 
  group_by(neg_pos) %>% 
  summarise(polarity_sum = sum(value),
            polarity_count = n()) %>% 
  mutate(polarity_prop = (polarity_count / sum(polarity_count)) %>% round(2)) -> afd_senti_tab
(#tab:afd_senti_tab)Zusammenfassung von SentiWS
neg_pos polarity_sum polarity_count polarity_prop
neg -52.6461 219 0.27
pos 29.6063 586 0.73

Die Analyse zeigt, dass die emotionale Bauart des Textes durchaus interessant ist: Es gibt viel mehr positiv getönte Wörter als negativ getönte. Allerdings sind die negativen Wörter offenbar deutlich stärker emotional aufgeladen, dennn die Summe an Emotionswert der negativen Wörter ist überraschenderweise deutlich größer als die der positiven.

Betrachten wir also die intensivsten negativ und positive konnotierten Wörter näher.

afd_senti %>% 
  distinct(token, .keep_all = TRUE) %>% 
  mutate(value_abs = abs(value)) %>% 
  top_n(20, value_abs) %>% 
  pull(token)
##  [1] "ungerecht"    "besonders"    "gefährlich"   "überflüssig"  "behindern"   
##  [6] "gefährden"    "brechen"      "unzureichend" "gemein"       "verletzt"    
## [11] "zerstören"    "trennen"      "falsch"       "vermeiden"    "zerstört"    
## [16] "schwach"      "belasten"     "schädlich"    "töten"        "verbieten"

Diese “Hitliste” wird zumeist (19/20) von negativ polarisierten Begriffen aufgefüllt, wobei “besonders” ein Intensivierwort ist, welches das Bezugswort verstärkt (“besonders gefährlich”). Das Argument keep_all = TRUE sorgt dafür, dass alle Spalten zurückgegeben werden, nicht nur die durchsuchte Spalte token. Mit pull haben wir aus dem Dataframe, der von den dplyr-Verben überwegeben wird, die Spalte pull “herausgezogen”; hier nur um Platz zu sparen bzw. der Übersichtlichkeit halber.

Nun könnte man noch den erzielten “Netto-Sentimentswert” des Corpus ins Verhältnis setzen Sentimentswert des Lexikons: Wenn es insgesamt im Sentiment-Lexikon sehr negativ zuginge, wäre ein negativer Sentimentwer in einem beliebigen Corpus nicht überraschend. skimr::skim() gibt uns einen Überblick der üblichen deskriptiven Statistiken.

sentiws %>% 
  select(value, neg_pos) %>% 
  #group_by(neg_pos) %>% 
  skim()

Insgesamt ist das Lexikon ziemlich ausgewogen; negative Werte sind leicht in der Überzahl im Lexikon. Unser Corpus hat eine ähnliche mittlere emotionale Konnotation wie das Lexikon:

afd_senti %>% 
  summarise(senti_sum = mean(value) %>% round(2))
## # A tibble: 1 × 1
##   senti_sum
##       <dbl>
## 1     -0.03