Trier des produits par pertinence
Ordonner les produits d’une page de catalogue pour que les plus pertinents apparaissent en premier.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0 l’emporte parce que le seul livrable qui compte ici est un ordre qu’on peut défendre : les quatre poids sont des arguments d’appel, la fonction rend les signaux à côté du score, et une réclamation du rayon se règle en déplaçant un nombre l’après-midi même. N1 ne coûte rien de plus au service — une somme pondérée des mêmes signaux, rendus à côté du score — mais il échange un test unitaire contre un journal de clics à collecter, à conserver et à croire, et son propre test montre qu’il n’apprend rien d’un signal que le classement précédent n’a pas fait varier. Montez à N1 le jour où votre trafic dit quelque chose que vos poids ne disent pas, et servez alors avec sa propre fonction : des poids appris peuvent être négatifs, et la moyenne de N0 les refuse.
Score pondéré sur quatre signaux compris entre zéro et un
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Trier des produits par un score pondéré : correspondance textuelle,
disponibilité, marge, popularité.
Niveau N0. Déterministe, sans dépendance, et surtout discutable : les quatre
pondérations sont des arguments, pas des constantes enfouies dans le code.
C'est tout l'intérêt de l'approche. Quand le commerce dit que le premier
résultat est mauvais, la discussion porte sur un nombre qu'un responsable de
rayon peut lire, et la réponse arrive dans l'après-midi plutôt que dans un
cycle de réentraînement.
Deux décisions la rendent utilisable.
Chaque signal est sur la même échelle de zéro à un avant que les pondérations
ne le touchent : le texte et la disponibilité par construction, la marge et
la popularité parce qu'une valeur hors de l'échelle y est ramenée plutôt que
laissée écraser les autres. Un poids de deux compte vraiment deux fois plus,
et le score, moyenne sur des pondérations qui ne peuvent pas être négatives,
reste dans cette échelle.
Ramenée, et non refusée : une marge de -0,05 existe pour de bon — un
déstockage vendu à perte — et cette fonction est appelée sur chaque page de
résultats. Un seul produit mal renseigné ne doit pas faire tomber toute la
page de catégorie. Les produits dont un signal a dû être déplacé ressortent
nommés, pour que le défaut se voie au lieu de se taire. Les pondérations sont
une autre affaire : elles viennent du code, et une pondération qui n'est pas
un nombre fini, nul ou positif, est refusée.
Le tri est stable : deux produits que le score ne sait pas séparer restent
dans l'ordre où le catalogue les a donnés. Un tri instable rebattrait les
résultats à égalité d'un affichage à l'autre, et plus personne ne saurait
reproduire une réclamation.
"""
import math
import unicodedata
# The order the weights are applied in. Fixing it keeps the arithmetic
# identical everywhere, which is what makes a ranking reproducible.
SIGNALS = ("text", "availability", "margin", "popularity")
# A starting point, not a truth. These are the numbers to argue about.
DEFAULT_WEIGHTS = {"text": 6.0, "availability": 2.0, "margin": 1.0, "popularity": 3.0}
def fold(text: str) -> str:
"""
Lowercase and drop accents, so that "crème" finds "creme".
Only the diacritics Latin scripts use (U+0300 to U+036F) are dropped: in
Devanagari or Arabic the vowel signs are marks too, and dropping them
would turn one word into another.
"""
decomposed = unicodedata.normalize("NFD", text.lower())
return "".join(c for c in decomposed if not "\u0300" <= c <= "\u036f")
def terms(text: str) -> list[str]:
"""Split on anything that is not a letter, a mark or a digit."""
letters = "".join(c if c.isalnum() or unicodedata.category(c)[0] == "M" else " " for c in fold(text))
return letters.split()
def text_match(query: str, product: dict) -> float:
"""
Share of the query terms found at the start of a word of the product.
Prefix matching, not equality: a shopper who types "chauss" is looking
for "chaussures", and "sandale" finds "sandales". Not the other way round:
"sandales" does not find "sandale".
It is a share, not a relevance score. On a one-word query every product
that holds the word scores 1.0, and the ranking is then decided entirely by
stock, margin and popularity. If you already run a search engine — BM25 in
PostgreSQL, Elasticsearch, Meilisearch — put its score here instead,
normalised to nought-to-one. The value of this rung is the arbitration
between the four weights, not this signal.
"""
wanted = terms(query)
if not wanted:
return 0.0
haystack = terms(product["title"] + " " + " ".join(product.get("tags", ())))
found = sum(1 for term in wanted if any(word.startswith(term) for word in haystack))
return found / len(wanted)
# The signals that come from the catalogue rather than from the query, and can
# therefore arrive out of scale.
FROM_DATA = ("margin", "popularity")
def on_scale(value) -> tuple[float, bool]:
"""
Bring a value onto the nought-to-one scale, and say whether it had to move.
Anything that is not a finite number reads as nought: a margin arriving as
None, as a string or as NaN is a data fault, not a ranking signal, and it
is reported rather than trusted.
"""
number = value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
if number is None or not math.isfinite(number):
return 0.0, True
bounded = min(max(float(number), 0.0), 1.0)
return bounded, bounded != float(number)
def out_of_scale(product: dict) -> list[str]:
"""The catalogue signals `signals` had to move, named for the caller."""
return [name for name in FROM_DATA if on_scale(product[name])[1]]
def signals(product: dict, query: str) -> dict[str, float]:
"""The four signals, each on the same nought-to-one scale."""
measured = {
"text": text_match(query, product),
"availability": 1.0 if product["in_stock"] else 0.0,
}
for name in FROM_DATA:
measured[name] = on_scale(product[name])[0]
return measured
def score(measured: dict[str, float], weights: dict[str, float]) -> float:
"""Weighted mean of the signals, so the score stays on the same scale."""
missing = [name for name in SIGNALS if name not in weights]
if missing:
raise ValueError(f"every signal needs a weight, and these have none: {', '.join(missing)}")
for name in SIGNALS:
weight = weights[name]
ok = isinstance(weight, (int, float)) and not isinstance(weight, bool) and math.isfinite(weight)
if not ok or weight < 0:
# Unlike a signal, a weight comes from the code, not from the
# catalogue: it is refused rather than brought back into line.
raise ValueError(f"a weight must be a finite number, nought or above: {name} is {weight!r}")
total = 0.0
weighted = 0.0
for name in SIGNALS:
total += weights[name]
weighted += weights[name] * measured[name]
return weighted / total if total else 0.0
def rank(products: list[dict], query: str, weights: dict = DEFAULT_WEIGHTS) -> list[dict]:
"""
Sort the catalogue, best first, and hand back the reason for each place.
Returning the signals alongside the score costs nothing and settles most
arguments before they start: whoever asks why a product came third can see
which signal held it back. `out_of_scale` names the catalogue signals that
had to be brought back onto the scale, so that a margin of -0.05 shows up
in the output instead of taking the page down.
"""
scored = []
for product in products:
measured = signals(product, query)
scored.append({
"product": product,
"score": score(measured, weights),
"signals": measured,
"out_of_scale": out_of_scale(product),
})
# Stable: products the score cannot separate keep their catalogue order.
scored.sort(key=lambda row: -row["score"])
return scoredJavaScript
/**
* Trier des produits par un score pondéré : correspondance textuelle,
* disponibilité, marge, popularité.
*
* Niveau N0. Déterministe, sans dépendance, et surtout discutable : les quatre
* pondérations sont des arguments, pas des constantes enfouies dans le code.
* C'est tout l'intérêt de l'approche. Quand le commerce dit que le premier
* résultat est mauvais, la discussion porte sur un nombre qu'un responsable de
* rayon peut lire, et la réponse arrive dans l'après-midi plutôt que dans un
* cycle de réentraînement.
*
* Deux décisions la rendent utilisable.
*
* Chaque signal est sur la même échelle de zéro à un avant que les pondérations
* ne le touchent : le texte et la disponibilité par construction, la marge et
* la popularité parce qu'une valeur hors de l'échelle y est ramenée plutôt que
* laissée écraser les autres. Un poids de deux compte vraiment deux fois plus,
* et le score, moyenne sur des pondérations qui ne peuvent pas être négatives,
* reste dans cette échelle.
*
* Ramenée, et non refusée : une marge de -0,05 existe pour de bon — un
* déstockage vendu à perte — et cette fonction est appelée sur chaque page de
* résultats. Un seul produit mal renseigné ne doit pas faire tomber toute la
* page de catégorie. Les produits dont un signal a dû être déplacé ressortent
* nommés, pour que le défaut se voie au lieu de se taire. Les pondérations sont
* une autre affaire : elles viennent du code, et une pondération qui n'est pas
* un nombre fini, nul ou positif, est refusée.
*
* Le tri est stable : deux produits que le score ne sait pas séparer restent
* dans l'ordre où le catalogue les a donnés. Un tri instable rebattrait les
* résultats à égalité d'un affichage à l'autre, et plus personne ne saurait
* reproduire une réclamation.
*/
// The order the weights are applied in. Fixing it keeps the arithmetic
// identical everywhere, which is what makes a ranking reproducible.
export const SIGNALS = ['text', 'availability', 'margin', 'popularity'];
// A starting point, not a truth. These are the numbers to argue about.
export const DEFAULT_WEIGHTS = { text: 6, availability: 2, margin: 1, popularity: 3 };
/**
* Lowercase and drop accents, so that "crème" finds "creme".
*
* Only the diacritics Latin scripts use (U+0300 to U+036F) are dropped: in
* Devanagari or Arabic the vowel signs are marks too, and dropping them would
* turn one word into another.
*/
export function fold(text) {
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
/** Split on anything that is not a letter, a mark or a digit. */
export function terms(text) {
return fold(text).match(/[\p{L}\p{M}\p{N}]+/gu) ?? [];
}
/**
* Share of the query terms found at the start of a word of the product.
*
* Prefix matching, not equality: a shopper who types "chauss" is looking for
* "chaussures", and "sandale" finds "sandales". Not the other way round:
* "sandales" does not find "sandale".
*
* It is a share, not a relevance score. On a one-word query every product that
* holds the word scores 1.0, and the ranking is then decided entirely by stock,
* margin and popularity. If you already run a search engine — BM25 in
* PostgreSQL, Elasticsearch, Meilisearch — put its score here instead,
* normalised to nought-to-one. The value of this rung is the arbitration
* between the four weights, not this signal.
*/
export function textMatch(query, product) {
const wanted = terms(query);
if (wanted.length === 0) return 0;
const haystack = terms(`${product.title} ${(product.tags ?? []).join(' ')}`);
const found = wanted.filter((term) => haystack.some((word) => word.startsWith(term)));
return found.length / wanted.length;
}
// The signals that come from the catalogue rather than from the query, and can
// therefore arrive out of scale.
export const FROM_DATA = ['margin', 'popularity'];
/**
* Bring a value onto the nought-to-one scale, and say whether it had to move.
*
* Anything that is not a finite number reads as nought: a margin arriving as
* null, as a string or as NaN is a data fault, not a ranking signal, and it is
* reported rather than trusted.
*/
export function onScale(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return [0, true];
const bounded = Math.min(Math.max(value, 0), 1);
return [bounded, bounded !== value];
}
/** The catalogue signals `signals` had to move, named for the caller. */
export function outOfScale(product) {
return FROM_DATA.filter((name) => onScale(product[name])[1]);
}
/** The four signals, each on the same nought-to-one scale. */
export function signals(product, query) {
// A field the catalogue does not have at all is a schema fault, not a dirty
// value: it is refused, as it is in Python.
for (const name of [...FROM_DATA, 'inStock']) {
if (!(name in product)) throw new RangeError(`the catalogue gives no ${name} for this product`);
}
return {
text: textMatch(query, product),
availability: product.inStock ? 1 : 0,
margin: onScale(product.margin)[0],
popularity: onScale(product.popularity)[0],
};
}
/** Weighted mean of the signals, so the score stays on the same scale. */
export function score(measured, weights) {
const missing = SIGNALS.filter((name) => !(name in weights));
if (missing.length > 0) {
throw new RangeError(`every signal needs a weight, and these have none: ${missing.join(', ')}`);
}
for (const name of SIGNALS) {
const weight = weights[name];
// Unlike a signal, a weight comes from the code, not from the catalogue:
// it is refused rather than brought back into line.
if (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 0) {
throw new RangeError(`a weight must be a finite number, nought or above: ${name} is ${weight}`);
}
}
let total = 0;
let weighted = 0;
for (const name of SIGNALS) {
total += weights[name];
weighted += weights[name] * measured[name];
}
return total ? weighted / total : 0;
}
/**
* Sort the catalogue, best first, and hand back the reason for each place.
*
* Returning the signals alongside the score costs nothing and settles most
* arguments before they start: whoever asks why a product came third can see
* which signal held it back. `outOfScale` names the catalogue signals that had
* to be brought back onto the scale, so that a margin of -0.05 shows up in the
* output instead of taking the page down.
*/
export function rank(products, query, weights = DEFAULT_WEIGHTS) {
const scored = products.map((product) => {
const measured = signals(product, query);
return {
product,
score: score(measured, weights),
signals: measured,
outOfScale: outOfScale(product),
};
});
// Array.prototype.sort is stable: products the score cannot separate keep
// their catalogue order.
scored.sort((a, b) => b.score - a.score);
return scored;
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Le classement rend les quatre signaux avec le score : qui veut discuter un rang voit dans le tableau ce qui l’a produit.
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 qu’il renvoie
| Produit | Texte | En stock | Marge | Popularité | Score |
|---|---|---|---|---|---|
| Chaussures de course Route 5 | 100 % | oui | 35 % | 80 % | 85 % |
| Chaussures de course Trail 3 | 100 % | oui | 42 % | 30 % | 60 % |
| Chaussettes de running | 33 % | oui | 60 % | 55 % | 58 % |
| Sac à dos de randonnée | 33 % | oui | 48 % | 20 % | 39 % |
| Montre GPS Cadence | 0 % | non | 25 % | 70 % | 37 % |
| Sandales de randonnée Ultra | 33 % | oui | 40 % | 5 % | 31 % |
Poids : texte 3, stock 2, marge 1, popularité 6. La cellule surlignée est le signal qui a le plus poussé le premier produit.
Là, ça casseVoir le niveau au-dessus
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é : aucune donnée personnelle n’entre dans le score, et le catalogue ne quitte pas votre infrastructure
- La marge est un paramètre de classement explicite : l’information due sur les principaux paramètres de classement est à vérifier selon votre statut d’intermédiaire
Point de rupture
Les pondérations sont réglées à la main sur le catalogue du moment, et elles vieillissent en silence. Le test les règle sur le catalogue d’automne, où les produits populaires étaient aussi les pertinents ; au printemps, une gamme qui n’a presque rien vendu perd sur sa propre requête, « sandales randonnée », contre le succès de la saison précédente — mêmes poids, même code. Rien ne le signale : ni exception, ni test rouge, ni ligne de journal. Il faut que quelqu’un s’en aperçoive et déplace un nombre.
Quand passer au niveau suivant
Vous rejouez le même arbitrage entre pertinence, marge et popularité à chaque changement de saison, et votre journal de clics est assez fourni pour dire ce que les acheteurs choisissent réellement.
Les quatre options
- RecommandéN0Score pondéré sur quatre signaux compris entre zéro et unGratuit · Instantané
- N1Pondérations apprises du journal de clics, paire par pairenégligeable · Instantané
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Pondérations apprises du journal de clics, paire par pairenégligeable · Instantané
Pondérations apprises du journal de clics, paire par paire
Modèle classique légerN1négligeableInstantané
Python
"""
Apprendre les pondérations de classement des interactions passées au lieu de
les régler à la main.
Niveau N1. Le score repose sur les mêmes quatre signaux qu'en N0, chacun
entre zéro et un. Ce qui change, c'est l'origine des quatre pondérations : le
jugement d'un responsable de rayon en N0, le journal de clics ici.
La méthode est par paires, et une seule sorte de paire compte : un produit
cliqué contre un produit montré **au-dessus de lui** et laissé de côté. C'est
la stratégie « Click > Skip Above » de Joachims et de ses coauteurs, et sa
raison d'être est le biais de position qu'ils ont mesuré : un produit montré
sous le clic n'a peut-être jamais été regardé, si bien que l'apparier au clic
n'enseigne rien sur les produits et tout sur l'ordre que le classement
précédent produisait déjà. Le modèle apprendrait à reproduire N0, ce qui est
l'inverse de ce pour quoi on monte.
Chaque paire retenue devient une ligne d'entraînement, la différence
entre les deux vecteurs de signaux, et une régression logistique sur ces
différences redonne les pondérations du score d'origine. Les signaux montrés
au rayon restent les mêmes ; le score, non. Des pondérations apprises peuvent
être négatives : le score est donc une simple somme sur des pondérations dont
les valeurs absolues totalisent un, entre moins un et un, là où N0 prend une
moyenne sur des pondérations qui ne peuvent pas être négatives.
Chaque paire est ajoutée dans les deux sens, l'une étiquetée gagnante et
l'autre perdante. Cela maintient les deux classes équilibrées, et c'est
pourquoi le modèle ne porte pas d'ordonnée à l'origine : une constante
décalerait les deux sens d'une même paire de la même façon, ce qui n'a pas
de sens quand on compare deux produits d'une même page de résultats.
"""
import math
import numpy as np
from sklearn.linear_model import LogisticRegression
SIGNALS = ("text", "availability", "margin", "popularity")
def _in_scale(value) -> bool:
"""A finite number between 0 and 1, the scale N0 serves the signals on."""
if not isinstance(value, (int, float)) or isinstance(value, bool):
return False
return math.isfinite(value) and 0.0 <= value <= 1.0
def pairs(impressions: list[list[dict]]) -> tuple[np.ndarray, np.ndarray]:
"""
Turn result pages into training rows.
`impressions` is one entry per result page shown to a shopper, **in the
order the page displayed them**, each item holding the signals logged at
serving time and whether it was clicked. That order is the whole point:
only the products above a click are paired with it.
Logging the signals rather than recomputing them later matters too: a
product that has since gone out of stock must be trained on the
availability it had on the day, not on today's.
A log where the first result is always clicked produces no pair at all,
and `learn_weights` says so rather than inventing weights.
"""
rows, labels = [], []
for page in impressions:
# The type is checked, not only the range: a signal logged as None or
# as a string is a broken log, not a value to be cleaned up here.
if not all(_in_scale(item["signals"][name]) for item in page for name in SIGNALS):
raise ValueError("every logged signal must lie between 0 and 1")
for position, item in enumerate(page):
if not item["clicked"]:
continue
winner = item["signals"]
for above in page[:position]:
if above["clicked"]:
continue # two clicks say nothing about each other
difference = [winner[name] - above["signals"][name] for name in SIGNALS]
rows.append(difference)
labels.append(1)
rows.append([-value for value in difference])
labels.append(0)
return np.array(rows, dtype=float), np.array(labels)
def learn_weights(impressions: list[list[dict]], regularisation: float = 1.0) -> dict:
"""
Fit the weights, and hand them back on the scale of the hand-set ones.
Dividing by the total absolute weight makes the result readable next to
the numbers of N0, and comparable between two months of log. It changes no
ranking: scaling every weight scales every score the same way.
"""
rows, labels = pairs(impressions)
if len(rows) == 0:
raise ValueError(
"no click with an ignored product above it in the log: nothing to learn from"
)
model = LogisticRegression(C=regularisation, fit_intercept=False, max_iter=1000)
model.fit(rows, labels)
learnt = model.coef_[0]
scale = float(np.abs(learnt).sum())
if scale == 0:
raise ValueError("clicked and ignored products never differ in the log: nothing to learn from")
return {name: float(value) / scale for name, value in zip(SIGNALS, learnt)}
def rank(candidates: list[dict], weights: dict) -> list[dict]:
"""
Score candidates whose signals were computed by the serving pipeline.
A sum weighted over the same signals, the same stable sort, the signals
handed back with each candidate. Not N0's mean: dividing by the signed
total of learned weights would reverse the order when that total is
negative, and wipe it out when it is nought.
"""
scored = []
for candidate in candidates:
total = 0.0
for name in SIGNALS:
total += weights[name] * candidate["signals"][name]
scored.append({"candidate": candidate, "score": total})
scored.sort(key=lambda row: -row["score"])
return scoredJavaScript
/**
* Apprendre les pondérations de classement des interactions passées au lieu de
* les régler à la main.
*
* Niveau N1. Le score repose sur les mêmes quatre signaux qu'en N0, chacun
* entre zéro et un. Ce qui change, c'est l'origine des quatre pondérations : le
* jugement d'un responsable de rayon en N0, le journal de clics ici.
*
* La méthode est par paires, et une seule sorte de paire compte : un produit
* cliqué contre un produit montré **au-dessus de lui** et laissé de côté. C'est
* la stratégie « Click > Skip Above » de Joachims et de ses coauteurs, et sa
* raison d'être est le biais de position qu'ils ont mesuré : un produit montré
* sous le clic n'a peut-être jamais été regardé, si bien que l'apparier au clic
* n'enseigne rien sur les produits et tout sur l'ordre que le classement
* précédent produisait déjà. Le modèle apprendrait à reproduire N0, ce qui est
* l'inverse de ce pour quoi on monte.
*
* Chaque paire retenue devient une ligne d'entraînement, la différence
* entre les deux vecteurs de signaux, et une régression logistique sur ces
* différences redonne les pondérations du score d'origine. Les signaux montrés
* au rayon restent les mêmes ; le score, non. Des pondérations apprises peuvent
* être négatives : le score est donc une simple somme sur des pondérations dont
* les valeurs absolues totalisent un, entre moins un et un, là où N0 prend une
* moyenne sur des pondérations qui ne peuvent pas être négatives.
*
* Chaque paire est ajoutée dans les deux sens, l'une étiquetée gagnante et
* l'autre perdante. Cela maintient les deux classes équilibrées, et c'est
* pourquoi le modèle ne porte pas d'ordonnée à l'origine : une constante
* décalerait les deux sens d'une même paire de la même façon, ce qui n'a pas
* de sens quand on compare deux produits d'une même page de résultats.
*
* L'ajustement tient en quarante lignes de descente de gradient plutôt qu'en
* une dépendance, et c'est l'argument de ce niveau tout entier.
*/
export const SIGNALS = ['text', 'availability', 'margin', 'popularity'];
/**
* Every signal a finite number between 0 and 1, the scale N0 serves them on.
*
* The type is checked, not only the comparison: `null >= 0` is true in
* JavaScript, and a signal logged as null or as the string "0.9" would slip
* through and count as something. A logged signal is not a catalogue field to
* be cleaned up here — it is what the serving pipeline wrote down, and if it is
* not a number the log is broken.
*/
const inScale = (signals) => SIGNALS.every((name) => typeof signals[name] === 'number'
&& Number.isFinite(signals[name]) && signals[name] >= 0 && signals[name] <= 1);
/**
* Turn result pages into training rows.
*
* `impressions` is one entry per result page shown to a shopper, **in the order
* the page displayed them**, each item holding the signals logged at serving
* time and whether it was clicked. That order is the whole point: only the
* products above a click are paired with it.
*
* Logging the signals rather than recomputing them later matters too: a product
* that has since gone out of stock must be trained on the availability it had
* on the day, not on today's.
*
* A log where the first result is always clicked produces no pair at all, and
* `learnWeights` says so rather than inventing weights.
*/
export function pairs(impressions) {
const rows = [];
const labels = [];
for (const page of impressions) {
if (!page.every((item) => inScale(item.signals))) throw new RangeError('every logged signal must lie between 0 and 1');
page.forEach((item, position) => {
if (!item.clicked) return;
for (const above of page.slice(0, position)) {
if (above.clicked) continue; // two clicks say nothing about each other
const difference = SIGNALS.map((name) => item.signals[name] - above.signals[name]);
rows.push(difference);
labels.push(1);
rows.push(difference.map((value) => -value));
labels.push(0);
}
});
}
return { rows, labels };
}
/**
* Fit the weights, and hand them back on the scale of the hand-set ones.
*
* Dividing by the total absolute weight makes the result readable next to the
* numbers of N0, and comparable between two months of log. It changes no
* ranking: scaling every weight scales every score the same way.
*/
export function learnWeights(impressions, { regularisation = 1, epochs = 600, rate = 0.5 } = {}) {
const { rows, labels } = pairs(impressions);
if (rows.length === 0) {
throw new Error('no click with an ignored product above it in the log: nothing to learn from');
}
const learnt = new Array(SIGNALS.length).fill(0);
for (let epoch = 0; epoch < epochs; epoch += 1) {
const gradient = new Array(SIGNALS.length).fill(0);
for (let i = 0; i < rows.length; i += 1) {
const z = rows[i].reduce((sum, value, j) => sum + learnt[j] * value, 0);
const error = 1 / (1 + Math.exp(-z)) - labels[i];
rows[i].forEach((value, j) => { gradient[j] += (error * value) / rows.length; });
}
// The penalty of scikit-learn's `C`, divided by the row count because the
// gradient above is a mean: it is what makes both versions fit one model.
const penalty = regularisation * rows.length;
learnt.forEach((w, j) => { learnt[j] = w - rate * (gradient[j] + w / penalty); });
}
const scale = learnt.reduce((sum, value) => sum + Math.abs(value), 0);
if (!scale) throw new Error('clicked and ignored products never differ in the log: nothing to learn from');
return Object.fromEntries(SIGNALS.map((name, j) => [name, learnt[j] / scale]));
}
/**
* Score candidates whose signals were computed by the serving pipeline.
*
* A sum weighted over the same signals, the same stable sort, the signals
* handed back with each candidate. Not N0's mean: dividing by the signed total
* of learned weights would reverse the order when that total is negative, and
* wipe it out when it is nought.
*/
export function rank(candidates, weights) {
const scored = candidates.map((candidate) => ({
candidate,
score: SIGNALS.reduce((sum, name) => sum + weights[name] * candidate.signals[name], 0),
}));
scored.sort((a, b) => b.score - a.score);
return scored;
}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 faible
Ce que cela ajoute à votre périmètre réglementaire
- Le journal de clics est une donnée de comportement : rattaché à un identifiant de session ou de compte, il entre dans le périmètre du traitement de données personnelles sur votre infrastructure
- Sa conservation entre dans le périmètre du registre des activités de traitement et de la durée de conservation que vous fixez
- Les poids appris restent des paramètres de classement, avec le même périmètre d’information que les poids réglés à la main
Point de rupture
Le journal n’enseigne que ce que le classement précédent a fait varier. Le test donne la même marge à tous les produits de toutes les pages : la colonne correspondante ne contient plus que des zéros, le poids appris vaut exactement zéro, et une page où la marge est la seule différence entre deux produits ressort avec deux scores identiques. Même aveuglement à l’autre bout : un journal où l’acheteur prend toujours le premier résultat ne forme aucune paire, puisque ce niveau n’apparie un clic qu’aux produits montrés au-dessus de lui, et `learn_weights` le dit au lieu d’inventer des poids. Aucun surcroît de trafic n’y change rien : seul un changement de ce qu’on montre le peut — exploration, entrelacement, ou une petite part du trafic servie dans un ordre perturbé.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus dans cette fiche : au-delà, on ne change plus de modèle, on change ce qu’on montre — exploration, entrelacement, une part du trafic servie dans un ordre perturbé — pour que le journal enseigne autre chose que le classement de la veille.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Le classement se joue sur quatre signaux dont trois sont des chiffres de gestion — stock, marge, ventes — qui ne figurent dans aucun texte qu’un encodeur sémantique pourrait lire. Un modèle auto-hébergé n’améliorerait que la correspondance textuelle, sur des titres de produits de quelques mots, et il faudrait tenir un service en permanence pour cela.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Un classement se réclame : le rayon veut savoir pourquoi son produit est troisième, et la même requête doit rendre le même ordre le lendemain. Un modèle généraliste rend un ordre sans les nombres qui l’ont décidé, et son API n’est pas déterministe par défaut, de l’aveu même du fournisseur pris en exemple ; il faudrait en plus l’appeler sur chaque page de résultats pour un arbitrage entre marge, stock et pertinence qui, lui, tient dans quatre nombres.
Pour aller plus loin
- Joachims — Optimizing Search Engines using Clickthrough Data, l’apprentissage du classement par paires
- Joachims et al. — Accurately Interpreting Clickthrough Data as Implicit Feedback, sur ce qu’un clic dit et ne dit pas
- Python — Sorting HOW TO, et la stabilité garantie du tri
- MDN — Array.prototype.sort, stable depuis ES2019
Source des chiffres
- Règlement (UE) 2019/1150 (P2B), article 5 — les principaux paramètres de classement
- Joachims et al., Accurately Interpreting Clickthrough Data as Implicit Feedback — le biais de position et la stratégie « Click > Skip Above », dont N1 tire sa règle d’appariement
- CNIL — Donnée personnelle, définition
- CNIL — Le registre des activités de traitement
- OpenAI Cookbook — How to make your completions outputs reproducible with the new seed parameter
- scikit-learn — LogisticRegression
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.