2  Zero-Shot Klassifikation

Wie immer laden wir zuerst das tidyverse-Paket. Für die Verarbeitung von Bildern benötigen wir zudem das Paket magick.

library(tidyverse)
library(magick)

2.1 LLM-APIs nutzen

Für die LLM-basierte Klassifikation von Texten und Bilder nutzen wir das tidyllm-Paket, das für eine Reihe von LLM-APIs (u.a. von OpenAI, aber auch lokale Ollama-Modelle) einheitliche Funktionen bietet und strukturierte Daten zurückliefert.

library(tidyllm)

Wir beginnen mit einem ganz einfachen Beispiel: Wir bitten ein LLM, uns einen Witz zu erzählen. Dafür benötigen wir lediglich einen Text-Prompt sowie einen API-Endpunkt, die Konstruktion der API-Anfrage übernimmt die llm_message()-Funktion.

tidyllm::llm_message("Tell me a joke about penguins.")
Message History:
system: You are a helpful assistant
--------------------------------------------------------------
user: Tell me a joke about penguins.
--------------------------------------------------------------

Wir sehen, dass bereits per Default einige Felder, z.B. der system prompt, gesetzt sind. Nun wollen wir diese Anfrage an eine LLM-API senden, wofür in tidyllm je eine Funktion pro Anbieter existiert. Für kommerzielle Anbieter ist zumeist ein API Key erforderlich, den wir zuvor einmalig als Umgebungsvariable abspeichern müssen. Hier ein Beispiel für die OpenAI API.

Sys.setenv(OPENAI_API_KEY = "XXX") # eigenen KEY einsetzen

Wenn dies erfolgt ist, können wir die Message an die OpenAI-API übergeben und erhalten ein Antwortobjekt. Beim Aufruf der chatgpt()-Funktion können weitere Parameter eingestellt werden. Wichtig für später sind das Modell (z.B. gpt-4o oder gpt-4o-mini) und die sog. temperature, die wir auf 0 setzen, um deterministische Antworten zu erhalten.

tidyllm::llm_message("Tell me a joke about penguins.") |>
  tidyllm::chatgpt(.model = "gpt-4o", .temperature = 0)
Message History:
system: You are a helpful assistant
--------------------------------------------------------------
user: Tell me a joke about penguins.
--------------------------------------------------------------
assistant: Sure! Why don't you ever see penguins in the UK? 

Because they're afraid of Wales!
--------------------------------------------------------------

Der Output ist recht ausführlich, wir können mit der Funktion last_reply() nur den eigentlich Antworttext extrahieren, wobei mit \n Zeilenumbrüche gekennzeichnet sind.:

tidyllm::llm_message("Tell me a joke about cats.") |>
  tidyllm::chatgpt(.model = "gpt-4o", .temperature = 0) |>
  tidyllm::last_reply()
[1] "Why was the cat sitting on the computer?\n\nBecause it wanted to keep an eye on the mouse!"

Aus Gründen der Reproduzierbarkeit und Kostenkontrolle verwenden wir im Folgenden ein LLM, dass auf unserem eigenen Server gehostet wird (Achtung: Zugriff nur innerhalb des JGU-Netzes). Dafür wird Ollama verwendet, das verschiedene Modelle zur Verfügung stellen kann. Wir wählen das bekannte Gemma2-Modell von Google.

tidyllm::llm_message("Tell me a joke about cats.") |>
  tidyllm::ollama(
    .ollama_server = "https://llm.ifp.uni-mainz.de",
    .model = "gemma2",
    .temperature = 0
  ) |>
  tidyllm::last_reply()
[1] "Why don't cats play poker in the jungle? \n\nToo many cheetahs!  😹  \n"

2.2 Textklassifikation

2.2.1 Grundlagen

Die Zero-Shot-Klassifikation von Texten ist denkbar einfach: Wir kombinieren einfach eine Codieranweisung und den zu codierenden Text. Es empfiehlt sich, die Anweisung so zu gestalten, dass möglichst einheitliche, kurz Antworten gegeben werden. Die Anweisung kann auch deutlich umfangreicher ausfallen oder sogar Beispiele enthalten (sog. Few-Shot-Klassifikation), aber der Einfachheit halber wählen wir eine sehr knappe Codieranweisung:

zero_shot_prompt <- paste("Is this text about sports? Answer only with the words TRUE or FALSE.",
  "Mainz 05 schlägt Eintracht zuhause mit 2:0.",
  sep = "\n\n"
)

tidyllm::llm_message(zero_shot_prompt) |>
  tidyllm::ollama(
    .ollama_server = "https://llm.ifp.uni-mainz.de",
    .model = "gemma2",
    .temperature = 0
  ) |>
  tidyllm::last_reply()
[1] "TRUE \n"

Dieser Aufruf scheint zu funktionieren, ist aber recht umfangreich, vor allem wenn wir mehrere Codierungen durchführen lassen wollen. Daher erstellen wir eine eigene Funktion classify_text(), die das ganze für uns vereinfacht. Als Funktionsparameter wählen wir den Text und den Codiertask. Der Code innerhalb der Funktion ist fast identisch, allerdings verwenden wir am Ende der Pipeline die Funktion str_squish(), um überzählige Leerzeichen zu entfernen.

classify_text <- function(text, task) {
  prompt <- paste(task, text, sep = "\n\n")
  tidyllm::llm_message(prompt) |>
    tidyllm::ollama(
      .ollama_server = "https://llm.ifp.uni-mainz.de",
      .model = "gemma2",
      .temperature = 0
    ) |>
    tidyllm::last_reply() |>
    str_squish()
}

Wir probieren unsere Funktion aus:

classify_text(
  text = "Concerts cancelled after band split up.",
  task = "Is this text about sports? Answer only with the words TRUE or FALSE."
)
[1] "FALSE"

2.2.2 Mehrere Texte codieren

Um mehrere Texte codieren zu lassen, verwenden wir die map_chr()-Funktion. Diese verarbeitet Listen von Objekten und wendet dieselbe Funktion auf alle Elemente der Liste an, um daraus eine gleich lange Liste von Texten zu genieren. Wir erstellen zunächst eine Liste von drei Schlagzeilen headlines, die wir anschließend elementweise an die neue Klassifikationsfunktion übergeben. Der Task-Parameter ist dabei immer derselbe, nur der Text unterscheidet sich.

headlines <- list(
  "Arsenal coach fired after horrible loss at home.",
  "EU ministers meet in Brussels.",
  "How electric cars work."
)
map_chr(headlines, classify_text, task = "Is this text about sports? Answer only with the words TRUE or FALSE.")
[1] "TRUE"  "FALSE" "FALSE"
LLM-Outputs sind Texte

Die Antworten von den LLM sind immer Text-Variablen, auch wenn wir per Prompt TRUE und FALSE oder Zahlenwerte anfordern. Um diese in R weiterverwenden zu können, sollten wir sie in die korrekten Datentypen umwandeln. Dazu gibt es eine Reihe von parse_*()-Funktionen, wie parse_number() oder parse_logical(), die Character-Objekte entsprechend konvertieren. Nicht konvertierbare Werte werden in NA umgewandelt.

map_chr(headlines, classify_text, task = "Is this text about sports? Answer only with the words TRUE or FALSE.") |>
  parse_logical()
[1]  TRUE FALSE FALSE

Das Resultat ist nun eine Liste von logischen TRUE/FALSE Werten, wie man an den fehlenden Anführungszeichen erkennen kann.

Auf die gleiche Art und Weise können wir auch Zahlenwerte wieder in numerische Variablen umwandeln.

map_chr(headlines, classify_text, task = "Is this text about sports? Answer only with the numbers 1 (true) or 0 (false).") |>
  parse_number()
[1] 1 0 0

2.2.3 Beispiel: JGU auf Instagram

Bislang haben wir uns lediglich einfache Demonstrationen angeschaut, nun wollen wir unsere neu erworbenen Kenntnisse nutzen, um die Inhalte auf dem Instagram-Account der JGU zu untersuchen. Wir beginnen damit, die Daten zu laden.

jgu_insta <- read_tsv("data/jgu_insta.tsv")
jgu_insta
# A tibble: 20 × 4
       id text                                         date                img  
    <dbl> <chr>                                        <dttm>              <chr>
