Entraîner un réseau de neurones profond est essentiellement une tâche de compression. Nous voulons représenter notre distribution de données d’entraînement comme une fonction paramétrée par un ensemble de matrices. Plus la distribution est complexe, plus nous avons besoin de paramètres. La raison d’approximer la distribution entière est de pouvoir propager n’importe quel point valide lors de l’inférence en utilisant le même modèle, avec les mêmes poids. Mais que se passerait-il si notre modèle était entraîné à la volée, lors de l’inférence ? Alors, en propageant , nous n’aurions besoin de modéliser que la distribution locale autour de . Comme la région locale devrait avoir une dimensionnalité inférieure à celle de l’ensemble d’entraînement complet, un modèle bien plus simple suffirait !
C’est l’idée derrière l’approximation locale ou la régression locale. Considérons une tâche de régression simple.
Tâche
Nous disposons de échantillons des données suivantes :
où
Code de tracé
import numpy as np
import plotly.graph_objects as go
# Générer les données
np.random.seed(42)
n_points = 100
X = np.random.uniform(0, 1, n_points)
epsilon = np.random.normal(0, 1 / 3, n_points)
Y = np.sin(4 * X) + epsilon
# Fonction réelle
x_true = np.linspace(0, 1, 500)
y_true = np.sin(4 * x_true)
# Créer le graphique
fig = go.Figure()
# Ajouter les points pour les données bruitées
fig.add_trace(
go.Scatter(
x=X,
y=Y,
mode="markers",
name="Données bruitées",
marker=dict(color="gray"),
)
)
# Ajouter la fonction réelle
fig.add_trace(
go.Scatter(
x=x_true,
y=y_true,
mode="lines",
name="Fonction réelle",
line=dict(color="red"),
)
)
# Mettre à jour la mise en page
fig.update_layout(
title="Données",
xaxis_title="X",
yaxis_title="Y",
template="plotly_dark",
height=400,
width=730,
)
# Sauvegarder le graphique dans un fichier HTML
filename = "local_approximation_data.html"
fig.write_html(filename)
print(f"Graphique sauvegardé dans {filename}")
# Afficher le graphique
fig.show()
Nous notons l’ensemble de données qui consiste en des échantillons .
Notre tâche est d’ajuster une courbe raisonnable à travers les données, qui correspond approximativement à la fonction réelle. Notons cette courbe .
K Plus Proches Voisins
Étant donné un certain , une approche consiste à prendre les valeurs les plus proches de , et à faire la moyenne de leurs valeurs comme estimation. C’est-à-dire,
où désigne les points les plus proches de .
Code de tracé
import plotly.graph_objects as go
import numpy as np
# Générer des données
np.random.seed(42)
n_points = 100
X = np.random.uniform(0, 1, n_points)
epsilon = np.random.normal(0, 1 / 3, n_points)
Y = np.sin(4 * X) + epsilon
# Fonction réelle
x_true = np.linspace(0, 1, 500)
y_true = np.sin(4 * x_true)
# k-NN pour une plage de k
x_curve = np.arange(0, 1, 0.01)
k_range = range(1, 21)
y_curves_knn = {}
for k in k_range:
y_curve = []
for x in x_curve:
distances = np.square(X - x)
nearest_indices = np.argsort(distances)[:k]
y_curve.append(np.mean(Y[nearest_indices]))
y_curves_knn[k] = y_curve
# Créer la figure Plotly
fig = go.Figure()
# Ajouter des traces statiques
fig.add_trace(
go.Scatter(x=X, y=Y, mode="markers", name="Données bruyantes", marker=dict(color="gray"))
)
fig.add_trace(
go.Scatter(
x=x_true, y=y_true, mode="lines", name="Fonction réelle", line=dict(color="red")
)
)
# Ajouter la première courbe k-NN (k=13, la position par défaut du curseur)
initial_k = 13
fig.add_trace(
go.Scatter(
x=x_curve,
y=y_curves_knn[initial_k],
mode="lines",
name="Courbe k-NN",
line=dict(color="yellow"),
)
)
# Définir les étapes du curseur
steps = []
for k in k_range:
step = dict(
method="update",
args=[
{"y": [Y, y_true, y_curves_knn[k]]}, # Mettre à jour les données y pour les traces
{
"title": f"Courbe k-NN interactive avec curseur pour k = {k}"
}, # Mettre à jour le titre dynamiquement
],
label=f"{k}",
)
steps.append(step)
# Ajouter le curseur à la mise en page
sliders = [
dict(
active=initial_k - 1,
currentvalue={"prefix": "k = "},
pad={"t": 50},
steps=steps,
)
]
fig.update_layout(
sliders=sliders,
title=f"Courbe k-NN interactive avec curseur pour k = {initial_k}",
xaxis_title="X",
yaxis_title="Y",
template="plotly_dark",
height=400,
width=730,
)
# Afficher et sauvegarder le tracé
fig.show()
html_path = "./knn_slider.html"
fig.write_html(html_path)
print(f"Tracé interactif sauvegardé sous {html_path}")
Vous pouvez voir en utilisant le curseur qu’un plus grand donne une courbe plus lisse, mais les courbes avec un faible intègrent un certain bruit. Aux extrêmes, suit exactement les données d’entraînement et donne une moyenne globale plate.
Régression à noyau de Nadaraya–Watson
Au lieu de limiter votre sous-ensemble de données à points, vous pourriez plutôt considérer tous les points de l’ensemble, mais pondérer la contribution de chaque point en fonction de sa proximité à . Considérez le modèle
où est un noyau, que nous utiliserons comme métrique de proximité.
Cette fonction est paramétrée par , appelé la largeur de bande, qui contrôle la plage de valeurs de dans les données qui jouent un rôle dans la sortie de . Cela devient clair si nous traçons ces fonctions.
Fonctions de Noyau
Ce qui est tracé ci-dessous est
où assure que s’intègre à sur son support.
Code de tracé
import numpy as np
import plotly.graph_objects as go
from scipy.integrate import quad
# Définir les fonctions de noyau
def epanechnikov_kernel(u):
return np.maximum(0, 0.75 * (1 - u**2))
def tricube_kernel(u):
return np.maximum(0, (1 - np.abs(u) ** 3) ** 3)
def gaussian_kernel(u):
return np.exp(-0.5 * u**2) / np.sqrt(2 * np.pi)
def renormalized_kernel(kernel_func, u_range, bandwidth):
def kernel_with_lambda(u):
scaled_u = u / bandwidth
normalization_factor, _ = quad(lambda v: kernel_func(v / bandwidth), *u_range)
return kernel_func(scaled_u) / normalization_factor
return kernel_with_lambda
# Générateur de tracé de fonction de noyau
def generate_kernel_plot(
kernel_name, kernel_func, x_range, u_range, lambda_values, y_range
):
fig = go.Figure()
# Lambda initial
initial_lambda = lambda_values[len(lambda_values) // 2]
# Générer la courbe de noyau initiale
x = np.linspace(*x_range, 500)
kernel_with_lambda = renormalized_kernel(kernel_func, u_range, initial_lambda)
y = kernel_with_lambda(x)
fig.add_trace(
go.Scatter(
x=x,
y=y,
mode="lines",
name=f"{kernel_name} Kernel (λ={initial_lambda:.2f})",
line=dict(color="green"),
)
)
# Créer des trames pour le curseur
frames = []
for bandwidth in lambda_values:
kernel_with_lambda = renormalized_kernel(kernel_func, u_range, bandwidth)
y = kernel_with_lambda(x)
frames.append(
go.Frame(
data=[
go.Scatter(
x=x,
y=y,
mode="lines",
name=f"{kernel_name} Kernel (λ={bandwidth:.2f})",
line=dict(color="green"),
)
],
name=f"{bandwidth:.2f}",
)
)
# Ajouter les trames à la figure
fig.frames = frames
# Ajouter un curseur
sliders = [
{
"active": len(lambda_values) // 2,
"currentvalue": {"prefix": "Largeur de bande λ: "},
"steps": [
{
"args": [
[f"{bandwidth:.2f}"],
{"frame": {"duration": 0, "redraw": True}, "mode": "immediate"},
],
"label": f"{bandwidth:.2f}",
"method": "animate",
}
for bandwidth in lambda_values
],
}
]
# Mettre à jour la mise en page
fig.update_layout(
title=f"{kernel_name} Fonction de Noyau",
xaxis_title="u",
yaxis_title="K(u)",
yaxis_range=y_range,
template="plotly_dark",
sliders=sliders,
height=400, # Ajusté pour correspondre à la taille précédente
width=730, # Ajusté pour correspondre à la taille précédente
updatemenus=[
{
"direction": "left",
"pad": {"r": 10, "t": 87},
"showactive": False,
"type": "buttons",
"x": 0.1,
"xanchor": "right",
"y": 0,
"yanchor": "top",
}
],
)
return fig
# Fonctions de noyau
kernels = {
"Epanechnikov": epanechnikov_kernel,
"Tricube": tricube_kernel,
"Gaussian": gaussian_kernel,
}
# Paramètres
x_range_plot = (-3, 3) # Plage de valeurs de u pour le tracé
u_range_integration = (-3, 3) # Plage pour la normalisation
lambda_values = np.linspace(0.01, 2, 20) # Valeurs linéaires de lambda de 0.01 à 2
y_range_plot = (0, 1.5) # Plage ajustée pour s'adapter aux fonctions normalisées
# Générer et afficher les tracés pour chaque noyau
for kernel_name, kernel_func in kernels.items():
fig = generate_kernel_plot(
kernel_name,
kernel_func,
x_range_plot,
u_range_integration,
lambda_values,
y_range_plot,
)
# Sauvegarder la figure dans un fichier HTML
filename = f"{kernel_name}_dynamic_normalization_kernel_function.html"
fig.write_html(filename, auto_play=False)
print(f"Tracé du noyau {kernel_name} sauvegardé dans {filename}")
# Afficher la figure
fig.show()
Résultats
Nous traçons maintenant les résultats pour chacune des fonctions de noyau. Chaque graphique possède un curseur , qui contrôle la sortie en temps réel.
Code de tracé
import numpy as np
import plotly.graph_objects as go
# Définir les fonctions de noyau
def epanechnikov_kernel(u):
return np.maximum(0, 0.75 * (1 - u**2))
def tricube_kernel(u):
return np.maximum(0, (1 - np.abs(u) ** 3) ** 3)
def gaussian_kernel(u):
return np.exp(-0.5 * u**2) / np.sqrt(2 * np.pi)
# Fonction de régression par noyau
def kernel_regression(X, Y, x_curve, kernel_func, bandwidth):
y_curve = []
for x in x_curve:
distances = np.abs(X - x) / bandwidth
weights = kernel_func(distances)
weighted_average = (
np.sum(weights * Y) / np.sum(weights) if np.sum(weights) > 0 else 0
)
y_curve.append(weighted_average)
return y_curve
# Générer des données
np.random.seed(42)
n_points = 100
X = np.random.uniform(0, 1, n_points)
epsilon = np.random.normal(0, 1 / 3, n_points)
Y = np.sin(4 * X) + epsilon
# Courbe réelle
x_true = np.linspace(0, 1, 500)
y_true = np.sin(4 * x_true)
# Points pour l'estimation par noyau
x_curve = x_true
# Fonctions de noyau
kernels = {
"Epanechnikov": epanechnikov_kernel,
"Tricube": tricube_kernel,
"Gaussian": gaussian_kernel,
}
# Plage de valeurs de bande passante pour le curseur en échelle logarithmique
lambda_values = np.logspace(-2, 0, 20) # De 0.01 à 1
# Générer des graphiques séparés pour chaque noyau
for kernel_name, kernel_func in kernels.items():
fig = go.Figure()
# Ajouter des points pour les données bruitées
fig.add_trace(
go.Scatter(
x=X, y=Y, mode="markers", name="Données bruitées", marker=dict(color="gray")
)
)
# Ajouter la fonction réelle
fig.add_trace(
go.Scatter(
x=x_true,
y=y_true,
mode="lines",
name="Fonction réelle",
line=dict(color="red"),
)
)
# Ajouter la courbe initiale du noyau
initial_bandwidth = lambda_values[0]
y_curve = kernel_regression(X, Y, x_curve, kernel_func, initial_bandwidth)
fig.add_trace(
go.Scatter(
x=x_curve,
y=y_curve,
mode="lines",
name=f"Nadaraya-Watson ({kernel_name})",
line=dict(color="green"),
)
)
# Créer des trames pour le curseur
frames = []
for bandwidth in lambda_values:
y_curve = kernel_regression(X, Y, x_curve, kernel_func, bandwidth)
frames.append(
go.Frame(
data=[
go.Scatter(
x=X,
y=Y,
mode="markers",
name="Données bruitées",
marker=dict(color="gray"),
),
go.Scatter(
x=x_true,
y=y_true,
mode="lines",
name="Fonction réelle",
line=dict(color="red"),
),
go.Scatter(
x=x_curve,
y=y_curve,
mode="lines",
name=f"Nadaraya-Watson ({kernel_name})",
line=dict(color="green"),
),
],
name=f"{bandwidth:.2f}",
)
)
# Ajouter les trames à la figure
fig.frames = frames
# Ajouter le curseur
sliders = [
{
"active": 0,
"currentvalue": {"prefix": "Bande passante λ: "},
"steps": [
{
"args": [
[f"{bandwidth:.2f}"],
{"frame": {"duration": 0, "redraw": True}, "mode": "immediate"},
],
"label": f"{bandwidth:.2f}",
"method": "animate",
}
for bandwidth in lambda_values
],
}
]
# Mettre à jour la mise en page
fig.update_layout(
title=f"Régression par noyau de Nadaraya-Watson (Noyau {kernel_name})",
xaxis_title="X",
yaxis_title="Y",
template="plotly_dark",
sliders=sliders,
height=400,
width=730,
updatemenus=[
{
"buttons": [
{
"args": [
None,
{
"frame": {"duration": 500, "redraw": True},
"fromcurrent": True,
},
],
"label": "Jouer",
"method": "animate",
},
{
"args": [
[None],
{
"frame": {"duration": 0, "redraw": True},
"mode": "immediate",
},
],
"label": "Pause",
"method": "animate",
},
],
"direction": "left",
"pad": {"r": 10, "t": 87},
"showactive": False,
"type": "buttons",
"x": 0.1,
"xanchor": "right",
"y": 0,
"yanchor": "top",
}
],
)
# Sauvegarder la figure dans un fichier HTML
filename = f"{kernel_name}_kernel_regression.html"
fig.write_html(filename, auto_play=False)
print(f"Graphique du noyau {kernel_name} sauvegardé dans {filename}")
# Afficher la figure
fig.show()
Nous voyons qu’une simple moyenne pondérée des données permet de modéliser une sinusoïde assez bien.
Régression Linéaire Locale
Dans la régression à noyau de Nadaraya-Watson, nous prenons une moyenne pondérée dans un voisinage défini par la fonction noyau . Un problème potentiel avec cette approche est l’interpolation lisse au sein des voisinages locaux, car nous ne supposons pas réellement que la région suit un modèle particulier.
Et si nous supposons que chaque région est localement linéaire ? Alors, nous pourrions résoudre l’ajustement des moindres carrés et interpoler librement !
Région : -NN
Définissons notre région locale comme les plus proches voisins de notre entrée. Soit et les valeurs correspondantes. Les coefficients de l’ajustement des moindres carrés sont
Code de tracé
import plotly.graph_objects as go
import numpy as np
# Générer des données
np.random.seed(42)
n_points = 100
X = np.random.uniform(0, 1, n_points)
epsilon = np.random.normal(0, 1 / 3, n_points)
Y = np.sin(4 * X) + epsilon
# Fonction réelle
x_true = np.linspace(0, 1, 500)
y_true = np.sin(4 * x_true)
# Régression linéaire locale avec k-NN
def knn_linear_regression(X, Y, x_curve, k_range):
y_curves = {}
for k in k_range:
y_curve = []
for x in x_curve:
# Trouver les k plus proches voisins
distances = np.abs(X - x)
nearest_indices = np.argsort(distances)[:k]
# Sélectionner les k plus proches voisins
X_knn = X[nearest_indices]
Y_knn = Y[nearest_indices]
# Créer la matrice de conception pour les k plus proches voisins
X_design = np.vstack((np.ones_like(X_knn), X_knn)).T
# Résoudre pour beta en utilisant les moindres carrés ordinaires
beta = np.linalg.pinv(X_design.T @ X_design) @ X_design.T @ Y_knn
# Prédire la valeur de y
y_curve.append(beta[0] + beta[1] * x)
y_curves[k] = y_curve
return y_curves
# Variables communes
x_curve = np.arange(0, 1, 0.01)
k_range = range(1, 21) # Valeurs de k de 1 à 20
initial_k = 10 # Valeur par défaut de k
# Calculer la régression linéaire locale avec k-NN
y_curves_knn = knn_linear_regression(X, Y, x_curve, k_range)
# Créer la figure Plotly
fig = go.Figure()
# Ajouter des traces statiques
fig.add_trace(
go.Scatter(x=X, y=Y, mode="markers", name="Données bruitées", marker=dict(color="gray"))
)
fig.add_trace(
go.Scatter(
x=x_true, y=y_true, mode="lines", name="Fonction réelle", line=dict(color="red")
)
)
# Ajouter la première courbe k-NN (k=initial_k)
fig.add_trace(
go.Scatter(
x=x_curve,
y=y_curves_knn[initial_k],
mode="lines",
name="Courbe k-NN",
line=dict(color="yellow"),
)
)
# Définir les étapes du curseur
steps = []
for k in k_range:
step = dict(
method="update",
args=[
{"y": [Y, y_true, y_curves_knn[k]]}, # Mettre à jour les données y pour les traces
{
"title": f"Courbe de régression linéaire locale k-NN avec k = {k}"
}, # Mettre à jour le titre dynamiquement
],
label=f"{k}",
)
steps.append(step)
# Ajouter le curseur à la mise en page
sliders = [
dict(
active=k_range.index(initial_k), # Utiliser l'index de initial_k
currentvalue={"prefix": "k = "},
pad={"t": 50},
steps=steps,
)
]
fig.update_layout(
sliders=sliders,
title=f"Courbe de régression linéaire locale k-NN avec k = {initial_k}",
xaxis_title="X",
yaxis_title="Y",
template="plotly_dark",
height=400,
width=730,
)
# Afficher et sauvegarder le tracé
fig.show()
html_path = "./knn_slider_llr.html"
fig.write_html(html_path)
print(f"Tracé interactif k-NN sauvegardé sous {html_path}")
Nous voyons que la sortie peut être assez irrégulière pour de petites valeurs de .
Région : Fonction de Noyau
Peut-être pouvons-nous réutiliser certaines idées du noyau de Nadaraya-Watson. Nous aimerions considérer tous les points de l’ensemble d’entraînement à des degrés divers, avec des poids plus élevés à l’intérieur de la région locale et des poids plus faibles à l’extérieur.
Pour cela, nous pouvons utiliser l’objectif des moindres carrés pondérés, avec les poids . Cela donne la solution suivante :
Tracé des résultats pour diverses fonctions de noyau :
Code de tracé
import plotly.graph_objects as go
import numpy as np
# Générer des données
np.random.seed(42)
n_points = 100
X = np.random.uniform(0, 1, n_points)
epsilon = np.random.normal(0, 1 / 3, n_points)
Y = np.sin(4 * X) + epsilon
# Fonction réelle
x_true = np.linspace(0, 1, 500)
y_true = np.sin(4 * x_true)
# Noyaux
def gaussian_kernel(u):
return np.exp(-0.5 * u**2)
def epanechnikov_kernel(u):
return np.maximum(0, 1 - u**2)
def tricube_kernel(u):
return np.maximum(0, (1 - np.abs(u) ** 3) ** 3)
# Régression linéaire locale pour un noyau spécifique
def local_linear_regression(X, Y, x_curve, bandwidths, kernel):
y_curves = {}
for λ in bandwidths:
λ_rounded = round(λ, 2)
y_curve = []
for x in x_curve:
# Calculer les poids en utilisant le noyau spécifié
distances = (X - x) / λ
weights = kernel(distances)
W = np.diag(weights)
# Créer la matrice de conception
X_design = np.vstack((np.ones_like(X), X)).T
# Résoudre pour beta en utilisant les moindres carrés pondérés
beta = np.linalg.pinv(X_design.T @ W @ X_design) @ X_design.T @ W @ Y
# Prédire la valeur de y
y_curve.append(beta[0] + beta[1] * x)
y_curves[λ_rounded] = y_curve
return y_curves
# Variables communes
x_curve = np.arange(0, 1, 0.01)
bandwidths = np.linspace(0.05, 0.5, 20)
initial_λ = bandwidths[len(bandwidths) // 2]
# Générer des tracés pour chaque noyau
kernels = {
"Noyau Gaussien": gaussian_kernel,
"Noyau d'Epanechnikov": epanechnikov_kernel,
"Noyau Tricube": tricube_kernel,
}
plots = []
for kernel_name, kernel_func in kernels.items():
# Calculer la RLL avec le noyau spécifié
y_curves = local_linear_regression(X, Y, x_curve, bandwidths, kernel_func)
# Créer la figure Plotly
fig = go.Figure()
# Ajouter des traces statiques
fig.add_trace(
go.Scatter(
x=X, y=Y, mode="markers", name="Données Bruyantes", marker=dict(color="gray")
)
)
fig.add_trace(
go.Scatter(
x=x_true,
y=y_true,
mode="lines",
name="Fonction Réelle",
line=dict(color="red"),
)
)
# Ajouter la première courbe RLL (en utilisant la valeur médiane des bandes passantes)
fig.add_trace(
go.Scatter(
x=x_curve,
y=y_curves[round(initial_λ, 2)],
mode="lines",
name=f"Courbe {kernel_name}",
line=dict(color="yellow"),
)
)
# Définir les étapes du curseur
steps = []
for λ in bandwidths:
λ_rounded = round(λ, 2)
step = dict(
method="update",
args=[
{"y": [Y, y_true, y_curves[λ_rounded]]}, # Mettre à jour les données y pour les traces
{
"title": f"RLL : {kernel_name} avec Bande Passante λ = {λ_rounded}"
}, # Mettre à jour le titre dynamiquement
],
label=f"{λ_rounded}",
)
steps.append(step)
# Ajouter le curseur à la mise en page
sliders = [
dict(
active=len(bandwidths) // 2, # Utiliser l'index de la bande passante médiane
currentvalue={"prefix": "λ = "},
pad={"t": 50},
steps=steps,
)
]
fig.update_layout(
sliders=sliders,
title=f"RLL : {kernel_name} avec Bande Passante λ = {round(initial_λ, 2)}",
xaxis_title="X",
yaxis_title="Y",
template="plotly_dark",
height=400,
width=730,
)
plots.append(fig)
# Afficher et sauvegarder les tracés
for i, (kernel_name, fig) in enumerate(zip(kernels.keys(), plots)):
fig.show()
html_path = f"./llr_{kernel_name.lower().replace(' ', '_')}.html"
fig.write_html(html_path)
print(f"Tracé interactif sauvegardé pour {kernel_name} dans {html_path}")
Je pense que les résultats semblent beaucoup plus lisses !
Références
- The Elements of Statistical Learning - Hastie, Tibshirani, et Friedman (2009). Un guide complet sur l’exploration de données, l’inférence et la prédiction. En savoir plus.