Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Généralisation et sélection de modèles

Les chapitres précédents ont développé les deux modèles linéaires fondamentaux: la régression (MCO, Ridge) et la classification (régression logistique, entropie croisée). Mais un problème fondamental demeure: comment savoir si notre modèle généralisera bien à de nouvelles données? Ce chapitre explore cette question à travers le compromis biais-variance, les techniques d’expansion de caractéristiques, et les méthodes de sélection de modèle.

Source
import numpy as np
import matplotlib.pyplot as plt

# Configuration pour des figures haute résolution
%config InlineBackend.figure_format = 'retina'

Classes de modèles et expansion de caractéristiques

Les exemples de régularisation du chapitre précédent utilisaient des caractéristiques polynomiales: au lieu de prédire yy directement à partir de xx, nous avons construit des caractéristiques [1,x,x2,…,x15][1, x, x^2, \ldots, x^{15}] et appliqué un modèle linéaire dans cet espace étendu. Cette technique s’appelle l’expansion de caractéristiques et mérite d’être formalisée.

Trois familles de modèles

Situons les modèles linéaires dans une hiérarchie plus large. Nous distinguons trois familles de complexité croissante:

  1. Modèles linéaires: f(x;θ)=θ⊤x+bf(\mathbf{x}; \boldsymbol{\theta}) = \boldsymbol{\theta}^\top \mathbf{x} + b. La sortie est une combinaison linéaire des entrées. Simple, interprétable, mais limité aux relations linéaires.

  2. Modèles à expansion de caractéristiques: f(x;θ)=θ⊤ϕ(x)+bf(\mathbf{x}; \boldsymbol{\theta}) = \boldsymbol{\theta}^\top \boldsymbol{\phi}(\mathbf{x}) + b, où ϕ:Rd→RD\boldsymbol{\phi}: \mathbb{R}^d \to \mathbb{R}^D est une transformation non linéaire fixée à l’avance (par exemple, polynomiale). Le modèle reste linéaire dans les paramètres θ\boldsymbol{\theta}, ce qui facilite l’optimisation, mais peut capturer des relations non linéaires en x\mathbf{x}. L’espace de redescription a souvent une dimension D≫dD \gg d.

  3. Réseaux de neurones: f(x;θ)=fK(fK−1(⋯f1(x;θ1);θK−1);θK)f(\mathbf{x}; \boldsymbol{\theta}) = f_K(f_{K-1}(\cdots f_1(\mathbf{x}; \boldsymbol{\theta}_1); \boldsymbol{\theta}_{K-1}); \boldsymbol{\theta}_K). Une composition de KK fonctions non linéaires, chacune avec ses propres paramètres. Contrairement aux modèles à expansion fixe, les réseaux de neurones apprennent la représentation ϕ\boldsymbol{\phi} en même temps que les paramètres θ\boldsymbol{\theta}.

Cette progression capture l’évolution historique du domaine: des modèles linéaires classiques aux méthodes à noyaux (expansion implicite), puis aux réseaux profonds qui apprennent leurs propres représentations. Nous verrons les réseaux de neurones en détail dans les chapitres suivants; concentrons-nous ici sur les deux premières familles.

Expansion de caractéristiques

Pour capturer des relations non linéaires tout en gardant un modèle linéaire dans les paramètres, nous transformons les entrées. En régression polynomiale, nous appliquons une fonction ϕ:R→Rk+1\phi: \mathbb{R} \to \mathbb{R}^{k+1}:

ϕ(x)=[1,x,x2,…,xk]\phi(x) = [1, x, x^2, \ldots, x^k]

La prédiction devient f(x;θ)=θ⊤ϕ(x)f(x; \boldsymbol{\theta}) = \boldsymbol{\theta}^\top \phi(x). Le modèle est polynomial en xx mais linéaire en θ\boldsymbol{\theta}, ce qui permet d’utiliser les mêmes algorithmes d’optimisation (MCO, Ridge).

Le degré kk contrôle la capacité du modèle. Avec k=1k = 1, nous avons une droite. Avec kk élevé, le polynôme peut osciller pour passer par tous les points d’entraînement. Avec k=N−1k = N - 1, nous pouvons interpoler exactement les NN points: le risque empirique atteint zéro. Mais un polynôme qui passe exactement par les points d’entraînement n’a aucune raison de bien prédire les nouveaux points.

Illustrons ce phénomène avec les données de freinage. Nous ajustons des polynômes de degrés 1, 2, 5 et 15, et comparons leurs erreurs sur les ensembles d’entraînement et de test.

Source
import numpy as np
import matplotlib.pyplot as plt
import warnings

# Suppress polyfit warnings for high-degree polynomials (expected for this demo)
warnings.filterwarnings('ignore', message='Polyfit may be poorly conditioned')

# Données de freinage
speed = np.array([4, 4, 7, 7, 8, 9, 10, 10, 10, 11, 11, 12, 12, 12, 12, 13, 13, 13, 13, 14,
                  14, 14, 14, 15, 15, 15, 16, 16, 17, 17, 17, 18, 18, 18, 18, 19, 19, 19,
                  20, 20, 20, 20, 20, 22, 23, 24, 24, 24, 24, 25], dtype=float)
