Aller au contenu

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.

Tout part d’une formule que vous avez peut-être vue en probabilités. Pour une classe ω\omega (par exemple « jouer ») et une observation xx (la météo du jour) :

P(ω∣x)=p(x∣ω) P(ω)p(x)P(\omega \mid x) = \frac{p(x \mid \omega) \, P(\omega)}{p(x)}

Chaque terme a un nom et un rôle :

  • P(ω)P(\omega) est la probabilité a priori (prior) de la classe, avant de regarder la météo. Ici, P(jouer)=9/14P(\text{jouer}) = 9/14 et P(ne pas jouer)=5/14P(\text{ne pas jouer}) = 5/14.
  • p(x∣ω)p(x \mid \omega) est la vraisemblance (likelihood) : à quel point la météo xx est typique des jours de cette classe.
  • p(x)p(x) est la probabilité de l’observation, toutes classes confondues. Elle vaut ∑jp(x∣ωj)P(ωj)\sum_j p(x \mid \omega_j) P(\omega_j) et sert seulement à normaliser, pour que les probabilités des classes somment à 1.
  • P(ω∣x)P(\omega \mid x) 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.

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).

ω∗=arg⁡max⁡i  p(x∣ωi) P(ωi)\omega^* = \arg\max_{i} \; p(x \mid \omega_i) \, P(\omega_i)

La notation arg⁡max⁡i\arg\max_i signifie « la valeur de ii qui rend l’expression maximale ». Remarquez que p(x)p(x) 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 arg⁡max⁡ip(x∣ωi)\arg\max_i p(x \mid \omega_i) : 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.

densité × prior← MAP : jouerMAP : ne pas jouer →p(x | jouer) · P(jouer), P = 9/14p(x | non) · P(non), P = 5/14erreur de Bayes (aire)5060708090100110humidité (%)0,00,51,0a posteriorifrontière ≈ 89 %P(ne pas jouer | x)
Avec les lois normales du TP pour l'humidité : la règle MAP choisit la courbe la plus haute. La zone colorée, où l'autre classe reste possible, est l'erreur incompressible, même avec les vraies probabilités.

Comment estimer p(x∣ω)p(x \mid \omega) ? 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 xx, 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.

classe 1classe 2même covariance : frontière droite (LDA)classe 1classe 2covariances propres : frontière courbe (QDA)
Quand les deux classes partagent la même covariance, la frontière MAP est un hyperplan (LDA) ; avec une covariance par classe, elle devient une courbe du second degré (QDA).

La difficulté est que xx contient plusieurs caractéristiques : x=(x1,…,xd)x = (x_1, \dots, x_d). 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 :

p(x∣ω)=∏j=1dp(xj∣ω)p(x \mid \omega) = \prod_{j=1}^{d} p(x_j \mid \omega)

Le symbole ∏\prod désigne un produit : la vraisemblance d’une journée complète est le produit des vraisemblances de chacune de ses caractéristiques. Chaque p(xj∣ω)p(x_j \mid \omega) 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.

priorensoleilléfraishumiditéélevéeventproduita posteriorijouer××××= 0,20ne pas jouer××××= 0,80MAP : le plus grand produit l’emporte, « ne pas jouer » ; normaliser donne 0,80.9/142/93/93/93/90,00535/143/51/54/53/50,0206
Le calcul de l'exemple, facteur par facteur. Chaque classe multiplie son prior par une fréquence par caractéristique ; seule la comparaison des deux produits compte, la normalisation vient après.

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, log⁡P(ω)+∑jlog⁡p(xj∣ω)\log P(\omega) + \sum_j \log p(x_j \mid \omega) : 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_.

Essayez maintenant un jour couvert. Parmi les 5 jours sans joueurs, aucun n’était couvert : p(couvert∣non)=0/5=0p(\text{couvert} \mid \text{non}) = 0/5 = 0. 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 :

p(xj=v∣ω)=nv,ω+αnω+α Vjp(x_j = v \mid \omega) = \frac{n_{v,\omega} + \alpha}{n_\omega + \alpha \, V_j}
  • nv,ωn_{v,\omega} est le nombre d’exemples de la classe ω\omega pour lesquels la caractéristique jj vaut vv ;
  • nωn_\omega est le nombre d’exemples de la classe ω\omega ;
  • VjV_j est le nombre de valeurs possibles de la caractéristique jj (3 pour le ciel, 2 pour le vent) ;
  • α\alpha est l’intensité du lissage ; α=1\alpha = 1 correspond au lissage de Laplace proprement dit.
