teach.pascalyim.com
Sommaire

DL · Chapitre 2

Deep learning 2 — Classification avec un neurone logistique et PyTorch

Lancer sur Kaggle

Le chapitre précédent a posé les bases de l'apprentissage profond sur le problème le plus simple : la régression. Un neurone affine, u=Xw+bu = Xw + b, entraîné par descente de gradient sur l'erreur quadratique moyenne, suffit pour ajuster une droite à un nuage de points. Mais que se passe-t-il quand la cible n'est plus une grandeur continue ? Quand on cherche à savoir si une tumeur est maligne ou bénigne, si un manchot appartient à l'espèce Adelie, Gentoo ou Chinstrap, ou si un courriel est un spam ? Ce sont des problèmes de classification, et ils demandent un changement à la fois conceptuel — passer d'une grandeur réelle à une distribution de probabilité — et technique — remplacer la sortie linéaire par une fonction écrasante, et la MSE par une fonction de coût adaptée à la nature discrète de la cible.

Ce chapitre poursuit donc le fil tendu au cours précédent. Nous reprenons le neurone élémentaire, et nous le transformons en neurone logistique. Nous construisons sa fonction de coût à partir d'un raisonnement probabiliste — le maximum de vraisemblance d'une loi de Bernoulli — qui nous donne la binary cross-entropy. Nous re-dérivons le gradient à la main pour observer un fait remarquable : il prend exactement la même forme algébrique que pour la régression linéaire. Nous traduisons ensuite ce neurone en PyTorch, en exposant les pièges récurrents autour des logits et des pertes (BCELoss contre BCEWithLogitsLoss), avant d'empiler des couches et de généraliser au cas multiclasse, qui mobilise la fonction softmax et la perte CrossEntropyLoss.

Du neurone linéaire au neurone logistique

En classification binaire, la variable cible prend deux valeurs : y{0,1}y \in \{0, 1\}. La convention usuelle est d'appeler "classe positive" celle codée 1 (par exemple tumeur maligne, spam) et "classe négative" celle codée 0. L'objectif n'est pas de prédire directement une classe, mais d'estimer une probabilité conditionnelle :

P(y=1X)P(y = 1 \mid X)

C'est cette probabilité qui sera apprise par le modèle. La décision finale (classe 0 ou 1) sera prise ensuite, en seuillant la probabilité estimée — typiquement à 0,50{,}5, mais pas nécessairement, comme nous le verrons quand le coût d'un faux négatif diffère de celui d'un faux positif.

Modélisation probabiliste : loi de Bernoulli

Une variable binaire est naturellement modélisée par une loi de Bernoulli. Pour un paramètre p[0,1]p \in [0,1] :

P(y=1)=p,P(y=0)=1pP(y = 1) = p, \qquad P(y = 0) = 1 - p

Une écriture compacte, qui nous servira à construire la vraisemblance, regroupe ces deux cas :

P(yp)=py(1p)1yP(y \mid p) = p^{y}(1-p)^{1-y}

Quand y=1y = 1 on retrouve pp, quand y=0y = 0 on retrouve 1p1-p. Ici, pp est un paramètre inconnu, qui dépendra dans la suite des entrées XX.

De la probabilité à la vraisemblance

Dans un cadre supervisé, les données (Xi,yi)(X_i, y_i) sont observées et fixées. La quantité P(yp)P(y \mid p), vue comme fonction de pp, n'est plus une probabilité au sens strict : on l'appelle alors une vraisemblance. Pour une observation :

L(py)=P(yp)\mathcal{L}(p \mid y) = P(y \mid p)

Sous l'hypothèse que les nn observations sont indépendantes, la vraisemblance globale est le produit des vraisemblances individuelles :

L=i=1nP(yipi)\mathcal{L} = \prod_{i=1}^{n} P(y_i \mid p_i)

pip_i est la probabilité attribuée par le modèle à l'exemple ii.

Faire dépendre pp de XX : le neurone logistique

Pour que le modèle s'adapte aux entrées, pp doit dépendre de XX. Comme en régression, on commence par une combinaison affine :