dist = np.array([2, 10, 4, 22, 16, 10, 18, 26, 34, 17, 28, 14, 20, 24, 28, 26, 34, 34, 46,
                 26, 36, 60, 80, 20, 26, 54, 32, 40, 32, 40, 50, 42, 56, 76, 84, 36, 46,
                 68, 32, 48, 52, 56, 64, 66, 54, 70, 92, 93, 120, 85], dtype=float)

# Train/test split
np.random.seed(42)
indices = np.random.permutation(len(speed))
train_idx, test_idx = indices[:35], indices[35:]
speed_train, dist_train = speed[train_idx], dist[train_idx]
speed_test, dist_test = speed[test_idx], dist[test_idx]

degrees_to_plot = [1, 2, 5, 15]
degrees_eval = range(1, 16)
fig, axes = plt.subplots(2, 2, figsize=(10, 8))

# Pre-compute all errors for the summary plot later
all_train_errors = []
all_test_errors = []
for deg in degrees_eval:
    coeffs = np.polyfit(speed_train, dist_train, deg)
    all_train_errors.append(np.mean((dist_train - np.polyval(coeffs, speed_train))**2))
    all_test_errors.append(np.mean((dist_test - np.polyval(coeffs, speed_test))**2))

for ax, deg in zip(axes.flat, degrees_to_plot):
    # Fit polynomial
    coeffs = np.polyfit(speed_train, dist_train, deg)
    
    # Predictions
    pred_train = np.polyval(coeffs, speed_train)
    pred_test = np.polyval(coeffs, speed_test)
    
    # MSE
    mse_train = np.mean((dist_train - pred_train)**2)
    mse_test = np.mean((dist_test - pred_test)**2)
    
    # Plot
    ax.scatter(speed_train, dist_train, alpha=0.6, s=30, label='Entraînement')
    ax.scatter(speed_test, dist_test, alpha=0.6, s=30, marker='s', label='Test')
    
    speed_grid = np.linspace(3, 26, 200)
    pred_grid = np.polyval(coeffs, speed_grid)
    # Clip extreme predictions for visualization
    pred_grid = np.clip(pred_grid, -50, 200)
    ax.plot(speed_grid, pred_grid, 'k-', alpha=0.7)
    
    ax.set_xlim(3, 26)
    ax.set_ylim(-20, 150)
    ax.set_xlabel('Vitesse (mph)')
    ax.set_ylabel('Distance (ft)')
    ax.set_title(f'Degré {deg}: Entr. EQM={mse_train:.1f}, Test EQM={mse_test:.1f}')
    if deg == 1:
        ax.legend()

plt.tight_layout()
<Figure size 1000x800 with 4 Axes>

Le polynôme de degré 1 (droite) ne capture pas la courbure des données: c’est du sous-apprentissage. Le polynôme de degré 2 capture bien la relation quadratique. Le polynôme de degré 5 commence à osciller. Le polynôme de degré 15 passe près de tous les points d’entraînement, mais ses oscillations produisent des prédictions absurdes entre les points: c’est du surapprentissage.

Source
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(degrees_eval, all_train_errors, 'o-', linewidth=2, label='Erreur entraînement')
ax.plot(degrees_eval, all_test_errors, 's-', linewidth=2, label='Erreur test')

# Utiliser une échelle logarithmique car l'erreur de test explose
ax.set_yscale('log')

ax.set_xlabel('Degré du polynôme (complexité)')
ax.set_ylabel('EQM (échelle log)')
ax.set_xticks(range(1, 16, 2))
ax.grid(True, which="both", ls="-", alpha=0.2)
ax.legend()

ax.set_title('Compromis biais-variance')
plt.tight_layout()
<Figure size 800x500 with 1 Axes>

L’erreur d’entraînement diminue avec le degré du polynôme. L’erreur de test diminue d’abord (quand le modèle gagne en expressivité), puis augmente (quand le modèle commence à mémoriser le bruit). Le meilleur modèle se trouve à l’intersection de ces deux tendances. La régularisation Ridge, vue précédemment, est une alternative au choix du degré: elle permet d’utiliser un modèle de haute capacité tout en contrôlant le surapprentissage.

Encodage cyclique des variables périodiques

Les polynômes ne sont pas la seule expansion de caractéristiques utile. Considérons les variables périodiques comme l’heure de la journée, le jour de la semaine ou le mois de l’année. Ces variables posent un problème particulier lorsqu’on les utilise directement comme entrées d’un modèle linéaire.

Prenons l’heure de la journée. Si nous utilisons la valeur brute (un entier de 0 à 23), le modèle linéaire ne peut apprendre qu’une relation monotone: soit la cible augmente avec l’heure, soit elle diminue. Or, la consommation d’énergie n’est ni monotone croissante ni décroissante; elle est élevée le matin et le soir, faible la nuit et en milieu de journée. Un modèle linéaire avec l’heure brute ne peut pas capturer ce comportement cyclique.

Le problème est encore plus grave à la frontière du cycle. L’heure 23 et l’heure 0 sont adjacentes (une heure d’écart), mais numériquement elles diffèrent de 23. Un modèle linéaire n’a aucun moyen de « savoir » que ces valeurs sont proches. Si nous entraînons un modèle à prédire la température et qu’il observe que 22h et 23h ont des températures similaires, il n’a aucune raison d’extrapoler cette similarité à 0h et 1h.

L’encodage cyclique résout ces deux problèmes en projetant la variable sur un cercle à l’aide des fonctions sinus et cosinus:

