Neural ODEs
Résumé
Les Neural ODEs (Neural Ordinary Differential Equations, ou Équations Différentielles Ordinaires Neuronales) représentent l’une des avancées les plus élégantes de ces dernières années dans le domaine du deep learning. Introduites par Chen et al. en 2018 dans l’article « Neural Ordinary Differential Equations », elles proposent un changement de paradigme fondamental : au lieu d’empiler des couches neuronales discrètes comme le font les réseaux de neurones traditionnels, les Neural ODEs modélisent la transformation des données comme un processus continu gouverné par une équation différentielle ordinaire. Cette approche offre une mémoire constante pendant l’entraînement, une évaluation adaptative de la complexité computationnelle, et une capacité naturelle à traiter des séries temporelles irrégulièrement espacées. Dans ce guide complet, nous explorerons les fondements mathématiques, l’intuition derrière ce concept, l’implémentation pratique avec PyTorch et la bibliothèque torchdiffeq, ainsi que les cas d’usage concrets qui font des Neural ODEs un outil incontournable pour tout praticien du machine learning moderne.
Principe Mathématique
Des couches discrètes à la dynamique continue
Pour comprendre les Neural ODEs, il faut d’abord examiner comment fonctionne un réseau de neurones profond classique, en particulier un réseau résiduel (ResNet). Dans un ResNet, chaque couche calcule :
h_{t+1} = h_t + f(h_t, \theta_t)
où $h_t$ est l’état caché à la couche $t$, $f$ est une fonction différentiable (typiquement un petit réseau de neurones), et $\theta_t$ sont les paramètres spécifiques à cette couche. Si l’on observe cette équation attentivement, on reconnaît la forme d’une discrétisation d’Euler d’une équation différentielle ordinaire, avec un pas de temps $\Delta t = 1$.
Les Neural ODEs poussent cette observation à son extrémité logique. Au lieu de considérer des couches discrètes avec des paramètres $\theta_t$ qui changent à chaque étape, elles définissent une dynamique continue où la dérivée de l’état caché par rapport au temps (ou à la profondeur du réseau) est paramétrée par un seul réseau de neurones partagé :
$$\frac{dh(t)}{dt} = f(h(t), t, \theta)$$
Ici, $f$ est un réseau de neurones unique dont les paramètres $\theta$ sont partagés à travers tout le « temps » $t$. La sortie du réseau à un instant $t_1$, étant donnée une entrée à $t_0$, est obtenue par intégration numérique de cette équation différentielle :
$$h(t_1) = h(t_0) + \int_{t_0}^{t_1} f(h(t), t, \theta) \, dt$$
En pratique, cette intégrale est résolue par un solveur numérique d’ODE (comme Runge-Kutta d’ordre 4-5, dit « dopri5 »), qui adapte automatiquement la taille des pas d’intégration pour atteindre une précision donnée.
La méthode de l’adjoint pour le calcul des gradients
Le véritable tour de force des Neural ODEs réside dans la manière dont elles calculent les gradients pendant la rétropropagation. Dans un réseau profond classique, la rétropropagation nécessite de stocker toutes les activations intermédiaires, ce qui coûte une mémoire proportionnelle au nombre de couches : $O(L)$.
Pour les Neural ODEs, Chen et al. utilisent la méthode de l’adjoint (adjoint sensitivity method). L’idée est de définir un état adjoint :
$$a(t) = \frac{\partial L}{\partial h(t)}$$
où $L$ est la fonction de coût. Cet état adjoint satisfait sa propre équation différentielle :
$$\frac{da(t)}{dt} = -a(t)^\top \frac{\partial f(h(t), t, \theta)}{\partial h}$$
Pour obtenir les gradients par rapport aux paramètres $\theta$, on résout une ODE supplémentaire en arrière :
$$\frac{dL}{d\theta} = \int_{t_1}^{t_0} a(t)^\top \frac{\partial f(h(t), t, \theta)}{\partial \theta} \, dt$$
Cette approche a une conséquence remarquable : la mémoire utilisée est constante, $O(1)$, indépendamment de la complexité du réseau ou du nombre d’évaluations du solveur. On n’a pas besoin de stocker les activations intermédiaires ; il suffit de réintégrer le système vers l’arrière. C’est un avantage décisif pour les modèles très profonds ou les séries temporelles longues.
Tolérance du solveur : le compromis précision/vitesse
Contrairement aux réseaux de neurones traditionnels où le nombre de couches est fixé à l’avance, les Neural ODEs laissent le solveur ODE décider du nombre d’évaluations nécessaires pour atteindre une précision donnée. Cette précision est contrôlée par deux hyperparamètres :
atol(tolérance absolue) : l’erreur maximale acceptée en valeur absolue à chaque pas d’intégration.rtol(tolérance relative) : l’erreur maximale acceptée proportionnellement à la magnitude de l’état courant.
Des tolérances plus basses (par exemple atol=1e-7, rtol=1e-7) forcent le solveur à prendre plus de pas d’intégration, ce qui augmente la précision mais aussi le temps de calcul. À l’inverse, des tolérances plus élevées (atol=1e-3, rtol=1e-3) donnent un calcul plus rapide au prix d’une approximation plus grossière. Ce mécanisme offre un compromis adaptatif unique en deep learning.
Intuition
Pour saisir réellement ce que sont les Neural ODEs, une analogie physique est particulièrement éclairante.
Imaginez un réseau de neurones classique comme un escalier. Chaque marche correspond à une couche. Pour descendre de l’entrée jusqu’à la sortie, vous devez poser le pied sur chaque marche, une par une. Vous ne pouvez pas sauter une marche, et chaque marche vous amène à un niveau discret bien défini. Si vous voulez plus de précision dans votre descente, vous devez construire plus de marches — ce qui demande plus de mémoire pour vous souvenir de chaque étape.
Maintenant, imaginez une rampe continue à la place d’un escalier. Sur une rampe, votre descente est fluide, sans interruptions. Vous glissez le long d’une pente dont l’inclinaison peut changer progressivement. Il n’y a pas de niveaux discrets : vous êtes dans un continuum. C’est exactement ce que fait une Neural ODE : la transformation des données est comme un glissement le long d’une rampe, où la forme de la rampe est définie par le réseau $f(h(t), t, \theta)$.
Et pour savoir comment modifier la forme de cette rampe (c’est-à-dire ajuster les paramètres $\theta$ afin de minimiser l’erreur), on utilise la méthode de l’adjoint. L’intuition ici est que, plutôt que de remonter l’escalier marche par marche en se souvenant de chaque pas (ce qui coûterait beaucoup de mémoire), on utilise le même solveur ODE pour remonter la pente en sens inverse de manière fluide. Le solveur travaille aussi bien vers l’avant que vers l’arrière, ce qui élimine le besoin de stocker les états intermédiaires.
Cette analogie explique pourquoi les Neural ODEs sont particulièrement adaptées aux données continues : séries temporelles avec des observations à intervalles irréguliers, trajectoires physiques, processus dynamiques où le temps s’écoule de manière naturelle et non discrète. Le choix du solveur et de ses tolérances revient à choisir avec quelle finesse on explore la pente — on peut utiliser de grands pas pour une descente rapide, ou de petits pas pour une précision maximale.
Implémentation Python avec torchdiffeq
La bibliothèque torchdiffeq est l’implémentation de référence pour les Neural ODEs dans l’écosystème PyTorch. Elle fournit la fonction clé odeint, qui intègre une équation différentielle dont la fonction de droite est un réseau de neurones.
Installation
pip install torchdiffeq
Architecture de base d’une Neural ODE
Commençons par définir un réseau qui servira de fonction dynamique $f(h(t), t, \theta)$ :
import torch
import torch.nn as nn
from torchdiffeq import odeint
class ODEFunction(nn.Module):
"""
Fonction dynamique f(h(t), t, theta) parametree par un reseau de neurones.
C'est le coeur de la Neural ODE.
"""
def __init__(self, hidden_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(hidden_dim, 64),
nn.Tanh(),
nn.Linear(64, 64),
nn.Tanh(),
nn.Linear(64, hidden_dim)
)
def forward(self, t, h):
"""
Calcule dh/dt = f(h, t, theta).
Le temps t est fourni par le solveur mais peut etre ignore ou utilise.
"""
return self.net(h)
class NeuralODE(nn.Module):
"""
Neural ODE completee : integre l'ODEFunction sur un intervalle [t0, t1].
"""
def __init__(self, hidden_dim, method="dopri5", atol=1e-7, rtol=1e-7):
super().__init__()
self.ode_func = ODEFunction(hidden_dim)
self.method = method
self.atol = atol
self.rtol = rtol
def forward(self, h0, t_span):
"""
Integre h(t) de t_span[0] a t_span[-1].
Retourne la trajectoire completee si len(t_span) > 2,
ou simplement l'etat final si t_span = [t0, t1].
"""
h = odeint(
self.ode_func,
h0,
t_span,
method=self.method,
atol=self.atol,
rtol=self.rtol
)
return h
Comparaison ResNet vs Neural ODE sur MNIST
Pour illustrer la différence concrète, comparons un ResNet classique et une Neural ODE sur la classification MNIST :
import torch.nn.functional as F
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# Preparation des donnees MNIST
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST("./data", train=True, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
class ResNet_MNIST(nn.Module):
"""ResNet classique avec 6 couches residuelles."""
def __init__(self):
super().__init__()
self.fc_in = nn.Linear(28 * 28, 64)
self.res_layers = nn.ModuleList([
nn.Sequential(
nn.Linear(64, 64),
nn.ReLU(),
nn.Linear(64, 64)
) for _ in range(6)
])
self.fc_out = nn.Linear(64, 10)
def forward(self, x):
h = x.view(-1, 28 * 28)
h = self.fc_in(h)
for layer in self.res_layers:
h = h + layer(h) # connexion residuelle
return self.fc_out(h)
class NeuralODE_MNIST(nn.Module):
"""Neural ODE appliquee a MNIST."""
def __init__(self):
super().__init__()
self.fc_in = nn.Linear(28 * 28, 64)
self.ode_func = ODEFunction(hidden_dim=64)
self.fc_out = nn.Linear(64, 10)
def forward(self, x):
h = x.view(-1, 28 * 28)
h = self.fc_in(h)
# Integration de t=0 a t=1
t_span = torch.tensor([0.0, 1.0])
h = odeint(self.ode_func, h, t_span, method="dopri5",
atol=1e-7, rtol=1e-7)[-1] # on prend l'etat final
return self.fc_out(h)
# Comparaison de la memoire
def compare_memory():
"""Compare la consommation memoire ResNet vs Neural ODE."""
resnet = ResNet_MNIST()
neural_ode = NeuralODE_MNIST()
resnet_params = sum(p.numel() for p in resnet.parameters())
node_params = sum(p.numel() for p in neural_ode.parameters())
print("ResNet parametres: " + str(resnet_params))
print("Neural ODE parametres: " + str(node_params))
# La Neural ODE utilise beaucoup moins de parametres car les poids sont partages
# a travers le temps au lieu d etre repliques par couche.
Le point crucial est le suivant : dans le ResNet, chaque couche résiduelle a ses propres paramètres. Dans la Neural ODE, le réseau ode_func est réutilisé à chaque évaluation du solveur. Le solveur décide dynamiquement combien de fois l’évaluer. Cela donne un modèle plus compact en termes de nombre de paramètres, avec une mémoire de rétropropagation constante.
Application aux séries temporelles irrégulières
L’un des cas d’usage les plus puissants des Neural ODEs est le traitement de séries temporelles irrégulièrement espacées. Dans de nombreux domaines (médecine, finance, sciences environnementales), les observations ne sont pas collectées à intervalles réguliers. Les RNNs classiques nécessitent d’interpoler ou de remplir les valeurs manquantes. Les Neural ODEs, en revanche, modélisent naturellement une trajectoire continue et peuvent être évaluées à n’importe quel instant $t$.
class IrregularTimeSeriesODE(nn.Module):
"""
Neural ODE pour la modelisation de series temporelles irregulieres.
Le reseau peut etre evalue a n importe quel ensemble d instants.
"""
def __init__(self, input_dim, hidden_dim):
super().__init__()
self.encoder = nn.Linear(input_dim, hidden_dim)
self.ode_func = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim)
)
self.decoder = nn.Linear(hidden_dim, input_dim)
def forward(self, x, t_obs):
"""
x : tenseur de forme (batch, input_dim) - etat initial observe
t_obs : tenseur 1D des instants d observation [t_0, t_1, ..., t_n]
Retourne les predictions a chaque instant d observation.
"""
h0 = self.encoder(x)
t_obs = t_obs.to(x.device)
# Le solveur integre exactement aux instants demandes
trajectory = odeint(self.ode_func, h0, t_obs, method="dopri5",
atol=1e-7, rtol=1e-7)
predictions = self.decoder(trajectory)
return predictions
# Exemple d utilisation avec des observations irregulieres
def demo_irregular_timeseries():
batch_size = 32
input_dim = 5
# Instants d observation irreguliers : pas espaces uniformement
t_obs = torch.tensor([0.0, 0.3, 0.7, 1.5, 2.0, 3.1, 4.8, 6.0])
model = IrregularTimeSeriesODE(input_dim=input_dim, hidden_dim=32)
initial_state = torch.randn(batch_size, input_dim)
# Le modele retourne la trajectoire a chacun des 8 instants irreguliers
output = model(initial_state, t_obs)
print("Forme de la sortie : " + str(output.shape))
# Chaque instant recoit sa prediction, meme si les intervalles varient
# de 0.3 a 1.7 - le solveur gere automatiquement cette irregularite.
Cette capacité à évaluer le modèle à des instants arbitraires rend les Neural ODEs supérieures aux architectures séquentielles classiques (LSTM, GRU) lorsqu’il s’agit de données temporelles hétérogènes.
Hyperparamètres Clés
Le réglage des hyperparamètres d’une Neural ODE est crucial pour obtenir de bonnes performances. Voici les principaux paramètres à considérer :
Choix du solveur (method)
| Solveur | Type | Usage recommandé |
|---|---|---|
dopri5 |
Runge-Kutta d’ordre 4-5 adaptatif | Défaut recommandé — bon compromis précision/vitesse |
rk4 |
Runge-Kutta d’ordre 4 fixe | Rapide mais moins précis, utile pour le débogage |
euler |
Euler explicite | Très rapide mais instable, uniquement pour tests |
adams |
Adams-Bashforth-Moulton | Bon pour les problèmes raides |
bdf |
Backward Differentiation Formula | Pour les équations différentielles raides |
En pratique, dopri5 est le choix par défaut dans la grande majorité des cas. Son caractère adaptatif signifie qu’il ajuste automatiquement la taille du pas d’intégration : il prend de grands pas quand la dynamique est lente et de petits pas quand elle est rapide.
Tolérances (atol, rtol)
atol(tolérance absolue) : par défaut1e-7. Détermine l’erreur absolue maximale tolérée par le solveur à chaque pas.rtol(tolérance relative) : par défaut1e-7. Détermine l’erreur relative maximale tolérée.
En général, on utilise la même valeur pour les deux : atol=rtol. Pour des expériences rapides ou du débogage, on peut augmenter ces valeurs à 1e-3 ou 1e-4. Pour des résultats de publication, on les abaisse à 1e-7 ou 1e-8. Attention : des tolérances trop basses peuvent entraîner un nombre exponentiel d’évaluations de la fonction $f$, ce qui ralentit considérablement l’entraînement.
Temps d’intégration (t_span)
L’intervalle $[t_0, t_1]$ sur lequel on intègre l’ODE influence la profondeur effective du réseau. Un intervalle plus long $[0, 10]$ permet une transformation plus riche qu’un intervalle court $[0, 0.1]$. En pratique, $[0, 1]$ est un bon point de départ, et on peut ajuster selon la complexité du problème.
Avantages et Limites
Avantages
- Mémoire constante $O(1)$ : grâce à la méthode de l’adjoint, la mémoire de rétropropagation ne dépend pas du nombre d’évaluations du solveur. C’est un avantage majeur par rapport aux réseaux profonds classiques dont la mémoire croît linéairement avec le nombre de couches.
- Complexité computationnelle adaptative : le solveur ODE décide automatiquement du nombre d’évaluations nécessaires. Les échantillons « faciles » sont traités rapidement, tandis que les échantillons « difficiles » reçoivent plus de calcul. C’est une forme d’allocation dynamique de ressources.
- Modélisation de séries temporelles irrégulières : contrairement aux RNNs qui nécessitent des observations régulièrement espacées, les Neural ODEs peuvent être évaluées à n’importe quel instant $t$, ce qui les rend naturelles pour les données temporelles hétérogènes.
- Nombre de paramètres réduit : les poids du réseau dynamique $f$ sont partagés à travers tout l’intervalle d’intégration, contrairement aux réseaux profonds où chaque couche a ses propres paramètres.
- Connexion rigoureuse avec les mathématiques continues : la formalisation par des équations différentielles ouvre la porte à l’analyse théorique (stabilité, convergence) et aux outils numériques matures des méthodes de résolution d’ODE.
Limites
- Vitesse d’entraînement : l’intégration numérique à chaque passe (avant et arrière) est plus coûteuse qu’une simple propagation avant dans un réseau classique. L’entraînement peut être significativement plus lent.
- Instabilité lors de l’optimisation : les tolérances du solveur peuvent créer des gradients discontinus ou bruités, ce qui complique la convergence de l’optimiseur. Des stratégies comme la régularisation ou l’ajustement progressif des tolérances sont parfois nécessaires.
- Difficulté avec les dynamiques très raides : certaines fonctions $f$ produisent des équations différentielles « raides » (stiff) où la dynamique varie à des échelles de temps très différentes. Ces cas nécessitent des solveurs spécialisés (BDF) et sont plus difficiles à optimiser.
- Manque d’interprétabilité des états cachés : bien que la trajectoire continue soit mathématiquement élégante, il est souvent plus difficile d’interpréter ce que représente l’état $h(t)$ à un instant donné comparé aux activations d’une couche discrète.
- Sensible à l’initialisation : le choix des paramètres initiaux du réseau $f$ influence fortement le comportement de la trajectoire intégrée. Une mauvaise initialisation peut conduire à des trajectoires explosantes ou stagnantes.
4 Cas d’Usage Concrets
1. Modélisation de données médicales à sampling irrégulier
En médecine intensive, les signes vitaux d’un patient sont mesurés à des intervalles irréguliers — certaines mesures sont prises toutes les minutes, d’autres toutes les heures. Les Neural ODEs peuvent modéliser l’évolution continue de l’état du patient entre les observations, permettant de prédire des événements critiques (arrêt cardiaque, sepsis) même lorsque les données sont très espacées. Des travaux ont démontré que les Neural ODEs surpassent les LSTMs et les GRUs sur des données de soins intensifs réelles.
2. Systèmes dynamiques et physique apprise
Les Neural ODEs sont idéales pour apprendre les lois de la physique à partir de données d’observation. Par exemple, à partir de trajectoires observées d’un pendule ou d’un système planétaire, une Neural ODE peut apprendre la fonction de dynamique sous-jacente. Cette approche, appelée « Neural Laplace » ou « Hamiltonian Neural Network » dans certaines variantes, permet de découvrir des lois physiques implicites sans supervision explicite.
3. Generative modeling avec Continuous Normalizing Flows
Les Neural ODEs forment la base des Continuous Normalizing Flows (CNFs), une méthode de génération de données qui transforme une distribution simple (comme une gaussienne) en une distribution complexe via un flux continu et réversible. Contrairement aux GANs ou aux VAEs, les CNFs offrent une vraisemblance exacte (exact likelihood) grâce au théorème du changement de variables instantané (instantaneous change of variables). Cette approche est utilisée pour la génération d’images, la modélisation de densité et l’inférence variationnelle.
4. Contrôle optimal et robotique
Dans le domaine du contrôle de systèmes physiques (robots, drones, véhicules autonomes), les Neural ODEs offrent une manière naturelle de modéliser la dynamique continue du système et d’optimiser les commandes de contrôle. En combinant la méthode de l’adjoint avec des techniques de contrôle optimal (comme le principe du maximum de Pontryagin), on peut apprendre des politiques de contrôle qui respectent les équations différentielles du système physique tout en minimisant un coût donné.
Voir aussi
- Explorer les Polynômes Dynamiques avec Python : Guide Complet et Applications Pratiques
- Maîtriser l’Opérateur Floor en Python : La Révolution des Calculs Arithmétiques

