Textklassifizierung mit Embeddings
Auf dieser Seite
Lernziele
- Textklassifizierung ohne Modelltraining durchführen
- Zero-shot- und Few-shot-Klassifizierung mit Embeddings implementieren
- Die Leistung im Vergleich zu einem leichten überwachten Klassifikator bewerten
Zero-shot-Klassifizierung
Die semantische Suche findet Dokumente, die einer Abfrage unter Tausenden von Kandidaten am ähnlichsten sind. Die Klassifizierung kehrt dieses Setup um, ohne die zugrundeliegenden Mechaniken zu ändern: Ihre „Dokumente” sind nun Ihre Kategorien (fünf oder zehn Stück), und die „Abfrage” ist der Text, den Sie klassifizieren möchten. Die Kategorie, deren Embedding am nächsten liegt, wird als Vorhersage ausgewählt. Es wird kein Training durchgeführt, keine annotierten Daten benötigt, lediglich ein einziger API-Aufruf.
Zwei Implementierungsdetails im untenstehenden Code verdienen Aufmerksamkeit. Erstens gehen Text und Kategoriennamen in einen einzigen embeddings.create-Aufruf ein, was die Latenz um die Anzahl der Labels reduziert. Zweitens wandelt die Anwendung von Softmax auf die Ähnlichkeitswerte rohe Werte (die typischerweise zwischen 0,3 und 0,5 liegen) in Wahrscheinlichkeiten um, die sich zu 1 summieren. Diese Wahrscheinlichkeiten können einem Benutzer angezeigt oder einem Schwellenwert für das Vertrauen gegenübergestellt werden.
from openai import OpenAI
import numpy as np
client = OpenAI()
def classifier_zero_shot(
texte: str,
categories: list[str],
model: str = "text-embedding-3-large"
) -> dict:
"""Klassifiziert Text in Kategorien ohne Training."""
# Embeddings für Kategorien
all_inputs = [texte] + categories
response = client.embeddings.create(
input=all_inputs,
model=model
)
embs = [d.embedding for d in sorted(response.data, key=lambda x: x.index)]
texte_emb = np.array(embs[0])
cat_embs = np.array(embs[1:])
# Ähnlichkeit mit jeder Kategorie
scores = cat_embs @ texte_emb
# Softmax zur Ermittlung von Wahrscheinlichkeiten
exp_scores = np.exp(scores - np.max(scores))
probas = exp_scores / exp_scores.sum()
resultats = sorted(
zip(categories, probas),
key=lambda x: x[1], reverse=True
)
return {
"prediction": resultats[0][0],
"confiance": float(resultats[0][1]),
"scores": {cat: float(p) for cat, p in resultats}
}
# Nutzung
texte = "Le cours de l'action a chuté de 15% après l'annonce"
categories = ["Finance", "Sport", "Technologie", "Politique", "Santé"]
resultat = classifier_zero_shot(texte, categories)
print(f"Prédiction : {resultat['prediction']} "
f"({resultat['confiance']:.1%})")
print("Scores :", resultat["scores"])
In diesem Beispiel landet der Satz „der Aktienkurs ist nach der Ankündigung um 15 % gefallen” erwartungsgemäß in der Kategorie Finance. Der Ansatz stößt an seine Grenzen, wenn die Kategorien interne Labels sind, deren Namen keinen Hinweis auf ihren Inhalt geben. Ein Label wie „P1” oder „Level-2-Eskalation” trägt keine ausnutzbare semantische Bedeutung, wodurch die Klassifizierung im Wesentlichen zufällig wird.
Few-shot-Klassifizierung
Genau das löst die Few-shot-Klassifizierung. Statt eine Kategorie durch ihren Namen darzustellen, wird sie durch den Schwerpunkt (Centroid) weniger echter Beispiele repräsentiert. Die Kategorie „Bug” ist nicht mehr das Wort „Bug”, sondern der Durchschnitt der Embeddings für „die App stürzt beim Start ab”, „Fehler 500 auf der Anmeldeseite” und „der Button funktioniert auf dem Mobilgerät nicht”. Drei bis fünf Beispiele pro Klasse reichen in der Regel aus, und die Kosten bleiben denen eines einzelnen API-Aufrufs gleich, da alle Texte gleichzeitig gesendet werden.
def classifier_few_shot(
texte: str,
exemples: dict[str, list[str]],
model: str = "text-embedding-3-large"
) -> dict:
"""Klassifiziert nach Ähnlichkeit zu markierten Beispielen.
Args:
exemples: {"category": ["example1", "example2", ...]}
"""
# Alle Texte vorbereiten
tous_textes = [texte]
mapping = []
for cat, txts in exemples.items():
for t in txts:
tous_textes.append(t)
mapping.append(cat)
# Embeddings in einem einzigen Aufruf
response = client.embeddings.create(
input=tous_textes, model=model
)
embs = [d.embedding for d in sorted(response.data, key=lambda x: x.index)]
texte_emb = np.array(embs[0])
# Durchschnittliche Embeddings pro Kategorie
cat_embeddings = {}
for i, cat in enumerate(mapping):
emb = np.array(embs[i + 1])
if cat not in cat_embeddings:
cat_embeddings[cat] = []
cat_embeddings[cat].append(emb)
cat_moyennes = {
cat: np.mean(embs_list, axis=0)
for cat, embs_list in cat_embeddings.items()
}
# Ähnlichkeit
scores = {
cat: float(np.dot(texte_emb, emb_moy) /
(np.linalg.norm(texte_emb) * np.linalg.norm(emb_moy)))
for cat, emb_moy in cat_moyennes.items()
}
prediction = max(scores, key=scores.get)
return {"prediction": prediction, "scores": scores}
# Nutzung
exemples = {
"bug": [
"L'application crash au démarrage",
"Erreur 500 sur la page de connexion",
"Le bouton ne fonctionne pas sur mobile",
],
"feature": [
"Pourriez-vous ajouter un mode sombre ?",
"Il serait utile d'exporter en PDF",
"Suggestion : intégrer un calendrier",
],
"question": [
"Comment changer mon mot de passe ?",
"Où trouver les paramètres de notification ?",
"Quelle est la limite de stockage ?",
],
}
texte = "L'export CSV ne contient pas toutes les colonnes"
resultat = classifier_few_shot(texte, exemples)
print(f"Catégorie : {resultat['prediction']}")
Der Test-Ticket „die CSV-Exportfunktion enthält nicht alle Spalten” enthält weder das Wort „Bug” noch „Fehler”, ähnelt aber den drei bereitgestellten Funktionsstörungsbeispielen, weshalb er dort landet. Beachten Sie, dass die Mittelung von Embeddings eine homogene Kategorie voraussetzt. Wenn Ihre „Frage”-Beispiele Mischungen aus Abrechnungsanfragen und API-Fragen enthalten, wird sich ihr Schwerpunkt in einem semantischen Niemandsland befinden. In diesem Fall ist es besser, die Kategorie aufzuteilen.
Leichter überwachter Klassifikator
Wenn Sie pro Klasse mehrere Dutzend Beispiele haben, wird das Mittelung eine Verschwendung von Informationen: Sie glätten die Form der Punktwolke. Ein linearer Klassifikator, der auf denselben Embeddings trainiert wird, lernt eine Entscheidungsgrenze. Das Embedding übernimmt das Sprachverständnis, während LogisticRegression einfach eine geometrische Linie in diesem Raum zeichnet. Dies führt zu Trainingszeiten von wenigen Sekunden auf einer Standard-CPU, wohingegen das Feinabstimmen eines Sprachmodells eine GPU erfordern würde.
Die fünfteilige Kreuzvalidierung dient nicht nur der Show. Sie liefert die Standardabweichung der Genauigkeit und gibt einen Hinweis darauf, wie zuverlässig Ihre Schätzung ist. Ein Wert von 0,91 ± 0,02 ist nutzbar; 0,91 ± 0,15 signalisiert einen Datensatz, der zu klein oder unausgewogen ist.
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
import numpy as np
def entrainer_classifieur(
textes: list[str],
labels: list[str],
model: str = "text-embedding-3-large"
) -> LogisticRegression:
"""Trainiert einen Klassifikator auf Embeddings."""
# Generiere Embeddings
response = client.embeddings.create(
input=textes, model=model
)
X = np.array([d.embedding for d in sorted(response.data, key=lambda x: x.index)])
# Trainiere einen Klassifikator
clf = LogisticRegression(max_iter=1000)
# Kreuzvalidierung
scores = cross_val_score(clf, X, labels, cv=5)
print(f"Accuracy (5-fold CV) : {scores.mean():.3f} ± {scores.std():.3f}")
# Trainiere auf dem vollständigen Datensatz
clf.fit(X, labels)
return clf
def predire(clf, texte: str, model: str = "text-embedding-3-large") -> str:
"""Vorhersage der Kategorie eines Textes."""
emb = client.embeddings.create(
input=texte, model=model
).data[0].embedding
return clf.predict([emb])[0]
Vergleich der Ansätze
| Ansatz | Benötigte Beispiele | Genauigkeit | Latenz |
|---|---|---|---|
| Zero-shot | 0 | ~70-80 % | 1 API-Aufruf |
| Few-shot (3-5/Klasse) | 15-25 | ~80-90 % | 1 API-Aufruf |
| Überwacht (50+/Klasse) | 250+ | ~90-95 % | 1 API-Aufruf + Inferenz |
In dieser Reihenfolge gelesen, beschreiben diese drei Zeilen einen Fortschritt, den Sie während des gesamten Produktlebenszyklus verfolgen können. Beginnen Sie am ersten Tag mit Zero-shot, ohne jegliche Daten. Sobald Sie manuell etwa zwanzig Vorhersagen korrigiert haben, recyclen Sie diese als Few-shot-Beispiele. Wenn das Annotierungsvolumen pro Klasse fünfzig überschreitet, trainieren Sie den überwachenden Klassifikator. Denken Sie daran, dass die Zeile „Latenz” daran erinnert, dass selbst das überwachte Modell schnell bleibt; die lokale Inferenz fügt im Vergleich zum API-Aufruf für Embeddings eine vernachlässigbare Overhead hinzu.
Zusammenfassung
- Zero-shot-Klassifizierung vergleicht Text mit Labels über Embeddings
- Few-shot verwendet Beispiele, um jede Kategorie zu definieren
- Ein überwachter Klassifikator (LogisticRegression) erzielt die beste Leistung
- Beginnen Sie mit Zero-shot und fügen Sie Beispiele hinzu, wenn die Genauigkeit unzureichend ist