Stand: 27. November 2018

Vom ‘bag-of-words’ zur algorithmischen Textanalyse

  • In der quantitativen Textanalyse gibt es eine Reihe von Algorithmen, die als Grundlage eine Übersetzung von Texten in sogenannte Term-Dokument-Matrizen erfordern. Dies gilt etwa bei Topic-Modellen, aber auch für viele Verfahren des maschinellen Lernens oder für die in der Politikwissenschaft gängigen Wordscore- und Wordfish-Verfahren.

  • Term-Dokument-Matrizen beruhen auf einem sogenannten ‘bag-of-words’-Ansatz: Indem ein Text in einen Vektor mit Zählungen von Worten übersetzt wird, wird dessen grammatikalische Struktur und einschließlich der Sequenz des Textes aufgelöst. Ein Term-Dokument-Matrix führt die Vektor-Repräsentation von Texten zusammen, mit den Worten in den Reihen und Dokumenten in den Spalten. Jede Zelle der Matrix gibt an, wie oft Wort i in Dokument j auftritt.

  • Technisch müssen Term-Dokument-Matrizen als “dünnbesetzte Matrix” (sparse matrix) realisiert werden, weil bei einem ausdifferenzierten Vokabular bei weitem nicht jedes Wort in jedem Dokument mindestens einmal auftrifft. Das polmineR-Paket nutzt dabei die TermDocumentMatrix-Klasse des tm-Pakets, die als geringfügige Modifikation aus der simple_triplet_matrix des slam-Pakets hervorgeht.

Initialisierung

  • Ein Teil der im folgenden verwendeten Funktionen (Berechnung aller Kookkurrenzen in einem Korpus/einer Partition) sind im polmineR-Paket ab Version 0.7.10.9006 enthalten. Bei Bedarf wird die polmineR-Entwicklungsversion installiert.

  • Die Beispiele des Foliensatzes basieren auf dem GermaParl-Korpus. Der Datensatz in dem Paket muss nach dem Laden von polmineR mit der use()-Funktion aktiviert werden.

if (packageVersion("polmineR") < package_version("0.7.10.9006"))
  devtools::install_github("PolMine/polmineR", ref = "dev")
library(polmineR)
use("GermaParl")
  • Weitere hier verwendete Pakete werden falls erforderlich installiert und geladen.
for (pkg in c("magrittr", "slam", "tm", "quanteda", "Matrix")){
  if (!pkg %in% rownames(installed.packages())) install.packages(pkg)
  library(package = pkg, character.only = TRUE)
}

Dünnbesetzte, umwandelbare Matrizen

  • Im polmineR-Paket stehen die Methoden as.TermDocumentMatrix() und as.DocumentTermMatrix() zur Verfügung, um Objekte der Klassen TermDocumentMatrix oder DocumentTermMatrix zu gewinnen.

  • Je nachdem, welches Paket für eine weitergehende algorithmische Analyse genutzt wird, können auch Klassen des Matrix-Pakets (sparseMatrix) oder die document-feature matrix (dfm) des quanteda-Pakets gefordert sein. Der Weg dahin führt über eine einfache Typumwandlung.

  • Wichtig für das Verständnis die TermDocumentMatrix-Klasse des tm-Pakets ist, dass diese letztlich identisch ist mit der simple_triplet_matrix des slam-Pakets und diesem nur ein Attribut mit der Angabe eines Gewichtungsfaktors hinzufügt. Dies ist grundsätzlich die Term-Frequenz.

  • Eine simple_triplet_matrix wird definiert über drei Vektoren i, j, v. Der erste gibt die Reihe eines Wertes an, der zweite die Spalte eines Wertes und der dritte den Wert selbst. Indem nur definierte Werte der Matrix angegeben werden, lässt sich der Speicherplatzbedarf gering halten. Bei vielen Dokumenten und einem großen Vokabular könnten Term-Dokument-Matrizen ansonsten schnell riesig und zu große für den verfügbaren Speicher werden!

