Part-of-Speech Tagging

24.05.2019 - Valentin Gold - Reading time ~243 Minutes


Part-of-Speech Tagging mit spaCy

In vielen Fällen kann es nützlich sein, nur bestimmte Wortarten in die Analyse mit aufzunehmen, z.B. Nomen oder Adjektive. Die entscheidende Frage ist – wie immer – welche Wörter im Sinne der Fragestellung relevant sind. Wenn Sie zu dem Ergebnis kommen, dass nur bestimmte Wortarten wichtig sind, dann können Sie die Wortarten (und mehr) über Part-of-Speech (POS) Tagging Verfahren automatisch bestimmen. Es gibt ein paar Pakete, die dieses Tagging vornehmen, allerdings haben einige Pakete zusätzliche Anforderungen:

  • openNLP: POS direkt, allerdings ist eine funktionierende Java-Installation nötig
  • quanteda: POS über spacyr, aber funktionierende Python Installation plus spaCy und andere Pakete ist Voraussetzung
  • udpipe: Paket zur Einbindung der UDPipe C++ Libraries; sollte relativ einfach zu installieren und zu nutzen sein

Das Prinzip kann auf der Seite http://corenlp.run gut nachvollzogen werden und wird in der folgenden Abbildung demonstriert.

Funktionsweise CoreNLP, Quelle: http://corenlp.run

Figure 1: Funktionsweise CoreNLP, Quelle: http://corenlp.run

Die meisten POS-Tagger orientieren sich für die Bestimmung der Wortarten an der Penn Treebank; eine der ersten manuellen Schemata für die Bestimmung der Wortarten. Wir werden im Seminar auf https://spacy.io zurückgreifen. Das Tagging-Programm ist in Python geschrieben, allerdings gibt es das R-Paket spacyr, das auf die Python Library zurück greift.

Statistisches Modell, Quelle: https://spacy.io

Figure 2: Statistisches Modell, Quelle: https://spacy.io

POS-Tagging basiert auf statistischen Modellen (hier: CNN) – daher machen diese Tagger auch Fehler in der Zuordnung der Wortarten. Es macht zudem einen großen Unterschied, auf welchen Daten die Tagger trainiert wurden. Der Tagger von SpaCy basiert auf Nachrichtenseiten (TIGER) und Wikipedia-Artikeln (WikiNER). Wenn Sie nun ein anderes Genre benutzen, z.B. wörtliche Reden, dann ist damit zu rechnen, dass die Fehlerquote weiter ansteigt.

Fehlerquote spaCy, Quelle: https://spacy.io

Figure 3: Fehlerquote spaCy, Quelle: https://spacy.io

Die Dokumentation auf https://spacy.io ist relativ ausführlich; die POS-Tags für die deutsche Sprache sind unter https://spacy.io/api/annotation einzusehen.

Installation