ϕsin⁡(x)=sin⁡(2πxT),ϕcos⁡(x)=cos⁡(2πxT)\phi_{\sin}(x) = \sin\left(\frac{2\pi x}{T}\right), \quad \phi_{\cos}(x) = \cos\left(\frac{2\pi x}{T}\right)

où TT est la période (24 pour les heures, 7 pour les jours de la semaine, 12 pour les mois). Cette transformation a plusieurs propriétés importantes. Les valeurs adjacentes dans le cycle, y compris 23h et 0h, sont proches dans l’espace des caractéristiques. La représentation est continue et différentiable partout. Nous avons besoin de deux composantes (sin et cos) pour identifier de façon unique chaque point du cycle, car une seule ne suffirait pas.

Pour comprendre l’avantage de cet encodage, examinons la distance entre heures consécutives. Avec l’encodage naïf, la « distance » est simplement la différence absolue entre les valeurs numériques: ∣hi+1−hi∣|h_{i+1} - h_i|. Avec l’encodage cyclique, chaque heure hh devient un point (cos⁡(2πh/24),sin⁡(2πh/24))(\cos(2\pi h/24), \sin(2\pi h/24)) dans le plan, et la distance entre deux heures est la distance euclidienne entre ces points: (cos⁡i+1−cos⁡i)2+(sin⁡i+1−sin⁡i)2\sqrt{(\cos_{i+1} - \cos_i)^2 + (\sin_{i+1} - \sin_i)^2}.

Source
import numpy as np
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 3, figsize=(12, 4))

# Heures de la journée
heures = np.arange(24)
T = 24

# Encodage naïf: distance entre heures consécutives
ax = axes[0]
distances_naif = []
for h in range(24):
    h_suivant = (h + 1) % 24
    dist = abs(h_suivant - h) if h != 23 else abs(0 - 23)
    distances_naif.append(dist)
ax.bar(heures, distances_naif, color='steelblue', edgecolor='black')
ax.set_xlabel('Heure')
ax.set_ylabel('Distance à l\'heure suivante')
ax.set_title('Encodage naïf: discontinuité à minuit')
ax.set_xticks([0, 6, 12, 18, 23])
ax.axhline(1, color='green', linestyle='--', alpha=0.7, label='Distance idéale')
ax.legend()

# Encodage cyclique: projection sur le cercle
ax = axes[1]
theta = 2 * np.pi * heures / T
x_circle = np.cos(theta)
y_circle = np.sin(theta)

# Dessiner le cercle
circle_theta = np.linspace(0, 2*np.pi, 100)
ax.plot(np.cos(circle_theta), np.sin(circle_theta), 'k-', alpha=0.3)
ax.scatter(x_circle, y_circle, c=heures, cmap='twilight', s=100, zorder=5)

# Annoter quelques heures
for h in [0, 6, 12, 18, 23]:
    ax.annotate(f'{h}h', (x_circle[h]*1.15, y_circle[h]*1.15), ha='center', va='center')

ax.set_xlim(-1.4, 1.4)
ax.set_ylim(-1.4, 1.4)
ax.set_aspect('equal')
ax.set_xlabel(r'$\cos(2\pi h / 24)$')
ax.set_ylabel(r'$\sin(2\pi h / 24)$')
ax.set_title('Encodage cyclique: projection sur le cercle')
ax.axhline(0, color='gray', linewidth=0.5)
ax.axvline(0, color='gray', linewidth=0.5)

# Distance euclidienne entre heures consécutives (encodage cyclique)
ax = axes[2]
distances_cyclique = []
for h in range(24):
    h_suivant = (h + 1) % 24
    dist = np.sqrt((x_circle[h_suivant] - x_circle[h])**2 + 
                   (y_circle[h_suivant] - y_circle[h])**2)
    distances_cyclique.append(dist)
ax.bar(heures, distances_cyclique, color='coral', edgecolor='black')
ax.set_xlabel('Heure')
ax.set_ylabel('Distance euclidienne')
ax.set_title('Encodage cyclique: distances uniformes')
ax.set_xticks([0, 6, 12, 18, 23])
ax.axhline(distances_cyclique[0], color='green', linestyle='--', alpha=0.7, label='Distance constante')
ax.legend()

plt.tight_layout()
<Figure size 1200x400 with 3 Axes>

Le panneau de gauche montre le problème de l’encodage naïf: la distance ∣hi+1−hi∣|h_{i+1} - h_i| entre 23h et 0h est de 23, alors qu’entre toutes les autres heures consécutives elle est de 1. Cette discontinuité est problématique pour un modèle linéaire. Le panneau du centre illustre la projection sur le cercle unitaire: chaque heure occupe une position, et les heures adjacentes (y compris 23h et 0h) sont géométriquement proches. Le panneau de droite montre la distance euclidienne dans l’espace (cos⁡,sin⁡)(\cos, \sin) entre heures consécutives: elle est maintenant constante pour toutes les paires, y compris entre 23h et 0h.

Source
# Encodage cyclique pour différentes variables temporelles
def encodage_cyclique(valeurs, periode):
    """
    Transforme une variable périodique en deux composantes (sin, cos).
    
    Paramètres:
        valeurs: array de valeurs (ex: heures 0-23)
        periode: la période du cycle (ex: 24 pour les heures)
    
    Retourne:
        (composante_sin, composante_cos)
    """
    angle = 2 * np.pi * valeurs / periode
    return np.sin(angle), np.cos(angle)

