Le blog Klar

Comment BERT a appris à lire votre boîte mail

Premier volet de notre série BERT : comment le modèle public XLM-RoBERTa de Klar transforme un e-mail en quatre scores, avec les figures des articles et le code du moteur actuel.

Voici le premier volet d’une série en deux parties. Il documente le moteur et le modèle publics qui propulsent Klar aujourd’hui. La suite étudiera comment une pile BERT de production évolue après les mesures et le déploiement.

ChatGPT a rendu célèbre une branche de la famille Transformer : on lui donne une consigne, puis il écrit. BERT a suivi la branche du lecteur. Il voit un texte complet, relie ses différentes parties et résume cette lecture dans un vecteur. Une petite tête utilise ensuite ce vecteur pour choisir une catégorie, par exemple le spam.

BERT est la moitié encodeur d’un Transformer

Le Transformer d’origine comportait un encodeur qui lisait l’entrée et un décodeur qui produisait la sortie. BERT, pour Bidirectional Encoder Representations from Transformers, a conservé la pile d’encodage. Il a été conçu pour comprendre un texte, pas pour le prolonger token après token.

Dans chaque couche, l’auto-attention permet à chaque token de pondérer les informations portées par les autres. Le mot « transfert » ne représente pas la même chose dans un avis bancaire et dans un compte rendu de football, car les mots voisins modifient sa représentation. Bidirectionnel signifie que le contexte de gauche et celui de droite sont disponibles. Un générateur causal ne peut pas consulter les mots qu’il n’a pas encore produits. Un encodeur BERT reçoit toute l’entrée d’un coup.

En haut : chaque mot d’une phrase relié à tous les autres dans les deux sens, produisant une classe unique. En bas : chaque mot relié seulement au précédent, pour prédire le suivant.
La différence essentielle. Un encodeur pèse le contexte des deux côtés d’un mot et aboutit à un seul vecteur pour tout le message ; un décodeur ne voit que ce qui précède et prédit ce qui suit. Schéma Klar, d’après Devlin et al., 2018
ArchitectureCe qu’elle litCe qu’elle produitUsages adaptés
Encodeur de la famille BERTL’entrée complèteDes vecteurs contextuels ou des scores de classeClassification, recherche, extraction
Décodeur causalLes tokens situés à gaucheUne probabilité pour le token suivantRédaction et génération ouverte

Le pré-entraînement apprend la langue. L’entraînement spécialisé lui donne un métier.

Le BERT d’origine a appris à partir de textes non étiquetés grâce au masked language modelling : masquer certains tokens, puis les prédire grâce au contexte situé de part et d’autre. Il utilisait aussi la prédiction de la phrase suivante. Cette première étape coûteuse produisait un encodeur généraliste. Un jeu de données étiqueté bien plus petit suffisait ensuite à l’adapter aux questions-réponses, à l’analyse de sentiment ou à une autre tâche.

« Fine-tuner BERT » ne signifie pas toujours entraîner uniquement la dernière couche. La recette d’origine met à jour l’encodeur pré-entraîné avec la tête spécialisée. Geler l’encodeur et n’entraîner que la tête est un choix d’ingénierie distinct. Il réduit le coût et le risque des petites mises à jour locales, mais ne peut pas remodeler la représentation du langage.

Suivez la famille

Une idée d’encodeur, quatre étapes utiles

BERT désigne désormais autant une famille qu’un modèle précis. Sélectionnez une étape pour voir ce qu’elle a apporté et ce qu’il restait à construire.

L’apport Un encodeur bidirectionnel profond, pré-entraîné avec des tokens masqués puis adapté à de nombreuses tâches linguistiques.
Ce qui manquait encore Les modèles et le vocabulaire d’origine visaient surtout l’anglais, et la méthode d’entraînement n’était qu’une première version.

Le modèle public que Klar exécute aujourd’hui

Le modèle en ligne aujourd’hui est icosha/spam-xlmr-v1, un classifieur de séquence XLM-RoBERTa-large. Son encodeur compte 24 couches, 16 têtes d’attention et une largeur cachée de 1 024. Klar convertit l’encodeur en GGUF quantifié, extrait la tête de classification, puis exécute les deux localement avec llama.cpp et ggml. Le code public est sous AGPLv3 ; le modèle public possède sa propre licence CC-BY-NC-4.0.

