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