Étiqueter des articles par thème

Attribuer à chaque article les thèmes qu’il traite, pris dans la liste de thèmes de la maison.

Pas besoin d’IA générative

négligeableInstantanéVos données ne sortent pas

Le verdict

N1 est le premier niveau qui apprend quels mots vont avec un thème, au lieu de s’en tenir à ceux qu’on a listés : l’article de télétravail qui n’emploie aucun terme de la liste, et que le vocabulaire contrôlé laisse filer, il l’étiquette, et le test le mesure. Ce qu’il coûte n’est pas le code, mais des articles étiquetés à la main — qu’une rédaction qui étiquette depuis des années possède déjà. Son piège est l’article qui traite plusieurs thèmes : il n’atteint le seuil sur aucun. L’extrait y répond par un plancher, plus bas que le seuil, qui ne s’applique que lorsque deux thèmes au moins s’y tiennent — c’est la signature de l’article qui se partage entre plusieurs thèmes, et non de l’article qui n’en traite aucun. Réglez ce plancher sur votre fonds, comme le seuil. N2 lèverait la vraie limite de N1, l’ajout d’un thème sans corpus derrière lui, mais au prix d’un seuil qu’on ne choisit qu’avec des articles déjà étiquetés et d’un encodeur à charger dans le processus qui publie : montez-y le jour où la taxonomie bouge plus vite que vous n’étiquetez.

TF-IDF et classification multi-étiquette un contre tous

Pas besoin d’IA générative

Modèle classique légerN1négligeableInstantané

Python

snippets/tag-articles-by-topic/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Étiqueter des articles avec un classifieur un contre tous sur des traits
TF-IDF.

Niveau N1. Le vocabulaire contrôlé du niveau N0 voit les mots qu'un rédacteur
a énumérés. Celui-ci voit les mots qui vont avec un thème dans les articles
que vous avez déjà étiquetés — « bureau », « visioconférence » et
« domicile » finissent par porter le thème du télétravail, alors qu'aucun
rédacteur ne les aurait jamais inscrits dans une liste de termes.

Un classifieur par thème, chacun répondant à sa propre question par oui ou par
non. C'est ce que veut dire un contre tous, et c'est ce qui garde l'étiquetage
multi-étiquette : les thèmes ne se disputent pas un vainqueur unique, si bien
qu'un article peut ressortir avec deux étiquettes, ou avec aucune.

Le vecteur TF-IDF de l'article est de longueur un, cependant : plus il traite
de thèmes, moins chacun pèse. Un article fait des articles d'entraînement de
trois thèmes n'atteint le seuil sur aucun d'eux, et l'article le plus fouillé
de la semaine ressortirait sans étiquette. C'est à cela que sert le plancher,
dans `tag`.

