Atelier : MLP numpy qui apprend XOR
Entraîner 2 couches sans framework.
Énoncé
XOR a quatre entrées : (0,0) et (1,1) donnent 0 ; (0,1) et (1,0) donnent 1. Entraînez un réseau 2→4 ReLU→1 sigmoïde en NumPy, avec log-loss et descente de gradient. Tracez la perte et la frontière de décision. Recommencez en remplaçant ReLU par l'identité : un empilement de couches linéaires peut-il résoudre XOR ?
Contraintes
NumPy pour les calculs, Matplotlib pour les deux graphiques ; ni PyTorch ni scikit-learn pour entraîner. Graine et pas fixés, dimensions des matrices notées dans le carnet. Vérifiez que la perte baisse et que les quatre points sont classés correctement ; une seule de ces conditions ne suffit pas. Cet exemple minuscule démontre un mécanisme, pas une capacité de généralisation.
Indices
X a la forme (4,2), W1 (2,4), W2 (4,1). Avec log-loss + sigmoïde, le gradient du logit de sortie est (probabilité - cible) / 4. Remontez ensuite par W2.T puis multipliez par (z1 > 0) pour ReLU. Calculez tous les gradients avant de modifier un poids. Sans ReLU, remplacez sa dérivée par 1.
Correction disponibleCherchez d’abord par vous-même.Voir la correction
Correction
Enregistrez sous xor_numpy.py, puis lancez python xor_numpy.py. La boucle utilise les quatre points à chaque pas : c'est une descente de gradient full batch, pas du SGD (qui utiliserait un point ou mini-lot tiré à chaque pas).
import numpy as np
import matplotlib.pyplot as plt
X = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
y = np.array([[0.], [1.], [1.], [0.]])
def sigmoid(z):
return 1 / (1 + np.exp(-np.clip(z, -30, 30)))
def entrainer(avec_relu):
rng = np.random.default_rng(0)
W1 = rng.normal(0, 0.8, (2, 4))
b1 = np.zeros(4)
W2 = rng.normal(0, 0.8, (4, 1))
b2 = 0.
pertes = []
for _ in range(5000):
z1 = X @ W1 + b1
h = np.maximum(z1, 0) if avec_relu else z1
logits = h @ W2 + b2
proba = sigmoid(logits)
pertes.append(np.mean(np.logaddexp(0, logits) - y * logits))
dz2 = (proba - y) / len(X)
dW2 = h.T @ dz2
db2 = dz2.sum()
dz1 = (dz2 @ W2.T) * ((z1 > 0) if avec_relu else 1)
dW1 = X.T @ dz1
db1 = dz1.sum(axis=0)
W2 -= 0.5 * dW2
b2 -= 0.5 * db2
W1 -= 0.5 * dW1
b1 -= 0.5 * db1
return (W1, b1, W2, b2), pertes
def predire(points, poids, avec_relu):
W1, b1, W2, b2 = poids
z1 = points @ W1 + b1
h = np.maximum(z1, 0) if avec_relu else z1
return sigmoid(h @ W2 + b2).ravel()
poids_relu, pertes_relu = entrainer(True)
poids_lineaire, pertes_lineaire = entrainer(False)
for nom, poids, avec_relu, pertes in [
("ReLU", poids_relu, True, pertes_relu),
("identité", poids_lineaire, False, pertes_lineaire),
]:
p = predire(X, poids, avec_relu)
print(nom, "perte finale", round(pertes[-1], 4), "prédictions", (p >= 0.5).astype(int))
grille_x, grille_y = np.meshgrid(np.linspace(-0.3, 1.3, 100), np.linspace(-0.3, 1.3, 100))
grille = np.column_stack([grille_x.ravel(), grille_y.ravel()])
surface = predire(grille, poids_relu, True).reshape(grille_x.shape)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].plot(pertes_relu, label="ReLU")
axes[0].plot(pertes_lineaire, label="identité")
axes[0].set(xlabel="pas", ylabel="log-loss")
axes[0].legend()
axes[1].contourf(grille_x, grille_y, surface, levels=[0, 0.5, 1], alpha=0.5)
axes[1].scatter(X[:, 0], X[:, 1], c=y.ravel(), edgecolors="black")
axes[1].set(xlabel="x1", ylabel="x2", title="frontière ReLU")
fig.tight_layout()
fig.savefig("xor_resultats.png")
La version ReLU doit classer les quatre points, tandis que la version identité ne peut pas tracer une frontière qui sépare les deux diagonales. Le chiffre exact de perte dépend des réglages ; inspectez la courbe plutôt que de viser un seuil arbitraire. Si ReLU échoue, vérifiez d'abord les formes et le gradient numérique de la leçon précédente, puis essayez une autre initialisation , sans prétendre améliorer un test réel.