Capture errors, warnings and messages, but keep your list operations going

In a recent post about text mining, I discussed some solutions to webscraping the contents of our STATWORX blog using the purrr-package. However, while preparing the next the episode of my series on text mining, I remembered a little gimmick that I found quite helpful along the way. Thus, a little detour: How do I capture side-effects and errors when I perform operations on lists with purrr rather than using a loop?

meme blog loop

First of all, a quick motivating example: Imagine we will build a parser for the blog-section of our STATWORX website. However, we have no idea how many entries were posted in the meantime (the hive of data scientists in our office is pretty quick with writing these things, usually). Thus, we need such a function to be more robust in the sense that it can endure the cruelties of „404 – Not found“ error messages and still continues parsing after running into an error.

How could this possibly() work?

So let’s use some beautiful purrr adverbs to fearlessly record all our outputs, errors and warnings, rather than stopping and asking the user to handle side-effects the exact moment errors turn up. These adverbs are reminiscent of try(), however, these are a little more convenient for operations on lists.

Let’s consider a more complex motivating example first, but no worries – there are more obvious examples to help explain the nitty-gritties further down this page. The R-code below illustrates our use of possibly() for later use with puurr::map(). First, let us have a look at what we tried to achieve with our function. More specifically, what happens between the curly braces below: Our robust_parse() function will simply parse HTML-webpages for other links using URLs that we provide it with. In this case, we simply use paste0() to create a vector of links to our blog overview pages, extract the weblinks from these each of these pages using XML::xpathSApply(), pipe these weblinks into a data_frame and clean our results from duplicates using dplyr::filter() – there are various overview pages that group our blogs by category – and dplyr::distinct().

robust_parse <- possibly(function(value){
  htmlParse(paste0("http://www.statworx.com/de/blog/page/",
                   value, "/")) %>%
    xpathSApply(., "//a/@href") %>%
    data_frame(.) %>%
    filter(., grepl("/blog", .)) %>%
    filter(., !grepl("/blog/$|/blog/page/|/data-science/|/statistik/", .)) %>%
    distinct()
  }, otherwise = NULL)

Second, let us inspect how we employ possibly() in this context. possibly() expects a function to be modified from us, as well as the argument otherwise, stating what it is supposed to do when things go south. In this case, we want NULL as an output value. Another popular choice would be NA, signaling that somewhere, we have not produced a string as intended. However, in our example we are happy with NULL, since we only want to parse the pages that exist and do not require a specific listing of pages that do not exist (or what happened when we did not find a page).

webpages %
			unlist

webpages
.1 
"https://statworx-1727.demosrv.review/de/blog/strsplit-but-keeping-the-delimiter/"
.2 
"https://statworx-1727.demosrv.review/de/blog/data-science-in-python-vorstellung-von-nuetzlichen-datenstrukturen-teil-1/"
.3 
"https://statworx-1727.demosrv.review/de/blog/burglr-stealing-code-from-the-web/"
.4 
"https://statworx-1727.demosrv.review/de/blog/regularized-greedy-forest-the-scottish-play-act-i/"
...

Third, we use our new function robust_parse() to operate on a vector or list of integers from 0 to 100 (possible numbers of subpages we want to parse) and have a quick look at the beautiful links we extracted. Just as a reminder, below you find the code to extract and clean the contents of the individual pages, using another map_df()-based loop – which is the focus of another post.

tidy_statworx_blogs <- map_df(webpages, ~read_html(.) %>% 
                              	htmlParse(., asText = TRUE) %>%
                                xpathSApply(., "//p", xmlValue) %>%
                                paste(., collapse = "n") %>%
                                gsub("n", "", .) %>%
                                data_frame(text = .) %>%
                                unnest_tokens(word, text) %>%
                                anti_join(data_frame(word = stopwords("de"))) %>% 
                                anti_join(data_frame(word = stopwords("en"))) %>% 
                                mutate(author = .$word[2]))

However, we actually want to go back to our purrr-helpers and see what they can do for us. To be more specific, rather than helpers, these are actually called adverbs since we use them to modify the behavior of a function (i.e. a verb). Our current robust_parse() function does not produce entries when the loop does not successfully find a webpage to parse for links. Consider the situation where you intend to keep track of unsuccessfull operations and of errors that arise along the way. Instead of further exploring purrr adverbs using the above code, let us look at a much easier example to realise the possible contexts in which using purrr adverbs might help you out.

A much easier example: Try dividing a character string by 2

Suppose there is an element in our list where our amazing division powers are useless: We are going to try to divide all the elements in our list by 2 – but this time, we want purrr to note where the function i_divide_things resists dividing particular elements for us. Again, the otherwise argument helps us defining our output in situations that are beyond the scope of our function.

i_divide_things <- possibly(function(value){
				value /2},
                  		otherwise = "I won't divide this for you.")

# Let's try our new function

> purrr::map(list(1, 2, "a", 6), ~ i_divide_things(.))
[[1]]
[1] 0.5

[[2]]
[1] 1

[[3]]
[1] "I won't divide this for you."

