Zu Anfang dieser Einführung habe ich die Techniken, um die es in diesem Kapitel geht, als weniger relevant für die sozialwissenschaftliche Forschung beschrieben, was damit zusammenhängt, dass ihr Nutzen für Sozialwissenschaftler üblicherweise eher indirekt ist, während den in diesem Abschnitt behandelten Verfahren in der Computerlinguistik in der Regel eine zentrale Rolle zukommt. Diese Charakterisierung sollte aber nicht den Eindruck erwecken, Tagging (die Bestimmung von Wortklassen), Parsing (die Bestimmung von Satz–Konstituenten) und Entitätenerkennung (oder engl. “named entity recognition”) hätten in dieser Einführung keinen Platz. Im Gegenteil, diese Methoden können maßgeblich zur Verbesserung von Resultaten aus den vorausgehenden Kapiteln beitragen, wie die folgenden Beispiele hoffentlich überzeugend belegen. Ein basales Verständnis von Sprache ist für sozialwissenschaftliche Inhaltsanalysen auch deshalb hilfreich, weil man sich bspw. nicht blind auf komplexe Verfahren wie Themenmodelle verlassen muss, wenn man eine Vorstellung davon hat, welche linguistischen Features besonders relevant dafür sind, welche Themen in einem Text eine Rolle spielen und welche sprachlichen Merkmale diese Themen aufweisen. Auch sind etwa syntaktische Informationen ausgesprochen wertvoll, wenn man Muster erkennen will, die sich mithilfe des “Bag of words”-Ansatzes nicht adäquat abbilden lassen.

Während R lange Zeit keine mit Python vergleichbare Bibliotheken vorweisen konnte, scheint dieses Problem inzwischen (endlich) weitgehend gelöst. Technisch arbeiten wir in diesem Abschnitt mit zwei Paketen: udpipe und spacyr. Letzteres zeichnet sich wie auch andere hier vorgestellte Pakete durch direkte Interoperabilität mit quanteda aus. Spacyr ist allerdings insofern ein etwas komplizierter Sonderfall, weil das Paket seinerseits eine Schnittstelle zu einer externen Ressource darstellt, und zwar zu Python. Weil es lange keine nativen R–Bibliotheken für das Tagging und Parsing von Texten gab, führt der Weg hierfür zwangsläufig über Java oder Python. Glücklicherweise steht seit Anfang 2019 mit udpipe ein solches Paket zur Verfügung, welches direkt auf C++ basiert, und weder Java noch Python erfordert.

if(!require("quanteda")) install.packages("quanteda")
if(!require("tidyverse")) install.packages("tidyverse")
if(!require("scales")) install.packages("scales")
if(!require("udpipe")) {install.packages("udpipe"); library("udpipe")}
if(!require("googlenlp")) {install.packages("googlenlp"); library("googlenlp")}
theme_set(theme_bw())

Erste Gehversuche mit Hilfe von udpipe

Nach der Paketinstallation laden wir das sogenannte Sprachmodell, welches udpipe für die Annotation verwenden soll (hier “german”), und testen es an einem einfachen Beispielsatz. Ein Überblick über sämtliche unterstützen Sprachen (derzeit 56, mit z.T. mehrereen Implementationen von Varietäten der gleichen Sprache) findet sich im Download-Archiv von LIDAT/CLARIN, Performancemetriken sowie weitere Informationen, welche Aufschluss über die Genauigkeit der Tagger/Parser geben auf der Seite des udpipe-Projekts.

if (!file.exists("verschiedenes/german-gsd-ud-2.3-181115.udpipe")) udpipe_download_model(language = "german", model_dir = "verschiedenes") # download model if it doesn't exist
sprachmodell.udpipe <- udpipe_load_model(file = "verschiedenes/german-gsd-ud-2.3-181115.udpipe")
beispiel <- udpipe_annotate(sprachmodell.udpipe, "Dies ist ein einfacher Beispielsatz.")
beispiel <- as.data.frame(beispiel, detailed = T)
beispiel