Ce que coûte ce niveau n'est pas le code, qui est ci-dessous. C'est le corpus
étiqueté : des articles que quelqu'un doit étiqueter à la main, et étiqueter
de nouveau chaque fois que la taxonomie bouge.
"""

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import MultiLabelBinarizer


def train(articles: list[str], topics_per_article: list[list[str]]) -> dict:
    """
    `topics_per_article[i]` is the list of topics of `articles[i]`, possibly
    empty. An untagged article is a useful negative example, not a gap.
    """
    binariser = MultiLabelBinarizer()
    matrix = binariser.fit_transform(topics_per_article)
    pipeline = make_pipeline(
        # Word unigrams and bigrams. Words of one character are dropped first,
        # so "travailler à distance" gives the pair "travailler distance".
        TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True),
        OneVsRestClassifier(LogisticRegression(C=4.0, max_iter=1000)),
    )
    pipeline.fit(articles, matrix)
    return {"pipeline": pipeline, "topics": list(binariser.classes_)}


def score(model: dict, article: str) -> dict[str, float]:
    """One probability per topic, each independent of the others."""
    probabilities = model["pipeline"].predict_proba([article])[0]
    # With a single topic, scikit-learn solves a binary problem and returns two
    # columns, absent then present: the topics are always the last columns.
    probabilities = probabilities[-len(model["topics"]):]
    return {topic: float(p) for topic, p in zip(model["topics"], probabilities)}


def tag(model: dict, article: str, threshold: float = 0.5, floor: float = 0.3) -> list[str]:
    """
    The topics above the threshold, best first.

    The threshold is yours to set, and it is the first dial. Move it towards 1
    when a wrong tag is worse than a missing one, towards 0 when an editor
    reviews the list anyway and would rather see one topic too many.

    `floor` is the second, and it exists for the article that covers several
    topics at once. Its vector has length one, so the more topics it covers the
    less weight each one gets, and the most thorough article of the week can
    reach the threshold on none of them.

    When nothing reaches the threshold, two things are possible: the article is
    about none of the topics, or it is about several and they shared its
    weight. Two topics above the floor tell the second case from the first, and
    then both come back. One alone does not, because that is what an article
    about nothing looks like. Set `floor` equal to `threshold` to turn the
    whole thing off.
    """
    scored = score(model, article)
    order = lambda topic: (-scored[topic], topic)  # noqa: E731 - read once, used twice
    above = sorted((t for t, value in scored.items() if value >= threshold), key=order)
    if above:
        return above
    near = sorted((t for t, value in scored.items() if value >= floor), key=order)
    return near if len(near) > 1 else []

JavaScript

snippets/tag-articles-by-topic/n1.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Étiqueter des articles avec un classifieur un contre tous sur des traits
 * TF-IDF.
 *
 * Niveau N1. Le vocabulaire contrôlé du niveau N0 voit les mots qu'un rédacteur
 * a énumérés. Celui-ci voit les mots qui vont avec un thème dans les articles
 * que vous avez déjà étiquetés — « bureau », « visioconférence » et
 * « domicile » finissent par porter le thème du télétravail, alors qu'aucun
 * rédacteur ne les aurait jamais inscrits dans une liste de termes.
 *
 * Un classifieur par thème, chacun répondant à sa propre question par oui ou par
 * non. C'est ce que veut dire un contre tous, et c'est ce qui garde l'étiquetage
 * multi-étiquette : les thèmes ne se disputent pas un vainqueur unique, si bien
 * qu'un article peut ressortir avec deux étiquettes, ou avec aucune.
 *
 * Le vecteur TF-IDF de l'article est de longueur un, cependant : plus il traite
 * de thèmes, moins chacun pèse. Un article fait des articles d'entraînement de
 * trois thèmes n'atteint le seuil sur aucun d'eux, et l'article le plus fouillé
 * de la semaine ressortirait sans étiquette. C'est à cela que sert le plancher,
 * dans `tag`.
 *
 * Écrit à la main plutôt que tiré d'une bibliothèque, avec des vecteurs creux,
 * pour que l'entraînement croisse avec les mots de chaque article plutôt
 * qu'avec le vocabulaire entier. Ce que coûte ce niveau n'est pas le code
 * ci-dessous : c'est le corpus étiqueté que quelqu'un doit
 * étiqueter à la main, et étiqueter de nouveau chaque fois que la taxonomie
 * bouge.
 */

const TOKEN = /[\p{L}\p{N}_]{2,}/gu;

/**
 * Unigrams and bigrams. Words of one character are dropped first, so
 * "travailler à distance" gives the pair "travailler distance".
 */
function terms(text) {
  const words = text.toLowerCase().match(TOKEN) ?? [];
  const pairs = words.slice(1).map((word, i) => `${words[i]} ${word}`);
  return [...words, ...pairs];
}

/** Sublinear term frequency times inverse document frequency, L2 normalised, as column-value pairs. */
function vectorise(termList, vocabulary, idf) {
  const counts = new Map();
  for (const term of termList) {
    const column = vocabulary.get(term);
    if (column !== undefined) counts.set(column, (counts.get(column) ?? 0) + 1);
  }
  const columns = [...counts.keys()];
  const values = columns.map((column) => (1 + Math.log(counts.get(column))) * idf[column]);
  const norm = Math.sqrt(values.reduce((sum, v) => sum + v * v, 0));
  return { columns, values: norm ? values.map((v) => v / norm) : values };
}

/** The weighted sum of an article's features: the logit of one topic. */
function logit(weights, bias, { columns, values }, scale = 1) {
  let z = bias;
  for (let k = 0; k < columns.length; k += 1) z += scale * weights[columns[k]] * values[k];
  return z;
}

/** One binary classifier, trained by gradient descent one article at a time. */
function fit(rows, labels, width, { epochs, rate, decay }) {
  const weights = new Float64Array(width);
  let bias = 0;
  // The true weights are `scale * weights`. The decay term is the L2 penalty:
  // without it a corpus this small is memorised, and every article comes back
  // scored one or zero. Applied to `scale`, it shrinks every weight in one step.
  let scale = 1;
  for (let epoch = 0; epoch < epochs; epoch += 1) {
    for (let i = 0; i < rows.length; i += 1) {
      const error = 1 / (1 + Math.exp(-logit(weights, bias, rows[i], scale))) - labels[i];
      scale *= 1 - rate * decay;
      const { columns, values } = rows[i];
      for (let k = 0; k < columns.length; k += 1) weights[columns[k]] -= (rate * error * values[k]) / scale;
      bias -= rate * error;
      if (scale < 1e-9) {
        for (let j = 0; j < width; j += 1) weights[j] *= scale;
        scale = 1;
      }
    }
  }
  for (let j = 0; j < width; j += 1) weights[j] *= scale;
  return { weights, bias };
}

/**
 * `topicsPerArticle[i]` is the list of topics of `articles[i]`, possibly
 * empty. An untagged article is a useful negative example, not a gap.
 */
export function train(articles, topicsPerArticle, { epochs = 600, rate = 0.5, decay = 0.005 } = {}) {
  const topics = [...new Set(topicsPerArticle.flat())].sort();
  if (articles.length !== topicsPerArticle.length || topics.length === 0) {
    throw new RangeError('training needs one list of topics per article, and at least one topic');
  }
  const documents = articles.map(terms);
  const frequencies = new Map();
  for (const document of documents) {
    for (const term of new Set(document)) frequencies.set(term, (frequencies.get(term) ?? 0) + 1);
  }
  const vocabulary = new Map([...frequencies.keys()].map((term, column) => [term, column]));
  const idf = [...frequencies.values()].map((df) => Math.log((1 + documents.length) / (1 + df)) + 1);
  const rows = documents.map((document) => vectorise(document, vocabulary, idf));
  const classifiers = topics.map((topic) => {
    const labels = topicsPerArticle.map((list) => (list.includes(topic) ? 1 : 0));
    return fit(rows, labels, idf.length, { epochs, rate, decay });
  });
  return { vocabulary, idf, topics, classifiers };
}

/** One probability per topic, each independent of the others. */
export function score(model, article) {
  const vector = vectorise(terms(article), model.vocabulary, model.idf);
  const scored = {};
  model.topics.forEach((topic, index) => {
    const { weights, bias } = model.classifiers[index];
    scored[topic] = 1 / (1 + Math.exp(-logit(weights, bias, vector)));
  });
  return scored;
}

/**
 * The topics above the threshold, best first.
 *
 * The threshold is yours to set, and it is the first dial. Move it towards 1
 * when a wrong tag is worse than a missing one, towards 0 when an editor
 * reviews the list anyway and would rather see one topic too many.
 *
 * `floor` is the second, and it exists for the article that covers several
 * topics at once. Its vector has length one, so the more topics it covers the
 * less weight each one gets, and the most thorough article of the week can
 * reach the threshold on none of them.
 *
 * When nothing reaches the threshold, two things are possible: the article is
 * about none of the topics, or it is about several and they shared its weight.
 * Two topics above the floor tell the second case from the first, and then both
 * come back. One alone does not, because that is what an article about nothing
 * looks like. Set `floor` equal to `threshold` to turn the whole thing off.
 */
export function tag(model, article, threshold = 0.5, floor = 0.3) {
  const scored = score(model, article);
  const order = (a, b) => scored[b] - scored[a] || (a < b ? -1 : a > b ? 1 : 0);
  const above = model.topics.filter((topic) => scored[topic] >= threshold).sort(order);
  if (above.length > 0) return above;
  const near = model.topics.filter((topic) => scored[topic] >= floor).sort(order);
  return near.length > 1 ? near : [];
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Les classifieurs sont entraînés dans votre navigateur au chargement de la page, sur le fonds étiqueté écrit juste à côté. Aucun article ne part ailleurs.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

Ce qu’il renvoie

Étiquettes posées : Cybersécurité

Chaque thème répond pour lui seul ; le seuil de publication est à 0,50.

Le détail

Thème de la maisonScore du classifieur
Cybersécurité0,76
Télétravail0,15
Fiscalité0,11
Recrutement0,08

Quatre classifieurs entraînés sur 28 articles étiquetés à la main.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le corpus étiqueté comme le modèle entraîné restent sur votre infrastructure

Point de rupture

L’article qui traite plusieurs thèmes à la fois — souvent le plus fouillé de la semaine — n’atteint le seuil sur aucun : son vecteur a pour longueur un, et les thèmes se partagent son poids. Sans le plancher de `tag`, un article fait des articles de trois thèmes ressort sans une étiquette ; le témoin est l’article d’un seul thème, étiqueté du premier coup. Et un thème que personne n’a étiqueté n’existe pas : l’article sur le guichet de subvention ressort vide, et baisser le seuil le classe en télétravail plutôt que de faire apparaître le thème manquant.

Quand passer au niveau suivant

La rédaction ouvre une rubrique et attend des étiquettes le jour même, avant qu’un seul article n’ait été étiqueté à la main.

Les quatre options

  1. N0Vocabulaire contrôlé, correspondance de termes après retrait des terminaisonsGratuit · Instantané
  2. RecommandéN1TF-IDF et classification multi-étiquette un contre tousnégligeable · Instantané
  3. N2Similarité d’embeddings avec la description des thèmes, sans exemple étiquetéPeu coûteux · Rapide
  4. N3Étiquetage par appel à un modèle généralisteélevé · Une seconde

Les autres options

N0Vocabulaire contrôlé, correspondance de termes après retrait des terminaisonsGratuit · Instantané

Vocabulaire contrôlé, correspondance de termes après retrait des terminaisons

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/tag-articles-by-topic/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Étiqueter des articles depuis un vocabulaire contrôlé, par correspondance
après retrait des terminaisons courantes.

Niveau N0. Déterministe, bibliothèque standard seule, et vérifiable : chaque
étiquette se remonte jusqu'au terme qui l'a produite, ce que demande un
rédacteur la première fois qu'une étiquette paraît fausse.

Deux choses font que ça marche.

D'abord, l'article et le vocabulaire passent par exactement la même chaîne. Un
racineur appliqué d'un seul côté échouera allègrement à faire correspondre un
mot avec lui-même.

Ensuite, l'étiquetage est multi-étiquette par construction. Un article traite
plusieurs thèmes, ou aucun ; une fonction qui rend un thème par article répond
à une question que personne n'a posée.
"""