Die Installationsroutine ist auf der Webpage des Pakets spacyr (https://github.com/quanteda/spacyr oder alternativ http://spacyr.quanteda.io/index.html) gut dokumentiert. Das Verfahren ist aber nicht unbedingt ganz trivial und da einige Komponenten kompiliert werden müssen und/oder Pfade korrekt gesetzt werden müssen, kann bei der Installation immer mal etwas schief gehen. Zumindest ist das meine Erfahrung.

Daten

Die Funktionsweise des Pakets demonstriere ich anhand der Plenarprotokolle der 19.Legislaturperiode. Ich aggregieren die Daten auf Ebene der Reden, d.h. jede Rede eines Parlamentariers oder einer Parlamentarierin zählt als ein Beitrag ungeachtet der Anzahl der Unterbrechungen und Zwischenrufe.

#Pakete laden
library(tibble)
library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(magrittr)
library(stringr)

#Datensatz laden
load("../../../daten/bundestag19/lp19_xml.rda")
ls()
## [1] "lp19_xml"

#Inspektion
lp19_xml %>% 
  select(f, rowid, speaker, klasse, text) %>% 
  slice(1000:1200) %>% 
  DT::datatable(options = list(pageLength = 5))

#Sortieren nach rowid
lp19_xml <- lp19_xml %>% 
  arrange(rowid)

#Aggregieren auf Redebeiträge (und nicht Absätze)
#damit werden auch Kommentare aus dem Datensatz herausgenommen
lp19_agg <- lp19_xml %>% 
  #Kommentare löschen
  filter(klasse!="kommentar") %>% 
  #Sprecher*innenwechsel markieren
  mutate(speaker_change = case_when(
    speaker != lag(speaker) ~ 1, 
    TRUE ~ 0
  )) %>% 
  #kumulierte Summe, um Gruppen zu bilden
  mutate(cum_speaker_change = cumsum(speaker_change)) %>% 
  #Gruppieren nach Dokument und kumulierte Summe
  group_by(f, cum_speaker_change) %>% 
  #Aggregieren und relevante Variablen übernehmen
  summarise(rowid = first(rowid),
            doc_name = first(doc_name), 
            speaker = first(speaker), 
            top = first(top), 
            rolle = first(rolle), 
            fraktion = first(fraktion), 
            text = str_c(text, collapse=" ")) %>% 
  #Gruppierung aufheben
  ungroup() %>% 
  #Sortierung
  arrange(rowid)

#Neue Variable generieren, die nur die ersten 100 Zeichen beinhaltet
#dient der besseren Übersicht, ansonsten sind die Ausgaben immer so groß
lp19_agg <- lp19_agg %>% 
  mutate(text_short = str_extract(text, "^.{1,150}"), 
         text_short = case_when(
           str_count(text_short, ".") == 150 ~ str_c(text_short, " [...]"), 
           TRUE ~ text_short
           ))

#Inspektion
lp19_agg %>% 
  select(doc_name, rowid, speaker, text_short) %>% 
  slice(1:30) %>% 
  DT::datatable(options = list(pageLength = 5))

#Datensatz speichern
save(lp19_agg, file="../../../daten/bundestag19/lp19_agg.rda")

POS Tagging mit spacyr

Wenn spacyr erfolgreich installiert ist, dann ist die Anwendung vergleichsweise einfach. Hier demonstriere ich das einmal an einem kurzen Beispiel als auch an den Plenarprotokollen.

#Pakete laden
library(quanteda)
## Package version: 1.4.3
## Parallel computing: 2 of 4 threads used.
## See https://quanteda.io for tutorials and examples.
## 
## Attaching package: 'quanteda'
## The following object is masked from 'package:utils':
## 
##     View
library(spacyr)

#spacy initialisierung und deutsches Sprachmodell wählen
spacy_initialize(python_executable = "/anaconda/bin/python", 
                 model = "de")
## Found 'spacy_condaenv'. spacyr will use this environment
## successfully initialized (spaCy Version: 2.0.12, language model: de)
## (python options: type = "condaenv", value = "spacy_condaenv")

#--------------
#Test
#--------------

test <- "Berlin ist eine große Stadt, in der auch Herr Müller wohnt."
test_parsed <- spacy_parse(test, 
                           pos = TRUE, 
                           tag = TRUE, 
                           lemma = TRUE, 
                           entity = TRUE, 
                           dependency = TRUE, 
                           multithread = TRUE, 
                           additional_attributes = c("is_stop"))
## Warning in spacy_parse.character(test, pos = TRUE, tag = TRUE, lemma =
## TRUE, : lemmatization may not work properly in model 'de'

test_parsed %>% 
  DT::datatable(options = list(pageLength = 20))

#Extraktion von Nouns über quanteda::tokens_select Funktion
test_parsed %>% 
  as.tokens(include_pos = "pos") %>%
    tokens_select(pattern = c("*/NOUN"))
## tokens from 1 document.
## text1 :
## [1] "Stadt/NOUN" "Herr/NOUN"


#--------------
#reale Daten
#--------------

#in quanteda corpus überführen
lp19_corpus <- corpus(lp19_agg %>% select(text))
docnames(lp19_corpus) <- str_c(lp19_agg %>% pull(doc_name), "_", lp19_agg %>% pull(rowid))
docvars(lp19_corpus) <- lp19_agg %>% 
  select(doc_name, rowid, speaker, top, rolle, fraktion)
lp19_corpus
## Corpus consisting of 49,821 documents and 6 docvars.
summary(lp19_corpus, 5)
## Corpus consisting of 49821 documents, showing 5 documents:
## 
##    Text Types Tokens Sentences doc_name rowid
##    S1_2  1022   2652       133       S1     2
##  S1_108   304    644        40       S1   108
##  S1_143    12     14         1       S1   143
##  S1_146   324    629        40       S1   146
##  S1_177    13     14         1       S1   177
##                                 speaker                  top
##  Alterspräsident Dr. Hermann Otto Solms Tagesordnungspunkt 1
##                       Carsten Schneider Tagesordnungspunkt 2
##  Alterspräsident Dr. Hermann Otto Solms Tagesordnungspunkt 2
##                           Bernd Baumann Tagesordnungspunkt 2
##  Alterspräsident Dr. Hermann Otto Solms Tagesordnungspunkt 2
##                           rolle fraktion
##  AlterspräsidentAlterspräsident     <NA>
##                            <NA>      SPD
##                            <NA>     <NA>
##                            <NA>      AfD
##                            <NA>     <NA>
## 
## Source: /Users/vgold/Documents/teaching/goettingen/-2019.1_mmzs12/webpage/content/intro/* on x86_64 by vgold
## Created: Fri Jul 12 16:19:46 2019
## Notes:

#Nur für wenige Daten (hier: erste Sitzung)
lp19_s1 <- corpus_subset(lp19_corpus, doc_name=="S1")
lp19_s1
## Corpus consisting of 56 documents and 6 docvars.
summary(lp19_s1, 5)
## Corpus consisting of 56 documents, showing 5 documents:
## 
##    Text Types Tokens Sentences doc_name rowid
##    S1_2  1022   2652       133       S1     2
##  S1_108   304    644        40       S1   108
##  S1_143    12     14         1       S1   143
##  S1_146   324    629        40       S1   146
##  S1_177    13     14         1       S1   177
##                                 speaker                  top
##  Alterspräsident Dr. Hermann Otto Solms Tagesordnungspunkt 1
##                       Carsten Schneider Tagesordnungspunkt 2
##  Alterspräsident Dr. Hermann Otto Solms Tagesordnungspunkt 2
##                           Bernd Baumann Tagesordnungspunkt 2
##  Alterspräsident Dr. Hermann Otto Solms Tagesordnungspunkt 2
##                           rolle fraktion
##  AlterspräsidentAlterspräsident     <NA>
##                            <NA>      SPD
##                            <NA>     <NA>
##                            <NA>      AfD
##                            <NA>     <NA>
## 
## Source: /Users/vgold/Documents/teaching/goettingen/-2019.1_mmzs12/webpage/content/intro/* on x86_64 by vgold
## Created: Fri Jul 12 16:19:46 2019
## Notes:

#Anwendung spaCy Tagging
lp19_s1_tagged <- spacy_parse(lp19_s1, 
                              pos = TRUE, 
                              tag = TRUE, 
                              lemma = TRUE, 
                              entity = TRUE, 
                              dependency = TRUE, 
                              multithread = TRUE)
## Warning in spacy_parse.character(texts(x), ...): lemmatization may not work
## properly in model 'de'


#Inspektion
lp19_s1_tagged %>% 
  slice(1:500) %>% 
  DT::datatable(options = list(pageLength = 20))

Das Problem ist: Jedes Token ist in einer separaten Zeile zu finden, d.h. dieser Datensatz lässt sich so nicht direkt dem Ursprungsdatensatz wieder hinzufügen. Damit dies funktioniert, muss der Datensatz wieder auf Ebene der Reden aggregiert werden.

lp19_s1_tagged_agg <- lp19_s1_tagged %>% 
  #neue Variablen erstellen
  mutate(token_tag = str_c(token, "_", tag), 
         lemma_tag = str_c(lemma, "_", tag), 
         nouns = case_when(
           pos == "NOUN" | pos == "PROPN" ~ lemma,
           TRUE ~ ""
         )) %>% 
  #Gruppierung
  group_by(doc_id) %>% 
  #Variablen
  summarise(token = str_c(token, collapse=" "), 
            lemma = str_c(lemma, collapse=" "), 
            pos = str_c(pos, collapse= " "), 
            tag = str_c(tag, collapse= " "), 
            token_tag = str_c(token_tag, collapse=" "), 
            lemma_tag = str_c(lemma_tag, collapse=" "), 
            nouns = str_c(nouns, collapse=" "))

#Inspektion
lp19_s1_tagged_agg %>% 
  slice(1:50) %>% 
  select(token_tag, lemma_tag, nouns) %>% 
  DT::datatable(options = list(pageLength = 2))

Auf Basis der Variable doc_id können die Datensätze (bzw. genauer: der corpus und der Datensatz) wieder zusammengefügt werden, so dass ein Datensatz entsteht, der die Tags beinhaltet.

#Datensätze joinen
temp <- lp19_s1$documents %>% 
  mutate(doc_id = str_c(doc_name, "_", rowid))
lp19_s1_combined <- left_join(temp, lp19_s1_tagged_agg, by="doc_id")

lp19_s1_combined %>% 
  slice(1:50) %>% 
  select(doc_id, texts, nouns) %>% 
  DT::datatable(options = list(pageLength = 2))