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 ellmer-Paket, das für eine Reihe von LLM-APIs einheitliche Funktionen bietet und strukturierte Daten zurückliefert.

library(ellmer)

Wir definieren zunächst, mit welchem LLM wir arbeiten wollen. Die JGU bietet unter https://ki-chat.uni-mainz.de/ eine eigene OpenAI-kompatible API an, die wir nutzen können. Hierfür brauchen wir allerdings unseren API-Key, den wir unter Einstellungen auf der JGU-KI-Seite bekommen.

JGU_API_KEY <- "XYZ"

Nun definieren wir das Objekt jgu_chat, das die Zugangsdaten und Einstellungen für unser LLM enthält. Wichtig für später sind das Modell und die sog. temperature, die wir auf 0 setzen, um deterministische Antworten zu erhalten.

jgu_chat <- ellmer::chat_openai(
  base_url = "https://ki-chat.uni-mainz.de/api",
  model = "Qwen3 235B VL",
  api_key = JGU_API_KEY,
  params = params(temperature = 0)
)

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.

jgu_chat$chat("Tell me a dad joke")
Why don’t scientists trust atoms?

…because they make up everything! 😄

*(Classic dad joke — groan-worthy, but scientifically accurate.)*

2.2 Textklassifikation

2.2.1 Grundlagen

Die Zero-Shot-Klassifikation von Texten ist denkbar einfach: Wir kombinieren einfach eine Codieranweisung, den zu codierenden Text und die zu verwendenden Kategorien. 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.

Um zuverlässig nur gültige Codierungen zu erhalten, nutzen wir sog. structured output, d.h. wir zwingen das LLM, uns die Antworten in einem vorgegebenen Format zurückzugeben, hier type_boolean(), was einer dichotomen Ja/Nein Antwort entspricht. Demensprechend erhalten wir zwingend eine Spalte vom R-Type logical zurück, d.h. TRUE und FALSE Werte.

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"
)

response <- jgu_chat$chat_structured(zero_shot_prompt,
  type = type_object(sport = type_boolean())
)

response
$sport
[1] TRUE

2.2.2 Mehrere Texte codieren

Um mehrere Texte codieren zu lassen, erstellen wir zunächst eine Liste mit Codieraufgaben. Jede Codieraufgabe besteht immer aus einem Text und derselben Anweisung (coding_task). Diese werden mit der interpolate()-Funktion so verküpft, dass wie bei einem Lückentext die jeweiligen Variablen (ggf. wiederholt) kombiniert werden. Am Ende erhalten wir drei Codieraufgaben in einem Objekt tasks.

headlines <- list(
  "Arsenal coach fired after horrible loss at home.",
  "EU ministers meet in Brussels.",
  "How electric cars work."
)

coding_task <- "Is this headline about sports? Answer only with the words TRUE or FALSE."

tasks <- ellmer::interpolate("{{coding_task}} HEADLINE: {{headlines}}")
tasks
[1] │ Is this headline about sports? Answer only with the words TRUE or FALSE. HEADLINE: Arsenal coach fired after horrible loss at home.
[2] │ Is this headline about sports? Answer only with the words TRUE or FALSE. HEADLINE: EU ministers meet in Brussels.
[3] │ Is this headline about sports? Answer only with the words TRUE or FALSE. HEADLINE: How electric cars work.

Mit der Funktion parallel_chat_structured() können wir mehrere Tasks parallel an das LLM senden. Wir verwenden dieselbe Typendefinition, d.h. eine dichotome Sport-Variable. Wir erhalten nun ein Tibble mit den Ergebnissen zurück und fügen die 3 Headlines als weitere Spalte hinzu, um zu schauen, ob die Codierung erfolgreich war.

results <- parallel_chat_structured(jgu_chat, tasks, type = type_object(sport = type_boolean()))

results |>
  mutate(headline = headlines)
  sport                                         headline
1  TRUE Arsenal coach fired after horrible loss at home.
2 FALSE                   EU ministers meet in Brussels.
3 FALSE                          How electric cars work.

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 und auf die ersten 10 Zeilen zu beschränken.

jgu_insta <- read_tsv("data/jgu_insta.tsv") |>
  mutate(id = as.character(id)) |>
  head(10)
jgu_insta
# A tibble: 10 × 4
  id                  text                             date                img  
  <chr>               <chr>                            <dttm>              <chr>