import re
import unicodedata

# Endings stripped, longest of any two that overlap first. This is a
# plural-and-suffix stripper, not a lemmatiser: "embauchons" does not become
# "embaucher", "fiscaux" does not become "fiscal", and "poste" and "post" fold
# onto the same stem. Both sides of the comparison get the same treatment.
SUFFIXES = ("ements", "ement", "ations", "ation", "es", "s", "x", "e")

WORD = re.compile(r"[^\W_]+")


def normalise(text: str) -> str:
    """Lowercase and drop accents, so "Fiscalité" and "FISCALITE" meet."""
    # NFD does not split the ligatures, and a soft hyphen or a zero-width
    # space (format characters) would cut a word in two: both are handled here.
    text = text.lower().replace("œ", "oe").replace("æ", "ae")
    decomposed = unicodedata.normalize("NFD", text)
    return "".join(
        c for c in decomposed if not unicodedata.combining(c) and unicodedata.category(c) != "Cf"
    )


def strip_ending(word: str) -> str:
    """Strip one ending, and only when a stem of three letters is left."""
    for suffix in SUFFIXES:
        if word.endswith(suffix) and len(word) - len(suffix) >= 3:
            return word[: -len(suffix)]
    return word


def stems(text: str) -> str:
    """
    The text as a run of stems, padded with spaces at both ends.

    The padding is what lets a multi-word term be found with a plain substring
    search: "impot" can then never match inside "impotent".
    """
    return " " + " ".join(strip_ending(w) for w in WORD.findall(normalise(text))) + " "


