Raisonnez avec les probabilités grâce à Bayes
Un arbre de décision répond « jouer » ou « ne pas jouer ». Mais un entraîneur préférerait sans doute entendre : « il y a 80 % de chances que personne ne vienne ». Les méthodes bayésiennes raisonnent directement en probabilités : elles combinent ce que l’on sait d’avance avec ce que l’on observe, et elles disent à quel point elles sont sûres d’elles.
Dans ce chapitre, nous reprenons le club de tennis du chapitre 4 et ses 14 jours de météo (9 jours où l’on a joué, 5 où l’on n’a pas joué), et nous allons refaire à la main les calculs du TP.
La règle de Bayes
Section intitulée « La règle de Bayes »Tout part d’une formule que vous avez peut-être vue en probabilités. Pour une classe (par exemple « jouer ») et une observation (la météo du jour) :
Chaque terme a un nom et un rôle :
- est la probabilité a priori (prior) de la classe, avant de regarder la météo. Ici, et .
- est la vraisemblance (likelihood) : à quel point la météo est typique des jours de cette classe.
- est la probabilité de l’observation, toutes classes confondues. Elle vaut et sert seulement à normaliser, pour que les probabilités des classes somment à 1.
- est la probabilité a posteriori : ce qu’on sait de la classe une fois la météo observée.
L’idée est très naturelle : on part de ce qu’on croyait (le prior), on le corrige par ce qu’on voit (la vraisemblance), et on obtient une croyance mise à jour.
Décider : la règle MAP et l’erreur de Bayes
Section intitulée « Décider : la règle MAP et l’erreur de Bayes »Une fois les probabilités a posteriori calculées, comment décider ? On choisit la classe la plus probable : c’est la règle du maximum a posteriori (MAP).
La notation signifie « la valeur de qui rend l’expression maximale ». Remarquez que a disparu : il est le même pour toutes les classes, il ne change donc pas laquelle l’emporte.
Si toutes les classes ont le même prior, la règle devient : c’est le maximum de vraisemblance. Mais attention, ignorer le prior est risqué quand les classes sont déséquilibrées.
Pourquoi cette règle est-elle si importante ? Parce qu’on peut montrer que, si l’on connaissait les vraies probabilités, la règle MAP serait celle qui fait le moins d’erreurs possible. Son taux d’erreur s’appelle l’erreur de Bayes : c’est un plancher théorique qu’aucun classifieur ne peut franchir, parce qu’il reste des cas où deux classes produisent exactement la même observation (deux journées à la météo identique, l’une avec joueurs, l’autre sans). C’est l’équivalent, en classification, du bruit irréductible du chapitre 2.
Comment estimer ? Deux grandes approches existent. L’approche paramétrique suppose une forme de loi, par exemple une loi normale multivariée par classe, et en estime les paramètres (moyenne, covariance). Avec une covariance propre à chaque classe, la frontière entre deux classes est une surface quadratique (on parle d’analyse discriminante quadratique, QDA) ; si l’on impose la même covariance à toutes les classes, elle devient un hyperplan (analyse discriminante linéaire, LDA). L’approche non paramétrique estime la densité localement, autour de , et mène notamment aux k plus proches voisins du chapitre 6. Le classifieur bayésien naïf, lui, fait une hypothèse radicale qui rend tout calculable.
Le classifieur bayésien naïf
Section intitulée « Le classifieur bayésien naïf »La difficulté est que contient plusieurs caractéristiques : . Estimer la loi jointe de toutes les combinaisons (ciel, température, humidité, vent) demanderait énormément de données. L’hypothèse naïve consiste à supposer les caractéristiques indépendantes sachant la classe :
Le symbole désigne un produit : la vraisemblance d’une journée complète est le produit des vraisemblances de chacune de ses caractéristiques. Chaque s’estime simplement en comptant.
Cette hypothèse est presque toujours fausse (l’humidité et le ciel sont évidemment liés). Et pourtant, le classifieur marche souvent très bien, en particulier sur le texte (filtres anti-spam). Pourquoi ? Parce que pour décider, il suffit que la bonne classe obtienne le plus grand score, même si les probabilités elles-mêmes sont mal calibrées.
Avec des dizaines de caractéristiques, ces produits deviennent minuscules et finissent par valoir 0 en machine (dépassement par le bas, underflow). On calcule donc plutôt la somme des logarithmes, : le logarithme est croissant, donc la classe gagnante ne change pas. C’est pour cela que scikit-learn expose des attributs comme class_log_prior_ et feature_log_prob_.
Le problème des zéros et le lissage de Laplace
Section intitulée « Le problème des zéros et le lissage de Laplace »Essayez maintenant un jour couvert. Parmi les 5 jours sans joueurs, aucun n’était couvert : . Le produit entier vaut 0, quelles que soient les autres caractéristiques. Une seule valeur jamais vue suffit à éliminer une classe, ce qui est beaucoup trop brutal : ce n’est pas parce qu’on n’a jamais vu un événement sur 5 jours qu’il est impossible.
Le lissage de Laplace ajoute un compte fictif à chaque valeur :
- est le nombre d’exemples de la classe pour lesquels la caractéristique vaut ;
- est le nombre d’exemples de la classe ;
- est le nombre de valeurs possibles de la caractéristique (3 pour le ciel, 2 pour le vent) ;
- est l’intensité du lissage ; correspond au lissage de Laplace proprement dit.
En TP, nous avons d’ailleurs constaté que les probabilités de CategoricalNB ne correspondaient pas à nos comptages. Les priors, eux, étaient identiques ( et ). L’explication était ce lissage, appliqué par défaut avec : pour « ensoleillé sachant jouer », nous avions compté , alors que scikit-learn donnait ; pour « humidité normale sachant ne pas jouer », à la main contre pour scikit-learn. Le modèle entraîné sur les 14 jours les classait correctement à une exception près, soit une exactitude de 0,929 mesurée sur ces mêmes données d’apprentissage.
import pandas as pdfrom sklearn.naive_bayes import CategoricalNBfrom sklearn.preprocessing import OrdinalEncoder
df = pd.read_csv("weather.nominal.csv") # outlook, temperature, humidity, windy, playX = df.drop(columns=["play"]).astype(str)y = df["play"]
encodeur = OrdinalEncoder() # chaque catégorie devient un entierX_code = encodeur.fit_transform(X)
nb = CategoricalNB(alpha=1.0).fit(X_code, y) # alpha = 1 : lissage de Laplaceprint(nb.class_log_prior_) # log(5/14), log(9/14)print(nb.predict_proba(X_code[:3])) # probabilités a posteriori des 3 premiers joursLe Bayes naïf gaussien pour les variables continues
Section intitulée « Le Bayes naïf gaussien pour les variables continues »Et si la température est donnée en degrés plutôt qu’en « chaud / doux / frais » ? On ne peut plus compter les occurrences d’une valeur précise. Le Bayes naïf gaussien suppose que, dans chaque classe, chaque variable continue suit une loi normale, dont on estime la moyenne et l’écart-type sur les exemples de la classe. La vraisemblance devient une densité :
où et sont la moyenne et l’écart-type de la caractéristique parmi les exemples de la classe .
En TP, sur la version continue du jeu météo (températures en degrés Fahrenheit, humidité en pourcentage), nous avons classé à la main le jour (ensoleillé, 73 °F, 81 %, vent). Plutôt qu’une densité, nous avons utilisé la probabilité d’un petit intervalle, de autour de la valeur observée, lue sur la loi normale :
| Classe | Température : ; | Humidité : ; | ||
|---|---|---|---|---|
| ne pas jouer | 74,6 ; 7,06 | 0,1098 | 86,2 ; 8,70 | 0,0766 |
| jouer | 73,0 ; 5,81 | 0,1366 | 79,1 ; 9,63 | 0,0811 |
En multipliant ces valeurs par les vraisemblances catégorielles (ensoleillé : et ; vent : et ) puis par les priors, et en normalisant, nous avons obtenu et : le jour est classé « ne pas jouer », ce qui correspondait à l’étiquette attendue.
from sklearn.naive_bayes import GaussianNB
meteo = pd.read_csv("weather.csv")X_num = meteo[["temperature", "humidity"]] # les deux variables continuesgnb = GaussianNB().fit(X_num, meteo["play"])print(gnb.theta_) # moyennes par classeprint(gnb.predict_proba(pd.DataFrame({"temperature": [73], "humidity": [81]})))Pour combiner variables continues et catégorielles dans un même modèle, il faut soit discrétiser les continues (des tranches d’âge, par exemple), soit multiplier soi-même les vraisemblances des deux modèles, comme nous l’avons fait à la main.
En résumé
Section intitulée « En résumé »- La règle de Bayes combine le prior et la vraisemblance pour obtenir la probabilité a posteriori .
- La règle MAP choisit la classe qui maximise ; avec les vraies probabilités, elle atteint l’erreur de Bayes, le plancher théorique.
- Le Bayes naïf suppose les caractéristiques indépendantes sachant la classe, ce qui réduit la vraisemblance à un produit de termes faciles à compter.
- On calcule en logarithmes pour éviter les dépassements par le bas.
- Le lissage de Laplace évite qu’une valeur jamais vue annule toute une classe ; scikit-learn l’applique par défaut.
- Le Bayes naïf gaussien modélise chaque variable continue par une loi normale par classe.
Exercices
Section intitulée « Exercices »Exercice · Un jour couvert
facileAvec les 14 jours du fil rouge, sans lissage, calculez les scores bayésiens naïfs des deux classes pour le jour (couvert, frais, humidité élevée, vent). Que constatez-vous ?
Voir la solution
Pour « ne pas jouer », aucun des 5 jours n’était couvert : , donc le score de la classe est nul, quelles que soient les autres valeurs.
Pour « jouer » : .
La décision est « jouer » avec une probabilité a posteriori de 1 : le modèle est certain, uniquement parce qu’il n’a jamais vu de jour couvert sans joueurs sur un échantillon de 5 jours. C’est exactement le problème que résout le lissage de Laplace.
En code
import numpy as npimport pandas as pd
# Les 14 jours du fil rougemeteo = pd.DataFrame( [r.split() for r in """ensoleillé chaud élevée non nonensoleillé chaud élevée oui noncouvert chaud élevée non ouipluvieux doux élevée non ouipluvieux frais normale non ouipluvieux frais normale oui noncouvert frais normale oui ouiensoleillé doux élevée non nonensoleillé frais normale non ouipluvieux doux normale non ouiensoleillé doux normale oui ouicouvert doux élevée oui ouicouvert chaud normale non ouipluvieux doux élevée oui non""".splitlines()], columns=["ciel", "temperature", "humidite", "vent", "jouer"])jour = {"ciel": "couvert", "temperature": "frais", "humidite": "élevée", "vent": "oui"}
scores = {}for classe, groupe in meteo.groupby("jouer"): prior = len(groupe) / len(meteo) # vraisemblances sans lissage : simple proportion dans la classe vraisemblances = [np.mean(groupe[c] == v) for c, v in jour.items()] scores[classe] = prior * np.prod(vraisemblances)
print({c: round(float(s), 4) for c, s in scores.items()}) # {'non': 0.0, 'oui': 0.0106}print(float(scores["oui"] / sum(scores.values()))) # P(jouer | x) = 1.0# Les 14 jours du fil rougemeteo <- read.table(header = TRUE, text = "ciel temperature humidite vent jouerensoleillé chaud élevée non nonensoleillé chaud élevée oui noncouvert chaud élevée non ouipluvieux doux élevée non ouipluvieux frais normale non ouipluvieux frais normale oui noncouvert frais normale oui ouiensoleillé doux élevée non nonensoleillé frais normale non ouipluvieux doux normale non ouiensoleillé doux normale oui ouicouvert doux élevée oui ouicouvert chaud normale non ouipluvieux doux élevée oui non")jour <- c(ciel = "couvert", temperature = "frais", humidite = "élevée", vent = "oui")
scores <- sapply(c("non", "oui"), function(classe) { groupe <- meteo[meteo$jouer == classe, ] prior <- nrow(groupe) / nrow(meteo) # vraisemblances sans lissage : simple proportion dans la classe vraisemblances <- sapply(names(jour), function(c) mean(groupe[[c]] == jour[c])) prior * prod(vraisemblances)})round(scores, 4) # non 0, oui 0.0106scores["oui"] / sum(scores) # P(jouer | x) = 1Exercice · Le piège du test positif
moyenUne maladie touche 1 % de la population. Un test la détecte chez 99 % des malades, et il est positif à tort chez 5 % des personnes saines. Une personne est testée positive. Quelle est la probabilité qu’elle soit réellement malade ?
Voir la solution
On applique la règle de Bayes avec = malade et = test positif :
- prior : , donc ;
- vraisemblances : et .
Seulement 16,7 % ! Parce que la maladie est rare, les faux positifs parmi les 99 % de personnes saines sont bien plus nombreux que les vrais positifs. C’est l’importance du prior, et c’est aussi pourquoi la précision d’un modèle chute quand la classe positive est rare (chapitre 3).
En code
import numpy as np
p_malade = 0.01p_pos_si_malade, p_pos_si_sain = 0.99, 0.05
# Règle de Bayes : P(M | +) = P(+ | M) P(M) / P(+)p_pos = p_pos_si_malade * p_malade + p_pos_si_sain * (1 - p_malade)print(round(p_pos_si_malade * p_malade / p_pos, 3)) # 0.167
# Vérification par simulation sur un million de personnesrng = np.random.default_rng(0)malade = rng.random(1_000_000) < p_maladepositif = np.where(malade, rng.random(1_000_000) < p_pos_si_malade, rng.random(1_000_000) < p_pos_si_sain)print(round(malade[positif].mean(), 3)) # 0.167 : part de malades parmi les positifsp_malade <- 0.01p_pos_si_malade <- 0.99; p_pos_si_sain <- 0.05
# Règle de Bayes : P(M | +) = P(+ | M) P(M) / P(+)p_pos <- p_pos_si_malade * p_malade + p_pos_si_sain * (1 - p_malade)round(p_pos_si_malade * p_malade / p_pos, 3) # 0.167
# Vérification par simulation sur un million de personnesset.seed(0)n <- 1e6malade <- runif(n) < p_maladepositif <- ifelse(malade, runif(n) < p_pos_si_malade, runif(n) < p_pos_si_sain)round(mean(malade[positif]), 3) # 0.164 : part de malades parmi les positifsExercice · Le même jour, avec lissage
difficileReprenez le jour (couvert, frais, humidité élevée, vent) avec le lissage de Laplace () sur les vraisemblances, en gardant les priors non lissés comme le fait scikit-learn. Rappel : le ciel a 3 valeurs, la température 3, l’humidité 2 et le vent 2. Calculez .
Voir la solution
Pour « jouer » () : couvert ; frais ; humidité élevée ; vent .
Pour « ne pas jouer » () : couvert ; frais ; humidité élevée ; vent .
Après normalisation, . La décision reste « jouer », mais le modèle n’est plus absurdement certain : l’humidité élevée et le vent, plus fréquents les jours sans joueurs, pèsent désormais dans la balance.
En code
import numpy as npimport pandas as pdfrom sklearn.naive_bayes import CategoricalNBfrom sklearn.preprocessing import OrdinalEncoder
# Les 14 jours du fil rougemeteo = pd.DataFrame( [r.split() for r in """ensoleillé chaud élevée non nonensoleillé chaud élevée oui noncouvert chaud élevée non ouipluvieux doux élevée non ouipluvieux frais normale non ouipluvieux frais normale oui noncouvert frais normale oui ouiensoleillé doux élevée non nonensoleillé frais normale non ouipluvieux doux normale non ouiensoleillé doux normale oui ouicouvert doux élevée oui ouicouvert chaud normale non ouipluvieux doux élevée oui non""".splitlines()], columns=["ciel", "temperature", "humidite", "vent", "jouer"])X, y = meteo.drop(columns="jouer"), meteo["jouer"]jour = pd.DataFrame([["couvert", "frais", "élevée", "oui"]], columns=X.columns)
# À la main : (n_v + 1) / (n_classe + V), priors non lissésscores = {}for classe, groupe in meteo.groupby("jouer"): lik = [(np.sum(groupe[c] == jour[c][0]) + 1) / (len(groupe) + X[c].nunique()) for c in X] scores[classe] = len(groupe) / len(meteo) * np.prod(lik)print({c: round(float(s), 4) for c, s in scores.items()}) # {'non': 0.0046, 'oui': 0.0118}print(round(float(scores["oui"] / sum(scores.values())), 3)) # P(jouer | x) = 0.722
# Avec scikit-learn : même résultatenc = OrdinalEncoder().fit(X)nb = CategoricalNB(alpha=1.0).fit(enc.transform(X), y)print(nb.predict_proba(enc.transform(jour)).round(3)) # [[0.278 0.722]] (non, oui)# Les 14 jours du fil rougemeteo <- read.table(header = TRUE, text = "ciel temperature humidite vent jouerensoleillé chaud élevée non nonensoleillé chaud élevée oui noncouvert chaud élevée non ouipluvieux doux élevée non ouipluvieux frais normale non ouipluvieux frais normale oui noncouvert frais normale oui ouiensoleillé doux élevée non nonensoleillé frais normale non ouipluvieux doux normale non ouiensoleillé doux normale oui ouicouvert doux élevée oui ouicouvert chaud normale non ouipluvieux doux élevée oui non")jour <- c(ciel = "couvert", temperature = "frais", humidite = "élevée", vent = "oui")V <- sapply(meteo[names(jour)], function(col) length(unique(col))) # 3, 3, 2, 2 valeurs
scores <- sapply(c("non", "oui"), function(classe) { groupe <- meteo[meteo$jouer == classe, ] # lissage de Laplace sur les vraisemblances, prior non lissé lik <- sapply(names(jour), function(c) (sum(groupe[[c]] == jour[c]) + 1) / (nrow(groupe) + V[c])) nrow(groupe) / nrow(meteo) * prod(lik)})round(scores, 4) # non 0.0046, oui 0.0118round(scores["oui"] / sum(scores), 3) # P(jouer | x) = 0.722