# Exemple d'utilisation
heures = np.array([0, 6, 12, 18, 23])
heure_sin, heure_cos = encodage_cyclique(heures, periode=24)

print("Heure | sin | cos")
print("-" * 25)
for h, s, c in zip(heures, heure_sin, heure_cos):
    print(f"  {h:2d}  | {s:+.2f} | {c:+.2f}")
Heure | sin | cos
-------------------------
   0  | +0.00 | +1.00
   6  | +1.00 | +0.00
  12  | +0.00 | -1.00
  18  | -1.00 | -0.00
  23  | -0.26 | +0.97
VariablePériode TTExemple
Heure du jour240h →\to (0, 1), 12h →\to (0, -1)
Jour de la semaine7Lundi →\to (0.78, 0.62), Dimanche →\to (-0.78, 0.62)
Mois de l’année12Janvier →\to (0.5, 0.87), Juillet →\to (-0.5, -0.87)

Décomposition biais-variance

Ce compromis peut être formalisé mathématiquement. Supposons que les données suivent le modèle y=f∗(x)+ϵy = f^*(\mathbf{x}) + \epsilon, où f∗f^* est la vraie fonction (le prédicteur de Bayes optimal pour la perte quadratique), et ϵ\epsilon est un bruit de moyenne nulle et de variance σ2\sigma^2.

Clarification sur les variables aléatoires: Dans cette analyse, il y a trois sources d’aléa qu’il faut bien distinguer:

Autrement dit, l’espérance E[f^(x)]\mathbb{E}[\hat{f}(\mathbf{x})] moyenne sur tous les échantillons d’entraînement possibles: si nous pouvions répéter l’expérience d’apprentissage un grand nombre de fois avec différents D\mathcal{D}, quelle serait la prédiction moyenne pour ce x\mathbf{x}?

Dérivation mathématique

L’erreur quadratique moyenne, moyennée sur les échantillons d’entraînement possibles et le bruit, se décompose comme suit:

ED,ϵ[(f^(x)−y)2]=E[(f^(x)−f∗(x)−ϵ)2]\mathbb{E}_{\mathcal{D}, \epsilon}[(\hat{f}(\mathbf{x}) - y)^2] = \mathbb{E}[(\hat{f}(\mathbf{x}) - f^*(\mathbf{x}) - \epsilon)^2]

En développant le carré et utilisant E[ϵ]=0\mathbb{E}[\epsilon] = 0 ainsi que l’indépendance entre ϵ\epsilon et f^\hat{f}:

=E[(f^(x)−f∗(x))2]+σ2= \mathbb{E}[(\hat{f}(\mathbf{x}) - f^*(\mathbf{x}))^2] + \sigma^2

Pour le premier terme, ajoutons et retranchons E[f^(x)]\mathbb{E}[\hat{f}(\mathbf{x})]:

E[(f^(x)−f∗(x))2]=E[(f^(x)−E[f^(x)]+E[f^(x)]−f∗(x))2]\mathbb{E}[(\hat{f}(\mathbf{x}) - f^*(\mathbf{x}))^2] = \mathbb{E}[(\hat{f}(\mathbf{x}) - \mathbb{E}[\hat{f}(\mathbf{x})] + \mathbb{E}[\hat{f}(\mathbf{x})] - f^*(\mathbf{x}))^2]

En développant et utilisant E[f^(x)−E[f^(x)]]=0\mathbb{E}[\hat{f}(\mathbf{x}) - \mathbb{E}[\hat{f}(\mathbf{x})]] = 0:

=E[(f^(x)−E[f^(x)])2]⏟Var(f^(x))+(E[f^(x)]−f∗(x))2⏟Biais2(f^(x))= \underbrace{\mathbb{E}[(\hat{f}(\mathbf{x}) - \mathbb{E}[\hat{f}(\mathbf{x})])^2]}_{\text{Var}(\hat{f}(\mathbf{x}))} + \underbrace{(\mathbb{E}[\hat{f}(\mathbf{x})] - f^*(\mathbf{x}))^2}_{\text{Biais}^2(\hat{f}(\mathbf{x}))}

Nous obtenons la décomposition biais-variance:

E[(f^(x)−y)2]=Biais2(f^(x))+Var(f^(x))+σ2\boxed{\mathbb{E}[(\hat{f}(\mathbf{x}) - y)^2] = \text{Biais}^2(\hat{f}(\mathbf{x})) + \text{Var}(\hat{f}(\mathbf{x})) + \sigma^2}

Interprétation des termes

Chaque terme a une interprétation précise:

Cette décomposition explique pourquoi l’erreur de test a une forme en U: à faible complexité, le biais domine; à haute complexité, la variance domine. Le minimum se trouve au point où la somme des deux est minimale.

Lien avec Ridge

Cette décomposition éclaire aussi pourquoi Ridge fonctionne. Rappelons que Ridge rétrécit les coefficients vers zéro, avec un rétrécissement différencié selon les valeurs singulières:

Le coefficient λ\lambda contrôle ce compromis. Un λ\lambda trop petit n’aide pas assez (variance élevée). Un λ\lambda trop grand introduit trop de biais. Le λ\lambda optimal minimise l’erreur totale.

Intuition géométrique: pourquoi la dimension supérieure aide