def tag(article: str, vocabulary: dict[str, list[str]], min_terms: int = 1) -> list[str]:
    """
    Every topic whose terms appear in the article, best supported first.

    `vocabulary` maps a topic name to the terms that stand for it, single or
    multi-word. Keeping it a parameter is the point of this rung: the taxonomy
    belongs to whoever edits the articles, not to the code.

    `min_terms` is how many distinct terms a topic needs before it is claimed.
    Raise it when a single passing mention is not enough to file an article.
    """
    if min_terms < 1:
        raise ValueError("min_terms must be at least 1: a topic needs a term")
    haystack = stems(article)
    hits = {}
    for topic, terms in vocabulary.items():
        remaining, found = haystack, 0
        # Longest first, and a term found is removed from the text: "impot"
        # inside "credit d impot" is the same mention, not a second term.
        for term in sorted({stems(term) for term in terms}, key=len, reverse=True):
            if term.strip() and term in remaining:
                found += 1
                remaining = remaining.replace(term, " ")
        if found >= min_terms:
            hits[topic] = found
    # Best supported first, then by name once lowercased and stripped of
    # accents, the same order in both languages: a tagging run has to be
    # replayable, and a set has no order to replay.
    return sorted(hits, key=lambda topic: (-hits[topic], normalise(topic), topic))

JavaScript

snippets/tag-articles-by-topic/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Étiqueter des articles depuis un vocabulaire contrôlé, par correspondance
 * après retrait des terminaisons courantes.
 *
 * Niveau N0. Déterministe, bibliothèque standard seule, et vérifiable : chaque
 * étiquette se remonte jusqu'au terme qui l'a produite, ce que demande un
 * rédacteur la première fois qu'une étiquette paraît fausse.
 *
 * Deux choses font que ça marche.
 *
 * D'abord, l'article et le vocabulaire passent par exactement la même chaîne. Un
 * racineur appliqué d'un seul côté échouera allègrement à faire correspondre un
 * mot avec lui-même.
 *
 * Ensuite, l'étiquetage est multi-étiquette par construction. Un article traite
 * plusieurs thèmes, ou aucun ; une fonction qui rend un thème par article répond
 * à une question que personne n'a posée.
 */

// Endings stripped, longest of any two that overlap first. This is a
// plural-and-suffix stripper, not a lemmatiser: "embauchons" does not become
// "embaucher", "fiscaux" does not become "fiscal", and "poste" and "post" fold
// onto the same stem. Both sides of the comparison get the same treatment.
const SUFFIXES = ['ements', 'ement', 'ations', 'ation', 'es', 's', 'x', 'e'];

const WORD = /[\p{L}\p{N}]+/gu;

/** Lowercase and drop accents, so "Fiscalité" and "FISCALITE" meet. */
export function normalise(text) {
  // NFD does not split the ligatures, and a soft hyphen or a zero-width space
  // (format characters) would cut a word in two: both are handled here.
  const unfolded = text.toLowerCase().replaceAll('œ', 'oe').replaceAll('æ', 'ae');
  return unfolded.normalize('NFD').replace(/[\p{M}\p{Cf}]+/gu, '');
}

/** Strip one ending, and only when a stem of three letters is left. */
export function stripEnding(word) {
  for (const suffix of SUFFIXES) {
    if (word.endsWith(suffix) && word.length - suffix.length >= 3) {
      return word.slice(0, -suffix.length);
    }
  }
  return word;
}

/**
 * The text as a run of stems, padded with spaces at both ends.
 *
 * The padding is what lets a multi-word term be found with a plain substring
 * search: "impot" can then never match inside "impotent".
 */
export function stems(text) {
  const found = normalise(text).match(WORD) ?? [];
  return ` ${found.map(stripEnding).join(' ')} `;
}

/**
 * Every topic whose terms appear in the article, best supported first.
 *
 * `vocabulary` maps a topic name to the terms that stand for it, single or
 * multi-word. Keeping it a parameter is the point of this rung: the taxonomy
 * belongs to whoever edits the articles, not to the code.
 *
 * `minTerms` is how many distinct terms a topic needs before it is claimed.
 * Raise it when a single passing mention is not enough to file an article.
 */
