Réseaux de neurones : la théorie de l’inférence

Du perceptron au réseau feedforward, avec PyTorch

Auteur·rice

Augustin Chevallier

Cet article pose le cadre mathématique des réseaux de neurones, du perceptron le plus simple jusqu’au réseau feedforward complet, puis montre comment les manipuler concrètement avec PyTorch. Il s’agit ici uniquement d’inférence : comment un réseau déjà entraîné produit une prédiction. La question de l’entraînement — comment trouver les bons paramètres — est le sujet du cours suivant.

1 Le cadre mathématique

1.1 Objectif

On se place dans un cadre de statistique standard. On dispose de variables explicatives \(x = (x_1, \dots, x_p) \in \mathbb{R}^p\) et d’une variable à expliquer \(y \in \mathbb{R}^q\), ainsi que de \(n\) données \(X\) et \(Y\). Le but est de trouver une fonction \(f : \mathbb{R}^p \rightarrow \mathbb{R}^q\) telle que \(f(x) \approx y\).

1.2 Un modèle est un ensemble de fonctions

En statistique et en apprentissage automatique, un modèle n’est pas une fonction unique mais un ensemble de fonctions \(\mathcal{F}\) de \(\mathbb{R}^p\) vers \(\mathbb{R}^q\). Pour les réseaux de neurones, cet ensemble est paramétré par \(\theta\) :

\[\mathcal{F} = \{f(\cdot, \theta) \mid \theta \in \mathbb{R}^k\}\]

Choisir un modèle, c’est choisir une valeur particulière de \(\theta\), puis regarder \(f(x; \theta)\).

1.3 Le régime surparamétré

Un trait distingue radicalement les réseaux de neurones de la statistique classique. Pour entraîner un réseau de neurones efficacement, il faut généralement que le nombre de paramètres \(k\) soit très supérieur au nombre de données \(n\) (\(k \gg n\)) — l’inverse de la recommandation classique, qui voudrait plutôt \(k \ll n\) pour éviter le surapprentissage. Les réseaux de neurones s’avèrent, de façon assez contre-intuitive, robustes au surapprentissage malgré ce très grand nombre de paramètres. C’est un phénomène qui reste aujourd’hui partiellement mal compris, et sur lequel on reviendra dans le cours sur l’apprentissage.

2 Le perceptron : un neurone

Le perceptron est le bloc de base : un unique neurone. Il reçoit des entrées \(X = (x_1, x_2, \dots, x_n)\), chacune associée à un poids \(w_i\), ainsi qu’un biais \(b\) (parfois noté \(w_0\)), et produit une sortie

\[y = h\left(\sum_i w_i x_i + b\right)\]

\(h\) est la fonction d’activation.

2.1 Les fonctions d’activation les plus courantes

Plusieurs choix sont possibles pour \(h\), chacun avec ses propriétés :

  1. Fonction seuil (Heaviside) : \(h(x) = 1\) si \(x \geq 0\), \(0\) sinon — la version la plus proche du neurone biologique, mais non différentiable.
  2. Sigmoïde : \(h(x) = \dfrac{1}{1+e^{-x}}\) — une version adoucie du seuil, à valeurs dans \((0,1)\).
  3. Tanh : \(h(x) = \tanh(x)\) — similaire à la sigmoïde, mais centrée en 0.
  4. ReLU : \(h(x) = \max(0, x)\) — la plus utilisée aujourd’hui, pour des raisons développées dans le cours sur les architectures profondes.
Afficher le code
import numpy as np
import matplotlib.pyplot as plt

# Plage d'entrée
x = np.linspace(-10, 10, 400)

# Fonctions d'activation
def heaviside(x):
    return np.where(x >= 0, 1, 0)

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def tanh(x):
    return np.tanh(x)

def relu(x):
    return np.maximum(0, x)

# Création de la figure
plt.figure(figsize=(10, 5))