z=Xw+bz = Xw + b

Cette quantité, appelée logit, n'est pas bornée : elle peut prendre n'importe quelle valeur réelle. Pour la transformer en probabilité, on lui applique la fonction logistique (ou sigmoïde) :

σ(z)=11+ez\sigma(z) = \frac{1}{1 + e^{-z}}

Cette fonction écrase R\mathbb{R} vers (0,1)(0,1), est dérivable partout, et possède la propriété élégante σ(z)=σ(z)(1σ(z))\sigma'(z) = \sigma(z)(1-\sigma(z)) que l'on retrouvera dans le calcul du gradient. On pose alors :

p=P(y=1X)=σ(Xw+b)p = P(y = 1 \mid X) = \sigma(Xw + b)

Le neurone logistique est donc un estimateur de probabilité conditionnelle. À comparer avec le perceptron historique, qui décidait directement y^=1\hat{y} = 1 si Xw+b0Xw + b \geq 0 et y^=0\hat{y} = 0 sinon : décision dure, sans probabilité, non différentiable, donc incompatible avec la descente de gradient. La sigmoïde est la version lisse et probabiliste de ce seuil.

Cross-entropy et maximum de vraisemblance

En remplaçant chaque pip_i par la sortie du neurone logistique, la vraisemblance devient :

L(w,b)=i=1n[σ(Xiw+b)]yi[1σ(Xiw+b)]1yi\mathcal{L}(w, b) = \prod_{i=1}^{n} \left[\sigma(X_i w + b)\right]^{y_i} \left[1 - \sigma(X_i w + b)\right]^{1 - y_i}

L'apprentissage consiste à choisir ww et bb qui rendent les données observées les plus vraisemblables possibles : c'est le principe du maximum de vraisemblance.

Pourquoi passer au logarithme

Maximiser un produit de nombres compris entre 0 et 1 conduit rapidement à des valeurs numériquement minuscules : 0,910000{,}9^{1000} vaut déjà 104610^{-46}. On maximise donc plutôt la log-vraisemblance :

logL(w,b)=i=1n[yilog(pi)+(1yi)log(1pi)]\log \mathcal{L}(w, b) = \sum_{i=1}^{n} \left[ y_i \log(p_i) + (1 - y_i) \log(1 - p_i) \right]

Le logarithme est strictement croissant : il ne change pas la position du maximum. Il transforme en revanche un produit en somme, ce qui simplifie à la fois le calcul du gradient et la stabilité numérique.

La perte de cross-entropy binaire

Par convention, on minimise plutôt qu'on ne maximise. On définit donc la fonction de coût comme l'opposée de la log-vraisemblance, normalisée par nn :

LCE=1ni=1n[yilog(pi)+(1yi)log(1pi)]\mathcal{L}_{\text{CE}} = -\frac{1}{n} \sum_{i=1}^{n} \left[ y_i \log(p_i) + (1 - y_i) \log(1 - p_i) \right]

Cette fonction porte plusieurs noms selon les communautés : log-loss en science des données, binary cross-entropy en deep learning, negative log-likelihood de la Bernoulli en statistique. Ce sont trois manières de désigner la même quantité.

Pourquoi pas la MSE ? On pourrait être tenté d'utiliser l'erreur quadratique moyenne entre yy et σ(Xw+b)\sigma(Xw+b). Cela fonctionne encore, mais le gradient devient pathologiquement plat quand σ(z)\sigma(z) sature à 0 ou 1 — l'apprentissage s'arrête prématurément. La cross-entropy, dérivée du maximum de vraisemblance, donne au contraire un gradient proportionnel à l'erreur de prédiction, comme nous allons le voir.

Calcul du gradient : un détour qui paye

Le neurone logistique se décompose en trois blocs :

wziuiEiw \longrightarrow z_i \longrightarrow u_i \longrightarrow E_i

avec zi=Xiw+bz_i = X_i w + b, ui=σ(zi)u_i = \sigma(z_i) et Ei=[yilog(ui)+(1yi)log(1ui)]E_i = -[y_i \log(u_i) + (1-y_i) \log(1-u_i)]. La règle de dérivation en chaîne donne :