Diretissima

  • Der einfachste Weg zur Gewinnung einer DocumentTermMatrix ist, die as.DocumentTermMatrix-Methode auf ein Korpus anzuwenden. Erforderlich ist nur die Angabe

    • eines p-Attributs (die token sind dann in den Spalten)
    • und eines s-Attributs (die auftretenden s-Attribute sind dann in den Zeilen).
dtm <- polmineR::as.DocumentTermMatrix("GERMAPARL", p_attribute = "word", s_attribute = "date")

Flexibilität qua partition_bundle

  • Die beiden zunächst vorgestellten Nutzungsszenarien setzen voraus, dass ein bereits vorhandenes s-Attribut die Gliederung des Korpus / der Partitionen in Dokumente abbildet. Wenn sich die Definition der Dokumente für die Dokument-Term-Matrix erst aus einer Kombination von s-Attributen ergibt, kann die as.DocumentTermMatrix()-Methode auch flexibel an einem partition_bundle ansetzen, wobei jede erdenkliche Kombination von s-Attributen für die Bildung der partition-Objekte im partition_bundle herangezogen werden kann.

  • Das folgende Szenario illustriert die Verfahrensschritte. Wichtig ist, dass die partition-Objekte im partition_bundle zunächst um eine Zählung über das für die Zählung der Worthäufigkeiten angereichert werden müssen. Bei der as.DocumentTermMatrix()-Methode gibt man dann über das Argument col an, aus welcher Spalte (hier: die Zählung) die Zellen der Dokument-Term-Matrix gewonnnen werden.

bt16 <- partition("GERMAPARL", lp = 16, interjection = FALSE)
bt16_speakers <- partition_bundle(bt16, s_attribute = "speaker", progress = TRUE)
bt16_speakers <- enrich(bt16_speakers, p_attribute = "word", progress = TRUE)
dtm <- polmineR::as.DocumentTermMatrix(bt16_speakers, col = "count")

Mit as.speeches zum partition_bundle

  • Im Fall von Plenarprotokollkorpora ist eine plausible Definition der Dokumente, die einer Dokument-Term-Matrix zugrunde liegen die einzelne Rede von Abgeordneten. Ein Korpus bzw. ein partition-Objekt können mit der as.speeches()-Methode in ein partition_bundle zerlegt werden.

  • Diese Zerlegung erfolgt anhand einer Heuristik, nach der als Rede der Beitrag eines Redners an einem Plenartag dient, der höchstens von 250 Worten anderer Redner unterbrochen wird.

  • Damit wird ausgeschlossen, dass kurze Unterbrechungen (Zwischenrufe, insbesondere auch Zwischenfragen) den Effekt haben, dass einzelne Redepassagen als eigenständige Reden begriffen werden, obwohl sie der Sache nach eine zusammenhängende Rede darstellen. Zugleich kann erkannt werden, wenn ein Sprecher in einer Sitzung zwei oder mehr verschiedene Reden gehalten hat.

bt2015 <- partition("GERMAPARL", year = 2015, interjection = FALSE)
bt2015_speeches <- as.speeches(bt2015, s_attribute_date = "date", s_attribute_name = "speaker")
bt2015_speeches <- enrich(bt2015_speeches, p_attribute = "word")
dtm <- polmineR::as.DocumentTermMatrix(bt2015_speeches, col = "count")

Schrumpfung der Matrix

  • Für die meisten Anwendungsszenarien (z.B. Topicmodelling) wird eine gänzlich ungefilterte Matrix unnötig gross sein, den Rechenaufwand unnötig erhöhen und durch “Rauschen” zu verunreinigten Ergebnissen führen. Es empfiehlt sich, eine Bereinigung um seltene Worte vorzunehmen, Rauschen und auch Worte auf einer Stopwort-Liste zu entfernen.

  • Mit dem folgenden ersten Filter-Schritt entfernen wir zunächst Dokumente, die unterhalb einer geforderten Mindestlänge bleiben (hier: 100 Worte). Die Länge des Dokuments ermitteln wir durch Aufsummierung der Häufigkeit der Token in den Reihen (row_sums).

short_docs <- which(slam::row_sums(dtm) < 100)
if (length(short_docs) > 0) dtm <- dtm[-short_docs,]
  • In einem zweiten Schritt identifizieren wir Worte, die seltener als 5-mal auftreten (col_sums). Diese Worte werden aus der Dokument-Term-Matrix (dtm) entfernt.