ensoleillécouvertpluvieux0,00,10,20,30,40,50,60,7p(ciel | ne pas jouer)3/54/80/51/82/53/8probabilité nulle :elle annule tout le produitsans lissage : n / 5Laplace : (n + 1) / (5 + 3)
Le lissage ajoute un jour fictif à chaque valeur du ciel : la probabilité de « couvert » passe de 0 à 1/8, et les autres se resserrent un peu vers 1/3.

En TP, nous avons d’ailleurs constaté que les probabilités de CategoricalNB ne correspondaient pas à nos comptages. Les priors, eux, étaient identiques (5/145/14 et 9/149/14). L’explication était ce lissage, appliqué par défaut avec α=1\alpha = 1 : pour « ensoleillé sachant jouer », nous avions compté 2/9≈0,2222/9 \approx 0{,}222, alors que scikit-learn donnait (2+1)/(9+3)=0,25(2 + 1)/(9 + 3) = 0{,}25 ; pour « humidité normale sachant ne pas jouer », 1/51/5 à la main contre (1+1)/(5+2)=2/7(1 + 1)/(5 + 2) = 2/7 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 pd
from sklearn.naive_bayes import CategoricalNB
from sklearn.preprocessing import OrdinalEncoder
df = pd.read_csv("weather.nominal.csv") # outlook, temperature, humidity, windy, play
X = df.drop(columns=["play"]).astype(str)
y = df["play"]
encodeur = OrdinalEncoder() # chaque catégorie devient un entier
X_code = encodeur.fit_transform(X)
nb = CategoricalNB(alpha=1.0).fit(X_code, y) # alpha = 1 : lissage de Laplace
print(nb.class_log_prior_) # log(5/14), log(9/14)
print(nb.predict_proba(X_code[:3])) # probabilités a posteriori des 3 premiers jours

Le 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 μ\mu et l’écart-type σ\sigma sur les exemples de la classe. La vraisemblance devient une densité :

p(xj∣ω)=1σj,ω2πexp⁡(−(xj−μj,ω)22 σj,ω2)p(x_j \mid \omega) = \frac{1}{\sigma_{j,\omega} \sqrt{2\pi}} \exp\left(-\frac{(x_j - \mu_{j,\omega})^2}{2 \, \sigma_{j,\omega}^2}\right)

où μj,ω\mu_{j,\omega} et σj,ω\sigma_{j,\omega} sont la moyenne et l’écart-type de la caractéristique jj parmi les exemples de la classe ω\omega.

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 ±1\pm 1 autour de la valeur observée, lue sur la loi normale :

ClasseTempérature : μ\mu ; σ\sigmaP(72≤T≤74)P(72 \le T \le 74)Humidité : μ\mu ; σ\sigmaP(80≤H≤82)P(80 \le H \le 82)
ne pas jouer74,6 ; 7,060,109886,2 ; 8,700,0766
jouer73,0 ; 5,810,136679,1 ; 9,630,0811

En multipliant ces valeurs par les vraisemblances catégorielles (ensoleillé : 3/53/5 et 2/92/9 ; vent : 3/53/5 et 3/93/9) puis par les priors, et en normalisant, nous avons obtenu P(ne pas jouer∣x)=0,672P(\text{ne pas jouer} \mid x) = 0{,}672 et P(jouer∣x)=0,328P(\text{jouer} \mid x) = 0{,}328 : le jour est classé « ne pas jouer », ce qui correspondait à l’étiquette attendue.