Eiwj=Eiuiuiziziwj\frac{\partial E_i}{\partial w_j} = \frac{\partial E_i}{\partial u_i} \cdot \frac{\partial u_i}{\partial z_i} \cdot \frac{\partial z_i}{\partial w_j}

Calculons chaque facteur séparément.

Loss par rapport à la sortie. En dérivant terme à terme :

Eiui=yiui+1yi1ui\frac{\partial E_i}{\partial u_i} = -\frac{y_i}{u_i} + \frac{1 - y_i}{1 - u_i}

Activation par rapport au pré-activé. La dérivée classique de la sigmoïde :

uizi=ui(1ui)\frac{\partial u_i}{\partial z_i} = u_i (1 - u_i)

Pré-activé par rapport au poids. Comme zi=jxijwj+bz_i = \sum_j x_{ij} w_j + b :

ziwj=xij,zib=1\frac{\partial z_i}{\partial w_j} = x_{ij}, \qquad \frac{\partial z_i}{\partial b} = 1

Recomposition. En multipliant les trois et en simplifiant le produit du premier facteur par ui(1ui)u_i(1-u_i) :

(yiui+1yi1ui)ui(1ui)=yi(1ui)+(1yi)ui=uiyi\left(-\frac{y_i}{u_i} + \frac{1 - y_i}{1 - u_i}\right) u_i (1 - u_i) = -y_i (1 - u_i) + (1 - y_i) u_i = u_i - y_i

D'où, après simplification :

Eiwj=(uiyi)xij\frac{\partial E_i}{\partial w_j} = (u_i - y_i) \, x_{ij}

En version vectorielle puis batch, on obtient les expressions remarquablement compactes :

Ew=1nX(uy),Eb=1ni=1n(uiyi)\frac{\partial E}{\partial w} = \frac{1}{n} X^{\top}(u - y), \qquad \frac{\partial E}{\partial b} = \frac{1}{n} \sum_{i=1}^{n} (u_i - y_i)

u=σ(Xw+b)u = \sigma(Xw + b).

Le miracle algébrique

Comparons avec le gradient du neurone linéaire (régression) :

  • régression linéaire : u=Xw+bu = Xw + b, gradient 1nX(uy)\frac{1}{n} X^{\top}(u - y)
  • classification logistique : u=σ(Xw+b)u = \sigma(Xw + b), gradient 1nX(uy)\frac{1}{n} X^{\top}(u - y)

Les expressions du gradient sont formellement identiques. Seule la définition de uu change. Conséquence pratique : pour transformer un neurone linéaire from scratch en neurone logistique, il suffit de modifier deux lignes — la sortie de forward et la fonction de coût — et de garder rigoureusement la même boucle d'entraînement, le même calcul de gradient, les mêmes mises à jour. C'est ce que nous allons faire.

Implémentation from scratch en NumPy

Le code suivant condense tout ce que nous venons de voir : initialisation paresseuse des poids, méthode forward qui applique la sigmoïde, méthode predict qui seuille à 0,50{,}5, et boucle d'entraînement qui calcule la BCE et applique la formule de gradient. Le mode batch, sgd ou minibatch est un paramètre, comme dans le chapitre précédent.

class LogisticNeuron: def __init__(self): self.w = None self.b = 0.0 self.history = [] def forward(self, X): z = X @ self.w + self.b return 1.0 / (1.0 + np.exp(-z)) def predict(self, X, threshold=0.5): u = self.forward(X) return (u >= threshold).astype(int) def fit(self, X, y, lr=0.1, epochs=100, mode="batch", batch_size=32): n, m = X.shape if self.w is None: self.w = np.random.uniform(size=m) self.history = [] for _ in range(epochs): if mode == "batch": idx = np.arange(n) elif mode == "sgd": idx = np.array([np.random.randint(0, n)]) elif mode == "minibatch": B = min(batch_size, n) idx = np.random.choice(n, size=B) Xb, yb = X[idx], y[idx] u = self.forward(Xb) eps = 1e-12 loss = -np.mean(yb * np.log(u + eps) + (1 - yb) * np.log(1 - u + eps)) self.history.append(loss) grad_w = (Xb.T @ (u - yb)) / len(idx) grad_b = (u - yb).mean() self.w -= lr * grad_w self.b -= lr * grad_b return self

