Aller au contenu principal

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

ApprocheExemples requisPrécisionLatence
Zero-shot0~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