rare_words <- which(slam::col_sums(dtm) < 5)
if (length(rare_words) > 0) dtm <- dtm[,-rare_words]

Weitere Filter-Schritte

  • Die noise()-Methode des polmineR-Pakets unterstützt die Identifikation “rauschiger” Worte in einem Vokabular (Token mit Sonderzeichen, Stopworte). Auch diese werden entfernt.
noisy_tokens <- noise(colnames(dtm), specialChars = NULL, stopwordsLanguage = "de")
noisy_tokens_where <- which(unique(unlist(noisy_tokens)) %in% colnames(dtm))
dtm <- dtm[,-noisy_tokens_where]
  • Nicht erfasst werden dabei Stopwörter, die groß geschrieben wurden, weil sie am Anfang eines Satzes stehen. Diese Fälle erfassen wir gesondert, indem wir eine Stopwort-Liste mit großen Anfangsbuchstaben generieren und anwenden.
stopit <- tm::stopwords("de")
stopit_upper <- paste(toupper(substr(stopit, 1, 1)), substr(stopit, 2, nchar(stopit)), sep = "")
stopit_upper_where <- which(stopit_upper %in% colnames(dtm))
if (length(stopit_upper_where) > 0) dtm <- dtm[, -stopit_upper_where]

Berechnung eines Topic-Modells

  • Die durchgeführten Filter-Schritte können dazu führen, dass in der Matrix Dokumente verbleiben, für die aber tatsächlich keinerlei gezählte Token in der Matrix sind. Wir entfernen leere Dokumente, die in der Berechnung Probleme aufwerfen würden.
empty_docs <- which(slam::row_sums(dtm) == 0)
if (length(empty_docs) > 0) dtm <- dtm[-empty_docs,]
  • Genug der Vorarbeit: Wir initiieren die “klassische” Berechnung eines Latent Dirichlet Allocation-Topic-Modells aus dem lda-Paket.
lda <- topicmodels::LDA(
  dtm, k = 200, method = "Gibbs",
  control = list(burnin = 1000, iter = 3L, keep = 50, verbose = TRUE)
)
  • Um das Ergebnis zu überprüfen, beziehen wir das Vokabular, welches die einzelnen Topics indiziert. Die Ausgabe erfolgt auf der folgenden Seite.
lda_terms <- terms(lda, 10)

Topic-Term-Matrix

Filtern anhand von Part-of-Speech-Annotationen

pb <- partition("GERMAPARL", year = 2015, interjection = FALSE) %>%
  as.speeches(s_attribute_date = "date", s_attribute_name = "speaker") %>% 
  enrich(p_attribute = c("word", "pos"), progress = TRUE) %>%
  subset(pos == "NN")
  • An dieser Stelle ist nun noch ein Zwischenschritt erforderlich: Die Spalte mit der Part-of-Speech-Annotation müssen wir “manuell” fallen lassen.
pb@objects <- lapply(pb@objects, function(x){x@stat[, "pos" := NULL]; x@p_attribute <- "word"; x})

Das nächste Topic-Modell

dtm <- as.DocumentTermMatrix(pb, col = "count")

short_docs <- which(slam::row_sums(dtm) < 100)
if (length(short_docs) > 0) dtm <- dtm[-short_docs,]

rare_words <- which(slam::col_sums(dtm) < 5)
if (length(rare_words) > 0) dtm <- dtm[,-rare_words]

empty_docs <- which(slam::row_sums(dtm) == 0)
if (length(empty_docs) > 0) dtm <- dtm[-empty_docs,]

lda <- topicmodels::LDA(
  dtm, k = 200, method = "Gibbs",
  control = list(burnin = 1000, iter = 3L, keep = 50, verbose = TRUE)
)
if (doit == TRUE){
  saveRDS(lda, file = "~/Lab/tmp/lda_bt2015speeches_pos.RData")
} else {
  lda <- readRDS(file = "~/Lab/tmp/lda_bt2015speeches_pos.RData")
}

Topic-Term-Matrix