[[4]]
[1] 3

However, consider the case where „something did not work out“ might not suffice and you want to keep track of possible errors as well as warnings while still retaining the entire output. A job for safely(): As illustrated below, wrapping our function by safely(), helps us output a nested list. For each element of the input, the output provides two components – $result and $error. For all iterations where a list element is numeric, $result includes a numeric output and an empty (= NULL) error-element. Only for the third list element – where our function stumbled over a character input – we captured an error message, as well as the result we defined using otherwise.

i_divide_things <- safely(function(value){
                      value /2},
                      otherwise = "This did not quite work out.")

purrr::map(list(1, 2, "a", 6), ~ i_divide_things(.))
[[1]]
[[1]]$result
[1] 0.5

[[1]]$error
NULL


[[2]]
[[2]]$result
[1] 1

[[2]]$error
NULL


[[3]]
[[3]]$result
[1] "This did not quite work out."

[[3]]$error
<simpleError in value/2: non-numeric argument to binary operator>


[[4]]
[[4]]$result
[1] 3

[[4]]$error
NULL

In the example above, we have only been revealing our errors once we have looped over all elements of our list, by inspecting the output list. However, safely() also has the quiet argument – by default set to TRUE. If we set this to FALSE, we receive our errors the very moment they occur.

Now, we want to have a quick look at quietly(). We will define a warning, a message and print an output. This is to illustrate where purrr saves the individual components that our function returns. For each element of our input the returned list provides four components:

  • $result again returns the result of our operation
  • $output returns the output that would be printed in the console.
  • $warnings and $message return the strings that we defined.
i_divide_things <- purrr::quietly(function(value){
  if(is.numeric(value) == TRUE) {
          print(value / 2)
  } else{ 
          warning("Can't be done. Printing this instead.")
          message("Why would you even try dividing this?")
          print(value)
  }
  })

purrr::map(list(1, "a", 6), ~i_divide_things(.))
[[1]]
[[1]]$result
[1] 0.5

[[1]]$output
[1] "[1] 0.5"

[[1]]$warnings
character(0)

[[1]]$messages
character(0)


[[2]]
[[2]]$result
[1] "a"

[[2]]$output
[1] "[1] "a""

[[2]]$warnings
[1] "Can't be done. Printing this instead."

[[2]]$messages
[1] "Why would you even try dividing this?n"


[[3]]
[[3]]$result
[1] 3

[[3]]$output
[1] "[1] 3"

[[3]]$warnings
character(0)

[[3]]$messages
character(0)

Last, there is auto_browse(), which allows us to trigger the RStudio browser for debugging and brings the user to the approximate location of the error. This case is illustrated in the screenshot below.

i_divide_things <- purrr::auto_browse(function(value){

    print(value / 2)
})

purrr::map(list(1, "a", 6), ~i_divide_things(.)) 

output of auto_browse

Splendid – this was a quick wrap-up of how to wrap your functions for handling side-effects in your operations on lists using adverbs of purrr. Happy wrapping everyone!

In unserem ersten Blog-Beitrag zum Textmining im tidyverse haben wir uns mit den ersten Schritten zum Einlesen und Bereinigen von Texten mit den Mitteln des tidyverse befasst und bereits erste Sentimentanalysen begonnen. Die Grundlage hierzu bildete das epistemologische Werk The Grammar of Science von Karl Pearson. Im zweiten Teil wollen wir auf diesen Grundlagen aufbauen und damit ein weiteres von Pearsons vielfältigen Interessensgebieten anschneiden: Die deutsche Sprache. Pearson, der nach einem Studienaufenthalt in Heidelberg Karl anstelle Carl genannt werden wollte, verlieh seinem Interesse an Goethe auch in seinem Buch The New Werther Ausdruck.

Um den deutschsprachigen Korpora des Internets gerecht zu werden, wollen wir an dieser Stelle Lexika vorstellen, welche sich für die Bedeutungsanalyse von Texten eignen. Hierzu eignet sich der Sentimentwortschatz SentiWS der Universität Leipzig. In diesem Worschatz finden sich Ratings auf einer Skala von -1 (negatives Sentiment) bis 1 (positives Sentiment). Die aktuellste Version kann als .zip-File hier heruntergeladen werden.

Wie im ersten Blog der Serie beschrieben, ist der Weg zu ersten Analysen relativ kurz: Nach etwas Datenbereinigung und Zerlegung unseres Character-Strings in einzelne Tokens verbinden wir unsere Textdaten (in diesem Fall ein nach Autoren gruppierter Korpus unseres STATWORX-Blogs) mit dem Lexikon unserer Wahl, wodurch wir einen Datensatz von nach Sentiment bewerteten Wörtern erhalten, welche sowohl in unserem Datensatz als auch im Lexikon enthalten sind.

Deutschsprachige Blogs scrapen

Bevor wir beginnen, müssen wir uns allerdings zuerst der Erstellung eines Textdatensatzes widmen. Da wir als Beispiel einen vornehmlich deutschen, aber überschaubaren Korpus wählen möchten und uns das Befinden der STATWORX-Blogger verständlicher Weise sehr am Herzen liegt, möchten wir den STATWORX-Blog als Grundlage nutzen.