plt.subplot(2, 2, 1)
plt.plot(x, heaviside(x), label="Heaviside", color='blue')
plt.title("Fonction Seuil (Heaviside)")

plt.subplot(2, 2, 2)
plt.plot(x, sigmoid(x), label="Sigmoïde", color='green')
plt.title("Sigmoïde")

plt.subplot(2, 2, 3)
plt.plot(x, tanh(x), label="Tanh", color='red')
plt.title("Tanh")

plt.subplot(2, 2, 4)
plt.plot(x, relu(x), label="ReLU", color='purple')
plt.title("ReLU")

plt.tight_layout()
plt.show()

2.2 Classification avec un perceptron

Pour séparer deux classes, on utilise la sortie \(y\) d’un perceptron : si \(y > 0\), on prédit la classe 1 ; si \(y < 0\), la classe 2. Puisque \(y\) est une fonction affine des entrées (avant application de \(h\)), la frontière entre les deux classes est nécessairement affine — une droite en dimension 2, un hyperplan en dimension supérieure. C’est exactement la limite qu’on a rencontrée avec XOR dans l’article sur l’histoire du deep learning : un perceptron seul ne peut séparer que des classes linéairement séparables.

L’exemple ci-dessous illustre cette frontière de décision sur des données synthétiques, pour des poids fixés arbitrairement.

Afficher le code
import numpy as np
import matplotlib.pyplot as plt

# Génération de données fictives (deux classes linéairement séparables)
np.random.seed(0)
X_class1 = np.random.randn(50, 2) + np.array([2, 2])  # Classe 1
X_class2 = np.random.randn(50, 2) + np.array([-2, -2])  # Classe 2

# Paramètres du perceptron (exemple fictif)
w = np.array([1, 1])  # Poids du perceptron
b = -0.5  # Biais

# Calcul de la frontière de décision : w1*x1 + w2*x2 + b = 0
x_vals = np.linspace(-4, 6, 100)
decision_boundary = -(w[0] * x_vals + b) / w[1]

plt.figure(figsize=(8, 6))
plt.scatter(X_class1[:, 0], X_class1[:, 1], color='blue', label='Classe 1 (y>0)')
plt.scatter(X_class2[:, 0], X_class2[:, 1], color='red', label='Classe 2 (y<0)')
plt.plot(x_vals, decision_boundary, 'k--', label='Frontière de décision')
plt.title('Perceptron - Classifieur Linéaire')
plt.xlabel('x1')
plt.ylabel('x2')
plt.xlim([-4, 4])
plt.ylim([-4, 4])
plt.axhline(0, color='black', linewidth=1)
plt.axvline(0, color='black', linewidth=1)
plt.legend()
plt.grid(True)
plt.show()

3 Empiler des couches : le réseau feedforward

3.1 Une couche de neurones

Un seul neurone reste limité à des frontières affines. L’étape naturelle suivante consiste à en placer plusieurs côte à côte, formant une couche : \(N = \{\text{Neurone}_1, \dots, \text{Neurone}_m\}\), tous connectés aux mêmes entrées \(X = [x_1, \dots, x_n]\), mais avec des poids et biais propres à chacun.

Pour chaque neurone \(j\) de la couche, le calcul s’écrit :

\[z_j = \sum_{i=1}^{n} w_{ij}\, x_i + b_j\]

\(w_{ij}\) est le poids de la connexion entre l’entrée \(x_i\) et le neurone \(j\), et \(b_j\) le biais du neurone \(j\). La couche produit alors une sortie \([h(z_1), \dots, h(z_m)]\) : chaque neurone reçoit les mêmes entrées mais possède des poids et biais différents, ce qui lui permet d’apprendre une représentation différente des données.

En notant \(W\) la matrice des poids, \(b\) le vecteur des biais, \(x\) le vecteur d’entrée et \(z = (z_1, \dots, z_m)\), ce calcul se réécrit sous forme vectorisée : \(z = xW + b\). Le produit matriciel est ici une opération essentielle — c’est lui qui permet de vectoriser le code et de tirer parti du calcul parallèle, notamment sur GPU.