Datentransformation: Zur document-feature-matrix

Es gibt zahlreiche R-Pakete, die computergestütze Textanalysen ermöglichen. Während die meisten Pakete eine Art von Term-Dokumenten-Matrix als Ausgangspunkt nutzen, kann der spezifische Matrixtyp variieren. Methoden des beliebten quanteda-Paketes nutzen so eine Document-feature-matrix als Input. Mittels Typumwandlung können wir aus polmineR ausgegbene Matrizen in eine Document-feature-matrix umwandeln.

Erstellen wir zunächst wie zuvor gezeigt ein partition-bundle, das wir an dieser Stelle in eine transponierte, ‘sparse’ Matrix umwandeln.

pb <- partition("GERMAPARL", lp = 16, interjection = FALSE) %>%
  partition_bundle(s_attribute = "parliamentary_group")
pb <- pb[[names(pb)[!names(pb) %in% c("", "fraktionslos")] ]]
pb <- enrich(pb, p_attribute = "lemma")
dtm <- polmineR::as.sparseMatrix(pb, col = "count")
dtm <- Matrix::t(dtm)

Eine Document-feature-Matrix ist intern anders geordnet als unsere Matrix. In folgender Typumwandlung wird die Document-Term-Matrix also in eine Document-Feature-Matrix übersetzt.

pg_dfm <- new(
  "dfm",
  i = dtm@i,
  p = dtm@p,
  x = dtm@x,
  Dim = dtm@Dim,
  Dimnames = list(
    docs = dtm@Dimnames$Docs,
    features = dtm@Dimnames$Terms
  )
)

Anwendungsfall Wordfish I

Unter anderem bieten quanteda eine niedrigschwellige Implementierung von Wordfish. Wordfish ist ein bekanntes Modell zur Skalierung politischer Positionen. Hierbei werden Positionen unüberwacht aus Worthäufigkeiten geschlossen. Für einen Überblick über den zugrundeliegenden Algorithmus und eine Auswahl von Veröffentlichungen, die Wordfish nutzen, siehe hier.

Wir wollen im folgenden ein Wordfish-Modell berechnen. Da es hierfür relativ viel Speicherplatz bedarf und wir vorab keine umfangreiche Reduktion der Matrix durchgeführt haben, werden wir im folgenden die in quanteda implementierte dfm_trim()-Methode nutzen, um unsere Matrix zu verkleinern. Wir filtern Worte heraus, die nicht mindestens 10 mal vorkommen und reduzieren damit die Größe der Matrix auf etwa ein Fünftel der ursprünglichen Größe.

pg_dfm_red <- dfm_trim(pg_dfm, min_termfreq = 20)

Anwendungsfall Wordfish II

Nun können wir ein erstes Wordfish-Modell berechnen und mit der summary()-Methode einen ersten Eindruck gewinnen.

wfm_1 <- textmodel_wordfish(pg_dfm_red, c(4,3))
summary(wfm_1)
## 
## Call:
## textmodel_wordfish.dfm(x = pg_dfm_red, dir = c(4, 3))
## 
## Estimated Document Positions:
##           theta       se
## CDU/CSU  1.1397 0.004162
## FDP     -0.2275 0.005301
## SPD      0.9226 0.004280
## GRUENE  -0.6775 0.005339
## LINKE   -1.1572 0.005085
## 
## Estimated Feature Scores:
##         lieb Kollegin      und Kollege         ,         d Sitzung
## beta 0.03849  0.07014  0.03193 0.08584  0.005644 -0.002794 -0.0523
## psi  7.72221  7.85367 10.81022 8.36436 11.869444 12.320699  4.6331
##          sein eröffnen         .  dürfen      ich Sie|sie  bitten
## beta  0.02182   0.2593  0.003216 0.04565  0.06508 -0.3483 0.06289
## psi  10.91532   4.9465 11.708946 7.65031 10.14628 10.0336 6.14449
##      er|es|sie        zu erheben gestern erreichen   wir Nachricht     daß
## beta  -0.04307  0.009437 -0.1348 -0.2212    0.2187  0.22    0.1431 0.01917
## psi    9.35484 10.123002  5.1126  5.7880    7.0764 10.51    4.2228 6.30464
##       unser    Dr.   Ulrich   wenig     Tag      vor Vollendung  @card@
## beta 0.2498 0.2384 -0.08781 -0.1044 0.06023 -0.01449     0.4002 0.03156
## psi  8.6624 5.6342  2.94543  7.2534 6.75857  8.53741     2.0658 8.78996