1 3.28e18 "❄️ Wenn der #GutenbergCampus zum Winter Won… 2024-01-18 09:48:28 2024…
2 3.29e18 "Wenn sich ein Arzt oder eine Ärztin nicht … 2024-01-23 13:45:06 2024…
3 3.30e18 "#Edelsteine sind faszinierende Wunder der … 2024-02-15 16:04:08 2024…
4 3.32e18 "Europa im Herzen, das Mittelalter im Blick… 2024-03-06 16:03:07 2024…
5 3.32e18 "Das Forschungsfeld der Biologie hat sich i… 2024-03-07 17:25:08 2024…
# ℹ 15 more rows
Pfade und RStudio-Projekte

Wenn die Datei data/jgu_insta.tsv nicht geöffnet werden kann, liegt das fast immer daran, dass nicht das korrekte RStudio-Projekt BA-CCS-Track aktiv ist. Das kann man oben rechts im RStudio-Fenster erkennen, vgl. auch hier.

Anschließend nutzen wir map_chr() und unsere classify_text()-Funktion, um alle Texte im jgu_insta-Datensatz zu codieren. Wir interessieren uns hier für zwei Kategorien: Geht es in dem Text um wissenschaftliche Inhalte oder um Preise und Auszeichnungen?

jgu_insta_coded <- jgu_insta |>
  mutate(
    research = map_chr(text, classify_text,
      task = "Is this text about scientific research? Answer only with the words TRUE or FALSE."
    ),
    awards = map_chr(text, classify_text,
      task = "Is this text about awards or prizes? Answer only with the words TRUE or FALSE."
    )
  )

jgu_insta_coded |>
  select(research, awards, text)
# A tibble: 20 × 3
  research awards text                                                          
  <chr>    <chr>  <chr>                                                         
1 FALSE    FALSE  "❄️ Wenn der #GutenbergCampus zum Winter Wonderland wird ❄️ (Fo…
2 TRUE     FALSE  "Wenn sich ein Arzt oder eine Ärztin nicht mit Patienten vers…
3 TRUE     FALSE  "#Edelsteine sind faszinierende Wunder der Natur, umgeben von…
4 FALSE    FALSE  "Europa im Herzen, das Mittelalter im Blick 🇪🇺🏰 Die ersten S…
5 TRUE     FALSE  "Das Forschungsfeld der Biologie hat sich in den letzten Jahr…
# ℹ 15 more rows

Mit count() können wir uns die absoluten Häufigkeiten der beiden Kategorien ausgeben lassen. Laut LLM weisen 11 von 20 Texten einen Bezug zur Wissenschaft auf, während 2 der 20 Texte der Kategorie “Preise und Auszeichnungen” zuzuordnen sind.

count(jgu_insta_coded, research)
# A tibble: 2 × 2
  research     n
  <chr>    <int>
1 FALSE        9
2 TRUE        11
count(jgu_insta_coded, awards)
# A tibble: 2 × 2
  awards     n
  <chr>  <int>
1 FALSE     18
2 TRUE       2

2.3 Bildklassifikation

Neben Textdaten sind multimodale LLMs auch in der Lage, Bilder oder andere Formen von Daten (bspw. Video, Audio) zu verarbeiten. Nachfolgend wollen wir neben den Texten auch die Bilder auf dem Instagram-Account der Uni Mainz auswerten.

2.3.1 Grundlagen

Mit der Funktion image_read() aus dem Paket magick können wir uns Bilder in R anzeigen lassen, sowie erhalten einige Informationen über das Format und die Abmessungen des Bildes.

magick::image_read("data/jgu_insta/2024-04-15_06-05-06_UTC.jpg")

Für die Verwendung von Bildern müssen wir in der Funktion llm_message() mit dem Funktionsargument .imagefile den Pfad der Bilddatei angeben. Zudem tauschen wir das Gemma2-Modell von Google aus, da es lediglich mit Text umgehen kann. Stattdessen nutzen wir nun LLaVA von Haotian Liu.

tidyllm::llm_message("Does this image show flowers? Answer only with the words TRUE or FALSE.",
  .imagefile = "data/jgu_insta/2024-04-15_06-05-06_UTC.jpg"
) |>
  tidyllm::ollama(
    .ollama_server = "https://llm.ifp.uni-mainz.de",
    .model = "llava:34b",
    .temperature = 0
  )
Message History:
system: You are a helpful assistant
--------------------------------------------------------------
user: Does this image show flowers? Answer only with the words TRUE or FALSE.
 -> Attached Media Files:  2024-04-15_06-05-06_UTC.jpg 
--------------------------------------------------------------
assistant: TRUE
--------------------------------------------------------------