L’expansion de caractéristiques semble être un simple changement de variables, mais elle cache une idée géométrique profonde. Pour comprendre pourquoi projeter les données dans un espace de dimension supérieure permet de capturer des relations non linéaires, examinons d’abord le cas de la régression, puis celui de la classification.

Régression polynomiale comme modèle linéaire

Considérons une régression quadratique: f(x)=θ0+θ1x+θ2x2f(x) = \theta_0 + \theta_1 x + \theta_2 x^2. Cette fonction est non linéaire en xx (c’est une parabole), mais linéaire dans les paramètres θ=(θ0,θ1,θ2)\boldsymbol{\theta} = (\theta_0, \theta_1, \theta_2). Que signifie cette distinction géométriquement?

Introduisons l’espace des caractéristiques ϕ(x)=(1,x,x2)\phi(x) = (1, x, x^2). Chaque valeur de xx correspond à un point dans R3\mathbb{R}^3. Ces points ne sont pas dispersés arbitrairement: ils vivent sur une courbe particulière, la courbe des moments (moment curve), qui ressemble à une rampe tordue.

Source
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

# Créer la courbe des moments: (x, x², x³) pour visualisation
# On utilise (1, x, x²) mais on projette sur (x, x², y) pour la visualisation
fig = plt.figure(figsize=(12, 5))

# Gauche: les fonctions de base
ax1 = fig.add_subplot(121)
x = np.linspace(-2, 2, 100)
ax1.plot(x, np.ones_like(x), 'b-', linewidth=2, label=r'$\phi_0(x) = 1$')
ax1.plot(x, x, 'orange', linewidth=2, label=r'$\phi_1(x) = x$')
ax1.plot(x, x**2, 'g-', linewidth=2, label=r'$\phi_2(x) = x^2$')
ax1.axhline(0, color='gray', linewidth=0.5)
ax1.axvline(0, color='gray', linewidth=0.5)
ax1.set_xlabel('$x$')
ax1.set_ylabel(r'$\phi_j(x)$')
ax1.set_title('Les fonctions de base')
ax1.legend()
ax1.set_ylim(-2.5, 4.5)
ax1.grid(True, alpha=0.3)

# Droite: combinaisons linéaires
ax2 = fig.add_subplot(122)
x = np.linspace(-2, 2, 100)

# Différentes combinaisons de coefficients
combinations = [
    ((1, 0, 0), 'Constante: $1$'),
    ((0, 1, 0), 'Linéaire: $x$'),
    ((0, 0, 1), 'Quadratique: $x^2$'),
    ((1, -0.5, 0.5), 'Combinaison: $1 - 0.5x + 0.5x^2$'),
]

colors = ['tab:blue', 'tab:orange', 'tab:green', 'tab:red']
for (theta0, theta1, theta2), label in combinations:
    y = theta0 + theta1 * x + theta2 * x**2
    ax2.plot(x, y, linewidth=2, label=label)

ax2.axhline(0, color='gray', linewidth=0.5)
ax2.axvline(0, color='gray', linewidth=0.5)
ax2.set_xlabel('$x$')
ax2.set_ylabel('$f(x)$')
ax2.set_title('Combinaisons linéaires des fonctions de base')
ax2.legend(loc='upper center')
ax2.set_ylim(-2.5, 4.5)
ax2.grid(True, alpha=0.3)

plt.tight_layout()
<Figure size 1200x500 with 2 Axes>

Les fonctions de base {1,x,x2}\{1, x, x^2\} sont les “ingrédients” du modèle. La régression polynomiale cherche les coefficients θ0,θ1,θ2\theta_0, \theta_1, \theta_2 qui mélangent ces ingrédients de façon optimale. Chaque combinaison produit une courbe différente, mais toutes sont des paraboles (ou des cas dégénérés: droites, constantes).

Voici l’insight géométrique clé: dans l’espace (x,x2,y)(x, x^2, y), le modèle y=θ0+θ1x+θ2x2y = \theta_0 + \theta_1 x + \theta_2 x^2 définit un plan. La parabole que nous voyons dans le graphique (x,y)(x, y) est simplement la projection de ce plan sur notre espace de visualisation.

Exemple en 1D: données sur une droite

Considérons des points sur une droite, répartis en deux classes: les points bleus au centre, les points orange aux extrémités. Aucun seuil unique ne peut séparer ces deux classes.

Source
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# Classe bleue: points au centre
x_blue = np.random.uniform(-0.5, 0.5, 15)
# Classe orange: points aux extrémités
x_orange = np.concatenate([np.random.uniform(-1.5, -0.8, 8), 
                           np.random.uniform(0.8, 1.5, 8)])

fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# Gauche: espace original avec tentative de séparation
ax = axes[0]
ax.scatter(x_blue, np.zeros_like(x_blue), c='tab:blue', s=80, zorder=3)
ax.scatter(x_orange, np.zeros_like(x_orange), c='tab:orange', s=80, zorder=3)
ax.axhline(0, color='gray', linewidth=0.5, zorder=1)
ax.axvline(0.3, color='red', linestyle='--', linewidth=2, label='Seuil?')
ax.set_xlim(-2, 2)
ax.set_ylim(-0.5, 0.5)
ax.set_xlabel('$x$')
ax.set_yticks([])
ax.set_title('Espace original: pas de séparation linéaire')
ax.legend()