Stabilité numérique. Notez le eps = 1e-12 ajouté dans les log : si un uiu_i vaut exactement 0 ou 1 (par saturation de la sigmoïde), log(0)\log(0) vaut -\infty et la perte devient nan. Ce petit décalage évite l'écueil. PyTorch règle ce problème plus proprement avec BCEWithLogitsLoss, comme nous le verrons.

Sur le dataset cancer_mini, après normalisation par StandardScaler, ce neurone atteint typiquement 95 à 98 % d'accuracy en 100 époques avec un taux d'apprentissage de 0,5 — un score honorable pour un modèle qui tient en vingt lignes.

Le neurone logistique en PyTorch

PyTorch nous épargne le calcul manuel du gradient et la gestion des modes batch/SGD : le moteur autograd se charge de différencier toute fonction composée, et les classes du module torch.optim mettent à jour les paramètres. Reste à construire le modèle et à choisir la perte — et c'est précisément là que se cachent les pièges les plus fréquents.

Deux options, et une recommandation

Option 1 : sigmoïde explicite + BCELoss. Le modèle calcule explicitement la probabilité u=σ(z)u = \sigma(z), et la perte applique la binary cross-entropy.

model = nn.Sequential( nn.Linear(m, 1), nn.Sigmoid() ) criterion = nn.BCELoss()

Option 2 (recommandée) : pas de sigmoïde + BCEWithLogitsLoss. Le modèle renvoie les logits bruts z=Xw+bz = Xw + b, et la perte applique la sigmoïde et la cross-entropy en interne, de manière numériquement stable.

model = nn.Linear(m, 1) criterion = nn.BCEWithLogitsLoss()

Piège classique. Combiner nn.Sigmoid dans le modèle et BCEWithLogitsLoss revient à appliquer la sigmoïde deux fois : une fois explicitement, une fois à l'intérieur de la perte. La loss reste finie mais le modèle apprend péniblement. Inversement, oublier la sigmoïde et utiliser BCELoss produit l'erreur RuntimeError: all elements of input should be between 0 and 1 dès la première itération. La règle est simple : si la perte contient WithLogits, le modèle ne contient pas l'activation finale.

Préparation des tenseurs

Comme en régression, on convertit les arrays NumPy en tenseurs float32. La cible yy doit avoir la forme (n, 1), sinon le broadcasting implicite de PyTorch produit une perte sur une matrice (n,n)(n, n) — bug silencieux, modèle inutilisable.

X_train = torch.tensor(X_train, dtype=torch.float32) y_train = torch.tensor(y_train, dtype=torch.float32).view(-1, 1) X_test = torch.tensor(X_test, dtype=torch.float32) y_test = torch.tensor(y_test, dtype=torch.float32).view(-1, 1)

Forme de yy. BCEWithLogitsLoss attend des cibles float32 de même forme que les logits, soit (n, 1). La dernière dimension n'est pas accessoire : (n,) et (n, 1) ne sont pas équivalents pour le broadcaster.

Boucle d'entraînement

model = nn.Linear(m, 1) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1) epochs = 200 loss_history = [] for _ in range(epochs): optimizer.zero_grad() logits = model(X_train) loss = criterion(logits, y_train) loss.backward() optimizer.step() loss_history.append(loss.item())

Le squelette est strictement identique à celui de la régression : zero_grad, forward, calcul de la perte, backward, step. Seuls la dernière couche (sortie scalaire au lieu de vecteur) et le critère changent.

De logits à classes

À l'évaluation, on bascule en mode eval pour désactiver d'éventuelles couches sensibles (Dropout, BatchNorm — pas encore introduites, mais la discipline se prend tôt), on désactive le calcul du gradient avec torch.no_grad(), on applique manuellement la sigmoïde aux logits, et on seuille :

