AfD Mining - basales Textmining zum AfD-Parteiprogramm

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"))

plot of chunk wordcloud_tokens_afd

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)

plot of chunk vis_freq_most_freq_tokens_bars

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()

plot of chunk sent_hist

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.

  1. Dank an meinen Kollegen Karsten Lübke, dessen Fachkompetenz mir mindestens so geholfen hat wie seine Begeisterung an der Statistik ansteckend ist. ↩︎

  2. https://www.alternativefuer.de/wp-content/uploads/sites/7/2016/05/2016-06-27_afd-grundsatzprogramm_web-version.pdf ↩︎

  3. Ggf. benötigen Sie Administrator-Rechte, um Dateien auf Ihre Festplatte zu speichern. ↩︎

  4. http://www.omegahat.net/Rstem/stemming.pdf ↩︎

  5. https://cran.r-project.org/web/packages/wordcloud/index.html ↩︎