Une ligne d’e-mail transformée en tokens XLM-RoBERTa, commençant par le token spécial <s>, puis traversant 24 couches d’encodeur jusqu’à un vecteur de 1 024 valeurs.
L’entrée exacte que Klar construit. Le texte d’encadrement, le tokenizer qui coupe « verifying » en deux morceaux et le <s> initial dont le vecteur représente tout le message sont fixés par le modèle entraîné dessus. Schéma Klar, d’après Devlin et al., 2018
Chaîne de classification de Klar
RFC 822 email
  -> MIME parse and HTML-to-text
  -> normalized subject, sender and body
  -> canonical "User (email)" input
  -> XLM-RoBERTa-large encoder
  -> 1024-value CLS vector
  -> four-class head
  -> gibberish, marketing, regular or spam

Chaque flèche compte. Si l’entraînement utilise une représentation et la production une autre, la tête reçoit des vecteurs issus d’une distribution qu’elle n’a jamais apprise. Le moteur public impose donc un seul constructeur de l’entrée calibrée. Il enveloppe le message normalisé dans User (email): ..., puis ajoute les champs d’expéditeur uniquement lorsqu’ils existent. La classification et l’apprentissage local réutilisent exactement cette forme.

engine/spam_engine.cpp, extrait abrégé
for (const auto& exchange : transcript) {
  std::string from_type = exchange.from_type;
  if (!from_type.empty()) {
    from_type[0] = static_cast<char>(std::toupper(from_type[0]));
  }
  input_text += from_type + " (" + exchange.origin + "): "
              + exchange.text + "\n";
}

if (has_any_customer_signal) {
  input_text += "Customer Info:\n";
  if (!customer.name.empty()) input_text += "Name: " + customer.name + "\n";
  if (!customer.email.empty()) input_text += "Email: " + customer.email + "\n";
}

Étape 1 : des tokens à un vecteur pour toute la séquence

Le GgmlEncoder public utilise le tokenizer inclus dans le modèle GGUF. Il limite la longueur de la séquence, conserve le token de fin et vérifie que le token <s> de XLM-RoBERTa occupe la position zéro. Il joue le rôle de classification souvent décrit sous le nom [CLS]. Après le passage dans l’encodeur, le CLS pooling renvoie un vecteur pour l’ensemble du message.

engine/ggml_encoder.h, extrait abrégé
int n = llama_tokenize(vocab_, text.c_str(), text.size(),
                        token_buf_.data(), token_buf_.size(),
                        /* add_special = */ true,
                        /* parse_special = */ false);

if (n > max_tokens_) {
  n = max_tokens_;
  token_buf_[n - 1] = llama_vocab_eos(vocab_);
}

const llama_token bos = llama_vocab_bos(vocab_);
if (token_buf_.empty() || token_buf_.front() != bos) {
  token_buf_.insert(token_buf_.begin(), bos);
}

auto batch = llama_batch_get_one(token_buf_.data(), token_buf_.size());
llama_encode(ctx_, batch);

const float* emb = llama_get_embeddings_seq(ctx_, 0);
return std::vector<float>(emb, emb + n_embd_);

La vérification manuelle du début de séquence n’est pas décorative. Un convertisseur de modèle avait produit des métadonnées GGUF qui désactivaient le token spécial, même quand la tokenisation le demandait. Le pooling en position zéro renvoyait alors le premier mot ordinaire au lieu du résumé de la séquence. Le programme continuait à fonctionner, mais chaque embedding avait changé de sens. La parité de déploiement fait partie du modèle.

Étape 2 : du vecteur aux probabilités de classe

Le vecteur agrégé entre dans une petite tête de classification : une projection dense, tanh, une projection de sortie, puis softmax. Le moteur actuel expose quatre catégories neuronales. C’est cette partie qui transforme une représentation générale du langage en réponse propre à l’e-mail.

