Textantworten (offene Nennungen) codieren mit R: stringr und regex

Oft wird ein großer Teil der Projektzeit nicht für die spannenden Modelle, sondern für die meist etwas weniger spannend empfundene Datenaufbereitung verwendet. Ein typischer Stolperstein dabei ist die Codierung von Textantworten (offene Nennungen). Wie können wir uns diese Arbeit mit R erleichtern?

Anhand eines einfachen Beispiels („Warum treiben Sie Sport?“) beginnen wir mit einer Zuordnung der Antworten zu festen Kategorien mittels stringr::str_detect. Im Folgenden verbessern wir die erste Lösung schrittweise, indem wir Unterschiede in der Groß- und Kleinschreibung berücksichtigen, den Code mit einer eigenen Funktion straffen und schließlich einen kleinen Blick auf die mächtigen regulären Ausdrücke werfen. Den R-Code finden Sie auch hier auf github.

Datenbeispiel: Warum treiben Sie Sport?

Nehmen wir an, es wurde gefragt, warum jemand Sport treibt. Hier einige Antworten (alphabetisch sortiert, mit ID):

22Aus verschiedenen Gründen
12Ausdauer verbessern
4Aussehen
3besser aussehen
23Diverse Gründe
17fitness
9Fitness
18formaufbau
14Formaufbau
8gesund bleiben
2Ich will gut aussehen
5Ich will Muskeln aufbauen
13In Form kommen
24Mehrere Gründe
1Muskelaufbau
19Muskeltraining
6muskulär verbessern
7Prävention
10Reha
15Reha-Training
16Rehabilitation
20Rückenprävention
21Rückenschmerzen vorbeugen
11will ich ausdauernder werden
Warum treiben Sie Sport? Beispiel für Textantworten auf Offene Frage.

Vorgegebene Kategorien zuordnen mit stringr

Wir greifen auf das R-Paket stringr zurück, das zum tidyverse gehört – einer Sammlung von Paketen, die gut auf einander abgestimmt sind und die Datenaufbereitung, Visualisierung und Analyse wesentlich erleichtern. stringr unterstützt die Orientierung über die verfügbaren Funktionen: sie beginnen mit str_ – somit kann man in RStudio über ?str_ ein Kontextmenü erhalten, das die Funktionen auflistet.

Nehmen wir an, wir haben uns schon ein paar Gedanken gemacht (das ist bei der Textanalyse häufig unvermeidlich) und uns Kategorien überlegt, in die die Antworten zusammengefasst werden sollen:

Ausdauer
Aussehen
Gesundheit / Prävention allg.
Muskeln aufbauen
Fitness / Form allg.
Rehabilitation
Rückenschmerzen vorbeugen / lindern
Sonstiges
Vorgegebene Kategorien, denen die Offenen Antworten zugeordnet werden sollen

Es liegt nahe, nach Stichworten zu suchen und die entsprechende Zuordnung damit vorzunehmen. Hier ein Code-Beispiel:

library(tidyverse)

daten <- daten %>%
  mutate(warum_sport_c = case_when(
    str_detect(warum_sport, "Ausdauer") ~ "Ausdauer",
    str_detect(warum_sport, "Aussehen") ~ "Aussehen",
    str_detect(warum_sport, "Fitness") ~ "Fitness / Form allg.",
    str_detect(warum_sport, "Form") ~ "Fitness / Form allg.",
    str_detect(warum_sport, "gesund") ~ "Gesundheit / Prävention allg.",
    str_detect(warum_sport, "Prävention") ~ "Gesundheit / Prävention allg.",
    str_detect(warum_sport, "Muskel") ~ "Muskeln aufbauen",
    str_detect(warum_sport, "Reha") ~ "Rehabilitation",
    str_detect(warum_sport, "Rücken") ~ "Rückenschmerzen vorbeugen / lindern",
    TRUE ~ "Sonstiges")
)

Wer das sonderbar anmutende Zeichen %>% noch nicht kennt, kann hier mehr darüber erfahren.

Mit str_detect() können wir nach Zeichenketten suchen. Mit der dplyr-Funktion mutate() wird eine neue Variable erstellt: warum_sport_c. Das „c“ am Ende soll für „categories“ – die kategorisierte Version der offenen Antworten – stehen. case_when stammt ebenfalls aus dplyr und ermöglicht uns eine Zuordnung in Abhängigkeit der gefundenen Stichworte.

Was haben wir damit erreicht?

idwarum_sportwarum_sport_c
12Ausdauer verbessernAusdauer
4AussehenAussehen
9FitnessFitness / Form allg.
14FormaufbauFitness / Form allg.
13In Form kommenFitness / Form allg.
8gesund bleibenGesundheit / Prävention allg.
7PräventionGesundheit / Prävention allg.
5Ich will Muskeln aufbauenMuskeln aufbauen
1MuskelaufbauMuskeln aufbauen
19MuskeltrainingMuskeln aufbauen
10RehaRehabilitation
15Reha-TrainingRehabilitation
16RehabilitationRehabilitation
20RückenpräventionRückenschmerzen vorbeugen / lindern
21Rückenschmerzen vorbeugenRückenschmerzen vorbeugen / lindern
22Aus verschiedenen GründenSonstiges
3besser aussehenSonstiges
23Diverse GründeSonstiges
17fitnessSonstiges
18formaufbauSonstiges
2Ich will gut aussehenSonstiges
24Mehrere GründeSonstiges
6muskulär verbessernSonstiges
11will ich ausdauernder werdenSonstiges
Zuordnung der Offenen Antworten zu vorgegebenen Kategorien – erster Versuch