# Droite: espace transformé
ax = axes[1]
ax.scatter(x_blue, x_blue**2, c='tab:blue', s=80, zorder=3, label='Classe A')
ax.scatter(x_orange, x_orange**2, c='tab:orange', s=80, zorder=3, label='Classe B')

# Ligne de séparation dans l'espace transformé
x_line = np.linspace(-2, 2, 100)
threshold = 0.6
ax.axhline(threshold, color='green', linestyle='-', linewidth=2, label='Frontière linéaire')
ax.fill_between(x_line, 0, threshold, alpha=0.1, color='blue')
ax.fill_between(x_line, threshold, 2.5, alpha=0.1, color='orange')

# Parabole de référence
x_curve = np.linspace(-1.6, 1.6, 100)
ax.plot(x_curve, x_curve**2, 'k--', alpha=0.3, linewidth=1)

ax.set_xlim(-2, 2)
ax.set_ylim(-0.1, 2.5)
ax.set_xlabel('$x$')
ax.set_ylabel('$x^2$')
ax.set_title(r'Espace transformé $\phi(x) = (x, x^2)$: séparation linéaire!')
ax.legend()

plt.tight_layout()
<Figure size 1200x400 with 2 Axes>

Dans l’espace transformé, une simple droite horizontale sépare les deux classes. Cette droite correspond, dans l’espace original, à deux seuils: x2<0.6x^2 < 0.6, soit ∣x∣<0.6≈0.77|x| < \sqrt{0.6} \approx 0.77. L’expansion de caractéristiques a transformé une frontière de décision non linéaire (un intervalle) en une frontière linéaire (une droite).

Exemple en 2D: cercles concentriques

Passons à un exemple plus visuel. Considérons deux classes disposées en cercles concentriques: la classe bleue forme un disque central, la classe orange forme un anneau extérieur. Aucune droite ne peut séparer ces deux régions.

Appliquons l’expansion ϕ(x1,x2)=(x1,x2,x12+x22)\phi(x_1, x_2) = (x_1, x_2, x_1^2 + x_2^2). La troisième coordonnée est le carré de la distance à l’origine: z=r2z = r^2. Les points proches du centre (petit rr) sont “soulevés” moins haut que les points éloignés (grand rr).

Source
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(123)

# Classe bleue: disque central
n_blue = 50
r_blue = np.random.uniform(0, 0.7, n_blue)
theta_blue = np.random.uniform(0, 2*np.pi, n_blue)
x_blue = r_blue * np.cos(theta_blue)
y_blue = r_blue * np.sin(theta_blue)

# Classe orange: anneau extérieur
n_orange = 70
r_orange = np.random.uniform(1.0, 1.5, n_orange)
theta_orange = np.random.uniform(0, 2*np.pi, n_orange)
x_orange = r_orange * np.cos(theta_orange)
y_orange = r_orange * np.sin(theta_orange)

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Gauche: vue 2D originale
ax = axes[0]
ax.scatter(x_blue, y_blue, c='tab:blue', s=40, alpha=0.7, label='Classe A')
ax.scatter(x_orange, y_orange, c='tab:orange', s=40, alpha=0.7, label='Classe B')
ax.set_xlim(-2, 2)
ax.set_ylim(-2, 2)
ax.set_xlabel('$x_1$')
ax.set_ylabel('$x_2$')
ax.set_aspect('equal')
ax.legend()
ax.set_title('Cercles concentriques: pas de séparation linéaire en 2D')

# Droite: vue 2D avec frontière circulaire
ax = axes[1]
ax.scatter(x_blue, y_blue, c='tab:blue', s=40, alpha=0.7, label='Classe A')
ax.scatter(x_orange, y_orange, c='tab:orange', s=40, alpha=0.7, label='Classe B')

# Cercle de décision (projection du plan z = 0.75)
theta_circle = np.linspace(0, 2*np.pi, 100)
r_decision = np.sqrt(0.75)
ax.plot(r_decision * np.cos(theta_circle), r_decision * np.sin(theta_circle), 
        'g-', linewidth=2.5, label=f'Frontière: $r = {r_decision:.2f}$')

ax.set_xlim(-2, 2)
ax.set_ylim(-2, 2)
ax.set_xlabel('$x_1$')
ax.set_ylabel('$x_2$')
ax.set_aspect('equal')
ax.legend()
ax.set_title('Frontière de décision projetée en 2D')

plt.tight_layout()
<Figure size 1200x500 with 2 Axes>

Dans l’espace 3D (x1,x2,x12+x22)(x_1, x_2, x_1^2 + x_2^2), un plan horizontal z=0.75z = 0.75 sépare les deux classes. Ce plan correspond, dans l’espace original 2D, à un cercle de rayon 0.75≈0.87\sqrt{0.75} \approx 0.87. La frontière de décision linéaire en 3D devient une frontière circulaire en 2D.

Le principe unificateur

Ces exemples de régression et de classification illustrent le même principe géométrique:

RégressionClassification
ObjectifAjuster les donnéesSéparer les classes
Dans l’espace originalCourbe (parabole, etc.)Frontière courbe (cercle, etc.)
Dans l’espace des caractéristiquesHyperplan d’ajustementHyperplan séparateur
La courbe/frontière est...L’intersection du plan avec la surface ϕ(x)\phi(x)La projection de l’hyperplan