engine/spam_engine.cpp, extrait abrégé
const auto logits = impl_->trainable_head->forward(
    embedding, cache_for_training);
const auto probabilities = impl_->trainable_head->softmax(logits);

return ClassScores{
    probabilities[0],  // gibberish
    probabilities[1],  // marketing
    probabilities[2],  // regular
    probabilities[3],  // spam
};

La limite de tokens, l’encodeur et la tête de classification façonnent chacun une partie différente du modèle : modifier l’un apporte ce que les autres ne peuvent pas donner.

ChangementCe que cela apporteCe que cela coûte
Limite de tokensL’encodeur lit une plus grande partie du message.Un contexte plus long consomme mémoire et calcul, et le coût de l’attention augmente vite. La vue d’entraînement doit garder la même limite.
EncodeurChaque message reçoit une nouvelle représentation.La tête doit être entraînée dans ce nouvel espace. Conversion, pooling, quantification et latence sur le matériel cible sont à revalider.
Tête de classificationLa frontière entre les catégories se déplace.Presque rien. Les connaissances linguistiques de l’encodeur restent fixes, et cette petite partie peut s’adapter localement.

Personnaliser ne signifie pas réentraîner tout BERT

L’ajustement complet de l’encodeur appartient à une chaîne d’entraînement hors ligne, avec un corpus vaste et mesuré. Une correction utilisateur est plus petite et plus personnelle. Le chemin local de Klar calcule l’embedding avec l’encodeur gelé, mémorise le passage avant de la tête, rétropropage uniquement dans cette tête, puis applique une étape d’optimisation encadrée. La tête utilise un écrêtage des gradients, un rappel vers ses poids d’origine et une limite dure de dérive afin qu’une série de corrections dans un seul sens ne puisse pas la déplacer sans borne.

engine/spam_engine.cpp
float SpamEngine::train_embedding(
    const std::vector<float>& embedding,
    int correct_label) {
  (void)classify_embedding(embedding, true);
  const float loss = impl_->trainable_head->backward(correct_label);
  impl_->trainable_head->step(1);
  return loss;
}

Cette séparation donne un rôle clair à chaque partie. Le grand encodeur gelé fournit une compréhension générale dans de nombreuses langues. La petite tête apprend où le propriétaire de la boîte place la frontière. Les deux fonctionnent sur l’appareil, mais seule la tête change après une correction locale.

Ce que BERT sait bien faire, et ce qu’il ne sait pas faire

  • Adapté : classification, recherche et extraction quand toute l’entrée est disponible avant la réponse.
  • Adapté : sorties compactes et fixes qui ne nécessitent ni modèle génératif ni aller-retour vers le cloud.
  • Inadapté : rédaction ouverte. Un encodeur ne possède pas le décodeur autorégressif nécessaire à la production d’une réponse.
  • Insuffisant seul : identité, authentification, réputation et préférences personnelles. Ces éléments exigent des preuves extérieures au texte.

BERT reste important parce que tous les problèmes d’IA ne sont pas des conversations. Certains des modèles les plus utiles lisent une fois, renvoient une réponse bornée, puis disparaissent dans le logiciel qui les entoure. C’est son rôle dans Klar. Le moteur AGPLv3 complet, avec tous les extraits ci-dessus, est consultable sur github.com/klar-im/engine.

La deuxième partie reviendra sur les effets d’un changement d’encodeur, de représentation d’entrée et de contraintes de déploiement.

Consulter les sources originales

Ces articles scientifiques et dépôts de code tracent le chemin utile entre le Transformer d’origine et le moteur présenté ici.

  1. Attention Is All You Need Vaswani et al. · 2017
  2. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding Devlin et al. · 2018
  3. RoBERTa: A Robustly Optimized BERT Pretraining Approach Liu et al. · 2019
  4. Unsupervised Cross-lingual Representation Learning at Scale Conneau et al. · 2020
  5. Modèle anti-spam XLM-RoBERTa public de Klar icosha · CC-BY-NC-4.0
  6. Moteur anti-spam Klar et milter Postfix Klar · AGPLv3

Tous les articles

L’option en local

Gratuit, privé, dans Apple Mail.

Télécharger dans l’ App Store