5060708090100température (°F)jouer : μ = 73,0 ; σ = 5,81P(72 à 74) = 0,1366ne pas jouer : μ = 74,6 ; σ = 7,06P(72 à 74) = 0,10985060708090100110humidité (%)jouer : μ = 79,1 ; σ = 9,63P(80 à 82) = 0,0811ne pas jouer : μ = 86,2 ; σ = 8,70P(80 à 82) = 0,0766
Les lois normales estimées en TP et les petites aires de ±1 autour du jour observé (73 °F, 81 %). Ces aires remplacent les comptages du cas catégoriel dans le produit.
from sklearn.naive_bayes import GaussianNB
meteo = pd.read_csv("weather.csv")
X_num = meteo[["temperature", "humidity"]] # les deux variables continues
gnb = GaussianNB().fit(X_num, meteo["play"])
print(gnb.theta_) # moyennes par classe
print(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.

  • La règle de Bayes combine le prior P(ω)P(\omega) et la vraisemblance p(x∣ω)p(x \mid \omega) pour obtenir la probabilité a posteriori P(ω∣x)P(\omega \mid x).
  • La règle MAP choisit la classe qui maximise p(x∣ω)P(ω)p(x \mid \omega) P(\omega) ; 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.

Exercice · Un jour couvert

facile

Avec 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 : p(couvert∣non)=0p(\text{couvert} \mid \text{non}) = 0, donc le score de la classe est nul, quelles que soient les autres valeurs.

Pour « jouer » : 914×49×39×39×39≈0,0106\tfrac{9}{14} \times \tfrac{4}{9} \times \tfrac{3}{9} \times \tfrac{3}{9} \times \tfrac{3}{9} \approx 0{,}0106.

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 np
import pandas as pd
# Les 14 jours du fil rouge
meteo = pd.DataFrame(
[r.split() for r in """ensoleillé chaud élevée non non
ensoleillé chaud élevée oui non
couvert chaud élevée non oui
pluvieux doux élevée non oui
pluvieux frais normale non oui
pluvieux frais normale oui non
couvert frais normale oui oui
ensoleillé doux élevée non non
ensoleillé frais normale non oui
pluvieux doux normale non oui
ensoleillé doux normale oui oui
couvert doux élevée oui oui
couvert chaud normale non oui
pluvieux 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

Exercice · Le piège du test positif

moyen

Une 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 ω\omega = malade et xx = test positif :

  • prior : P(M)=0,01P(M) = 0{,}01, donc P(Mˉ)=0,99P(\bar{M}) = 0{,}99 ;
  • vraisemblances : P(+∣M)=0,99P(+ \mid M) = 0{,}99 et P(+∣Mˉ)=0,05P(+ \mid \bar{M}) = 0{,}05.
P(M∣+)=0,99×0,010,99×0,01+0,05×0,99=0,00990,0099+0,0495≈0,167P(M \mid +) = \frac{0{,}99 \times 0{,}01}{0{,}99 \times 0{,}01 + 0{,}05 \times 0{,}99} = \frac{0{,}0099}{0{,}0099 + 0{,}0495} \approx 0{,}167

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.01
p_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 personnes
rng = np.random.default_rng(0)
malade = rng.random(1_000_000) < p_malade
positif = 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 positifs

Exercice · Le même jour, avec lissage

difficile

Reprenez le jour (couvert, frais, humidité élevée, vent) avec le lissage de Laplace (α=1\alpha = 1) 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 P(jouer∣x)P(\text{jouer} \mid x).

Voir la solution

Pour « jouer » (nω=9n_\omega = 9) : couvert (4+1)/(9+3)=5/12(4+1)/(9+3) = 5/12 ; frais (3+1)/(9+3)=4/12(3+1)/(9+3) = 4/12 ; humidité élevée (3+1)/(9+2)=4/11(3+1)/(9+2) = 4/11 ; vent (3+1)/(9+2)=4/11(3+1)/(9+2) = 4/11.

914×512×412×411×411≈0,0118\tfrac{9}{14} \times \tfrac{5}{12} \times \tfrac{4}{12} \times \tfrac{4}{11} \times \tfrac{4}{11} \approx 0{,}0118

Pour « ne pas jouer » (nω=5n_\omega = 5) : couvert (0+1)/(5+3)=1/8(0+1)/(5+3) = 1/8 ; frais (1+1)/(5+3)=2/8(1+1)/(5+3) = 2/8 ; humidité élevée (4+1)/(5+2)=5/7(4+1)/(5+2) = 5/7 ; vent (3+1)/(5+2)=4/7(3+1)/(5+2) = 4/7.

514×18×28×57×47≈0,0046\tfrac{5}{14} \times \tfrac{1}{8} \times \tfrac{2}{8} \times \tfrac{5}{7} \times \tfrac{4}{7} \approx 0{,}0046

Après normalisation, P(jouer∣x)≈0,0118/(0,0118+0,0046)≈0,72P(\text{jouer} \mid x) \approx 0{,}0118 / (0{,}0118 + 0{,}0046) \approx 0{,}72. 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 np
import pandas as pd
from sklearn.naive_bayes import CategoricalNB
from sklearn.preprocessing import OrdinalEncoder
# Les 14 jours du fil rouge
meteo = pd.DataFrame(
[r.split() for r in """ensoleillé chaud élevée non non
ensoleillé chaud élevée oui non
couvert chaud élevée non oui
pluvieux doux élevée non oui
pluvieux frais normale non oui
pluvieux frais normale oui non
couvert frais normale oui oui
ensoleillé doux élevée non non
ensoleillé frais normale non oui
pluvieux doux normale non oui
ensoleillé doux normale oui oui
couvert doux élevée oui oui
couvert chaud normale non oui
pluvieux 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és
scores = {}
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ésultat
enc = 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)