L’expansion de caractéristiques transforme un problème non linéaire en un problème linéaire dans un espace de dimension supérieure. Les modèles linéaires, simples à optimiser et à analyser, deviennent alors suffisants pour capturer des structures complexes.

En augmentant la dimension de l’espace de représentation, nous augmentons la capacité du modèle. Mais cette flexibilité a un coût: plus l’espace est grand, plus le modèle risque de mémoriser les particularités des données d’entraînement plutôt que d’apprendre la structure sous-jacente. C’est le compromis biais-variance, et c’est pourquoi la régularisation est si importante pour les modèles à haute capacité.

Évaluation et choix de modèle

En pratique, nous estimons le risque par le risque empirique sur un ensemble de test Dtest\mathcal{D}_{\text{test}} disjoint de l’ensemble d’entraînement. Un troisième ensemble, l’ensemble de validation, sert à choisir parmi plusieurs modèles ou à régler des hyperparamètres. L’ensemble de test doit rester intact jusqu’à l’évaluation finale, pour fournir une estimation non biaisée.

Cette séparation est importante. Si nous utilisons l’ensemble de test pour faire des choix (quel modèle garder, quelle valeur d’hyperparamètre utiliser), l’estimation de performance sur ce même ensemble devient optimiste.

Hyperparamètres et validation

De nombreux modèles ont des hyperparamètres: des choix qui doivent être faits avant l’entraînement et qui ne sont pas appris à partir des données. Le degré kk d’un polynôme, le nombre de voisins dans les kk plus proches voisins, ou le coefficient de régularisation λ\lambda sont des exemples d’hyperparamètres.

Un hyperparamètre mal choisi peut mener au surapprentissage (modèle trop complexe) ou au sous-apprentissage (modèle trop simple). La méthode standard pour choisir un hyperparamètre est la validation: on réserve une partie des données (typiquement 20%) comme ensemble de validation, on entraîne le modèle pour plusieurs valeurs de l’hyperparamètre, et on retient celle qui minimise l’erreur sur l’ensemble de validation.

Plus formellement, pour un hyperparamètre hh, définissons le risque de validation:

R^hval=R^(f^h(Dtrain),Dvalid)\hat{\mathcal{R}}^{\text{val}}_h = \hat{\mathcal{R}}\left(\hat{f}_h(\mathcal{D}_{\text{train}}), \mathcal{D}_{\text{valid}}\right)

où f^h(Dtrain)\hat{f}_h(\mathcal{D}_{\text{train}}) est le modèle entraîné avec l’hyperparamètre hh. La recherche par grille consiste à évaluer ce risque pour un ensemble de valeurs candidates et à retenir:

h∗=arg⁡min⁡h∈{h1,…,hK}R^hvalh^* = \arg\min_{h \in \{h_1, \ldots, h_K\}} \hat{\mathcal{R}}^{\text{val}}_h

Une fois h∗h^* choisi, on peut ré-entraîner le modèle sur l’ensemble des données (entraînement + validation) pour obtenir le modèle final.

Validation croisée

Quand les données sont peu nombreuses, réserver 20% pour la validation peut être coûteux. La validation croisée offre une alternative.

L’idée est de partitionner les données en KK blocs. Pour chaque bloc kk, on entraîne le modèle sur les K−1K-1 autres blocs et on évalue sur le bloc kk. Le risque de validation croisée est la moyenne des KK évaluations:

R^hcv=1K∑k=1KR^(f^h(D−k),Dk)\hat{\mathcal{R}}^{\text{cv}}_h = \frac{1}{K} \sum_{k=1}^K \hat{\mathcal{R}}\left(\hat{f}_h(\mathcal{D}_{-k}), \mathcal{D}_k\right)

où D−k\mathcal{D}_{-k} désigne toutes les données sauf le bloc kk.

Source
import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(10, 3))

K = 5
for fold in range(K):
    for k in range(K):
        if k == fold:
            ax.barh(fold, 1, left=k, color='C1', edgecolor='black', linewidth=1, label='Validation' if fold == 0 else '')
        else:
            ax.barh(fold, 1, left=k, color='C0', edgecolor='black', linewidth=1, label='Entraînement' if fold == 0 and k == 0 else '')

ax.set_yticks(range(K))
ax.set_yticklabels([f'Itération {k+1}' for k in range(K)])
ax.set_xticks(np.arange(K) + 0.5)
ax.set_xticklabels([f'Bloc {k+1}' for k in range(K)])
ax.set_xlabel('Blocs de données')
ax.legend(loc='upper right')
ax.set_title(f'Validation croisée à {K} blocs')
ax.set_xlim(0, K)

plt.tight_layout()
<Figure size 1000x300 with 1 Axes>

Le cas particulier K=NK = N (un bloc par exemple) est appelé validation croisée leave-one-out. Elle utilise au maximum les données disponibles, mais est coûteuse en calcul. En pratique, K=5K = 5 ou K=10K = 10 offrent un bon compromis.

Biais inductifs

Il n’existe pas de modèle universel qui fonctionne optimalement pour tous les problèmes. Ce résultat, connu sous le nom de théorème du no free lunch, affirme qu’un algorithme d’apprentissage qui performe bien sur une classe de problèmes performe nécessairement moins bien sur d’autres.