Wofür lässt sich der Output des PoS-Taggings konkret nutzen? Im folgenden Beispiel laden wir das bereits in Kapitel 5 eingesetzte Korpus aus der Onlineausgabe der Wochenzeitung “Die Zeit”" und filtern den Output anschließend so, dass wir (a) nur Nomen (mit upos == “NOUN”), dem Genus Femininum bzw. Maskulinum (mit “Gender=Fem” und “Gender=Masc”) und der Endung “-in” bzw. “-er” erhalten. Dieses etwas hemdsärmelige Verfahren liefert uns Begriffspaare wie etwa “Politikerin” / “Politiker” zurück und erlaubt so eine grobe Schätzung des Anteils weiblicher Berufsbezeichnungen gegenüber generisch-maskulinen Bezeichnungen (in der Zeit etwa 1:10). Dabei sind allerdings sowohl falsch-positive Ergebnisse enthalten (“Putin”, “Kilometer”), als auch Fälle, in denen man nicht von einer stilistischen Wahlentscheidung sprechen kann (“Bundeskanzlerin”). Jedenfalls gibt das Beispiel bereits einen ersten Vorgeschmack darauf, welche Möglichkeiten sich aufgrund der zusätzlichen Informationen, welche eine PoS-Tagger liefert, für die sozialwissenschaftliche Analyse bieten, wenn man nur etwas kreativ wird.

load("daten/zeit/zeit.sample.korpus.RData")
zeit.sample <- texts(zeit.korpus)
zeit.pos <- udpipe_annotate(sprachmodell.udpipe, zeit.sample, doc_id = docnames(zeit.korpus), tagger = "default", parser = "none")
zeit.pos.df <- as.data.frame(zeit.pos)
nomen.fem <- filter(zeit.pos.df, upos == "NOUN" & str_detect(feats, "Gender=Fem") & str_detect(lemma, "in$"))
nomen.masc <- filter(zeit.pos.df, upos == "NOUN" & str_detect(feats, "Gender=Masc") & str_detect(lemma, "er$"))
as.data.frame(head(sort(table(nomen.fem$lemma, dnn = list("Term")), decreasing = T), 10), responseName = "Frequenz")
as.data.frame(head(sort(table(nomen.masc$lemma, dnn = list("Term")), decreasing = T), 10), responseName = "Frequenz")

Weitergehende Analyse mittels spacyr

Wir wenden uns nun dem Paket spacyr zu, das entgegen udpipe auch noch einen weiteren Vorteil bietet: es erkennt auch sog. benannted Entitäten (“named entities”). Wir installieren zunächst das Paket, bzw. laden dieses sofern bereits vorhanden. Die Installation von spacyr ist vergleichsweise aufwändig, weil zusätzlich eine aktuelle Version von Python benötigt wird, und kann mitunter eine Zeit in Anspruch nehmen. Zusästzliche benötigen wir zudem auch noch ein Sprachmodul für Deutsch, welches Tagging, Parsing und Entitätenerkennung in dieser Sprache ermöglicht. Nach dem Laden erfolgt schließlich die Initialisierung von spacyr, welche jeweils auf die zu benutzende Sprache abgestimmt sein muss.

if (!require("spacyr")) {
  install.packages("spacyr")
  library("spacyr")
  spacy_install()
  spacy_download_langmodel("de")
}
## Loading required package: spacyr

Nun können wir das Paket einsetzen, um etwa die Wortklassen in einem Korpus zu bestimmen. Zunächst laden wir wieder das Korpus mit Beiträgen Schweizer Tageszeitungen zum Thema Finanzkrise. Wir beginnen mit einem einfachen Überblick des Outputs von spacyr. Die Diagnostik-Meldungen aus Python können wir dabei ignorieren.

load("daten/cosmas/finanzkrise/finanzkrise.korpus.RData")
korpus.finanzkrise.sample <- corpus_sample(korpus.finanzkrise, size = 1000)
spacy_initialize(mode = "de")
## spacy python option is already set, spacyr will use:
##  condaenv = "spacy_condaenv"
## successfully initialized (spaCy Version: 2.0.16, language model: de)
## (python options: type = "condaenv", value = "spacy_condaenv")
finanzkrise.pos <- spacy_parse(korpus.finanzkrise.sample, lemma = F, entity = T, dependency = T)
spacy_finalize()
head(finanzkrise.pos, 100)

Die tabellarische Auflistung ist bereits hinlänglich bekannt, neu sind aber die Felder pos (Wortart), head_token_id (Hauptwort), dep_rel (syntaktische Abhängigkeitsbeziehung) und entity (Art der Entität), welche durch die linguistische Analyse hinzugekommen sind.