3.2 Plusieurs couches : le réseau complet

Un réseau de neurones feedforward étend simplement cette idée à plusieurs couches empilées : une couche d’entrée, une ou plusieurs couches cachées, une couche de sortie (généralement sans fonction d’activation). Les sorties de chaque couche deviennent les entrées de la suivante, et chaque neurone d’une couche est connecté à tous les neurones de la couche suivante — on parle de connexions entièrement connectées. L’information circule dans une seule direction, de l’entrée vers la sortie, sans boucle ni rétroaction : c’est précisément ce qui caractérise un réseau feedforward, par opposition aux réseaux récurrents.

3.3 Un exemple chiffré

Prenons un petit réseau à trois couches, de poids et biais :

\[W^{(1)} = [1,2,3], \quad W^{(2)} = \begin{bmatrix} 3 & 4 \\ -2 & 0 \\ 1 & 2 \end{bmatrix}, \quad W^{(3)} = \begin{bmatrix} 1 \\ 2 \end{bmatrix}\]

\[b^{(1)} = (0,0,0), \quad b^{(2)} = (1,1), \quad b^{(3)} = (-1)\]

avec une fonction d’activation \(\tanh\). L’espace de départ est ici de dimension 1 (l’entrée \(x\) est un scalaire), et \(W^{(1)}\) le projette vers un espace de dimension 3 ; \(W^{(2)}\) ramène cette représentation à la dimension 2, et \(W^{(3)}\) produit finalement une sortie scalaire. Suivre le calcul pour une entrée \(x = 3\), couche après couche, est un bon exercice pour vérifier qu’on a bien compris comment s’enchaînent ces opérations.

4 Inférence versus entraînement

Il est important de distinguer clairement deux régimes bien différents.

L’entraînement consiste à chercher \(\theta\) tel que \(f(x, \theta)\) corresponde le mieux possible aux données observées. C’est une phase d’optimisation, coûteuse en calcul, qui fait l’objet du cours suivant.

L’inférence, celle qui nous occupe ici, suppose \(\theta\) fixe : on se contente de produire une nouvelle prédiction de \(y\) pour un nouveau \(x\). C’est l’usage quotidien d’un modèle une fois qu’il a été entraîné — c’est ce qui se passe, par exemple, chaque fois qu’un modèle de langage répond à une question.

5 En pratique : PyTorch

Trois bibliothèques dominent aujourd’hui l’écosystème des réseaux de neurones : TensorFlow, PyTorch et JAX. On utilisera ici PyTorch, la plus populaire des trois — les trois sont utilisables en Python.

Le module torch.nn.functional, généralement importé sous l’alias F, contient notamment les fonctions d’activation et les fonctions de coût.

Afficher le code
import torch
import torch.nn as nn
import torch.nn.functional as F

5.1 Les tensors

L’objet central de PyTorch est le tensor, l’équivalent des ndarray de NumPy, avec une syntaxe très proche.

Afficher le code
tensor_ex = torch.tensor([[1.,2,3],[4,5,6]])
print(tensor_ex[0,:]) #première ligne
print(tensor_ex[:,0]) #première colonne
print(tensor_ex.shape) #dimensions
print(tensor_ex[1,1]) #accéder à un élément
tensor([1., 2., 3.])
tensor([1., 4.])
torch.Size([2, 3])
tensor(5.)

Un cas particulier mérite d’être noté : les scalaires (un seul nombre) sont eux aussi représentés comme des tenseurs, de dimension 0. Pour en extraire l’unique valeur, on utilise .item() plutôt que l’indexation [0], qui ne fonctionne pas sur un tenseur sans dimension.

Afficher le code
dim0_tensor = torch.tensor(1.0)
#dim0_tensor[0] #Ne fonctionne pas !
print(dim0_tensor.item())
1.0

6 Un exemple complet : classifier des vêtements