Ein guter Anfang, aber nicht perfekt:

  • Groß- und Kleinschrift wird nicht beachtet. Während „Aussehen“ und „Fitness“ korrekt zugeordnet werden, gelingt das bei „aussehen“ und „fitness“ nicht.
  • „muskulär“ statt „Muskel“ wird nicht zugeordnet
  • Für „gesund“ und „Prävention“, die in der gleichen Kategorie landen sollen, benötigen wir zwei separate Codezeilen, ebenso für „Fitness“ und „Form“.

Erste Verbesserung: Groß- und Kleinschrift

Bisher haben wir der str_detect-Funktion als zweites Argument das jeweilige Stichwort übergeben. Dieses wird als regulärer Ausdruck interpretiert – dazu gleich mehr. Hier spielte es keine Rolle, da wir nur „normalen“ Text verwendeten. Wir können stringr sagen, dass es unseren Text „wörtlich“ nehmen soll, also nicht auf Sonderzeichen der regulären Ausdrücke untersuchen. Dazu stehen uns die Funktionen fixed (schnell und ungenau) und coll (berücksichtigt Gebietsschema, daher genauer und langsamer) zur Verfügung. Wir nehmen coll und nutzen die Möglichkeit, zusätzliche Argumente anzugeben, hier vor allem: ignore_case = TRUE. Das bedeutet: Ignoriere Unterschiede in der Groß- und Kleinschreibung.

Code-Beispiel:

daten <- daten %>%
  mutate(warum_sport_c = case_when(
    str_detect(warum_sport, "Ausdauer") ~ "Ausdauer",
    str_detect(warum_sport, coll("Aussehen", ignore_case = TRUE, locale = "de_DE")) ~ "Aussehen",
    str_detect(warum_sport, "Fitness") ~ "Fitness / Form allg.",
    str_detect(warum_sport, "Form") ~ "Fitness / Form allg.",
    str_detect(warum_sport, "gesund") ~ "Gesundheit / Prävention allg.",
    str_detect(warum_sport, "Prävention") ~ "Gesundheit / Prävention allg.",
    str_detect(warum_sport, "Muskel") ~ "Muskeln aufbauen",
    str_detect(warum_sport, "Reha") ~ "Rehabilitation",
    str_detect(warum_sport, "Rücken") ~ "Rückenschmerzen vorbeugen / lindern",
    TRUE ~ "Sonstiges"
)
)

Eine alternative Lösung besteht darin, die Antworten vor der Zuordnung zu Kategorien durchgängig in Kleinschrift (weniger üblich: Großschrift) umzuwandeln. Das geht mittels der Base R-Funktionen tolower() bzw. toupper() für Großschrift.

Ich habe die Änderung nur beim Stichwort „Aussehen“ implementiert. Um sie durchgängig zu verwenden, müsste ich sie in weiteren, ggf. allen Zeilen einbauen. Das ist mir zu umständlich.

Zweite Verbesserung: Eigene Funktion

Eleganter ist es, eine eigene Funktion zu schreiben. Damit vermeiden wir Code-Duplikation, was nicht nur angenehmer zu schreiben und kürzer ist, sondern den Code auch leichter pflegbar macht: Eine künftige Anpassung muss nur an einer Stelle vorgenommen werden anstatt in vielen einzelnen Zeilen.

Der Code sieht jetzt so aus:

str_detect_sport <- function(Muster) {
   str_detect(daten$warum_sport, coll(Muster, ignore_case = TRUE, locale = "de_DE"))
}

daten$warum_sport_c <- NA

daten <- daten %>%
  mutate(warum_sport_c = case_when(
    str_detect_sport("Ausdauer") ~ "Ausdauer",
    str_detect_sport("Aussehen") ~ "Aussehen",
    str_detect_sport("Fitness") ~ "Fitness / Form allg.",
    str_detect_sport("Form") ~ "Fitness / Form allg.",
    str_detect_sport("gesund") ~ "Gesundheit / Prävention allg.",
    str_detect_sport("Prävention") ~ "Gesundheit / Prävention allg.",
    str_detect_sport("Muskel") ~ "Muskeln aufbauen",
    str_detect_sport("Reha") ~ "Rehabilitation",
    str_detect_sport("Rücken") ~ "Rückenschmerzen vorbeugen / lindern",
TRUE ~ "Sonstiges"
  )
)