Ein praktisches Beispiel zeigt konkrete Vorteile der Annotation von Wortart und Satzkonstituente: Wir ermitteln die frequentesten Satzsubjekte im Finanzkrise-Korpus, also solche Nomen, die das Hauptwort eines Satzes darstellen. Dies gibt Aufschluss über die Akteure die im Korpus eine Rolle spielen, auch wenn es sich beim Subjekt um eine rein syntaktische Kategorie handelt.

subjekte <- finanzkrise.pos %>% 
  filter(pos == "NOUN", dep_rel == "sb") %>% 
  mutate(Wort = str_to_lower(token)) %>% 
  group_by(Wort) %>% 
  summarise(Frequenz = n()) %>% 
  arrange(desc(Frequenz)) %>% 
  mutate(Rang = row_number()) %>% 
  filter(Rang <= 25)
ggplot(subjekte, aes(reorder(Wort, Rang), Frequenz)) + geom_bar(stat = "identity") + theme(axis.text.x = element_text(angle = 45, hjust = 1)) + xlab("") + ggtitle("Frequenteste Satzsubjekte im Finanzkrise-Korpus")

Wie das Ergebnis zeigt, lassen sich im Gegensatz zu einer einfachen Frequenzliste aller Wörter im Korpus viel leichter Rückschlüsse über wichtige Akteure anstellen.

Wir wenden uns nun einem anderen Beispiel zu, nämlich den Trump-Clinton-Tweets. Hier interessieren uns besonders Adjektive, und ganz spezifisch, welche für wen der beiden Kandidaten besonders charakteristisch sind. Zunächst müssel wir spacy erneut initialisieren, und zwar um die Sprache von Deutsch auf Englisch umzustellen. Anschließend wenden wir wieder die Funktion spacy_parse an. Wir beschränken uns hier darauf, die Wortart zu bestimmen.

load("daten/twitter/trumpclinton.korpus.RData")
spacy_initialize(mode = "en")
## Python space is already attached.  If you want to switch to a different Python, please restart R.
## successfully initialized (spaCy Version: 2.0.16, language model: en)
## (python options: type = "condaenv", value = "spacy_condaenv")
twitter.pos <- spacy_parse(korpus, lemma = F, entity = F)
spacy_finalize()
head(twitter.pos, 100)

Nun vergleichen wir anhand einer zuvor aus der Gesamtliste aller verwendeten Adjektive ausgewählten Gruppe von Adjektiven, wie charakteristische diese jeweils für Donald Trump und Hillary Clinton sind.

adjektive.trumpclinton <- scan("daten/twitter/adjektive.trumpclinton.txt", what = "char", sep = "\n", quiet = T)
adjektive <- twitter.pos %>% 
  mutate(Wort = str_to_lower(token)) %>% 
  filter(pos == "ADJ", Wort %in% adjektive.trumpclinton) %>% 
  left_join(korpus.stats, by = c("doc_id" = "Text")) %>% 
  group_by(Wort, Kandidat) %>% 
  summarise(Frequenz = n()) %>% 
  complete(Wort, Kandidat) %>%
  replace(., is.na(.), 0) %>% 
  arrange(Wort, Kandidat)
## Warning: Column `doc_id`/`Text` joining character vector and factor,
## coercing into character vector
adjektive.diff <- data.frame(Wort = unique(adjektive$Wort), Differenz = adjektive$Frequenz[adjektive$Kandidat == "Clinton"] - adjektive$Frequenz[adjektive$Kandidat == "Trump"]) %>% 
  mutate(Differenz.S = as.vector(scale(Differenz, center = 0))) %>% 
  arrange(Differenz.S)

ggplot(adjektive.diff, aes(reorder(Wort, Differenz.S), Differenz.S)) + geom_bar(stat = "identity") + theme(axis.text.x = element_text(angle = 90,  hjust = 1, vjust = 0.5)) + xlab("") + ggtitle("Adjektive Trump vs. Clinton (mit 'great', 'big' enfernt)") + ylab("Trump                                                               Clinton") + coord_flip()

Gerade die Adjektive machen die Unterschiede zwischen den Kandidaten bezüglich des politischen Stils sehr deutlich, und nicht nur deshalb, weil Trump deutlich häufiger negative Adjektive verwendet als Clinton.