Nach dem Laden der relevanten Pakete (auch in diesem Eintrag möchte ich wieder Pakete aus dem tidyverse empfehlen), konstruieren wir mit Hilfe des purrr-Paketes zwei aufgeräumte, kompakte Code-Blöcke zum Sammeln der entsprechenden Blog-Links und zum Auslesen und Präparieren selbiger.

# load packages
library(XML)
library(xml2)
library(tidyverse)
library(tidytext)
library(tokenizers)

Im folgenden Code-Block durchsuchen wir die fünf bisher existierenden Blog-Übersichten auf der STATWORX-Homepage nach Links zu den einzelnen Blogs. Dafür nutzen wir hmtlParse und xpathSApply aus dem XML-Paket um die Übersichtsseiten einzulesen und nach Links zu durchforsten. Mit Hilfe von filter und distinct aus dem dplyr-Paket trennen wir daraufhin Übersichten von den eigentlichen Artikeln und filtern Duplikate aus den Links heraus.

# Extraction of first five pages of Statworx-Blogs
# Extraction of all links that contain "blog", but filter the overview pages
# get unique blog posts

webpages %
  xpathSApply(., "//a/@href") %>%
  data_frame(.) %>%
  filter(., grepl("/blog", .)) %>%
  filter(., !grepl("/blog/$|/blog/page/|/data-science/|/statistik/", .)) %>%
  distinct()) %>% unlist

Nun sind wir bereit, mit den entsprechenden Links, den bereits angesprochenen xpathSApply und htmlParse, sowie read_html aus dem xml2-Paket die eigentlichen Blogeinträge auszulesen. Mit Hilfe von paste und gsub bereinigen wir die Absätze im Text. Anschließend nutzen wir unnest_tokens aus dem tidytext-Paket, um einzelne Worte aus den Blogeinträgen zu isolieren. Weiterhin nutzen wir dplyr und das tokenizers-Paket, um mit anti_join und stopwords(„de“) deutschsprachige Stopwords aus dem Text zu entfernen (für genauere Beschreibungen der Begrifflichkeiten und der Natur dieser Bereinigungen möchte ich an dieser Stelle noch einmal auf den ersten Teil unserer Serie Textmining im tidyverse verweisen). Zuletzt fügen wir noch eine Spalte zum Dataframe hinzu (da dieser Block in purrr::map_df eingewickelt ist, erhalten wir als Output unserer Pipe einen Dataframe), welcher den Nachnamen des jeweiligen STATWORX-Bloggers angibt.

# read in blog posts, output should be a dataframe
# parse HTML, extract text, clean line breaks
# unnest tokens (in this case terms) and remove stop words
# add a column with the author name

tidy_statworx_blogs %
  htmlParse(., asText = TRUE) %>%
  xpathSApply(., "//p", xmlValue) %>%
  paste(., collapse = "n") %>%
  gsub("n", "", .) %>%
  data_frame(text = .) %>%
  unnest_tokens(word, text) %>%
  anti_join(data_frame(word = stopwords("de"))) %>%
  mutate(author = .$word[2]))

Im nächsten Schritt wollen wir unseren Blog-Datensatz mit dem oben genannten Leipziger Sentimentwortschatz verbinden. Wir lesen sowohl die negativen, als auch die positiven Sentimentrating-txt-Files ein, beachten dabei t als Trennzeichen und setzen fill = TRUE. Mit bind_rows aus dem dplyr-Paket verbinden wir beide Rating-Datensätze, selektieren nur die ersten beiden Spalten und benennen diese mit word und value.

setwd("/Users/obiwan/jedi_documents")
sentis %
  dplyr::select(., 1:2)
names(sentis) <- c("word", "value")

Anschließend nutzen wir str_to_lower aus dem stringr-Paket, um die character-Daten im Ratingdatensatz komplett in Kleinbuchstaben umzuwandeln und gsub, um die Worttypbeschreibungen aus den Strings zu entfernen. Mit inner_join aus dem dplyr-Paket verbinden wir nun die Blog-Eintragsdaten mit den Sentimentratings und zwar nur für jene Worte, welche sowohl in den Blogs vorkommen, als auch im Leipziger Sentimentwortschatz geratet sind. Für weitere Analysen können wir auch noch in der gleichen Pipe eine Spalte hinzufügen, welche dichotom beschreibt, ob einem Wort ein positives, oder ein negatives Sentiment zugeordnet wird – dazu mehr beim nächsten Mal.

tidy_statworx_blogs_sentis %
  mutate(word = stringr::str_to_lower(word)) %>%
  mutate(word = gsub("Dnn", "", word)) %>%
  inner_join(., tidy_statworx_blogs, by = "word") %>%
  mutate(sent_bin = ifelse(value >= 0, "positive", "negative"))

Wir erhalten einen Datensatz mit Ratings, welcher wie folgt aussieht:

tbl_df(tidy_statworx_blogs_sentis)
# A tibble: 360 x 4
word value author sent_bin
   
1 abhängigkeit -0.3653 darrall    negative
2 abhängigkeit -0.3653 darrall    negative
3 abhängigkeit -0.3653 darrall    negative
4 abhängigkeit -0.3653 moreau     negative
5 absturz      -0.4739 krabel     negative
6 abweichung   -0.3462 aust       negative
7 abweichung   -0.3462 moreau     negative
8 abweichung   -0.3462 gepp       negative
9 angriff      -0.2120 bornschein negative
10 auflösung   -0.0048 heinz      negative
# ... with 350 more rows

Durchschnittliche Sentimentratings – Ein Stimmungsbarometer?

Da uns nun interessieren könnte, welchem Blogger aus dem Team wir besser nicht krumm kommen sollten, könnten wir nun zu unserer Sicherheit das durchschnittliche Sentimentrating pro Autor visualisieren. Wir gruppieren unsere Analyse pro Autor, aggregieren die Sentimentratings als arithemtische Mittel auf Gruppenebene und pipen den entstehenden Dataframe in eine ggplot-Funktion. Letztere erstellt für uns absteigend geordnete Säulen mit dem mittleren Sentimentrating pro Blogger, zeichnet das mittlere Sentimentrating des gesamten STATWORX-Teams ein, dreht die Koordinaten und ändert das ggplot-Theme zu theme_minimal für den optischen Feinschliff.

tidy_statworx_blogs_sentis %>%
  group_by(author) %>%
  summarise(mean_senti = mean(value, na.rm = TRUE)) %>%
  ggplot(.) +
    geom_bar(aes(x = reorder(author, mean_senti), y = mean_senti),
             stat = "identity", alpha = 0.8, colour = "Darkgrey") +
    labs(title = "Mean Sentiment Rating by Author",
         x = "Author", y = "Mean Sentiment") +
    geom_hline(yintercept = mean(sentis$value, na.rm = TRUE),
               linetype = "dashed", colour = "Grey30", alpha = 0.7) +
    coord_flip() +
    theme_minimal()

Mean Sentiment Rating by Author

Eine andere Darstellung, welche für uns interessant ist, ist die Verteilung der Sentimentratings pro Autor. An dieser Stelle wählen wir einen gruppierten Densitiyplot, obwohl durchaus viele Darstellungen hier hilfreich sein können:

tidy_statworx_blogs_sentis %>% ggplot(.) +
  geom_density(aes(value, fill = author), alpha = 0.7, bw = 0.08) +
  xlim(-1,1) +
  labs(title = "Densities of Sentiment Ratings by Author",
       x = "Sentiment Rating") + theme_minimal()

Densities of Sentiment Ratings by Author

Mit diesen wenigen Handgriffen haben wir nun auch ein paar erste Analysen zu einem deutschsprachigen Textkorpus gemacht. Meine Formulierung verrät wohl bereits: Wir stehen mit dem Textmining trotz ersten Fortschritten noch ziemlich am Anfang. Allerdings haben wir uns nun für deutlich komplexere Aufgaben ausgerüstet: Der näheren Erfassung von Inhalt und Semantik in unseren Korpora. Im nächsten Teil befassen wir uns Term-Dokument-Matrizen, Dokument-Term-Matrizen, sowie der Latent Dirichlet Allocation und verwandten Techniken.

Referenzen

  1. Duncan Temple Lang and the CRAN Team (2017). XML: Tools for Parsing and Generating XML Within R and S-Plus. R package version 3.98-1.9. https://CRAN.R-project.org/package=XML
  2. Hadley Wickham, James Hester and Jeroen Ooms (2017). xml2: Parse XML. R package version 1.1.1. https://CRAN.R-project.org/package=xml2
  3. Hadley Wickham, Romain Francois, Lionel Henry and Kirill Müller (2017). dplyr: A Grammar of Data Manipulation. R package version 0.7.4. https://CRAN.R-project.org/package=dplyr
  4. Kirill Müller and Hadley Wickham (2017). tibble: Simple Data Frames. R package version 1.3.4. https://CRAN.R-project.org/package=tibble
  5. Lincoln Mullen (2016). tokenizers: A Consistent Interface to Tokenize Natural Language Text. R package version 0.1.4. https://CRAN.R-project.org/package=tokenizers
  6. Lionel Henry and Hadley Wickham (2017). purrr: Functional Programming Tools. R package version 0.2.3. https://CRAN.R-project.org/package=purrr
  7. Pearson, Karl (1880). The New Werther. C. Kegan & Co. https://archive.org/stream/newwertherbylok00peargoog#page/n6/mode/2up
  8. Pearson, Karl (1892). The Grammar of Science. London: Walter Scott. Dover Publications.
  9. https://archive.org/stream/grammarofscience00pearrich#page/n9/mode/2up
  10. Porter, T. (2017). Karl Pearson. In Encyclopædia Britannica. Retrieved from https://www.britannica.com/biography/Karl-Pearson
  11. Silge, J., & Robinson, D. (2017). Text mining with R: a tidy approach. Sebastopol, CA: OReilly Media.