Pour rendre tout cela concret, construisons un classifieur d’images sur le jeu de données FashionMNIST : des images en niveaux de gris de 28×28 pixels, associées à un label parmi dix types de vêtements (numérotés de 0 à 9).

Afficher le code
from torch.utils.data import Dataset
from torchvision import datasets
from torchvision.transforms import ToTensor
import matplotlib.pyplot as plt

training_data = datasets.FashionMNIST(
    root="data",
    train=True,
    download=True,
    transform=ToTensor()
)

test_data = datasets.FashionMNIST(
    root="data",
    train=False,
    download=True,
    transform=ToTensor()
)

labels_map = {
    0: "T-Shirt",
    1: "Trouser",
    2: "Pullover",
    3: "Dress",
    4: "Coat",
    5: "Sandal",
    6: "Shirt",
    7: "Sneaker",
    8: "Bag",
    9: "Ankle Boot",
}
figure = plt.figure(figsize=(8, 8))
cols, rows = 3, 3
for i in range(1, cols * rows + 1):
    sample_idx = torch.randint(len(training_data), size=(1,)).item()
    img, label = training_data[sample_idx]
    figure.add_subplot(rows, cols, i)
    plt.title(labels_map[label])
    plt.axis("off")
    plt.imshow(img.squeeze(), cmap="gray")
plt.show()

6.1 Le modèle : entrée, sortie

Il s’agit d’un problème de classification : on cherche à apprendre le type de vêtement en fonction de l’image. L’espace de départ est \(\mathbb{R}^{28 \times 28}\) (l’image aplatie), l’espace d’arrivée \(\mathbb{R}^{10}\). Pour une image \(x\), la sortie du réseau est un vecteur \(y \in \mathbb{R}^{10}\) dont le \(i\)-ème élément correspond à un score associé au \(i\)-ème type de vêtement ; pour déterminer la prédiction finale, il suffit de prendre l’argmax de \(y\).

Le modèle retenu comporte deux couches cachées de 512 paramètres chacune, avec une activation ReLU :

  • couche 1 : poids \(w_1\), matrice \((28 \times 28) \times 512\) ; biais \(b_1\), vecteur de taille 512
  • couche 2 : poids \(w_2\), matrice \(512 \times 512\) ; biais \(b_2\), vecteur de taille 512
  • couche de sortie : poids \(w_3\), matrice \(512 \times 10\) ; biais \(b_3\), vecteur de taille 10

6.2 Créer le modèle

En PyTorch, un modèle se définit en général comme une classe héritant de nn.Module, avec deux méthodes : __init__, où l’on initialise/définit les paramètres \(\theta\) du modèle, et forward, où l’on évalue le modèle en \(x\). Voici le squelette à compléter :

class SimpleNN(nn.Module):
    def __init__(self):
        super(SimpleNN, self).__init__()
        # initialiser les paramètres ici !

    def forward(self, x):
        # calculer la sortie du modèle ici !
        return something

Et voici la version complète, avec des poids et biais déclarés explicitement grâce à nn.Parameter — ce qui indique à PyTorch qu’il s’agit bien d’une partie des paramètres \(\theta\) du modèle — et une passe forward qui enchaîne les transformations et les fonctions d’activation. nn.Flatten() transforme un tenseur multi-dimensionnel (ici, une image 28×28) en un tenseur 1-dimensionnel ; F.relu est la fonction d’activation ; matmul réalise la multiplication matricielle.

Afficher le code
class SimpleNN(nn.Module):
    def __init__(self):
        super(SimpleNN, self).__init__()
        self.flatten = nn.Flatten()
        # Définir les poids et biais pour trois couches
        self.w1 = nn.Parameter(torch.randn(28*28,512) * 0.01)
        self.b1 = nn.Parameter(torch.zeros(512))
        self.w2 = nn.Parameter(torch.randn(512, 512) * 0.01)
        self.b2 = nn.Parameter(torch.zeros(512))
        self.w3 = nn.Parameter(torch.randn(512, 10) * 0.01)
        self.b3 = nn.Parameter(torch.zeros(10))

    def forward(self, x):
        x = self.flatten(x)
        # Appliquer les transformations et les fonctions d'activation
        x = F.relu(x.matmul(self.w1) + self.b1)
        x = F.relu(x.matmul(self.w2) + self.b2)
        x = x.matmul(self.w3) + self.b3
        return x