Zum Schluss wechseln wir noch einmal den Ansatz und schauen uns die Erwähnung von Entitäten im UN-Korpus – in diesem Fall Organisationen – etwas genauer an. Die Entitätenerkennung von spacyr ist alles andere als fehlerfrei – man sollte die Ergebnisse auf jeden Fall genau inspizieren. Dennoch lassen sich durchaus interessante Ergebnisse erzielen. Die Frequenzliste enthält vor allem Dopplungen (“The United Nations” vs. “United Nations”) die sich aber relativ leicht entfernen lassen. So lässt sich etwa ein Lexikon generieren, welches Organizationen bspw. nach Organisationstyp oder nach geographischem Fokus einordnen könnte.

load("daten/un/un.korpus.RData")
korpus.un.sample <- corpus_sample(korpus.un, size = 100)
spacy_initialize(mode = "en")
## Python space is already attached.  If you want to switch to a different Python, please restart R.
## successfully initialized (spaCy Version: 2.0.16, language model: en)
## (python options: type = "condaenv", value = "spacy_condaenv")
un.parsed <- spacy_parse(korpus.un.sample, pos = F, lemma = F)
un.entities <- entity_extract(un.parsed)
spacy_finalize()
organizations <- un.entities$entity[un.entities$entity_type=="ORG"] %>% 
  str_remove("\n|\t") %>% 
  str_replace_all("_+", " ") %>% 
  str_trim(.) %>% 
  str_to_title(.) %>% 
  table(.) %>% 
  sort(., decreasing = T)
head(organizations[names(organizations)!=""], 25)
## .
##                         The United Nations 
##                                        794 
##                       The General Assembly 
##                                        259 
##                             United Nations 
##                                        247 
##                       The Security Council 
##                                        184 
##                               Organization 
##                                        168 
##                                   Assembly 
##                                        130 
##                                      State 
##                                        120 
##                           Security Council 
##                                         91 
##                         The European Union 
##                                         46 
##                                    Council 
##                                         43 
##                                 Government 
##                                         36 
##                           General Assembly 
##                                         35 
##                           The Organization 
##                                         34 
##           The Millennium Development Goals 
##                                         26 
## The Democratic People 'S Republic Of Korea 
##                                         24 
##                               Burkina Faso 
##                                         23 
##                                        Oau 
##                                         22 
##                               Sierra Leone 
##                                         22 
##                             The World Bank 
##                                         20 
##                               The Assembly 
##                                         19 
##                     The Secretary- General 
##                                         19 
##                                       Nato 
##                                         17 
##                                Secretariat 
##                                         17 
##                                     States 
##                                         17 
##                                        Plo 
##                                         16

Annotation mit Hilfe des Paketes googlenlp und der Cloud Natural Language API

Die Methoden der (teil)automatisierten Textanalyse, die in dieser Einführung im Mittelpunkt stehen, sind bisher anhand von Funktionen und Paketen vorgestellt worden, die man selbst auf dem eigenen Rechner installieren, ausführen und mit der notwendigen Expertise auch einsehen und verändern kann. Ob es sich nun um solche Verfahren handelt, die zum Funktionsumfang von quanteda gehören, um jene, die über ein zusätzliche Pakete wie etwa RTextTools, topicmodels, stm oder spacyr realisiert werden, oder um einfache ad-hoc-Verfahren, die man anhand vorhandenerer Funktionen selbst zusammenstellen kann, die Lösung wurde bisher immer auf Grundlage von Paketen erzielt, über die man als R-Nutzer selbst vollständige Kontrolle hat.

In diesem Abschnitt geht es um Programmierschittestellen (application programming interfaces oder APIs), also um Schnittstellen, die den Zugang zu Daten, aber auch die Anwendung bestimmter computergestützter Analyseverfahren über das Internet ermöglichen. Zunächst ist am Zugang zu Daten über APIs nichts auszusätzen – statt mühevoll Parser zu schreiben, die aus Webseiten oder uneinheitlich formatierten PDF-Dokumenten strukturierte Daten herausholen verwendet man oft lieber eine gut dokumentierte API, die ordentliche Daten in genau der Form liefert, wie man sie haben möchte. APIs gibt es in ganz unterschiedlichen Formen und für unterschiedliche Zielgruppen. Da aber häufig gerade bei kommerziellen Social Media-Plattformen die Entwicklung von Apps durch Drittentwickler der eigentliche Grund für das Vorhandensein einer API ist, sind nicht unbedingt alle APIs ideal für die Untersuchung sozialwissenschaftlicher Fragegestellungen geeignet.