1 3286635500117489152 "Wenn sich ein Arzt oder eine Ä… 2024-01-23 13:45:06 2024…
2 3303375298671884288 "#Edelsteine sind faszinierende… 2024-02-15 16:04:08 2024…
3 3317870312789501440 "Europa im Herzen, das Mittelal… 2024-03-06 16:03:07 2024…
4 3318636360354637824 "Das Forschungsfeld der Biologi… 2024-03-07 17:25:08 2024…
5 3319334968143100928 "Schon gehört? Kürzlich hat sic… 2024-03-08 16:33:07 2024…
# ℹ 5 more rows
ImportantPfade 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 parallel_chat_structured(), 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 gute Neuigkeiten aus der Universität? Zunächste erstellen wir wieder eine Lists von Tasks mit interpolate(), in der wir die Spalte text aus dem Datensatz verwenden.

science <- "Is this text about scientific research? Answer only with the words TRUE or FALSE."
positive_news <- "Does this text represent positive news or success stories involving the university or its members? Answer only with TRUE or FALSE."

tasks <- interpolate("Please classify the following posts according to these categories: Science: {{science}} \n Positive News: {{positive_news}} \n Post: {{jgu_insta$text}}")

head(tasks, 1)
[1] │ Please classify the following posts according to these categories: Science: Is this text about scientific research? Answer only with the words TRUE or FALSE. 
    │ Positive News: Does this text represent positive news or success stories involving the university or its members? Answer only with TRUE or FALSE. 
    │ Post: Wenn sich ein Arzt oder eine Ärztin nicht mit Patienten verständigen kann, weil diese kaum Deutsch sprechen, ist das ein ernstes Problem. Und solche Situationen sind keine Seltenheit. Fachleute plädieren daher für den flächendeckenden Einsatz von Sprachmittlung im Gesundheitswesen. Einer der renommiertesten Experten auf diesem Gebiet ist Prof. Bernd Meyer, Leiter des Arbeitsbereichs Interkulturelle Kommunikation am @ftsk_unimainz der #UniMainz. Im #JGUMagazin haben wir mit ihm darüber gesprochen, warum der Einsatz von #Sprachmittlung in der #Patientenkommunikation so wichtig ist, aber auch welche Herausforderungen gemeistert werden müssen – und warum Künstliche Intelligenz kein adäquater Ersatz ist. Den kompletten Beitrag findet ihr im JGU-Magazin unter 👉 www.magazin.uni-mainz.de (oder über unsere Insta-Landingpage in der Bio) #InterkulturelleKommunikation

Anschließend erstellen wir einen neuen Datensatz mit den Codierungen und der id-Spalte, damit wir die Codierdaten später ggf. mit anderen Variablen kombinieren können.

jgu_insta_coded <- parallel_chat_structured(jgu_chat, tasks,
  type = type_object(
    research = type_boolean(),
    positive_news = type_boolean()
  )
)
jgu_insta_coded <- jgu_insta_coded |>
  mutate(id = jgu_insta$id)
jgu_insta_coded
   research positive_news                  id
1     FALSE          TRUE 3286635500117489152
2      TRUE          TRUE 3303375298671884288
3     FALSE          TRUE 3317870312789501440
4      TRUE          TRUE 3318636360354637824
5     FALSE          TRUE 3319334968143100928
6     FALSE          TRUE 3320564063891673088
7     FALSE          TRUE 3327287349719803904
8     FALSE          TRUE 3327835972106941952
9      TRUE          TRUE 3339419327055165952
10    FALSE          TRUE 3341768140331910144

Mit count() können wir uns die absoluten Häufigkeiten der beiden Kategorien ausgeben lassen.

count(jgu_insta_coded, research)
  research n
1    FALSE 7
2     TRUE 3
count(jgu_insta_coded, positive_news)
  positive_news  n
1          TRUE 10

Zum Schluss kombinieren wir die Codierdaten mit den ursprünglichen Instagram-Daten zu einem Gesamtdatensatz. Dafür verwenden wir left_join() und als Schlüsselvariable die ID.

jgu_complete <- jgu_insta |>
  left_join(jgu_insta_coded, by = "id")
jgu_complete |>
  glimpse()
Rows: 10
Columns: 6
$ id       <chr> "3286635500117489152", "3303375298671884288", "33178703127895…
$ text     <chr> "Wenn sich ein Arzt oder eine Ärztin nicht mit Patienten vers…
$ date     <dttm> 2024-01-23 13:45:06, 2024-02-15 16:04:08, 2024-03-06 16:03:0…
$ img      <chr> "2024-01-23_13-45-06_UTC.jpg", "2024-02-15_16-04-08_UTC.jpg",…
$ research <lgl> FALSE, TRUE, FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, TRUE, FA…
$ awards   <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE…

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 lediglich das Bild über die Funktion content_image_file() dem Textprompt hinzufügen. Hier codieren wir das Vorkommen von Blumen.