model.eval() with torch.no_grad(): logits = model(X_test) proba = torch.sigmoid(logits) y_hat = (proba >= 0.5).float()

L'évaluation finale repasse classiquement par scikit-learn :

from sklearn.metrics import accuracy_score, confusion_matrix y_hat_np = y_hat.cpu().numpy().reshape(-1) y_test_np = y_test.cpu().numpy().reshape(-1) print("Accuracy =", accuracy_score(y_test_np, y_hat_np)) print(confusion_matrix(y_test_np, y_hat_np))

Choix du seuil. Le seuil 0,5 est un défaut, pas une vérité. Sur cancer_mini, manquer une tumeur maligne (faux négatif) est bien plus grave qu'une fausse alerte (faux positif). On peut alors abaisser le seuil — par exemple à 0,3 — pour augmenter le rappel au prix d'un peu de précision. La courbe ROC et l'analyse précision/rappel, vues en ML, restent pertinentes ici.

Réseaux multicouches : profondeur et non-linéarité

Un seul neurone logistique réalise une frontière de décision linéaire : il sépare l'espace par un hyperplan. Pour traiter des frontières plus complexes, on empile des couches, chacune suivie d'une activation non linéaire (typiquement ReLU) :

XLinear+ReLUh1Linear+ReLUh2LinearzX \xrightarrow{\text{Linear} + \text{ReLU}} h_1 \xrightarrow{\text{Linear} + \text{ReLU}} h_2 \xrightarrow{\text{Linear}} z

La sortie zz reste un logit, et la dernière couche n'a pas d'activation. La probabilité est obtenue après, soit explicitement par torch.sigmoid, soit implicitement par BCEWithLogitsLoss.