Das methodische Schaffenswerk Karl Pearsons ist durchaus bekannt – kaum ein Student einer Disziplin mit quantitativen Spielarten wird am Namen des ersten Statistik-Lehrstuhlinhabers der Welt vorbeikommen. Durchaus weniger bekannt ist jedoch Pearsons Werk The Grammar of Science und seine Anschauungen zum – ihm zufolge vornehmlich deskriptiven statt erklärenden – Wesen der wissenschaftlichen Methode. Diese vielfältigen Beiträge zur Erkenntnistheorie inspirierten nachweislich die Arbeit Albert Einsteins, ebenso wie die anderer namenhafter Wissenschaftler.

Karl Pearson 1910

Zugegeben, das 540-Seiten-starke Buch liest sich nicht unbedingt in der Mittagspause – können uns moderne Data Science Methoden dabei helfen, uns dem mehr als 200-Jahre-alten Grundsatzwerk schneller anzunähern? Das Manuskript des berühmten UCL-Professors ist frei im Netz zugänglich und stellt damit die ideale Textgrundlage für unsere ersten Textmining-Schritte im tidyverse dar. In diesem Beitrag wollen wir uns um vorbereitende Schritte im Textmining kümmern und unsere ersten Gehversuche im Bereich der Aufbereitung und Visualisierung machen.

Mit dem tidytext-Paket von Julia Silge und David Robinson gehen diese aber wirklich sehr schnell (ihre großartige Einführung in das Paket und das Thema Textmining finden Sie hier). Nach dem Laden (bzw. Installieren) der Pakete tidytext, dplyr (zur allgemeinen Datenaufbereitung) und tibble (gemeinsam enthalten im Paket tidyverse) sind wir bereit, die ersten Textaufbereitungsschritte zu gehen. Ein Download von The Grammar of Science als .txt-File lässt sich ganz angenehm mit R durchführen.

# load packages 
library(tidytext) 
library(tidyverse) 

# tell R where your .txt-File should go, dowload the file and give it a name 
setwd("/Users/obiwan/jedi_documents") 
download.file("https://archive.org/stream/grammarofscience00pearuoft/
               grammarofscience00pearuoft_djvu.txt",
              destfile = "grammar.txt")

Anschließend lesen wir mit readChar() das .txt-File aus und bereinigen im Text vorhandene Absätze (durch „n“ gekennzeichnet). Wichtig für die weitere Verarbeitung ist auch, dass wir einen tibble mit einer character-Variable erstellen, mit der wir im Folgenden weiterarbeiten.

# read the .txt contents and clean line breaks 
grammar_os_text <- readChar('grammar.txt', file.info('grammar.txt')$size) 
grammar_os_text <- gsub("n", "", paste(grammar_os_text, collapse = "n")) 

# create a tibble with a character vector 
grammar_os_df <- tibble(content = grammar_os_text)

Im Weiteren zerlegen wir mit der tidytext-Funktion unnest_tokens() den Text in Tokens – in unserem Fall die einzelnen Worte im Text, die Standardeinstellung der Funktion. Die Funktion ist allerdings deutlich vielseitiger und kann beispielsweise Paragraphen, Sätze oder Wortteile nach regulären Ausdrücken zerlegen und ausgeben. Ein Blick auf den Datensatz zeigt, dass wir die ersten Worte der Titelseite (sowie den restlichen Inhalt des Buches) als Zeilen in unserem Datensatz angelegt haben.

# zerlegen in einzelne Woerter
grammar_os_df % unnest_tokens(word, content) 
head(grammar_os_df) 

# Ausgabe
       word 
1       the 
1.1 grammar 
1.2      of 
1.3 science 
1.4   first 
1.5 edition 

Selbstverständlich sind wir mit der Bereinigung der word-Variable noch am Anfang. Nach dem Laden des tokenizers-Paketes, haben wir nun die Möglichkeit, sogenannte Stop Words („a“, „an“, „and“, „are“, etc.) aus dem Datensatz herauszufiltern. Hierbei ist uns die dplyr-Funktion anti_join() behilflich, welche hier alle Reihen aus dem ersten Argument (unser grammar_os_df) ausgibt, welche nicht im zweiten Argument (ein Datensatz aus Stop Words) vorhanden sind.

# Stop words
library(tokenizers) 

data(stop_words) 
grammar_os_df <- anti_join(grammar_os_df, tibble(word = stopwords("en"))) 

Nachdem wir die ersten Bereinigungen unserer Daten vorgenommen haben, sind wir nun bereit, Sentiment-Lexika zu verwenden – wenn man so will Wörterbücher. Praktischerweise steht bei tidytext hierfür direkt der Befehl get_sentiments() zur Verfügung. Dieser erlaubt es uns, vier unterschiedliche Sentiment-Bibliotheken im tidy-Format zu laden, bei denen pro Eintrag eine Zeile besteht.