# Instancier le modèle
model = SimpleNN()

6.3 Pourquoi il faut de bons paramètres

À l’instanciation, les paramètres du modèle sont initialisés aléatoirement : évaluer ce modèle tel quel ne produira donc rien de pertinent. Trouver de bons paramètres est précisément l’objet de l’entraînement, traité en détail dans le cours suivant. Pour illustrer l’inférence de façon concrète, on entraîne ici le modèle très rapidement — quelques passages sur les données, largement insuffisants pour une performance optimale, mais suffisants pour obtenir des prédictions sensées.

Afficher le code
from torch.utils.data import DataLoader

train_dataloader = DataLoader(training_data, batch_size=64, shuffle=True)
test_dataloader = DataLoader(test_data, batch_size=64)

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)


def train_loop(dataloader, model, loss_fn, optimizer):
    model.train()
    for X, y in dataloader:
        pred = model(X)
        loss = loss_fn(pred, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()


def test_loop(dataloader, model, loss_fn):
    model.eval()
    size = len(dataloader.dataset)
    test_loss, correct = 0, 0
    with torch.no_grad():
        for X, y in dataloader:
            pred = model(X)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
    print(f"Précision sur le test : {100 * correct / size:>0.1f}%")


epochs = 3
for t in range(epochs):
    train_loop(train_dataloader, model, loss_fn, optimizer)
    test_loop(test_dataloader, model, loss_fn)
Précision sur le test : 83.7%
Précision sur le test : 86.3%
Précision sur le test : 86.1%

En pratique, une fois le modèle entraîné, on sauvegarde généralement ses paramètres dans un fichier (torch.save(model.state_dict(), ...)), pour pouvoir les recharger plus tard sans repasser par l’entraînement (model.load_state_dict(torch.load(...))) — c’est ce mécanisme qui permet de séparer, dans la pratique, la phase d’entraînement (coûteuse, faite une fois) de la phase d’inférence (rapide, répétée à chaque utilisation).

6.4 L’inférence, enfin

On y est : le modèle est entraîné, ses paramètres sont fixés. Prenons une donnée du jeu de test — x, l’image, et y, le vrai label — et regardons ce que prédit le modèle. La fonction squeeze() sert simplement à retirer les dimensions inutiles de l’image (ici, la dimension du canal, puisque l’image est en niveaux de gris).

Afficher le code
(x, y) = test_data[0]
print(x.shape)
plt.imshow(x.squeeze())
print("vrai label", y, labels_map[y])
torch.Size([1, 28, 28])
vrai label 9 Ankle Boot

Pour l’inférence proprement dite, deux précautions s’imposent. Il faut d’abord passer le modèle en mode « évaluation » (model.eval()), par opposition au mode entraînement — une distinction dont l’intérêt apparaît surtout avec des couches comme le dropout ou la batch normalization, vues dans le cours sur les architectures profondes. Il faut ensuite désactiver le calcul du gradient (torch.no_grad()), inutile en inférence, ce qui permet de gagner en performance.

Afficher le code
model.eval()

with torch.no_grad():
    prediction = model(x)
    label_idx = prediction.argmax().item()
    print("prédiction :", label_idx, labels_map[label_idx])
prédiction : 9 Ankle Boot

C’est là toute l’inférence : une passe avant dans le réseau, des paramètres déjà fixés, et une prédiction en sortie. La question qui reste ouverte — comment ces paramètres ont-ils été trouvés ? — est celle du prochain article, consacré à l’apprentissage.