Folgend verwenden wir das Paket googlenlp, welches die Dienste von Googles Cloud Natural Language API in R zugänglich macht. Um diese Dienste nutzen zu können ist eine Anmeldung auf der Google Cloud Platform notwendig, sowie die Registrierung eines Projekts und die Freischaltung der eigentlichen API, die nur eine von sehr (!) vielen Diensten darstellt, die Google auf diesem Weg anbietet. Zudem müssen Zahlungsmethoden hinterlegt werden für den Fall, dass ein Projekt eine größere Anzahl von Anfragen generiert. Alle großen Internet-Unternehmen bieten solche Dienste an, die in der Regel im geringen Umfang kostenlos nutzbar sind. Für die Google Cloud Platform kann man sich hier kostenlos registrieren, näheres zur Cloud Natural Lanuage API findet sich hier. Wichtigster Schritt ist nach der Registrierung eines Projekts und der API-Freischaltung das Kopieren eines API-Schlüssels, welchen man braucht, um Anfragen an Google schicken zu können.

Nachdem wir den Schlüssel aus einer Textdatei geladen haben, schicken wir einen Beispielsatz an die API, welcher mittels annotate_text() annotiert wird (die Funktion fasst hierbei die Funktionen Tagging, Parsing, Entitätenerkennung, Spracherkennung sowie Sentimentanalyse zusammen).

Zunächst schauen wir uns das Ergebnis des PoS-Taggings und Syntax-Parsings an.

gl.apikey <- scan("verschiedenes/googlenlp_api.key", what = "char", quiet = T)
set_api_key(gl.apikey)
gl.annotation <- annotate_text("Dies ist ein einfacher Satz, analysiert mit den Diensten von Google.")
gl.annotation$tokens

In einem zweiten Schritt nehmen wir den Entitäten-Auswertung in den Blick, welche die Anfrage gleich mitgeliefert hat.

gl.annotation$entities

Um nicht bei diesem etwas dürftigen Beispiel zu bleiben, wenden wir die Entitätenerkennung auf die Zeit-Daten an, die auch vielfältig genug sind, um die Qualität der Annotation zu demonstrieren.

load("daten/zeit/zeit.sample.korpus.RData")
zeit.sample <- sample(texts(zeit.korpus), size = 5)
entitaeten <- tibble(doc_id = character(0), name = character(0), entity_type = character(0), mid = character(0), wikipedia_url = character(0), salience = double(0), content = character(0), beginOffset = integer(0), mentions_type = character(0))
for (i in 1:5) {
  print(paste0("Analyzing document ", names(zeit.sample[i]), "..."))
  entitaeten <- bind_rows(entitaeten, data.frame(doc_id = names(zeit.sample[i]), analyze_entities(zeit.sample[i])$entities, stringsAsFactors = F))
} 
## [1] "Analyzing document text276..."
## [1] "Analyzing document text258..."
## [1] "Analyzing document text128..."
## [1] "Analyzing document text46..."
## [1] "Analyzing document text193..."
entitaeten

Wie man sieht, liefert die Google Cloud Natural Language API auch für deutsch gute Ergebnisse. Wir gehen hier nicht weiter auf die Facetten Sentimentanalyse, Sprachbestimmung, sowie automatische Übersetzung der Google-Dienste ein, aber auch diese sind durchaus nützlich für inhaltsanalytische Projekte.

Zusammenfassend sind die Funktionen Tagging, Parsing und Entitätenerkennung nützliche Hilfswerkzeuge für die sozialwissenschaftliche Untersuchung von Textinhalten. Der vielleicht zentralste Vorteil besteht in der Möglichkeit dieser Ansätze, gezielt Wortklassen zu identifizieren die für die Analyse relevant sind (i.d.R. die Inhaltswortklassen Nomen, Verben und Adjektive). Ein weiterer Nutzen besteht darin, sehr basale Akteursbeziehungen zu identifizieren (Parsing) und eine verbesserte Erkennung von Akteuren, Orten und abstrakten Konzepten zu erreichen (Entitätenerkennung). Das die letztgenannten Verfahren gerade für deutschsprachige Inhalte nicht unbedingt immer besonders gute Ergebnisse liefern, hat schlicht damit zu tun, dass es für deutschsprachige Inhalte immer noch vergleichsweise weniger exakte Modelle existieren als etwas für Englisch.