Classification de texte par embeddings
Mis à jour le 29 juillet 2026
Objectifs
- Classifier des textes sans entraîner de modèle
- Implémenter la classification zero-shot et few-shot par embeddings
- Comparer avec un classifieur supervisé léger
Classification zero-shot
La recherche sémantique consistait à trouver, parmi des milliers de documents, ceux qui ressemblent à une requête. La classification renverse le décor sans rien changer à la mécanique : les « documents » sont maintenant vos catégories, au nombre de cinq ou dix, et la « requête » est le texte à ranger. Le label dont l’embedding est le plus proche devient la prédiction. Aucun entraînement, aucune donnée annotée, un seul appel API.
Deux détails d’implémentation méritent l’attention dans le code ci-dessous. Le texte et les catégories partent dans un unique appel embeddings.create, ce qui divise la latence par le nombre de labels. Et le softmax appliqué aux similarités convertit des scores bruts, tous compris entre 0,3 et 0,5 environ, en probabilités qui somment à 1 et que l’on peut afficher à un utilisateur ou comparer à un seuil de confiance.
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:
"""Classifie un texte parmi des catégories sans entraînement."""
# Embeddings des catégories
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:])
# Similarité avec chaque catégorie
scores = cat_embs @ texte_emb
# Softmax pour avoir des probabilités
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}
}
# Utilisation
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"])
Sur l’exemple, « le cours de l’action a chuté de 15 % » tombe sans surprise dans Finance. La limite de l’approche apparaît quand les catégories sont des étiquettes internes dont le nom ne dit rien du contenu : un label « P1 » ou « escalade niveau 2 » n’a aucune signification sémantique exploitable, et le classement devient aléatoire.
Classification few-shot
C’est précisément ce que corrige le few-shot. Au lieu de représenter une catégorie par son nom, on la représente par le centre de gravité de quelques exemples réels. La catégorie « bug » n’est plus le mot « bug » mais la moyenne des embeddings de « l’application crash au démarrage », « erreur 500 sur la page de connexion » et « le bouton ne fonctionne pas sur mobile ». Trois à cinq exemples par classe suffisent généralement, et le coût reste d’un seul appel API puisque tous les textes partent ensemble.
def classifier_few_shot(
texte: str,
exemples: dict[str, list[str]],
model: str = "text-embedding-3-large"
) -> dict:
"""Classifie par similarité avec des exemples labellisés.
Args:
exemples: {"catégorie": ["exemple1", "exemple2", ...]}
"""
# Préparer tous les textes
tous_textes = [texte]
mapping = []
for cat, txts in exemples.items():
for t in txts:
tous_textes.append(t)
mapping.append(cat)
# Embeddings en un seul appel
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])
# Moyenne des embeddings par catégorie
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()
}
# Similarité
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}
# Utilisation
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']}")
Le ticket testé — « l’export CSV ne contient pas toutes les colonnes » — ne contient ni le mot « bug » ni le mot « erreur », mais il ressemble aux trois dysfonctionnements donnés en exemple, et c’est là qu’il atterrit. Notez que la moyenne d’embeddings suppose une catégorie homogène : si vos exemples de « question » mélangent des interrogations sur la facturation et sur l’API, leur barycentre tombera dans un no man’s land sémantique. Mieux vaut alors scinder la catégorie.
Classifieur supervisé léger
Quand vous disposez de plusieurs dizaines d’exemples par classe, la moyenne devient un gaspillage d’information : elle écrase la forme du nuage de points. Un classifieur linéaire entraîné sur les mêmes embeddings apprend, lui, une frontière de décision. L’embedding fait le travail de compréhension du langage, LogisticRegression ne fait plus qu’un tracé géométrique dans cet espace — d’où un entraînement de quelques secondes sur un CPU ordinaire, là où un modèle de langage fine-tuné demanderait un GPU.
La validation croisée à cinq plis n’est pas décorative : elle vous donne l’écart-type de la précision, donc une idée de la fiabilité de votre estimation. Un score de 0,91 ± 0,02 est exploitable ; un 0,91 ± 0,15 signale un dataset trop petit ou déséquilibré.
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:
"""Entraîne un classifieur sur des embeddings."""
# Générer les 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)])
# Entraîner un classifieur
clf = LogisticRegression(max_iter=1000)
# Validation croisée
scores = cross_val_score(clf, X, labels, cv=5)
print(f"Accuracy (5-fold CV) : {scores.mean():.3f} ± {scores.std():.3f}")
# Entraîner sur tout le dataset
clf.fit(X, labels)
return clf
def predire(clf, texte: str, model: str = "text-embedding-3-large") -> str:
"""Prédit la catégorie d'un texte."""
emb = client.embeddings.create(
input=texte, model=model
).data[0].embedding
return clf.predict([emb])[0]
Comparaison des approches
| Approche | Exemples requis | Précision | Latence |
|---|---|---|---|
| Zero-shot | 0 | ~70-80 % | 1 appel API |
| Few-shot (3-5/classe) | 15-25 | ~80-90 % | 1 appel API |
| Supervisé (50+/classe) | 250+ | ~90-95 % | 1 appel API + inference |
Lues dans l’ordre, ces trois lignes décrivent une progression que vous pouvez suivre au fil de la vie du produit. Démarrez en zero-shot dès le premier jour, sans aucune donnée. Dès que vous avez corrigé à la main une vingtaine de prédictions, recyclez-les en exemples few-shot. Et quand le volume d’annotations dépasse la cinquantaine par classe, entraînez le classifieur supervisé — sachant que la ligne « latence » vous rappelle que même le modèle supervisé reste rapide, l’inférence locale ne pesant rien à côté de l’appel d’embedding.
Résumé
- La classification zero-shot compare le texte aux labels via embeddings
- Le few-shot utilise des exemples pour définir chaque catégorie
- Un classifieur supervisé (LogisticRegression) atteint les meilleures performances
- Commencez par le zero-shot, ajoutez des exemples si la précision est insuffisante