Für diesen Post benötigte R-Pakete:
library(stringr) # Textverarbeitung
library(tidytext) # Textmining
library(pdftools) # PDF einlesen
library(downloader) # Daten herunterladen
# library(knitr) # HTML-Tabellen
library(htmlTable) # HTML-Tabellen
library(lsa) # Stopwörter
library(SnowballC) # Wörter trunkieren
library(wordcloud) # Wordcloud anzeigen
library(gridExtra) # Kombinierte Plots
library(dplyr) # Datenjudo
library(ggplot2) # Visualisierung
Ein einführendes Tutorial zu Textmining; analysiert wird das Parteiprogramm der Partei “Alternative für Deutschland” (AfD). Vor dem Hintergrund des gestiegenen Zuspruchs von Rechtspopulisten und der großen Gefahr, die von diesem Gedankengut ausdünstet, erscheint mir eine facettenreiche Analyse des Phänomens “Rechtspopulismus” nötig.
Ein großer Teil der zur Verfügung stehenden Daten liegt nicht als braves Zahlenmaterial vor, sondern in “unstrukturierter” Form, z.B. in Form von Texten. Im Gegensatz zur Analyse von numerischen Daten ist die Analyse von Texten1 weniger verbreitet bisher. In Anbetracht der Menge und der Informationsreichhaltigkeit von Text erscheint die Analyse von Text als vielversprechend.
In gewisser Weise ist das Textmining ein alternative zu klassischen qualitativen Verfahren der Sozialforschung. Geht es in der qualitativen Sozialforschung primär um das Verstehen eines Textes, so kann man für das Textmining ähnliche Ziele formulieren. Allerdings: Das Textmining ist wesentlich schwächer und beschränkter in der Tiefe des Verstehens. Der Computer ist einfach noch wesentlich dümmer als ein Mensch, in dieser Hinsicht. Allerdings ist er auch wesentlich schneller als ein Mensch, was das Lesen betrifft. Daher bietet sich das Textmining für das Lesen großer Textmengen an, in denen eine geringe Informationsdichte vermutet wird. Sozusagen maschinelles Sieben im großen Stil. Da fällt viel durch die Maschen, aber es werden Tonnen von Sand bewegt.
Grundlegende Analyse
Text-Daten einlesen
Nun lesen wir Text-Daten ein; das können beliebige Daten sein. Eine gewisse Reichhaltigkeit ist von Vorteil. Nehmen wir das Parteiprogramm der Partei AfD23. Die AfD schätzt die liberal-freiheitliche Demokratie gering, befürchte ich. Damit stellt die AfD die Grundlage von Frieden und Menschenwürde in Frage. Grund genug, sich ihre Themen näher anzuschauen; hier mittels einiger grundlegender Textmining-Analysen.
afd_url <- "https://www.alternativefuer.de/wp-content/uploads/sites/7/2016/05/2016-06-27_afd-grundsatzprogramm_web-version.pdf"
afd_pfad <- "afd_programm.pdf"
download(afd_url, afd_pfad)
afd_raw <- pdf_text(afd_pfad)
str_sub(afd_raw[3], start = 1, end = 200) # ersten 200 Zeichen der Seite 3 des Parteiprogramms
#> [1] "3\t Programm für Deutschland | Inhalt\n 7 | Kultur, Sprache und Identität\t\t\t\t 45 9 | Einwanderung, Integration und Asyl\t\t\t 57\n 7.1 \t\t Deutsc"
Mit download
haben wir die Datei mit der URL afd_url
heruntergeladen und als afd_pfad
gespeichert. Für uns ist pdf_text
sehr praktisch, da diese Funktion Text aus einer beliebige PDF-Datei in einen Text-Vektor einliest.
Der Vektor afd_raw
hat 96 Elemente (entsprechend der Seitenzahl des Dokuments); zählen wir die Gesamtzahl an Wörtern. Dazu wandeln wir den Vektor in einen tidy text Dataframe um. Auch die Stopwörter entfernen wir wieder wie gehabt.
afd_df <- data_frame(Zeile = 1:96,
afd_raw = afd_raw)
afd_df %>%
unnest_tokens(token, afd_raw) %>%
filter(str_detect(token, "[a-z]")) -> afd_df
dplyr::count(afd_df)
#> # A tibble: 1 × 1
#> n
#> <int>
#> 1 26396
Eine substanzielle Menge von Text. Was wohl die häufigsten Wörter sind?
Worthäufigkeiten auszählen
afd_df %>%
na.omit() %>% # fehlende Werte löschen
dplyr::count(token, sort = TRUE)
#> # A tibble: 7,087 × 2
#> token n
#> <chr> <int>
#> 1 die 1151
#> 2 und 1147
#> 3 der 870
#> # ... with 7,084 more rows
Die häufigsten Wörter sind inhaltsleere Partikel, Präpositionen, Artikel… Solche sogenannten “Stopwörter” sollten wir besser herausfischen, um zu den inhaltlich tragenden Wörtern zu kommen. Praktischerweise gibt es frei verfügbare Listen von Stopwörtern, z.B. im Paket lsa
.
data(stopwords_de)
stopwords_de <- data_frame(word = stopwords_de)
stopwords_de <- stopwords_de %>%
dplyr::rename(token = word) # neu = alt
afd_df %>%
anti_join(stopwords_de) -> afd_df
Unser Datensatz hat jetzt viel weniger Zeilen; wir haben also durch anti_join
Zeilen gelöscht (herausgefiltert). Das ist die Funktion von anti_join
: Die Zeilen, die in beiden Dataframes vorkommen, werden herausgefiltert. Es verbleiben also nicht “Nicht-Stopwörter” in unserem Dataframe. Damit wird es schon interessanter, welche Wörter häufig sind.
afd_df %>%
dplyr::count(token, sort = TRUE) -> afd_count
afd_count %>%
top_n(10) %>%
htmlTable()
token | n | |
---|---|---|
1 | deutschland | 190 |
2 | afd | 171 |
3 | programm | 80 |
4 | wollen | 67 |
5 | bürger | 57 |
6 | euro | 55 |
7 | dafür | 53 |
8 | eu | 53 |
9 | deutsche | 47 |
10 | deutschen | 47 |
Ganz interessant; aber es gibt mehrere Varianten des Themas “deutsch”. Es ist wohl sinnvoller, diese auf den gemeinsamen Wortstamm zurückzuführen und diesen nur einmal zu zählen. Dieses Verfahren nennt man “stemming” oder trunkieren.
afd_df %>%
mutate(token_stem = wordStem(.$token, language = "german")) %>%
dplyr::count(token_stem, sort = TRUE) -> afd_count
afd_count %>%
top_n(10) %>%
htmlTable()
token_stem | n | |
---|---|---|
1 | deutschland | 219 |
2 | afd | 171 |
3 | deutsch | 119 |
4 | polit | 88 |
5 | staat | 85 |
6 | programm | 81 |
7 | europa | 80 |
8 | woll | 67 |
9 | burg | 66 |
10 | soll | 63 |
Das ist schon informativer. Dem Befehl wordStem
füttert man einen Vektor an Wörtern ein und gibt die Sprache an (Default ist Englisch4). Das ist schon alles.
Visualisierung der Worthäufigkeiten
Zum Abschluss noch eine Visualisierung mit einer “Wordcloud” dazu.
wordcloud(words = afd_count$token_stem, freq = afd_count$n, max.words = 100, scale = c(2,.5), colors=brewer.pal(6, "Dark2"))
Man kann die Anzahl der Wörter, Farben und einige weitere Formatierungen der Wortwolke beeinflussen5.
Weniger verspielt ist eine schlichte visualisierte Häufigkeitsauszählung dieser Art, z.B. mit Balkendiagrammen (gedreht).
afd_count %>%
top_n(30) %>%
ggplot() +
aes(x = reorder(token_stem, n), y = n) +
geom_col() +
labs(title = "mit Trunkierung") +
coord_flip() -> p1
afd_df %>%
dplyr::count(token, sort = TRUE) %>%
top_n(30) %>%
ggplot() +
aes(x = reorder(token, n), y = n) +
geom_col() +
labs(title = "ohne Trunkierung") +
coord_flip() -> p2
grid.arrange(p1, p2, ncol = 2)
Die beiden Diagramme vergleichen die trunkierten Wörter mit den nicht trunkierten Wörtern. Mit reorder
ordnen wir die Spalte token
nach der Spalte n
. coord_flip
dreht die Abbildung um 90°, d.h. die Achsen sind vertauscht. grid.arrange
packt beide Plots in eine Abbildung, welche 2 Spalten (ncol
) hat.
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 Lexika weisen differenzierte Gefühlskonnotationen auf. Wir nutzen hier dieses Lexikon. Der Einfachheit halber gehen wir im Folgenden davon aus, dass das Lexikon schon aufbereitet vorliegt. Die Aufbereitung kann hier zur Vertiefung nachgelesen werden.
neg_df <- read_tsv("~/Downloads/SentiWS_v1.8c_Negative.txt", col_names = FALSE)
names(neg_df) <- c("Wort_POS", "Wert", "Inflektionen")
neg_df %>%
mutate(Wort = str_sub(Wort_POS, 1, regexpr("\\|", .$Wort_POS)-1),
POS = str_sub(Wort_POS, start = regexpr("\\|", .$Wort_POS)+1)) -> neg_df
pos_df <- read_tsv("~/Downloads/SentiWS_v1.8c_Positive.txt", col_names = FALSE)
names(pos_df) <- c("Wort_POS", "Wert", "Inflektionen")
pos_df %>%
mutate(Wort = str_sub(Wort_POS, 1, regexpr("\\|", .$Wort_POS)-1),
POS = str_sub(Wort_POS, start = regexpr("\\|", .$Wort_POS)+1)) -> pos_df
bind_rows("neg" = neg_df, "pos" = pos_df, .id = "neg_pos") -> sentiment_df
sentiment_df %>% select(neg_pos, Wort, Wert, Inflektionen, -Wort_POS) -> sentiment_df
Unser Sentiment-Lexikon sieht so aus:
htmlTable(head(sentiment_df))
neg_pos | Wort | Wert | Inflektionen | |
---|---|---|---|---|
1 | neg | Abbau | -0.058 | Abbaus,Abbaues,Abbauen,Abbaue |
2 | neg | Abbruch | -0.0048 | Abbruches,Abbrüche,Abbruchs,Abbrüchen |
3 | neg | Abdankung | -0.0048 | Abdankungen |
4 | neg | Abdämpfung | -0.0048 | Abdämpfungen |
5 | neg | Abfall | -0.0048 | Abfalles,Abfälle,Abfalls,Abfällen |
6 | 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. Besser wäre noch: Wir könnten die Sentiment-Werte pro Treffer addieren (und nicht für jeden Term 1 addieren). Aber das heben wir uns für später auf.
sentiment_neg <- match(afd_df$token, filter(sentiment_df, neg_pos == "neg")$Wort)
neg_score <- sum(!is.na(sentiment_neg))
sentiment_pos <- match(afd_df$token, filter(sentiment_df, neg_pos == "pos")$Wort)
pos_score <- sum(!is.na(sentiment_pos))
round(pos_score/neg_score, 1)
#> [1] 2.7
Hier schauen wir für jedes negative (positive) Token, ob es einen “Match” im Sentiment-Lexikon (sentiment_df$Wort
) gibt; das geht mit match
. match
liefert NA
zurück, wenn es keinen Match gibt (ansonsten die Nummer des Sentiment-Worts). Wir brauchen also nur die Anzahl der Nicht-NAs (!is.na
) auszuzählen, um die Anzahl der Matches zu bekommen.
Entgegen dem, was man vielleicht erwarten würde, ist der Text offenbar positiv geprägt. Der “Positiv-Wert” ist ca. 2.6 mal so groß wie der “Negativ-Wert”. Fragt sich, wie sich dieser Wert mit anderen vergleichbaren Texten (z.B. andere Parteien) misst. Hier sei noch einmal betont, dass die Sentiment-Analyse bestenfalls grobe Abschätzungen liefern kann und keinesfalls sich zu einem hermeneutischen Verständnis aufschwingt.
Welche negativen Wörter und welche positiven Wörter wurden wohl verwendet? Schauen wir uns ein paar an.
afd_df %>%
mutate(sentiment_neg = sentiment_neg,
sentiment_pos = sentiment_pos) -> afd_df
afd_df %>%
filter(!is.na(sentiment_neg)) %>%
dplyr::select(token) -> negative_sentiments
head(negative_sentiments$token,50)
#> [1] "mindern" "verbieten" "unmöglich" "töten"
#> [5] "träge" "schädlich" "unangemessen" "unterlassen"
#> [9] "kalt" "schwächen" "ausfallen" "verringern"
#> [13] "verringern" "verringern" "verringern" "belasten"
#> [17] "belasten" "fremd" "schädigenden" "klein"
#> [21] "klein" "klein" "klein" "eingeschränkt"
#> [25] "eingeschränkt" "entziehen" "schwer" "schwer"
#> [29] "schwer" "schwer" "verharmlosen" "unerwünscht"
#> [33] "abgleiten" "wirkungslos" "schwach" "verschleppen"
#> [37] "vermindern" "vermindern" "ungleich" "widersprechen"
#> [41] "zerstört" "zerstört" "erschweren" "auffallen"
#> [45] "unvereinbar" "unvereinbar" "unvereinbar" "abhängig"
#> [49] "abhängig" "abhängig"
afd_df %>%
filter(!is.na(sentiment_pos)) %>%
select(token) -> positive_sentiments
head(positive_sentiments$token, 50)
#> [1] "optimal" "aufstocken" "locker"
#> [4] "zulässig" "gleichwertig" "wiederbeleben"
#> [7] "beauftragen" "wertvoll" "nah"
#> [10] "nah" "nah" "überzeugt"
#> [13] "genehmigen" "genehmigen" "überleben"
#> [16] "überleben" "genau" "verständlich"
#> [19] "erlauben" "aufbereiten" "zugänglich"
#> [22] "messbar" "erzeugen" "erzeugen"
#> [25] "ausgleichen" "ausreichen" "mögen"
#> [28] "kostengünstig" "gestiegen" "gestiegen"
#> [31] "bedeuten" "massiv" "massiv"
#> [34] "massiv" "massiv" "einfach"
#> [37] "finanzieren" "vertraulich" "steigen"
#> [40] "erweitern" "verstehen" "schnell"
#> [43] "zugreifen" "tätig" "unternehmerisch"
#> [46] "entlasten" "entlasten" "entlasten"
#> [49] "entlasten" "helfen"
Anzahl der unterschiedlichen negativen bzw. positiven Wörter
Allerdings müssen wir unterscheiden zwischen der Anzahl der negativen bzw. positiven Wörtern und der Anzahl der unterschiedlichen Wörter.
Zählen wir noch die Anzahl der unterschiedlichen Wörter im negativen und positiven Fall.
afd_df %>%
filter(!is.na(sentiment_neg)) %>%
summarise(n_distinct_neg = n_distinct(token))
#> # A tibble: 1 × 1
#> n_distinct_neg
#> <int>
#> 1 96
afd_df %>%
filter(!is.na(sentiment_pos)) %>%
summarise(n_distinct_pos = n_distinct(token))
#> # A tibble: 1 × 1
#> n_distinct_pos
#> <int>
#> 1 187
Dieses Ergebnis passt zum vorherigen: Die Anzahl der positiven Wörter (187) ist ca. doppelt so groß wie die Anzahl der negativen Wörter (96).
Gewichtete Sentiment-Analyse
Oben haben wir nur ausgezählt, ob ein Term der Sentiment-Liste im Corpus vorkam. Genauer ist es, diesen Term mit seinem Sentiment-Wert zu gewichten, also eine gewichtete Summe zu erstellen.
sentiment_df %>%
rename(token = Wort) -> sentiment_df
afd_df %>%
left_join(sentiment_df, by = "token") -> afd_df
afd_df %>%
filter(!is.na(Wert)) %>%
summarise(Sentimentwert = sum(Wert, na.rm = TRUE)) -> afd_sentiment_summe
afd_sentiment_summe$Sentimentwert
#> [1] -23.9
afd_df %>%
group_by(neg_pos) %>%
filter(!is.na(Wert)) %>%
summarise(Sentimentwert = sum(Wert)) %>%
htmlTable()
neg_pos | Sentimentwert | |
---|---|---|
1 | neg | -51.9793 |
2 | pos | 28.1159 |
Zuerst benennen wir Wort
in token
um, damit es beiden Dataframes (sentiment_df
und afd_df
) eine Spalte mit gleichen Namen gibt. Diese Spalte können wir dann zum “Verheiraten” (left_join
) der beiden Spalten nutzen. Dann summieren wir den Sentiment-Wert jeder nicht-leeren Zeile auf.
Siehe da: Nun ist der Duktus deutlich negativer als positiver. Offenbar werden mehr positive Wörter als negative verwendet, aber die negativen sind viel intensiver.
Tokens mit den extremsten Sentimentwerten
Schauen wir uns die intensivsten Wörter mal an.
afd_df %>%
filter(neg_pos == "pos") %>%
distinct(token, .keep_all = TRUE) %>%
arrange(-Wert) %>%
filter(row_number() < 11) %>%
dplyr::select(token, Wert) %>%
htmlTable()
token | Wert | |
---|---|---|
1 | besonders | 0.5391 |
2 | genießen | 0.4983 |
3 | wichtig | 0.3822 |
4 | sicher | 0.3733 |
5 | helfen | 0.373 |
6 | miteinander | 0.3697 |
7 | groß | 0.3694 |
8 | wertvoll | 0.357 |
9 | motiviert | 0.3541 |
10 | gepflegt | 0.3499 |
afd_df %>%
filter(neg_pos == "neg") %>%
distinct(token, .keep_all = TRUE) %>%
arrange(Wert) %>%
filter(row_number() < 11) %>%
dplyr::select(token, Wert) %>%
htmlTable()
token | Wert | |
---|---|---|
1 | schädlich | -0.9269 |
2 | schwach | -0.9206 |
3 | brechen | -0.7991 |
4 | ungerecht | -0.7844 |
5 | behindern | -0.7748 |
6 | falsch | -0.7618 |
7 | gemein | -0.7203 |
8 | gefährlich | -0.6366 |
9 | verbieten | -0.629 |
10 | vermeiden | -0.5265 |
Tatsächlich erscheinen die negativen Wörter “dampfender” und “fauchender” als die positiven.
Die Syntax kann hier so übersetzt werden:
Nehmen den Dataframe adf_df UND DANN
filtere die Token mit negativen Sentiment UND DANN
lösche doppelte Zeilen UND DANN
sortiere (absteigend) UND DANN
filtere nur die Top 10 UND DANN
zeige nur die Saplten token und Wert UND DANN
zeige eine schöne Tabelle.
Relativer Sentiments-Wert
Nun könnte man noch den erzielten “Netto-Sentiments wert” des Corpus ins Verhältnis setzen Sentiments wert des Lexikons: Wenn es insgesamt im Sentiment-Lexikon sehr negativ zuginge, wäre ein negativer Sentimentwert in einem beliebigen Corpus nicht überraschend.
sentiment_df %>%
filter(!is.na(Wert)) %>%
ggplot() +
aes(x = Wert) +
geom_histogram()
Es scheint einen (leichten) Überhang an negativen Wörtern zu geben. Schauen wir auf die genauen Zahlen.
sentiment_df %>%
filter(!is.na(Wert)) %>%
dplyr::count(neg_pos)
#> # A tibble: 2 × 2
#> neg_pos n
#> <chr> <int>
#> 1 neg 1818
#> 2 pos 1650
Tatsächlich ist die Zahl negativ konnotierter Terme etwas größer als die Zahl der positiv konnotierten. Jetzt gewichten wir die Zahl mit dem Sentimentswert der Terme, in dem wir die Sentimentswerte (die ein negatives bzw. ein positives Vorzeichen aufweisen) aufaddieren.
sentiment_df %>%
filter(!is.na(Wert)) %>%
summarise(sentiment_summe = sum(Wert)) -> sentiment_lexikon_sum
sentiment_lexikon_sum$sentiment_summe
#> [1] -187
Im Vergleich zum Sentiment der Lexikons ist unser Corpus deutlich negativer. Um genau zu sein, um diesen Faktor:
sentiment_lexikon_sum$sentiment_summe / afd_sentiment_summe$Sentimentwert
#> [1] 7.83
Der relative Sentimentswert (relativ zum Sentiment-Lexikon) beträgt also ~7.8.
Verknüpfung mit anderen Variablen
Kann man die Textdaten mit anderen Daten verknüpfen, so wird die Analyse reichhaltiger. So könnte man überprüfen, ob sich zwischen Sentiment-Gehalt und Zeit oder Autor ein Muster findet/bestätigt. Uns liegen in diesem Beispiel keine andere Daten vor, so dass wir dieses Beispiel nicht weiter verfolgen.
Verweise
- Das Buch Tidy Text Minig ist eine hervorragende Quelle vertieftem Wissens zum Textmining mit R.
-
Dank an meinen Kollegen Karsten Lübke, dessen Fachkompetenz mir mindestens so geholfen hat wie seine Begeisterung an der Statistik ansteckend ist. ↩
-
https://www.alternativefuer.de/wp-content/uploads/sites/7/2016/05/2016-06-27_afd-grundsatzprogramm_web-version.pdf ↩
-
Ggf. benötigen Sie Administrator-Rechte, um Dateien auf Ihre Festplatte zu speichern. ↩
-
http://www.omegahat.net/Rstem/stemming.pdf ↩
-
https://cran.r-project.org/web/packages/wordcloud/index.html ↩