Tout modèle encode des biais inductifs: des hypothèses implicites ou explicites sur la structure du problème. La régression linéaire suppose que la relation entre entrées et sorties est linéaire. Les k plus proches voisins supposent que les points proches dans l’espace des entrées ont des sorties similaires. Les modèles plus complexes, comme les réseaux de neurones, encodent d’autres hypothèses sur la structure des données.

Ces hypothèses sont nécessaires pour que l’apprentissage soit possible. Sans elles, nous n’aurions aucune raison de croire que la performance sur l’échantillon d’entraînement prédit la performance sur de nouvelles données. Le choix du modèle et de ses hypothèses est une décision que l’algorithme ne peut pas prendre seul; elle requiert une connaissance du domaine.

Fonctions de perte de substitution

La perte 0-1 pose un problème pratique. Les méthodes d’optimisation itératives, comme la descente de gradient, requièrent que la fonction objectif soit différentiable. Or la perte 0-1 est constante par morceaux: sa dérivée est nulle presque partout et indéfinie aux points de discontinuité.

Nous contournons ce problème en utilisant des fonctions de perte de substitution: des approximations convexes et différentiables de la perte originale. L’entropie croisée, que nous avons dérivée dans le chapitre précédent à partir du maximum de vraisemblance, en est un exemple central. Examinons ici une vue unifiée des pertes de substitution les plus courantes.

Pour la classification binaire, plutôt que de prédire directement une classe, les modèles produisent souvent un score s=f(x)s = f(\mathbf{x}) (un nombre réel). La prédiction de classe se fait ensuite en prenant le signe de ce score: si s>0s > 0, on prédit la classe +1; si s<0s < 0, on prédit la classe -1. La valeur absolue de ss mesure la confiance: plus ∣s∣|s| est grand, plus le modèle est confiant dans sa prédiction.

Avec la convention y∈{−1,+1}y \in \{-1, +1\} (plutôt que {0,1}\{0, 1\}), la perte logistique s’écrit:

ℓlog(y,s)=log⁡(1+e−y⋅s)\ell_{\text{log}}(y, s) = \log(1 + e^{-y \cdot s})

où s=f(x)s = f(\mathbf{x}) est le score produit par le modèle. Cette formulation est équivalente à l’entropie croisée binaire du chapitre 3, mais avec la convention y∈{−1,+1}y \in \{-1, +1\} plutôt que y∈{0,1}y \in \{0, 1\}. La fonction est convexe et différentiable partout. Lorsque yy et ss ont le même signe (prédiction correcte avec confiance), la perte est faible. Lorsqu’ils ont des signes opposés (erreur), la perte croît linéairement avec l’amplitude de l’erreur.

La perte à charnière (hinge loss) est utilisée dans les machines à vecteurs de support:

ℓhinge(y,s)=max⁡(0,1−y⋅s)\ell_{\text{hinge}}(y, s) = \max(0, 1 - y \cdot s)

Cette fonction est convexe mais non différentiable au point y⋅s=1y \cdot s = 1. Elle est nulle lorsque la prédiction est correcte avec une marge suffisante (y⋅s≥1y \cdot s \geq 1), et croît linéairement sinon.

Ces deux fonctions majorent la perte 0-1: pour tout yy et ss, nous avons ℓ0−1≤ℓlog\ell_{0-1} \leq \ell_{\text{log}} et ℓ0−1≤ℓhinge\ell_{0-1} \leq \ell_{\text{hinge}}. Minimiser ces substituts garantit donc un certain contrôle sur la perte originale.

Source
import numpy as np
import matplotlib.pyplot as plt

# Margin: y * s (positive = correct prediction, negative = error)
margin = np.linspace(-3, 3, 500)

# 0-1 loss: 1 if margin < 0, else 0
loss_01 = (margin < 0).astype(float)

# Logistic loss: log(1 + exp(-margin))
loss_log = np.log(1 + np.exp(-margin))

# Hinge loss: max(0, 1 - margin)
loss_hinge = np.maximum(0, 1 - margin)

fig, ax = plt.subplots(figsize=(8, 5))

ax.plot(margin, loss_01, 'k-', linewidth=2, label='Perte 0-1')
ax.plot(margin, loss_log, 'C0-', linewidth=2, label='Perte logistique')
ax.plot(margin, loss_hinge, 'C1-', linewidth=2, label='Perte à charnière')

ax.axvline(0, color='gray', linestyle=':', alpha=0.5)
ax.axhline(1, color='gray', linestyle=':', alpha=0.3)

ax.set_xlabel(r'Marge $y \cdot s$')
ax.set_ylabel('Perte')
ax.set_xlim(-3, 3)
ax.set_ylim(-0.1, 4)
ax.legend()
ax.set_title('Fonctions de perte de substitution comme bornes supérieures convexes')

# Annotate regions
ax.text(-1.5, 3.5, 'Erreur\n(prédiction incorrecte)', ha='center', fontsize=9, color='gray')
ax.text(1.5, 0.3, 'Correct\n(prédiction juste)', ha='center', fontsize=9, color='gray')

plt.tight_layout()
<Figure size 800x500 with 1 Axes>

Résumé

Ce chapitre a exploré les fondements théoriques et pratiques de la généralisation:

Mais d’où viennent ces choix de fonctions de perte? Pourquoi la perte quadratique pour la régression et l’entropie croisée pour la classification? Le chapitre suivant répond à ces questions en présentant le cadre probabiliste, qui unifie ces approches via le maximum de vraisemblance et le maximum a posteriori.

Exercices