Zur Auswahl steht erstens bing, ein Lexikon mit zwei Klassen: Positive und negative Worte. Zweitens afinn, ein Lexikon mit Ratings auf einer Skala von minus fünf (negativ) bis fünf (positiv). Die letzten beiden Möglichkeiten sind nrc und loughran, zwei Lexica welche etwas nuanciertere Klassifikationen bieten. Das Lexikon nrc bietet zehn Kategorien (joy, fear, disgust, anticipation, anger, trust, surprise, sadness, positive, negative), während loughran sechs Kategorien (litigious, constraining, superfluous, uncertainty, sowie positive und negative) bietet. Eine beispielhafte Klassifizierung aus unserem tidytext sieht wie folgt aus:

word sentiment
displeased sadness
superficial negative
university anticipation

Eine sehr simple Form der Analyse unserer Textdaten beruht dementsprechend auf einem simplen inner_join() des aufbereiteten Datensatzes und den zuvor genannten Lexika. Mit einem so gematchen Datensatz können wir aufschlussreiche erste Visualisierungen machen. Im Folgenden Code-Snippet erledigen wir folgende Schritte:

  • Aufrufen des nrc-Lexikons, sowie inner_join() von nrc-Lexikon und dem grammar_os_df in einem Schritt
  • Auszählung von Worten gruppiert nach Sentiment (auch dplyr::count() könnte hier als eine Kurzform der Kombination von group_by() und tally() verwendet werden)
  • Erstellung einer Termmatrix, d.h. Konversion des Long- zu einem Wide-Datensatz, in dem eine Spalte pro Sentiment erstellt wird und leere Zeilen mit Nullen gefüllt werden. Da das wordcloud-Paket eine Matrix mit Zeilennamen benötigt, müssen wir den tibble, welchen spread() ausgibt, noch entsprechend umwandeln.
  • Visualisierung der Termmatrix in Form einer Comparison Cloud – eine Wordcloud gruppiert nach Klassen. Als letztere Klassen verwenden wir die zuvor gebildeten Sentimentspalten, die Größe der Worte wird – wie bei Wordclouds üblich – über die Anzahl bestimmt. Als zusätzliche Pakete verwenden wir wordcloud, sowie den RColorBrewer, um eine zehnstufige Farbpalette zu bilden, bei der die benachbarten Farben deutlich genug unterscheidbar sind.
inner_join(grammar_os_df, get_sentiments("nrc")) %>% 
  group_by(., word, sentiment) %>% tally(., sort = TRUE) %>% 
  spread(., key = sentiment, value = n, fill = 0)  %>%  
  remove_rownames(.) %>%  
  column_to_rownames(., "word") %>%  
  comparison.cloud(data.matrix(.), scale=c(3.5,.75),
                   colors = brewer.pal(10, "Paired"),
                   max.words = 1000)

Wordcloud Grammar of sciende

Zum Vergleich erstellen wir den selben Plot noch einmal mit dem zuvor genannten loughran-Lexikon:

Wordcloud Grammar of sciende - loughran

Die beiden Comparison-Clouds zeigen hierbei bereits die deutlichen Unterschiede in den Lexika auf. Erstere könnte auf eine unterrepräsentierte Furchtdimension im Text hinweisen – eine Darstellung die den eingefleischten Pearson-Fans nicht widerstreben dürfte. Wie korrekt die einzelnen Klassifizierungen wirklich erscheinen und ob sich erstere Wordcloud wirklich für Einschätzungen über die Furchtlosigkeit in Pearsons Werk eignet, das wollen wir an dieser Stelle allerdings offenlassen.

Soviel zu unseren ersten Gehversuchen im Tidyverse der Textanalyse. Während sich dieser Beitrag nur mit allerersten Schritten der Vorbereitung und Exploration von Textdaten beschäftigt, wollen wir in den nächsten Beiträgen natürlich tiefer in die Materie eindringen: Dabei werden wir – ganz im Sinne des Goethe-begeisterten Pearson – einen Ausblick zur Verarbeitung von deutschen Texten geben und entsprechende deutsche Lexika vorstellen. Zusätzlich werden wir auch in fortgeschrittene Analysemethoden vordringen und versuchen, mit Hilfe statistischer Modelle Bedeutungsstrukturen zu erkunden.

Wem die Arbeit mit epistemologischer Literatur etwas zu staubig erscheint – dem sei ganz dringend der Blog meines Kollegen Lukas ans Herz gelegt: Dort geht gibt es alles Wissenswerte zum effektiven Anzapfen von Twitter als Datenoase.