model = nn.Sequential( nn.Linear(m, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1) # logits, pas d'activation finale ) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

Pas de ReLU sur la dernière couche. Une ReLU finale renverrait des valeurs dans [0,+)[0, +\infty), donc des logits exclusivement positifs — le modèle ne pourrait plus exprimer P(y=1X)<0,5P(y=1 \mid X) < 0{,}5. La règle générale : la dernière couche est presque toujours un Linear nu, et l'activation propre à la tâche (sigmoïde pour binaire, softmax pour multiclasse) est soit explicite, soit incorporée dans la perte.

La boucle d'entraînement, elle, ne change pas. C'est la grande économie cognitive de PyTorch : modifier l'architecture du modèle ne demande aucune réécriture du training loop, parce que loss.backward() propage automatiquement les gradients à travers toutes les couches enregistrées dans le graphe d'autograd.

Définir un module avec nn.Module

Pour des architectures plus expressives — branches multiples, sauts de connexion, paramètres partagés — nn.Sequential ne suffit plus. On hérite alors de nn.Module :

class LogisticMLP(nn.Module): def __init__(self, m, hidden=(64, 32)): super().__init__() self.fc1 = nn.Linear(m, hidden[0]) self.fc2 = nn.Linear(hidden[0], hidden[1]) self.out = nn.Linear(hidden[1], 1) self.act = nn.ReLU() def forward(self, x): x = self.act(self.fc1(x)) x = self.act(self.fc2(x)) return self.out(x) # logits

Toute couche affectée à self.something est automatiquement enregistrée comme paramètre du module : model.parameters() les expose à l'optimiseur, model.to(device) les déplace vers GPU, model.state_dict() les sérialise.

Classification multiclasse : softmax et CrossEntropyLoss

Quand la cible prend C>2C > 2 valeurs — espèce de manchot, chiffre manuscrit, type de cellule — le neurone logistique se généralise au cas multiclasse. La cible est :

y{0,1,,C1}y \in \{0, 1, \dots, C-1\}

et chaque observation appartient à exactement une classe. L'objectif est d'estimer la distribution complète :

P(y=cX),c=0,,C1P(y = c \mid X), \qquad c = 0, \dots, C-1

Logits multiclasse et softmax

Le réseau produit cette fois un vecteur de scores réels, un par classe :

z=(z0,z1,,zC1)z = (z_0, z_1, \dots, z_{C-1})

ce qui se traduit en PyTorch par une dernière couche nn.Linear(h, C). Pour transformer ce vecteur de logits en distribution de probabilité, on applique la fonction softmax :

P(y=cX)=ezck=0C1ezkP(y = c \mid X) = \frac{e^{z_c}}{\sum_{k=0}^{C-1} e^{z_k}}

La softmax généralise la sigmoïde : elle écrase chaque composante dans (0,1)(0, 1) tout en garantissant que la somme vaut 1, ce qui en fait une distribution de probabilité valide.

La perte de cross-entropy multiclasse

Pour un exemple ii dont la vraie classe est yiy_i, la perte est :

Li=logP(y=yiXi)=log(ezi,yikezi,k)\mathcal{L}_i = -\log P(y = y_i \mid X_i) = -\log \left( \frac{e^{z_{i, y_i}}}{\sum_k e^{z_{i,k}}} \right)

C'est l'extension naturelle de la log-vraisemblance Bernoulli au cas multinomial. En PyTorch, on n'a pas besoin d'écrire la softmax soi-même :

criterion = nn.CrossEntropyLoss()

Cette perte applique en interne LogSoftmax puis la cross-entropy, encore une fois pour des raisons de stabilité numérique.

Trois pièges connexes. Premièrement : ne pas appliquer de softmax dans le modèleCrossEntropyLoss attend des logits, pas des probabilités. Deuxièmement : la cible yy est un vecteur d'indices entiers de forme (n,) et de type torch.long, pas un encodage one-hot (n,C)(n, C). Troisièmement : ne pas confondre CrossEntropyLoss (logits multiclasse, cible entière) avec NLLLoss (sortie déjà passée par LogSoftmax, cible entière) ni avec BCEWithLogitsLoss (logits binaires ou multilabel, cible flottante).

Anatomie d'un modèle multiclasse

C = 3 # nombre de classes model = nn.Sequential( nn.Linear(m, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, C) # logits multiclasse ) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

À l'évaluation, on prédit la classe par argmax :

with torch.no_grad(): logits = model(X_test) # (n_test, C) y_hat = torch.argmax(logits, dim=1) # (n_test,)

soit, formellement :

y^=argmaxczc\hat{y} = \arg\max_c z_c

Notez qu'on n'a pas besoin de calculer la softmax pour prédire : appliquer une fonction strictement croissante (l'exponentielle) à chaque composante, puis normaliser, ne change pas l'argmax. C'est une économie qu'on apprend à apprécier sur de gros volumes.

Étude de cas : penguins_mini

Le dataset penguins_mini propose trois espèces de manchots — Adelie, Gentoo, Chinstrap — à reconnaître à partir de variables morphologiques (longueur du bec, profondeur du bec, longueur des nageoires, masse corporelle) et catégorielles (île, sexe). Le pipeline de préparation est typique :

  1. imputation des valeurs manquantes (par exemple, remplir sex par "male" puis supprimer les lignes restantes incomplètes) ;
  2. encodage des variables catégorielles (map pour sex, pd.get_dummies pour island) ;
  3. encodage de la cible en indices : Adelie → 0, Gentoo → 1, Chinstrap → 2 — qu'on peut faire à la main ou via LabelEncoder ;
  4. split stratifié train/test avec stratify=y pour préserver la distribution des classes ;
  5. normalisation des entrées numériques avec StandardScaler.

Côté tenseurs :

X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.long) # long, pas float

Le passage par un DataLoader permet de gérer proprement les minibatches :

from torch.utils.data import TensorDataset, DataLoader train_loader = DataLoader( TensorDataset(X_train_t, y_train_t), batch_size=32, shuffle=True, )

et la boucle d'entraînement itère sur les batches :

for _ in range(epochs): epoch_loss, nb_batches = 0.0, 0 for Xb, yb in train_loader: optimizer.zero_grad() logits = model(Xb) # (B, C) loss = criterion(logits, yb) # yb shape (B,) loss.backward() optimizer.step() epoch_loss += loss.item() nb_batches += 1 loss_history.append(epoch_loss / nb_batches)

Sur ce jeu de données très propre, un MLP à deux couches cachées atteint sans peine une accuracy supérieure à 98 % en 300 époques avec Adam et un taux d'apprentissage de 10310^{-3}. La matrice de confusion permet d'identifier où le modèle se trompe — souvent entre Adelie et Chinstrap, qui partagent la même île dans une partie du dataset.

Récapitulatif : quelle perte pour quelle tâche ?

Avant les exercices, fixons la grille de décision PyTorch — c'est le tableau qu'il faut avoir en tête à chaque nouveau modèle de classification.

TâcheSortie du modèlePerteCible attendue
Binaire1 logit (n, 1)BCEWithLogitsLossfloat32, (n, 1)
Binaire (avec sigmoïde)1 proba (n, 1)BCELossfloat32, (n, 1)
MulticlasseCC logits (n, C)CrossEntropyLosslong, (n,)
MultilabelCC logits (n, C)BCEWithLogitsLossfloat32, (n, C)

Multilabel vs multiclasse. Le cas multilabel (un texte peut être étiqueté à la fois "sport" et "économie") n'est pas couvert par CrossEntropyLoss, qui suppose une classe et une seule. On revient alors à CC classifications binaires indépendantes via BCEWithLogitsLoss sur une sortie (n, C).

Exercices

Exercice 1 — Dérivation du gradient logistique

On considère zi=Xiw+bz_i = X_i w + b, ui=σ(zi)=1/(1+ezi)u_i = \sigma(z_i) = 1/(1 + e^{-z_i}) et la perte Ei=[yilog(ui)+(1yi)log(1ui)]E_i = -[y_i \log(u_i) + (1 - y_i) \log(1 - u_i)].

  1. Calculer Ei/ui\partial E_i / \partial u_i.
  2. Calculer ui/zi\partial u_i / \partial z_i en utilisant la définition de la sigmoïde.
  3. Calculer zi/wj\partial z_i / \partial w_j et zi/b\partial z_i / \partial b.
  4. Appliquer la règle de dérivation en chaîne pour obtenir Ei/wj\partial E_i / \partial w_j, et simplifier pour obtenir l'expression compacte (uiyi)xij(u_i - y_i)\, x_{ij}.
  5. Écrire les gradients en version batch et reconnaître la forme matricielle 1nX(uy)\frac{1}{n} X^{\top}(u - y).

Rappels utiles : (logv)=v/v(\log v)' = v'/v, (expv)=vexpv(\exp v)' = v' \exp v, (1v)=v(1 - v)' = -v', (1/v)=v/v2(1/v)' = -v'/v^{2}.

Exercice 2 — Du neurone linéaire au neurone logistique from scratch

À partir du code du LinearNeuron du chapitre précédent, construire un LogisticNeuron :

  1. modifier forward(X) pour renvoyer u=σ(Xw+b)u = \sigma(Xw + b) ;
  2. remplacer la MSE par la BCE dans fit, en ajoutant une constante ϵ=1012\epsilon = 10^{-12} pour éviter log(0)\log(0) ;
  3. conserver telles quelles les formules du gradient — elles sont identiques au cas linéaire ;
  4. ajouter une méthode predict(X, threshold=0.5) qui renvoie une classe binaire ;
  5. tester sur cancer_mini : split avec random_state=42, StandardScaler (fit sur train, transform sur test), entraînement, évaluation par accuracy.

Exercice 3 — Neurone logistique PyTorch sur cancer_mini

  1. charger cancer_mini, construire XX et yy (binaire 0/1) ;
  2. split stratifié avec test_size=0.2, random_state=42, stratify=y ;
  3. normaliser via StandardScaler ;
  4. convertir en tenseurs float32, en mettant yy au format (n, 1) ;
  5. définir un modèle PyTorch d'un seul nn.Linear(m, 1) avec BCEWithLogitsLoss ;
  6. entraîner en full batch sur 200 époques, stocker la perte, tracer la courbe ;
  7. à l'évaluation, calculer les logits, appliquer torch.sigmoid, seuiller à 0,5 ;
  8. calculer accuracy, matrice de confusion, précision et rappel ; comparer plusieurs seuils (0,3, 0,5, 0,7).

Exercice 4 — MLP binaire profond

Reprendre l'exercice 3 en remplaçant le nn.Linear unique par un nn.Sequential à deux couches cachées (par exemple 64 puis 32 unités, ReLU). Comparer la courbe de perte et les métriques de test avec celles du modèle linéaire. À quel moment l'augmentation de la profondeur devient-elle contre-productive sur ce jeu de données ?

Exercice 5 — Classification multiclasse sur penguins_mini

  1. charger penguins, traiter les valeurs manquantes, encoder sex en 0/1, encoder island par one-hot, encoder species en indices entiers ;
  2. split stratifié (stratify=y, random_state=42) puis normalisation ;
  3. tenseurs : X en float32, y en long (sans view(-1, 1)) ;
  4. construire un MLP avec une couche de sortie nn.Linear(h, C), où C=3C = 3 ;
  5. utiliser nn.CrossEntropyLoss — sans softmax dans le modèle ;
  6. entraîner via un DataLoader (minibatches de taille 32) sur 300 époques avec Adam et lr=1e-3 ;
  7. évaluer : argmax sur les logits de test, accuracy, matrice de confusion ;
  8. bonus : comparer un modèle linéaire (nn.Linear(m, C) seul) à votre MLP. Quelle hauteur de précision peut-on déjà atteindre sans non-linéarité sur ce dataset ?

Exercice 6 — Pièges PyTorch

Pour chacune des situations suivantes, indiquer si le code lèvera une erreur, donnera un résultat correct mais pédagogiquement louche, ou produira un bug silencieux. Justifier brièvement.

  1. model = nn.Sequential(nn.Linear(m, 1), nn.Sigmoid()) avec criterion = nn.BCEWithLogitsLoss().
  2. model = nn.Linear(m, 1) avec criterion = nn.BCELoss().
  3. model = nn.Sequential(nn.Linear(m, C), nn.Softmax(dim=1)) avec criterion = nn.CrossEntropyLoss().
  4. Cible yy encodée en one-hot (n, C), type float32, avec CrossEntropyLoss.
  5. yy de forme (n,) avec un modèle qui sort (n, 1) et BCEWithLogitsLoss.

Pour aller plus loin

  • Documentation officielle PyTorch. Les pages nn.Linear, nn.BCEWithLogitsLoss, nn.CrossEntropyLoss et torch.nn.functional sont tenues à jour à chaque release et précisent exactement les formes attendues, les types et les options (poids des classes, ignorance d'index, label smoothing).
  • Tutoriel "60-minute blitz" sur le site de PyTorch, et la série Learn the Basics — couvrent en chemin Dataset, DataLoader, nn.Module, et le déplacement vers GPU.
  • Goodfellow, Bengio, Courville, Deep Learning, MIT Press 2016 (chap. 3 et 6, libre en ligne sur deeplearningbook.org) — la référence pour la dérivation probabiliste de la cross-entropy.
  • Bishop, Pattern Recognition and Machine Learning — chapitre 4 pour la régression logistique vue par un statisticien, et chapitre 5 pour les réseaux de neurones.
  • Aurélien Géron, Hands-On Machine Learning with Scikit-Learn, Keras and TensorFlow — bonne mise en perspective pratique, même si le code y est en Keras plutôt qu'en PyTorch ; les concepts (logits, cross-entropy, softmax) sont identiques.
  • Andrej Karpathy, série YouTube Neural Networks: Zero to Hero — la première vidéo, micrograd, refait le calcul de gradient à la main que nous avons mené ici, mais étendu à un graphe d'autograd minimaliste : extrêmement formateur.

Au prochain chapitre, nous garderons ce neurone logistique comme brique de base, mais nous nous attaquerons à des entrées dont la structure ne se résume pas à un vecteur de variables tabulaires : les images, qui demandent une nouvelle classe de couches — les convolutions.