Auch hier bietet es sich wieder an eine eigene Funktion zu schreiben, um die Codierung der Bilder zu erleichtern. Analog zu classify_text() nennen wir unsere Funktion classify_image().

classify_image <- function(image, task) {
  tidyllm::llm_message(task, .imagefile = image) |>
    tidyllm::ollama(
      .ollama_server = "https://llm.ifp.uni-mainz.de",
      .model = "llava:34b",
      .temperature = 0
    ) |>
    tidyllm::last_reply() |>
    str_squish()
}

Ein kurzer Testlauf, ob unsere neue Funktion auch wie vorgesehen funktioniert:

classify_image("data/jgu_insta/2024-04-15_06-05-06_UTC.jpg",
  task = "Does this image show flowers? Answer only with the words TRUE or FALSE."
)
[1] "TRUE"

2.3.2 Beispiel: JGU auf Instagram

Wie zuvor bei den Texten auf Instagram können wir auch mehrere Bilder codieren. Wir müssen lediglich noch die Dateipfade anpassen. Die Bilder liegen in einem Ordner mit dem Pfad data/jgu_insta/ und die Dateinamen finden sich im jgu_insta_coded-Datensatzen, welche wir mit paste0() zusammenbinden. Nachfolgend fragen wir das Modell, ob das Bild Studierende zeigt.

jgu_insta_coded_img <- jgu_insta_coded |>
  mutate(img_paths = paste0("data/jgu_insta/", img)) |>
  mutate(
    students = map_chr(img_paths, classify_image,
      task = "Does this image show university students? Answer only with the words TRUE or FALSE."
    )
  )

Es zeigt sich, dass etwas mehr als die Hälfte der Bilder Studierende zeigt. Zudem erhalten wir trotz unserer Instruktion nicht nur TRUE & FALSE zurück, sondern auch TRUE mit einem speziellen Token <|im. Dies kann immer wieder passieren, weshalb teilweise noch weitere Datenbearbeitungsschritte notwendig sind.

jgu_insta_coded_img |>
  count(students)
# A tibble: 3 × 2
  students     n
  <chr>    <int>
1 FALSE       10
2 TRUE         9
3 TRUE<|im     1

Nun extrahieren wir noch die Dateipfade der Bild, welche laut Modell Studierende zeigen soll, um diese anschließend zu inspizieren.

student_images <- jgu_insta_coded_img |>
  filter(str_detect(students, "TRUE")) |>
  pull(img_paths)

student_images
 [1] "data/jgu_insta/2024-03-06_16-03-07_UTC.jpg"  
 [2] "data/jgu_insta/2024-03-07_17-25-08_UTC.jpg"  
 [3] "data/jgu_insta/2024-03-08_16-33-07_UTC.jpg"  
 [4] "data/jgu_insta/2024-03-20_10-03-05_UTC.jpg"  
 [5] "data/jgu_insta/2024-04-11_07-52-11_UTC_1.jpg"
 [6] "data/jgu_insta/2024-04-15_06-05-06_UTC.jpg"  
 [7] "data/jgu_insta/2024-04-26_17-25-21_UTC_1.jpg"
 [8] "data/jgu_insta/2024-05-10_12-33-13_UTC_1.jpg"
 [9] "data/jgu_insta/2024-05-30_14-03-05_UTC.jpg"  
[10] "data/jgu_insta/2024-06-10_12-03-08_UTC.jpg"  

Nun können wir alle Bilder, die Studierende zeigen sollten, in einer Collage darstellen:

student_images |>
  magick::image_read() |>
  magick::image_montage(tile = "5x2")

Hausaufgabe

  1. Lassen Sie die JGU-Instagram-Posts daraufhin zero-shot-codieren, ob über Studierende geschrieben wird. Zu welchen Ergebnissen kommen die Klassifikationen im Vergleich zu Ihrer eigenen Einschätzung und zur visuellen Analyse?

  2. Wie muss man die classify_text()-Funktion modifizieren, damit statt Ollama die OpenAI-API aufgerufen wird? Erstellen Sie eine neue Funktion classify_text_gpt() für diesen Zweck.

  3. Legen Sie eine R-Datei für eigene Analysen an, in der nur die notwendigen Pakete und Funktionen erhalten sind, die für die Text- und Bildklassifikation nötig sind. Diese werden wir als Ausgangspunkt für unsere Projekte verwenden.