jgu_chat$chat_structured("Does this image show flowers? Answer only with the words TRUE or FALSE.",
  content_image_file("data/jgu_insta/2024-04-15_06-05-06_UTC.jpg"),
  type = type_object(flowers = type_boolean())
)
$flowers
[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 den Dateipfad angeben, um mittels content_image_file() die Dateien für das LLM bereitstellen zu können. Die Bilder liegen in einem Ordner mit dem Pfad data/jgu_insta/. list.files() gibt die vollständigen Dateipfade zurück, welche wir mit dem Textprompt kombinieren. Unser Ziel ist es, herauszufinden, ob die Bilder Studierende zeigen. map() erlaubt es uns, über jedes Bild, welches in der Liste image_paths enthalten ist, zu iterieren, d.h. wir erstellen 10 Aufgaben (tasks) für das LLM. Der Textprompt bleibt dabei unverändert.

task <- "Does this image show university students? Answer only with the words TRUE or FALSE."
image_paths <- list.files("data/jgu_insta/", "*.jpg", full.names = TRUE) |>
  head(10)

tasks <- image_paths |>
  map(~ list(task, content_image_file(.x)))

tasks[[1]]
[[1]]
[1] "Does this image show university students? Answer only with the words TRUE or FALSE."

[[2]]
<ellmer::ContentImageInline>
 @ type: chr "image/jpeg"
 @ data: chr "/9j/4AAQSkZJRgABAQAAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBY"| __truncated__

Anschließend nutzen wir wieder parallel_chat_structured(), um die Anfragen an das LLM zu schicken. Da es sich um ein multimodales LLM handelt, können wir das jgu_chat-Objekt weiterverwenden. Anschließend fügen wir den Dateipfad wieder an, damit wir wissen, um welches Bild es sich gehandelt hat.

jgu_insta_coded_img <- parallel_chat_structured(jgu_chat, tasks,
  type = type_object(students = type_boolean())
)
jgu_insta_coded_img <-
  jgu_insta_coded_img |>
  mutate(img = image_paths)
jgu_insta_coded_img
   students                                           img
1     FALSE   data/jgu_insta//2024-01-23_13-45-06_UTC.jpg
2     FALSE   data/jgu_insta//2024-02-15_16-04-08_UTC.jpg
3      TRUE   data/jgu_insta//2024-03-06_16-03-07_UTC.jpg
4      TRUE   data/jgu_insta//2024-03-07_17-25-08_UTC.jpg
5     FALSE   data/jgu_insta//2024-03-08_16-33-07_UTC.jpg
6     FALSE   data/jgu_insta//2024-03-10_09-15-06_UTC.jpg
7     FALSE data/jgu_insta//2024-03-19_15-53-05_UTC_1.jpg
8      TRUE   data/jgu_insta//2024-03-20_10-03-05_UTC.jpg
9     FALSE data/jgu_insta//2024-04-05_09-37-09_UTC_1.jpg
10    FALSE data/jgu_insta//2024-04-08_15-23-47_UTC_1.jpg

Es zeigt sich, dass etwas mehr als die Hälfte der Bilder Studierende zeigt. Gerade bei kleineren (multimodalen) LLMs kann es sein, dass, trotz unserer Instruktion, die Ausgabe des Modells nicht exakt den geforderten Werten entspricht. Beispielsweise könnten wir nicht nur TRUE & FALSE erhalten, sondern auch den Wert TRUE mit einem speziellen Token <|im. Dies kann immer wieder passieren, weshalb teilweise noch weitere Datenbearbeitungsschritte notwendig sind.

jgu_insta_coded_img |>
  count(students)
  students n
1    FALSE 7
2     TRUE 3

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)

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-20_10-03-05_UTC.jpg"

Nun können wir alle Bilder, die Studierende zeigen sollten, mit magick in einer Collage darstellen, um uns einen Eindruck von der Klassifikationsgüte zu verschaffen:

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. Finden Sie auf https://ki-chat.uni-mainz.de/ heraus, welche (multimodalen) LLMs aktuell verfügbar sind. Wählen Sie ein anderes Modell für die Textklassifikation aus. Anschließend nutzen Sie das neugewählte Modell, um erneut die ersten 10 Zeilen des jgu-insta-Datensatzes zu klassifizieren (Kategorien und Prompt müssen nicht angepasst werden). Vergleichen Sie die Ergebnisse. Gibt es Unterschiede?

  3. Untersuchen Sie die Bilder in den Instagram-Posts erneut. Dieses Mal sollen Sie allerdings herausfinden, ob eine Gruppe von Personen zu sehen ist oder nicht. Wieviele Gruppen befinden sich laut Klassifikationsergebnis in den Bilder?