Der Code für die Zuordnung ist jetzt kürzer. Nur oben in unserer neuen Funktion str_detect_sport steht ein Mal das Argument ignore_case = TRUE. Nun werden alle Stichworte erkannt, unabhängig von Unterschieden in der Groß- und Kleinschreibung:

12Ausdauer verbessernAusdauer
11will ich ausdauernder werdenAusdauer
4AussehenAussehen
3besser aussehenAussehen
2Ich will gut aussehenAussehen
17fitnessFitness / Form allg.
9FitnessFitness / Form allg.
18formaufbauFitness / Form allg.
14FormaufbauFitness / Form allg.
13In Form kommenFitness / Form allg.
8gesund bleibenGesundheit / Prävention allg.
7PräventionGesundheit / Prävention allg.
20RückenpräventionGesundheit / Prävention allg.
5Ich will Muskeln aufbauenMuskeln aufbauen
1MuskelaufbauMuskeln aufbauen
19MuskeltrainingMuskeln aufbauen
10RehaRehabilitation
15Reha-TrainingRehabilitation
16RehabilitationRehabilitation
21Rückenschmerzen vorbeugenRückenschmerzen vorbeugen / lindern
22Aus verschiedenen GründenSonstiges
23Diverse GründeSonstiges
24Mehrere GründeSonstiges
6muskulär verbessernSonstiges
Zuordnung der Offenen Antworten zu vorgegebenen Kategorien mit benutzerdefinierter Funktion. Weniger „Sonstige“

Dritte Verbesserung: Reguläre Ausdrücke

Reguläre Ausdrücke (engl. regular expressions) sind ein mächtiges, flexibles Werkzeug, um Muster in Texten zu finden. Darin sind Sonderzeichen für bestimmte Kriterien definiert, z. B. Kennzeichen für den Textanfang, das Textende, Verallgemeinerungen für „Zahlen“ oder „Buchstaben“, Möglichkeiten, optionale Zeichen anzugeben („0 mal oder öfter“, „1 mal oder öfter“, „3 bis 5 Wiederholungen“ etc.). Eine gute Einführung findet Ihr hier sowie in einer Vignette des stringr-Pakets, erreichbar über help(package = „stringr“) bzw. vignette(„regular-expressions“, package = „stringr“).

Wir wollen es nicht zu kompliziert treiben – wir nutzen die regulären Ausdrücke nur, um zwei Herausforderungen von oben zu lösen:

  • Für die Alternativen „gesund“ / „Prävention“ sowie „Fitness“ / „Form“ haben wir jeweils zwei Codezeilen gebraucht – das geht eleganter
  • Wir haben „Muskel“ zugeordnet, aber bisher nicht „muskulär“

Neuer Code:

str_detect_regex <- function(Muster) {
  str_detect(daten$warum_sport, regex(Muster, ignore_case = TRUE))
}

daten$warum_sport_c <- NA

daten <- daten %>%
  mutate(warum_sport_c = case_when(
    str_detect_regex("Ausdauer") ~ "Ausdauer",
    str_detect_regex("Aussehen") ~ "Aussehen",
    str_detect_regex("(Fitness|Form)") ~ "Fitness / Form allg.",
    str_detect_regex("Rücken") ~ "Rückenschmerzen vorbeugen / lindern",
    str_detect_regex("(gesund|Prävention)") ~ "Gesundheit / Prävention allg.",
    str_detect_regex("Musk(e|u)l") ~ "Muskeln aufbauen",
    str_detect_regex("Reha") ~ "Rehabilitation",
    TRUE ~ "Sonstiges"
)
)

Die entscheidenden Änderungen sind fett hervorgehoben. Statt coll() verwenden wir regex(). Wir nutzen hier nur eine der vielen Möglichkeiten der regulären Ausdrücke: Alternativen anzugeben. Innerhalb runder Klammern kann man mit dem Oder-Zeichen „|“ Alternativen spezifizieren. So benötigen wir weniger Codezeilen (Fitness / Form, gesund / Prävention) und können bequem sowohl „Muskel“ als auch „muskulär“ finden.

Ausblick: Texte zuordnen auf Basis von Ähnlichkeiten

Beim nächsten Mal sehen wir uns eine Möglichkeit an, Textantworten automatisch zu codieren, wenn sie viele abweichende Schreibweisen enthalten. Das könnte man umständlich mit regulären Ausdrücken lösen. Einfacher ist es, Texte auf Basis von Ähnlichkeiten zuzuordnen, nach der Idee der Korrelation: Ordne die Textantwort der Kategorie zu, mit der die Schreibweise am stärksten korreliert. Klingt spannend?

Den R-Code zu diesem Thema finden Sie hier zum Download.

Wenn Sie tiefer in das Text Mining mit R einsteigen wollen, empfehle ich das Werk von Julia Silge und David Robinson (der u. a. auch gganimate initiiert hat, das von Thomas Pederson weitergeführt wurde):

Und nun viel Erfolg bei Ihrer Datenaufbereitung!

4 Gedanken zu „Textantworten (offene Nennungen) codieren mit R: stringr und regex“

Freue mich über Kommentare!