Referenzen

  1. Erich Neuwirth (2014). RColorBrewer: ColorBrewer Palettes. R package version 1.1-2. https://CRAN.R-project.org/package=RColorBrewer
  2. Hadley Wickham, Romain Francois, Lionel Henry and Kirill Müller (2017). dplyr: A Grammar of Data Manipulation. R package version 0.7.4. https://CRAN.R-project.org/package=dplyr
  3. Ian Fellows (2014). wordcloud: Word Clouds. R package version 2.5. https://CRAN.R-project.org/package=wordcloud
  4. Kirill Müller and Hadley Wickham (2017). tibble: Simple Data Frames. R package version 1.3.4. https://CRAN.R-project.org/package=tibble
  5. Lincoln Mullen (2016). tokenizers: A Consistent Interface to Tokenize Natural Language Text. R package version 0.1.4. https://CRAN.R-project.org/package=tokenizers
  6. Porter, T. (2017). Karl Pearson. In Encyclopædia Britannica. Retrieved from https://www.britannica.com/biography/Karl-Pearson
  7. Silge, J., & Robinson, D. (2017). Text mining with R: a tidy approach. Sebastopol, CA: OReilly Media.

Das methodische Schaffenswerk Karl Pearsons ist durchaus bekannt – kaum ein Student einer Disziplin mit quantitativen Spielarten wird am Namen des ersten Statistik-Lehrstuhlinhabers der Welt vorbeikommen. Durchaus weniger bekannt ist jedoch Pearsons Werk The Grammar of Science und seine Anschauungen zum – ihm zufolge vornehmlich deskriptiven statt erklärenden – Wesen der wissenschaftlichen Methode. Diese vielfältigen Beiträge zur Erkenntnistheorie inspirierten nachweislich die Arbeit Albert Einsteins, ebenso wie die anderer namenhafter Wissenschaftler.

Karl Pearson 1910

Zugegeben, das 540-Seiten-starke Buch liest sich nicht unbedingt in der Mittagspause – können uns moderne Data Science Methoden dabei helfen, uns dem mehr als 200-Jahre-alten Grundsatzwerk schneller anzunähern? Das Manuskript des berühmten UCL-Professors ist frei im Netz zugänglich und stellt damit die ideale Textgrundlage für unsere ersten Textmining-Schritte im tidyverse dar. In diesem Beitrag wollen wir uns um vorbereitende Schritte im Textmining kümmern und unsere ersten Gehversuche im Bereich der Aufbereitung und Visualisierung machen.

Mit dem tidytext-Paket von Julia Silge und David Robinson gehen diese aber wirklich sehr schnell (ihre großartige Einführung in das Paket und das Thema Textmining finden Sie hier). Nach dem Laden (bzw. Installieren) der Pakete tidytext, dplyr (zur allgemeinen Datenaufbereitung) und tibble (gemeinsam enthalten im Paket tidyverse) sind wir bereit, die ersten Textaufbereitungsschritte zu gehen. Ein Download von The Grammar of Science als .txt-File lässt sich ganz angenehm mit R durchführen.

# load packages 
library(tidytext) 
library(tidyverse) 

# tell R where your .txt-File should go, dowload the file and give it a name 
setwd("/Users/obiwan/jedi_documents") 
download.file("https://archive.org/stream/grammarofscience00pearuoft/
               grammarofscience00pearuoft_djvu.txt",
              destfile = "grammar.txt")

Anschließend lesen wir mit readChar() das .txt-File aus und bereinigen im Text vorhandene Absätze (durch „n“ gekennzeichnet). Wichtig für die weitere Verarbeitung ist auch, dass wir einen tibble mit einer character-Variable erstellen, mit der wir im Folgenden weiterarbeiten.

# read the .txt contents and clean line breaks 
grammar_os_text <- readChar('grammar.txt', file.info('grammar.txt')$size) 
grammar_os_text <- gsub("n", "", paste(grammar_os_text, collapse = "n")) 

# create a tibble with a character vector 
grammar_os_df <- tibble(content = grammar_os_text)

Im Weiteren zerlegen wir mit der tidytext-Funktion unnest_tokens() den Text in Tokens – in unserem Fall die einzelnen Worte im Text, die Standardeinstellung der Funktion. Die Funktion ist allerdings deutlich vielseitiger und kann beispielsweise Paragraphen, Sätze oder Wortteile nach regulären Ausdrücken zerlegen und ausgeben. Ein Blick auf den Datensatz zeigt, dass wir die ersten Worte der Titelseite (sowie den restlichen Inhalt des Buches) als Zeilen in unserem Datensatz angelegt haben.

# zerlegen in einzelne Woerter
grammar_os_df % unnest_tokens(word, content) 
head(grammar_os_df) 

# Ausgabe
       word 
1       the 
1.1 grammar 
1.2      of 
1.3 science 
1.4   first 
1.5 edition 

Selbstverständlich sind wir mit der Bereinigung der word-Variable noch am Anfang. Nach dem Laden des tokenizers-Paketes, haben wir nun die Möglichkeit, sogenannte Stop Words („a“, „an“, „and“, „are“, etc.) aus dem Datensatz herauszufiltern. Hierbei ist uns die dplyr-Funktion anti_join() behilflich, welche hier alle Reihen aus dem ersten Argument (unser grammar_os_df) ausgibt, welche nicht im zweiten Argument (ein Datensatz aus Stop Words) vorhanden sind.

# Stop words
library(tokenizers) 

data(stop_words) 
grammar_os_df <- anti_join(grammar_os_df, tibble(word = stopwords("en"))) 

