library(tidyverse)
library(magick)
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
.
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.
::llm_message("Tell me a joke about penguins.") tidyllm
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.
::llm_message("Tell me a joke about penguins.") |>
tidyllm::chatgpt(.model = "gpt-4o", .temperature = 0) tidyllm
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.:
::llm_message("Tell me a joke about cats.") |>
tidyllm::chatgpt(.model = "gpt-4o", .temperature = 0) |>
tidyllm::last_reply() tidyllm
[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.
::llm_message("Tell me a joke about cats.") |>
tidyllm::ollama(
tidyllm.ollama_server = "https://llm.ifp.uni-mainz.de",
.model = "gemma2",
.temperature = 0
|>
) ::last_reply() tidyllm
[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:
<- paste("Is this text about sports? Answer only with the words TRUE or FALSE.",
zero_shot_prompt "Mainz 05 schlägt Eintracht zuhause mit 2:0.",
sep = "\n\n"
)
::llm_message(zero_shot_prompt) |>
tidyllm::ollama(
tidyllm.ollama_server = "https://llm.ifp.uni-mainz.de",
.model = "gemma2",
.temperature = 0
|>
) ::last_reply() tidyllm
[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.
<- function(text, task) {
classify_text <- paste(task, text, sep = "\n\n")
prompt ::llm_message(prompt) |>
tidyllm::ollama(
tidyllm.ollama_server = "https://llm.ifp.uni-mainz.de",
.model = "gemma2",
.temperature = 0
|>
) ::last_reply() |>
tidyllmstr_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.
<- list(
headlines "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"
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.
<- read_tsv("data/jgu_insta.tsv")
jgu_insta 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
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 |>
jgu_insta_coded 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.
::image_read("data/jgu_insta/2024-04-15_06-05-06_UTC.jpg") magick
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.
::llm_message("Does this image show flowers? Answer only with the words TRUE or FALSE.",
tidyllm.imagefile = "data/jgu_insta/2024-04-15_06-05-06_UTC.jpg"
|>
) ::ollama(
tidyllm.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()
.
<- function(image, task) {
classify_image ::llm_message(task, .imagefile = image) |>
tidyllm::ollama(
tidyllm.ollama_server = "https://llm.ifp.uni-mainz.de",
.model = "llava:34b",
.temperature = 0
|>
) ::last_reply() |>
tidyllmstr_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 |>
jgu_insta_coded_img 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.
<- jgu_insta_coded_img |>
student_images 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 ::image_read() |>
magick::image_montage(tile = "5x2") magick
Hausaufgabe
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?
Wie muss man die
classify_text()
-Funktion modifizieren, damit statt Ollama die OpenAI-API aufgerufen wird? Erstellen Sie eine neue Funktionclassify_text_gpt()
für diesen Zweck.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.