Für die Analyse interessante Parameter sind theta und beta. Theta gibt die Position eines Dokumentes (hier alle Debatten einer Fraktion) auf einer Skala an, während Beta aussagt, in welchem Ausmaß Worte diese Positionierung beeinflussen.

Anwendungsfall Wordfish III

Beide Werte können auf unterschiedliche Art und Weise visualisiert werden. Für Theta kann die implementierte Dotplot-Darstellung genutzt werden.

textplot_scale1d(wfm_1, doclabels = pg_dfm_red@Dimnames$docs)

Hier zeigt sich, dass die Interpretation eines Wordfish-Modells sorgsam vorgegangen werden muss. Auf welcher Skala macht diese Skalierung Sinn?

Hier kann es helfen, sich die Betawerte je Term anzuschauen.

head(betaterm[order(betaterm$beta),], 10)
##                              terms      beta
## 14632 Zukunftsinvestitionsprogramm -4.697070
## 14631             Friedensbewegung -3.918159
## 14626           Vermögenseinkommen -2.765413
## 11280            grundgesetzwidrig -2.570157
## 14622                   Faschismus -2.436870
## 10193       Landwirtschaftsbetrieb -2.287117
## 14602              Rüstungsprojekt -2.129474
## 2403                    Vermögende -2.032292
## 6650                   Erwerbslose -1.979593
## 14629                  Klimakiller -1.831668
head(betaterm[order(betaterm$beta, decreasing = TRUE),], 10)
##                            terms     beta
## 7843                   Solidität 4.503262
## 5149  CDU/CSU-Bundestagsfraktion 2.311445
## 14219               Vergabewesen 2.234523
## 13041            Pflichtleistung 2.142822
## 10871            Personalausgabe 1.854529
## 14573             Redaktionsstab 1.854529
## 14155              Pünktlichkeit 1.816273
## 13844              Wachstumspakt 1.793440
## 11868  Menschenrechtsgerichtshof 1.743584
## 3872           Industriestandort 1.656884

Anwendungsfall Wordfish IV

Bekannt sind die sogenannten Eifelturm-Darstellungen, die sich aus dem Wordfish-Modell ergeben. Hierbei wird auf der x-Achse abgetragen, inwiefern ein Wort auf die Skalierung aufläd. Auf der y-Achse wird die geschätzte Wordhäufigkeit psi dargestellt. Für die Skalierung spielen vor allem dokumentspezifische und selten vorkommende Terme eine Rolle, während häufig vorkommende und auf Dokumentebene gleichmäßig verteilte Begriffe eine geringe Rolle spielen.

textplot_scale1d(wfm_1, margin = "features",
                 highlighted = c("Solidität", "Vermögenssteuer", "Klimakiller",
                                "Industriestandort", "Freiheit", "Solidarität"))

Die Abbildung wird auf der nächsten Folie angezeigt.

Anwendungsfall Wordfish V

Fazit

Ob Term-Dokument-Matrix, Dokument-Term-Matrix oder Document-Feature-Matrix, ob sparse oder nicht: Die Darstellung von Texten in Matrizen mit Worten auf der einen und Dokumenten auf der anderen Seite ist für eine Vielzahl von Anwendungsbereichen ind er computergestützen Textanalyse von enormer Bedeutung. polmineR bietet hierbei die Möglichkeit, Korpora in diese Formen zu überführen und für weitere Analysen nutzbar zu machen. Zu beachten ist, dass in dieser Darstellung Wortzusammenhänge aufgelöst werden. Diese bag-of-words-Ansätze stehen so im Gegensatz zum hermeneutisch-interpretativen von beispielsweise Keyword-in-Context-Analysen. Ein triangulatives Vorgehen kann im Sinne der Validierung deshalb nur angeraten werden.