Nachdem wir die ersten Bereinigungen unserer Daten vorgenommen haben, sind wir nun bereit, Sentiment-Lexika zu verwenden – wenn man so will Wörterbücher. Praktischerweise steht bei tidytext hierfür direkt der Befehl get_sentiments() zur Verfügung. Dieser erlaubt es uns, vier unterschiedliche Sentiment-Bibliotheken im tidy-Format zu laden, bei denen pro Eintrag eine Zeile besteht.

Zur Auswahl steht erstens bing, ein Lexikon mit zwei Klassen: Positive und negative Worte. Zweitens afinn, ein Lexikon mit Ratings auf einer Skala von minus fünf (negativ) bis fünf (positiv). Die letzten beiden Möglichkeiten sind nrc und loughran, zwei Lexica welche etwas nuanciertere Klassifikationen bieten. Das Lexikon nrc bietet zehn Kategorien (joy, fear, disgust, anticipation, anger, trust, surprise, sadness, positive, negative), während loughran sechs Kategorien (litigious, constraining, superfluous, uncertainty, sowie positive und negative) bietet. Eine beispielhafte Klassifizierung aus unserem tidytext sieht wie folgt aus:

word sentiment
displeased sadness
superficial negative
university anticipation

Eine sehr simple Form der Analyse unserer Textdaten beruht dementsprechend auf einem simplen inner_join() des aufbereiteten Datensatzes und den zuvor genannten Lexika. Mit einem so gematchen Datensatz können wir aufschlussreiche erste Visualisierungen machen. Im Folgenden Code-Snippet erledigen wir folgende Schritte:

inner_join(grammar_os_df, get_sentiments("nrc")) %>% 
  group_by(., word, sentiment) %>% tally(., sort = TRUE) %>% 
  spread(., key = sentiment, value = n, fill = 0)  %>%  
  remove_rownames(.) %>%  
  column_to_rownames(., "word") %>%  
  comparison.cloud(data.matrix(.), scale=c(3.5,.75),
                   colors = brewer.pal(10, "Paired"),
                   max.words = 1000)

Wordcloud Grammar of sciende

Zum Vergleich erstellen wir den selben Plot noch einmal mit dem zuvor genannten loughran-Lexikon:

Wordcloud Grammar of sciende - loughran

Die beiden Comparison-Clouds zeigen hierbei bereits die deutlichen Unterschiede in den Lexika auf. Erstere könnte auf eine unterrepräsentierte Furchtdimension im Text hinweisen – eine Darstellung die den eingefleischten Pearson-Fans nicht widerstreben dürfte. Wie korrekt die einzelnen Klassifizierungen wirklich erscheinen und ob sich erstere Wordcloud wirklich für Einschätzungen über die Furchtlosigkeit in Pearsons Werk eignet, das wollen wir an dieser Stelle allerdings offenlassen.

Soviel zu unseren ersten Gehversuchen im Tidyverse der Textanalyse. Während sich dieser Beitrag nur mit allerersten Schritten der Vorbereitung und Exploration von Textdaten beschäftigt, wollen wir in den nächsten Beiträgen natürlich tiefer in die Materie eindringen: Dabei werden wir – ganz im Sinne des Goethe-begeisterten Pearson – einen Ausblick zur Verarbeitung von deutschen Texten geben und entsprechende deutsche Lexika vorstellen. Zusätzlich werden wir auch in fortgeschrittene Analysemethoden vordringen und versuchen, mit Hilfe statistischer Modelle Bedeutungsstrukturen zu erkunden.

Wem die Arbeit mit epistemologischer Literatur etwas zu staubig erscheint – dem sei ganz dringend der Blog meines Kollegen Lukas ans Herz gelegt: Dort geht gibt es alles Wissenswerte zum effektiven Anzapfen von Twitter als Datenoase.

Referenzen

  1. Erich Neuwirth (2014). RColorBrewer: ColorBrewer Palettes. R package version 1.1-2. https://CRAN.R-project.org/package=RColorBrewer
  2. Hadley Wickham, Romain Francois, Lionel Henry and Kirill Müller (2017). dplyr: A Grammar of Data Manipulation. R package version 0.7.4. https://CRAN.R-project.org/package=dplyr
  3. Ian Fellows (2014). wordcloud: Word Clouds. R package version 2.5. https://CRAN.R-project.org/package=wordcloud
  4. Kirill Müller and Hadley Wickham (2017). tibble: Simple Data Frames. R package version 1.3.4. https://CRAN.R-project.org/package=tibble
  5. Lincoln Mullen (2016). tokenizers: A Consistent Interface to Tokenize Natural Language Text. R package version 0.1.4. https://CRAN.R-project.org/package=tokenizers
  6. Porter, T. (2017). Karl Pearson. In Encyclopædia Britannica. Retrieved from https://www.britannica.com/biography/Karl-Pearson
  7. Silge, J., & Robinson, D. (2017). Text mining with R: a tidy approach. Sebastopol, CA: OReilly Media.