export function tag(article, vocabulary, minTerms = 1) {
  if (!(minTerms >= 1)) throw new RangeError('minTerms must be at least 1: a topic needs a term');
  const haystack = stems(article);
  const hits = new Map();
  for (const [topic, terms] of Object.entries(vocabulary)) {
    let remaining = haystack;
    let found = 0;
    // Longest first, and a term found is removed from the text: "impot"
    // inside "credit d impot" is the same mention, not a second term.
    for (const term of [...new Set(terms.map(stems))].sort((a, b) => b.length - a.length)) {
      if (term.trim() && remaining.includes(term)) {
        found += 1;
        remaining = remaining.replaceAll(term, ' ');
      }
    }
    if (found >= minTerms) hits.set(topic, found);
  }
  // Best supported first, then by name once lowercased and stripped of
  // accents, the same order in both languages: a tagging run has to be
  // replayable, and a set has no order to replay.
  const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
  return [...hits.keys()].sort(
    (a, b) => hits.get(b) - hits.get(a) || byName(normalise(a), normalise(b)) || byName(a, b),
  );
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : l’article et la taxonomie ne quittent pas votre infrastructure

Point de rupture

Un thème traité de bout en bout sans jamais être nommé. « L’équipe ne se retrouve au bureau que le mardi, les réunions se tiennent en visioconférence » : c’est du télétravail du premier au dernier mot, aucun terme de la liste n’y figure, et l’article ressort sans étiquette. La seule réparation est d’ajouter un terme après chaque article manqué, et l’article suivant, écrit autrement, repasse au travers.

Quand passer au niveau suivant

Votre liste de termes s’allonge à chaque relecture éditoriale, parce que des articles ressortent vides alors qu’ils traitent bien d’un thème.

N2Similarité d’embeddings avec la description des thèmes, sans exemple étiquetéPeu coûteux · Rapide

Similarité d’embeddings avec la description des thèmes, sans exemple étiqueté

Petit modèle spécialisé auto-hébergéN2Peu coûteuxRapide

Python

snippets/tag-articles-by-topic/n2.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Étiquetage sans exemple : comparer l'article aux étiquettes elles-mêmes.

Niveau N2. Le niveau N1 demandait un corpus étiqueté, et un thème nouveau
voulait dire tout réétiqueter. Ici un thème n'est qu'une courte description,
encodée comme n'importe quel autre texte : en ajouter un coûte une ligne, et
le premier article peut être étiqueté le jour même.

C'est tout l'attrait du niveau, et il est réel. Ce qu'il coûte est un fichier
de modèle à livrer et à tenir à jour, un processus chaud pour le garder en
mémoire, et un score qui est un cosinus plutôt qu'une probabilité : il peut
être négatif, les thèmes d'un même article ne somment pas à un, et un seul
seuil doit servir tous les thèmes.
"""

from __future__ import annotations

import re

import numpy as np

MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

# The encoder reads 128 tokens and silently drops the rest (`max_seq_length`
# in the model's sentence_bert_config.json). With its tokenizer, 400 characters
# of French prose come to 110 tokens, so the article is encoded in passages of
# that size. Text the tokenizer cuts finer, code or a table, can still overflow.
PASSAGE_CHARACTERS = 400

SENTENCE_END = re.compile(r"(?<=[.!?])\s+|\s*\n\s*")


def load_encoder(name: str = MODEL_NAME):
    """The real encoder: fetched once, then held in memory and run locally."""
    from sentence_transformers import SentenceTransformer  # pragma: no cover

    return SentenceTransformer(name)


def build_labeller(topics: dict[str, str], encoder=None) -> dict:
    """
    Encode the topic descriptions once, and keep the encoder for the articles.

    `topics` maps a topic name to the sentence that stands for it. Write it as
    a human would say it out loud: the model card describes an encoder of
    sentences and paragraphs.

    `encoder` is injected so this can be tested without loading a model. Left
    alone, it is the real one above.
    """
    encoder = load_encoder() if encoder is None else encoder
    names = list(topics)
    vectors = _encode(encoder, [topics[name] for name in names])
    return {"topics": names, "vectors": vectors, "encoder": encoder}


def passages(article: str, size: int = PASSAGE_CHARACTERS) -> list[str]:
    """Consecutive sentences joined into pieces of at most `size` characters."""
    pieces: list[str] = []
    for sentence in filter(None, (s.strip() for s in SENTENCE_END.split(article.strip()))):
        if pieces and len(pieces[-1]) + 1 + len(sentence) <= size:
            pieces[-1] = f"{pieces[-1]} {sentence}"
        else:
            pieces.append(sentence)  # a sentence longer than `size` stays whole
    return pieces


def score(labeller: dict, article: str) -> dict[str, float]:
    """
    The best cosine, over the passages of the article, with each topic
    description: a topic covered in one paragraph counts.
    """
    pieces, known = passages(article), labeller["vectors"]
    if not pieces or not labeller["topics"]:
        return {topic: 0.0 for topic in labeller["topics"]}
    vectors = _encode(labeller["encoder"], pieces)
    if vectors.shape[1] != known.shape[1]:
        raise ValueError("the encoder returned vectors of another width for the article")
    # One row per passage, one column per topic: a matrix of cosines.
    best = (vectors @ known.T).max(axis=0)
    return {topic: float(value) for topic, value in zip(labeller["topics"], best)}


def tag(labeller: dict, article: str, threshold: float = 0.3) -> list[str]:
    """
    Every topic whose description is close enough, best first.

    Multi-label falls out of the shape of the thing: each topic is compared
    with the article on its own, so several can pass, or none.

    The threshold is not a probability. It is a cosine, it has no calibrated
    meaning, and choosing it by its effect on errors takes articles whose
    topics are already known — a labelled corpus, the very thing this rung was
    supposed to save you.
    """
    scored = score(labeller, article)
    kept = [topic for topic, value in scored.items() if value >= threshold]
    return sorted(kept, key=lambda topic: (-scored[topic], topic))


def _encode(encoder, texts: list[str]) -> np.ndarray:
    """
    One row of length one per text, or an error: a wrong count, a ragged
    table or a non-number is not a score. Cosine similarity is a dot product
    once both sides have length one.
    """
    raw = encoder.encode(texts)
    if len(raw) != len(texts):
        raise ValueError("the encoder returned a vector count that does not match the texts")
    if not texts:
        return np.zeros((0, 0))
    vectors = np.asarray(raw, dtype=float)  # raises on rows of different widths
    if vectors.ndim != 2 or not np.isfinite(vectors).all():
        raise ValueError("the encoder returned something that is not a table of finite numbers")
    norms = np.linalg.norm(vectors, axis=1, keepdims=True)
    return vectors / np.where(norms == 0, 1.0, norms)

JavaScript

snippets/tag-articles-by-topic/n2.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Étiquetage sans exemple : comparer l'article aux étiquettes elles-mêmes.
 *
 * Niveau N2. Le niveau N1 demandait un corpus étiqueté, et un thème nouveau
 * voulait dire tout réétiqueter. Ici un thème n'est qu'une courte description,
 * encodée comme n'importe quel autre texte : en ajouter un coûte une ligne, et
 * le premier article peut être étiqueté le jour même.
 *
 * C'est tout l'attrait du niveau, et il est réel. Ce qu'il coûte est un fichier
 * de modèle à livrer et à tenir à jour, un processus chaud pour le garder en
 * mémoire, et un score qui est un cosinus plutôt qu'une probabilité : il peut
 * être négatif, les thèmes d'un même article ne somment pas à un, et un seul
 * seuil doit servir tous les thèmes.
 */

// The ONNX conversion of sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2.
export const MODEL_NAME = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';

// The Python encoder reads 128 tokens and silently drops the rest. With this
// model's tokenizer, 400 characters of French prose come to 110 tokens, so the
// article is encoded in passages of that size, in both languages. Text the
// tokenizer cuts finer, code or a table, can still overflow.
export const PASSAGE_CHARACTERS = 400;

const SENTENCE_END = /(?<=[.!?])\s+|\s*\n\s*/;

/** The real encoder: fetched once, then held in memory and run locally. */
export async function loadEncoder(name = MODEL_NAME) {
  const { pipeline } = await import('@huggingface/transformers');
  // Full-precision weights, the ones Python loads, rather than a quantised copy.
  const extract = await pipeline('feature-extraction', name, { dtype: 'fp32' });
  return { encode: async (texts) => (await extract(texts, { pooling: 'mean' })).tolist() };
}

/**
 * Encode the topic descriptions once, and keep the encoder for the articles.
 *
 * `topics` maps a topic name to the sentence that stands for it. Write it as a
 * human would say it out loud: the model card describes an encoder of
 * sentences and paragraphs.
 *
 * `encoder` is injected so this can be tested without loading a model. Left
 * alone, it is the real one above.
 */
export async function buildLabeller(topics, encoder) {
  const model = encoder ?? (await loadEncoder());
  const names = Object.keys(topics);
  const vectors = await encode(model, names.map((name) => topics[name]));
  return { topics: names, vectors, encoder: model };
}

/** Consecutive sentences joined into pieces of at most `size` characters. */
export function passages(article, size = PASSAGE_CHARACTERS) {
  const pieces = [];
  for (const sentence of article.trim().split(SENTENCE_END).map((s) => s.trim()).filter(Boolean)) {
    if (pieces.length && pieces.at(-1).length + 1 + sentence.length <= size) {
      pieces[pieces.length - 1] = `${pieces.at(-1)} ${sentence}`;
    } else {
      pieces.push(sentence); // a sentence longer than `size` stays whole
    }
  }
  return pieces;
}

/**
 * The best cosine, over the passages of the article, with each topic
 * description: a topic covered in one paragraph counts.
 */
export async function score(labeller, article) {
  const pieces = passages(article);
  const vectors = pieces.length ? await encode(labeller.encoder, pieces) : [];
  const width = labeller.vectors[0]?.length;
  if (width !== undefined && vectors.some((vector) => vector.length !== width)) {
    throw new Error('the encoder returned vectors of another width for the article');
  }
  const scored = {};
  labeller.topics.forEach((topic, index) => {
    scored[topic] = vectors.length ? Math.max(...vectors.map((v) => dot(labeller.vectors[index], v))) : 0;
  });
  return scored;
}

/**
 * Every topic whose description is close enough, best first.
 *
 * Multi-label falls out of the shape of the thing: each topic is compared with
 * the article on its own, so several can pass, or none.
 *
 * The threshold is not a probability. It is a cosine, it has no calibrated
 * meaning, and choosing it by its effect on errors takes articles whose topics
 * are already known — a labelled corpus, the very thing this rung was supposed
 * to save you.
 */
export async function tag(labeller, article, threshold = 0.3) {
  const scored = await score(labeller, article);
  return labeller.topics
    .filter((topic) => scored[topic] >= threshold)
    .sort((a, b) => scored[b] - scored[a] || (a < b ? -1 : a > b ? 1 : 0));
}

/** One unit vector per text, or an error: a wrong count or a non-number is not a score. */
async function encode(encoder, texts) {
  const vectors = (await encoder.encode(texts)).map((vector) => Array.from(vector, Number));
  if (vectors.length !== texts.length || new Set(vectors.map((v) => v.length)).size > 1) {
    throw new Error('the encoder returned the wrong number or shape of vectors');
  }
  if (!vectors.every((vector) => vector.every(Number.isFinite))) {
    throw new Error('the encoder returned a value that is not a finite number');
  }
  return vectors.map(unit);
}

/** Cosine similarity is a dot product once both sides have length one. */
function unit(vector) {
  let sum = 0;
  for (const v of vector) sum += v * v;
  const norm = Math.sqrt(sum);
  return norm ? vector.map((v) => v / norm) : [...vector];
}

function dot(a, b) {
  let total = 0;
  for (let i = 0; i < a.length; i += 1) total += a[i] * b[i];
  return total;
}

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte modérée

Ce que cela ajoute à votre périmètre réglementaire

  • Traitement sur votre infrastructure : ni l’article ni les descriptions de thèmes n’en sortent
  • Les poids du modèle d’encodage viennent d’un tiers, avec la licence et la provenance des données d’entraînement que cela suppose

Point de rupture

Le score est un cosinus, pas une probabilité : il peut être négatif, les scores d’un même article ne somment pas à un, et un seul seuil sert tous les thèmes. Le test le montre sur des vecteurs écrits à la main. Choisir ce seuil d’après les erreurs qu’il fait demande des articles dont on connaît déjà les thèmes : le corpus que ce niveau devait épargner. Ce que vaut un même cosinus d’un thème à l’autre avec le vrai encodeur, aucun test d’ici ne le mesure.

Quand passer au niveau suivant

Aucun réglage du seuil ne vous donne à la fois les thèmes secondaires réels et le silence sur les thèmes absents, et vos relecteurs retirent plus d’étiquettes qu’ils n’en gardent.

N3Étiquetage par appel à un modèle généralisteélevé · Une seconde

Étiquetage par appel à un modèle généraliste

API de LLM généralisteN3élevéUne seconde

Python

snippets/tag-articles-by-topic/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Étiqueter des articles en interrogeant un modèle généraliste.

Niveau N3. Sur cette fiche, c'est le seul niveau qui ne demande ni liste de
termes, ni corpus étiqueté, ni fichier de modèle. Il est là pour qu'on voie ce
qu'il coûte, pas parce que cette fiche le recommande.

Voyez ce que le code doit faire et que le niveau N0 n'avait pas à faire :
borner la taille de l'entrée, réessayer en cas d'échec, analyser une réponse
qui n'est que probablement du JSON valide, et ne garder que les thèmes qui
existent dans votre taxonomie. Ce dernier point n'est pas facultatif — rien
n'empêche un modèle de répondre un nom absent de la liste — et c'est pourquoi
le vocabulaire reste un paramètre ici, exactement comme au niveau le plus bas.

Cette plomberie est le vrai coût de ce niveau, et c'est la part que vos tests
doivent couvrir, parce qu'un test contre un double ne dit rien de la façon
dont le modèle étiquette.
"""

from __future__ import annotations

import json
import unicodedata

PROMPT = (
    "Tag the article below with the topics it covers.\n"
    "Choose only from this list, and answer with the spellings given:\n"
    "{topics}\n"
    "An article may cover several topics, or none at all.\n"
    "Answer with JSON only: a list of topic names, empty if none apply.\n\n"
    "Article:\n{article}"
)

MAX_CHARACTERS = 12000

# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, temperature: float) -> str:
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
        )
        return response.choices[0].message.content


class TaggingUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def tag(article: str, topics: list[str], client=None, *, attempts: int = 3) -> list[str]:
    """
    The topics of the article, in the order of the taxonomy.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    # A model charges by the token. Refusing oversized input is not an
    # optimisation, it is a cost control.
    if len(article) > MAX_CHARACTERS:
        raise ValueError(f"article longer than {MAX_CHARACTERS} characters")
    # No text or no topic: the answer is known, and asking would still be billed.
    if not article.strip() or not topics:
        return []
    return _ask(client or ProviderClient(), article, topics, attempts)


def _key(name: str) -> str:
    """The same name whatever its case, its spacing or its Unicode form."""
    return unicodedata.normalize("NFC", name).strip().casefold()


def _ask(client, article: str, topics: list[str], attempts: int) -> list[str]:
    prompt = PROMPT.format(topics="\n".join(f"- {topic}" for topic in topics), article=article)
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            # Temperature zero, the low end of the range, which the provider
            # documents as more focused and deterministic. It does not promise
            # that two identical calls agree.
            answer = client.complete(prompt=prompt, temperature=0)
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
            continue
        try:
            return _decode(answer, topics)
        except ValueError as error:  # an unusable answer is asked for again
            last_error = error
    raise TaggingUnavailable(str(last_error))


def _decode(answer, topics: list[str]) -> list[str]:
    """
    Keep only what the taxonomy knows, in the taxonomy's own order. A model
    that answers "actualité juridique" must not create a topic in your
    database, and the order of its answer must not decide the order of yours.
    """
    if not isinstance(answer, str):  # the SDK types the content as optional
        raise ValueError("the model returned no text")
    # A JSON answer wrapped whole in one code fence is read; nothing else is:
    # a fence opened and never closed, or prose around it, is a failed answer.
    text = answer.strip()
    if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
        text = text[3:-3].removeprefix("json")
    try:
        parsed = json.loads(text)
    except RecursionError as error:
        raise ValueError("the answer is nested too deep to read") from error
    # A list of anything but names, or of names none of which is on the list,
    # must not pass for the legitimate empty answer.
    if not isinstance(parsed, list) or not all(isinstance(name, str) for name in parsed):
        raise ValueError("the model answered something that is not a list of names")
    answered = {_key(name) for name in parsed}
    kept = [topic for topic in topics if _key(topic) in answered]
    if parsed and not kept:
        raise ValueError("the model answered only names that are not in the taxonomy")
    return kept

JavaScript

snippets/tag-articles-by-topic/n3.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Étiqueter des articles en interrogeant un modèle généraliste.
 *
 * Niveau N3. Sur cette fiche, c'est le seul niveau qui ne demande ni liste de
 * termes, ni corpus étiqueté, ni fichier de modèle. Il est là pour qu'on voie ce
 * qu'il coûte, pas parce que cette fiche le recommande.
 *
 * Voyez ce que le code doit faire et que le niveau N0 n'avait pas à faire :
 * borner la taille de l'entrée, réessayer en cas d'échec, analyser une réponse
 * qui n'est que probablement du JSON valide, et ne garder que les thèmes qui
 * existent dans votre taxonomie. Ce dernier point n'est pas facultatif — rien
 * n'empêche un modèle de répondre un nom absent de la liste — et c'est pourquoi
 * le vocabulaire reste un paramètre ici, exactement comme au niveau le plus bas.
 *
 * Cette plomberie est le vrai coût de ce niveau, et c'est la part que vos tests
 * doivent couvrir, parce qu'un test contre un double ne dit rien de la façon
 * dont le modèle étiquette.
 */

const PROMPT = [
  'Tag the article below with the topics it covers.',
  'Choose only from this list, and answer with the spellings given:',
  '{topics}',
  'An article may cover several topics, or none at all.',
  'Answer with JSON only: a list of topic names, empty if none apply.',
  '',
  'Article:',
].join('\n');

export const MAX_CHARACTERS = 12000;

// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

export class TaggingUnavailable extends Error {}

/**
 * The topics of the article, in the order of the taxonomy.
 *
 * @param {string} article
 * @param {string[]} topics the controlled vocabulary, as on rung N0
 * @param {object} options
 * @param {{complete: Function}} [options.client] injected so this can be
 *   tested without a network call; defaults to a real provider client
 * @param {number} [options.attempts]
 */
export async function tag(article, topics, { client, attempts = 3 } = {}) {
  // A model charges by the token. Refusing oversized input is not an
  // optimisation, it is a cost control. Counted in code points, as Python
  // counts characters.
  if ([...article].length > MAX_CHARACTERS) {
    throw new RangeError(`article longer than ${MAX_CHARACTERS} characters`);
  }
  // No text or no topic: the answer is known, and asking would still be billed.
  if (!article.trim() || topics.length === 0) return [];
  return ask(client ?? (await providerClient()), article, topics, attempts);
}

/** The same name whatever its case, its spacing or its Unicode form. */
const key = (name) => name.normalize('NFC').trim().toLowerCase();

async function ask(client, article, topics, attempts) {
  const prompt = `${PROMPT.replace('{topics}', topics.map((t) => `- ${t}`).join('\n'))}\n${article}`;
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    let answer;
    try {
      // Temperature zero, the low end of the range, which the provider
      // documents as more focused and deterministic. It does not promise
      // that two identical calls agree.
      answer = await client.complete({ prompt, temperature: 0 });
    } catch (error) {
      lastError = error; // any provider failure is retried
      continue;
    }
    try {
      return decode(answer, topics);
    } catch (error) {
      lastError = error; // an unusable answer is asked for again
    }
  }
  throw new TaggingUnavailable(String(lastError));
}

/**
 * Keep only what the taxonomy knows, in the taxonomy's own order. A model that
 * answers "actualité juridique" must not create a topic in your database, and
 * the order of its answer must not decide the order of yours.
 */
function decode(answer, topics) {
  if (typeof answer !== 'string') throw new Error('the model returned no text');
  // A JSON answer wrapped whole in one code fence is read; nothing else is: a
  // fence opened and never closed, or prose around it, is a failed answer.
  const text = answer.trim();
  const fenced = text.startsWith('```') && text.endsWith('```') && text.split('```').length === 3;
  const parsed = JSON.parse(fenced ? text.slice(3, -3).replace(/^json/, '') : text);
  // A list of anything but names, or of names none of which is on the list,
  // must not pass for the legitimate empty answer.
  if (!Array.isArray(parsed) || !parsed.every((name) => typeof name === 'string')) {
    throw new Error('the model answered something that is not a list of names');
  }
  const answered = new Set(parsed.map(key));
  const kept = topics.filter((topic) => answered.has(key(topic)));
  if (parsed.length && !kept.length) {
    throw new Error('the model answered only names that are not in the taxonomy');
  }
  return kept;
}

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat variable d’un appel à l’autre
  • Difficile à tester
  • Dépend d’un fournisseur externe
  • Empreinte élevée

Ce que cela ajoute à votre périmètre réglementaire

  • Transfert du texte de l’article à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur
  • Ne vous dispense pas des engagements de confidentialité pris sur les articles non encore publiés

Point de rupture

La réponse est du texte libre : l’extrait demande du JSON, il ne peut pas l’imposer, et « Bien sûr ! Voici les thèmes de cet article : » arrive par le même canal qu’une liste. Or la liste vide est ici une réponse légitime, puisque des articles ne portent aucun thème : avaler l’erreur rendrait la panne indiscernable du résultat correct, et les articles tomberaient sans bruit de toutes les pages de rubrique. L’extrait lève une erreur, pour de la prose comme pour une liste d’autre chose que des noms ou de noms dont aucun n’est dans la taxonomie, et l’appelant décide.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus.

Pour aller plus loin

Métadonnées