Phystech@DataScience¶
Регуляризация¶
# Bot check
# HW_ID: phds_sem3
# Бот проверит этот ID и предупредит, если случайно сдать что-то не то
# Status: not final
# Перед отправкой в финальном решении удали "not" в строчке выше
# Так бот проверит, что ты отправляешь финальную версию, а не промежуточную
Баллы за задание:
- Задача 1 — 20 баллов
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import r2_score, mean_squared_error
from sklearn.linear_model import Ridge, Lasso
from sklearn.datasets import load_diabetes
import warnings
warnings.simplefilter(action="ignore")
Задача 1¶
В данной задаче мы продолжим работать с датасетом о диабете, с которым вы уже знакомились ранее.
Загрузим данные
data = load_diabetes()
X, y = data.data, data.target
Разбейте данные случайно на две части — обучающую и тестовую в соотношении 80:20.
X_train, X_test, y_train, y_test = <...>
Масштабируйте признаки, с помощью StandardScaler
scaler = StandardScaler()
X_train = <...>
X_test = <...>
Теоретическая справка: L1-регуляризация и градиентный спуск¶
Цель регуляризации: Борьба с переобучением за счет добавления штрафа за большие значения весов. В L1-регуляризации штраф пропорционален сумме абсолютных значений весов.
Функция потерь: $$ L(\mathbf{w}) = \underbrace{\frac{1}{n}\sum_{i=1}^n (y_i - \hat{y}_i)^2}_{\text{MSE}} + \lambda \sum_{j=1}^d |w_j| $$ где:
- $n$ — количество объектов в выборке
- $d$ — количество признаков
- $\hat{y}_i = \mathbf{x}_i^T\mathbf{w} + b$ — предсказание модели ($b$ — свободный коэффициент)
- $\lambda$ — коэффициент регуляризации
Градиенты для обновления параметров:
Для весов:
$$
\frac{\partial L}{\partial w_j} = \frac{2}{n} \sum_{i=1}^n x_{ij}(\hat{y}_i - y_i) + \lambda \cdot \text{sign}(w_j)
$$
Для свободного коэффициента: $$ \frac{\partial L}{\partial b} = \frac{2}{n} \sum_{i=1}^n (\hat{y}_i - y_i) $$
Особенности оптимизации:
При $w_j = 0$ функция $|w_j|$ не дифференцируема → используем субградиент: $$ \text{sign}(w_j) = \begin{cases} 1, & w_j > 0 \\ 0, & w_j = 0 \\ -1, & w_j < 0 \end{cases} $$
L1-штраф применяется только к весам ($w_j$). Градиент для смещения ($b$) содержит только производную от MSE
Обновление параметров происходит по правилу: $$ w_j = w_{j-1} - \eta \frac{\partial L}{\partial w_{j-1}}, \quad b_j = b_{j-1} - \eta \frac{\partial L}{\partial b_{j-1}} $$ где $\eta$ — скорость обучения
Реализация L1-регуляризации¶
Теперь реализуем Lasso регрессию на практике:
class MyLassoRegression:
"""
Класс для реализации Lasso регрессии с использованием градиентного спуска.
Параметры:
- learning_rate: скорость обучения (по умолчанию 0.01)
- lambda_: коэффициент L1-регуляризации (по умолчанию 0.1)
- n_iters: количество итераций градиентного спуска (по умолчанию 1000)
"""
def __init__(self, learning_rate=0.01, lambda_=0.1, n_iters=1000):
self.learning_rate = learning_rate
self.lambda_ = lambda_
self.n_iters = n_iters
self.coef_ = None
self.intercept_ = None
def fit(self, X, y):
"""
Обучение модели на тренировочных данных.
Параметры:
- X: матрица признаков (n_samples, n_features)
- y: вектор целевой переменной (n_samples,)
"""
n_samples, n_features = X.shape
# Инициализация параметров
self.coef_ = np.random.normal(scale=0.01, size=n_features)
self.intercept_ = 0
# Градиентный спуск
for _ in range(self.n_iters):
# Предсказание
y_pred = <...>
# Вычисление градиентов
dw = <...>
db = <...>
# Обновление параметров
self.coef_ -= <...>
self.intercept_ -= <...>
def predict(self, X):
"""Предсказание целевой переменной для новых данных"""
return <...>
def get_coef(self):
"""Получение коэффициентов модели"""
return self.coef_
Обучите написанную модель и модель Lasso
из sklearn.linear_model
на наших данных
# Создание и обучение кастомной модели
custom_model = MyLassoRegression(learning_rate=0.1, lambda_=0.1, n_iters=5000)
custom_model.fit(X_train, y_train)
# Создание и обучение модели из sklearn
sklearn_model = Lasso(alpha=0.1, max_iter=5000)
sklearn_model.fit(X_train, y_train)
Распечатайте коэффициенты и сравните их с коэффициентами модели из sklearn
.
print("Кастомная модель коэффициенты:", custom_model.get_coef())
print("Sklearn Lasso коэффициенты:", sklearn_model.coef_)
Сравните качество моделей со на тестовой выборке, используя mean_squared_error
и r2_score
из sklearn.metrics
.
# Расчет и вывод MSE
custom_pred = custom_model.predict(X_test)
sklearn_pred = sklearn_model.predict(X_test)
print("Кастомная модель MSE:", mean_squared_error(y_test, custom_pred))
print("Sklearn Lasso MSE:", mean_squared_error(y_test, sklearn_pred), "\n")
print("Кастомная модель R2:", r2_score(y_test, custom_pred))
print("Sklearn Lasso R2:", r2_score(y_test, sklearn_pred))
Теоретическая справка: L2-регуляризация (Ridge) и градиентный спуск¶
Цель регуляризации: Сокращение сложности модели за счет штрафа за большие значения весов. В L2-регуляризации штраф пропорционален сумме квадратов коэффициентов.
Функция потерь: $$ L(\mathbf{w}) = \underbrace{\frac{1}{n}\sum_{i=1}^n (y_i - \hat{y}_i)^2}_{\text{MSE}} + \lambda \sum_{j=1}^d w_j^2 $$ где:
- $n$ — количество объектов в выборке
- $d$ — количество признаков
- $\hat{y}_i = \mathbf{x}_i^T\mathbf{w} + b$ — предсказание модели
- $\lambda$ — коэффициент регуляризации
Градиент для обновления весов:
$$
\frac{\partial L}{\partial w_j} = \frac{2}{n}\sum_{i=1}^n x_{ij}(\hat{y}_i - y_i) + 2\lambda w_j
$$
Заметим, что функция дифференцируема во всех точках → стандартный градиентный спуск
Реализация L2-регуляризации¶
Настала ваша очередь реализовать свой класс уже для L2-регуляризация
class MyRidgeRegression:
"""
Класс для реализации линейной регрессии с L2-регуляризацией (Ridge)
Параметры:
- learning_rate: скорость обучения (по умолчанию 0.01)
- lambda_: коэффициент L2-регуляризации (по умолчанию 0.1)
- n_iters: количество итераций градиентного спуска (по умолчанию 1000)
"""
def __init__(self, learning_rate=0.01, lambda_=0.1, n_iters=1000):
self.learning_rate = learning_rate
self.lambda_ = lambda_
self.n_iters = n_iters
self.coef_ = None
self.intercept_ = None
def fit(self, X, y):
"""
Обучение модели с использованием градиентного спуска
Параметры:
- X: матрица признаков (n_samples, n_features)
- y: вектор целевой переменной (n_samples,)
"""
n_samples, n_features = X.shape
# Инициализация параметров
self.coef_ = np.random.normal(scale=0.01, size=n_features)
self.intercept_ = 0
# Градиентный спуск
for _ in range(self.n_iters):
y_pred = <...>
# Вычисление градиентов с L2-регуляризацией
dw = <...>
db = <...>
# Обновление параметров
self.coef_ -= <...>
self.intercept_ -= <...>
def predict(self, X):
"""Предсказание целевой переменной для новых данных"""
return <...>
def get_coef(self):
"""Получение коэффициентов модели"""
return self.coef_
Создайте и обучите кастомную модель и модель Ridge
из sklearn
.
# Создание и обучение кастомной модели
custom_model = MyRidgeRegression(learning_rate=0.1, lambda_=0.5, n_iters=1000)
custom_model.fit(X_train, y_train)
# Создание и обучение модели Ridge из sklearn
sklearn_model = Ridge(
alpha=0.5, # alpha в sklearn соответствует lambda_ в нашей реализации
)
sklearn_model.fit(X_train, y_train)
print("Кастомная модель коэффициенты:", custom_model.get_coef())
print("Sklearn Ridge коэффициенты:", sklearn_model.coef_)
Сравните качество моделей со на тестовой выборке, используя mean_squared_error
и r2_score
из sklearn.metrics
.
# Расчет и вывод MSE
custom_pred = custom_model.predict(X_test)
sklearn_pred = sklearn_model.predict(X_test)
print("Кастомная модель MSE:", mean_squared_error(y_test, custom_pred))
print("Sklearn Ridge MSE:", mean_squared_error(y_test, sklearn_pred), "\n")
print("Кастомная модель R2:", r2_score(y_test, custom_pred))
print("Sklearn Ridge R2:", r2_score(y_test, sklearn_pred))
❓ Вопрос ❓
Что вы можете сказать по полученным коэффициентам и метрикам?
Ответ:
Сделайте выводы по всей задаче.
Вывод:
Визуализация: Градиентный спуск¶
Что такое линии уровня?
Это проекция 3D-поверхности функции потерь на 2D-плоскость. Каждая линия объединяет точки с одинаковым значением функции потерь. Представьте, что:
- Высокие "горные хребты" = высокие значения потерь (плохие предсказания)
- "Долины" = низкие значения потерь (хорошие решения)
\
В пространстве 100+ признаков визуализация невозможна, но принципы остаются теми же. Профессионалы мысленно представляют эти ландшафты при настройке моделей!
Давайте запустим визуализацию и понаблюдаем за "бегом" градиента по линиям уровня:
Реализуем несколько функции для удобства.
def generate_polynomial_data(degree, n_samples=100, noise=0.1):
"""Генерирует синтетические данные для полиномиальной регрессии заданной
степени.
Возвращает X (без добавления единиц) и y с шумом.
"""
np.random.seed(0)
X = np.linspace(-1, 1, n_samples)
coef = np.random.randn(degree + 1)
y = np.polyval(coef[::-1], X)
y += np.random.normal(0, noise, y.shape)
return X.reshape(-1, 1), y
def create_mse_loss(X, y):
"""Создает функцию потерь MSE для данных X и y."""
X_b = np.c_[np.ones((len(X), 1)), X] # Добавляем столбец единиц
def mse_loss(theta):
return np.mean((X_b.dot(theta) - y) ** 2)
return mse_loss
def create_mse_gradient(X, y):
"""Создает функцию градиента MSE для данных X и y."""
X_b = np.c_[np.ones((len(X), 1)), X] # Добавляем столбец единиц
n = len(y)
def mse_grad(theta):
return (2 / n) * X_b.T.dot(X_b.dot(theta) - y)
return mse_grad
def gradient_descent(grad_func, start_theta, lr=0.1, n_iters=100):
"""Выполняет градиентный спуск и возвращает историю значений параметров."""
theta = start_theta.copy()
history = [theta.copy()]
for _ in range(n_iters):
grad = grad_func(theta)
theta -= lr * grad
history.append(theta.copy())
return np.array(history)
def visualize_gradient_descent(
loss_func, grad_func, start_theta=None, lr=0.1, bounds=[[-2, 2], [-2, 2]], n_iters=100
):
"""Визуализирует путь градиентного спуска на графике линий уровня.
Параметры:
loss_func: функция потерь, принимающая theta
grad_func: функция градиента, принимающая theta
start_theta: начальная точка (None для случайной)
lr: скорость обучения
bounds: границы для осей [theta0_range, theta1_range]
n_iters: количество итераций
"""
# Генерация начальной точки
if start_theta is None:
start_theta = np.array(
[
np.random.uniform(bounds[0][0], bounds[0][1]),
np.random.uniform(bounds[1][0], bounds[1][1]),
]
)
# Выполнение градиентного спуска
history = gradient_descent(grad_func, start_theta, lr, n_iters)
# Создание сетки для линий уровня
theta0 = np.linspace(*bounds[0], 100)
theta1 = np.linspace(*bounds[1], 100)
Theta0, Theta1 = np.meshgrid(theta0, theta1)
losses = np.array(
[loss_func([t0, t1]) for t0, t1 in zip(Theta0.ravel(), Theta1.ravel())]
)
losses = losses.reshape(Theta0.shape)
# Построение графика
plt.figure(figsize=(10, 6))
contour = plt.contour(Theta0, Theta1, losses, levels=20, alpha=0.5)
plt.plot(
history[:, 0],
history[:, 1],
"r.-",
markersize=10,
linewidth=1,
label="Путь спуска",
)
plt.scatter(history[-1, 0], history[-1, 1], c="green", s=100, label="Финиш")
plt.scatter(start_theta[0], start_theta[1], c="blue", s=100, label="Старт")
plt.xlabel("W1")
plt.ylabel("W2")
plt.title(f"Градиентный спуск (lr={lr})")
plt.legend()
plt.colorbar(contour, label="Значение функции потерь")
plt.grid(True)
plt.show()
Обратим внимание, что если не передавать параметр start_theta
в функцию visualize_gradient_descent
, то начальные параметры у нас инициализируются случайным образом.
Напишем пример функции, по которой будем запускать градиентный спуск из точки (-2, -7.5) и понаблюдаем.
def booth_loss(theta):
"""Формула функции:
f(x, y) = (x + 2y - 7)² + (2x + y - 5)²
"""
return (theta[0] + 2 * theta[1] - 7) ** 2 + (2 * theta[0] + theta[1] - 5) ** 2
def booth_grad(theta):
"""
df/dx = 2x + 10·cos(5x)
df/dy = 2y - 10·sin(5y)
"""
dx = 2 * (theta[0] + 2 * theta[1] - 7) + 4 * (2 * theta[0] + theta[1] - 5)
dy = 4 * (theta[0] + 2 * theta[1] - 7) + 2 * (2 * theta[0] + theta[1] - 5)
return np.array([dx, dy])
visualize_gradient_descent(
booth_loss, booth_grad, lr=0.01, start_theta=[-2, -7.5], bounds=[[-10, 10], [-10, 10]]
)
Как видим, все хорошо. Градиент без проблем попал в минимум. Попробуем поменять параметр lr
и как это скажется на траектории.
visualize_gradient_descent(
booth_loss, booth_grad, lr=0.1, start_theta=[-2, -7.5], bounds=[[-10, 10], [-10, 10]]
)
❓ Вопрос ❓
Как вы объясните такую траекторию градиентного спуска?
Чем она отличается от прошлой траектории ?
Ответ
Напишем уже пример функции посложнее, у которой будут локальные минимумы и глобальный в точке (0,0). Также запустим градиентный спуск и понаблюдаем.
def complex_oscillating_loss(theta):
"""Глобальный минимум в (0,0) с множеством локальных минимумов.
Формула: f(x,y) = x² + y² + 2*sin(5x) + 2*cos(5y)
"""
x, y = theta
return x**2 + y**2 + 2 * np.sin(5 * x) + 2 * np.cos(5 * y)
def complex_oscillating_grad(theta):
x, y = theta
dx = 2 * x + 10 * np.cos(5 * x)
dy = 2 * y - 10 * np.sin(5 * y)
return np.array([dx, dy])
visualize_gradient_descent(
loss_func=complex_oscillating_loss,
grad_func=complex_oscillating_grad,
lr=0.05,
bounds=[[-3, 3], [-3, 3]],
n_iters=50,
)
Предлагаем попробовать регулировать параметры lr
, start_theta
и посмотреть, сможет ли градиент сойтись к глобальному минимуму.
❓ Вопрос ❓
Как ведут себя траектории в примере со сложной функцией?
Ответ
Визуализация: L1-регуляризация, отбор признаков¶
Вы уже услышали на лекции, что L1-регуляризация может занулять некоторые веса модели. Теперь предлагаем посмотреть, как именно зануляется градиент и как его выглядят траектории.
Напишем несколько вспомогательных функций.
def load_and_prepare_data(feature_indices):
"""Загружает данные diabetes и подготавливает выбранные фичи."""
diabetes = load_diabetes()
X = diabetes.data[:, feature_indices]
y = diabetes.target
feature_names = [diabetes.feature_names[i] for i in feature_indices]
return X, y, feature_names
def plot_lasso_coefficients(X, y, feature_names, alphas=np.logspace(-4, 1, 100)):
"""Строит график изменения коэффициентов Lasso в зависимости от alpha."""
coefficients = []
for alpha in alphas:
model = Lasso(alpha=alpha, fit_intercept=False)
model.fit(X, y)
coefficients.append(model.coef_)
coefficients = np.array(coefficients)
plt.figure(figsize=(10, 6))
for i in range(X.shape[1]):
plt.plot(alphas, coefficients[:, i], label=feature_names[i])
plt.xscale("log")
plt.title("Изменение коэффициентов Lasso в зависимости от регуляризации")
plt.xlabel("Alpha (логарифмическая шкала)")
plt.ylabel("Значение коэффициента")
plt.legend()
plt.axhline(0, color="black", lw=0.5, ls="--")
plt.grid(True)
plt.show()
def calculate_loss_surface(X, y, alpha, w1_range, w2_range):
"""Рассчитывает поверхность потерь с L1-регуляризацией."""
w1 = np.linspace(*w1_range, 100)
w2 = np.linspace(*w2_range, 100)
W1, W2 = np.meshgrid(w1, w2)
loss = np.zeros_like(W1)
for i in range(W1.shape[0]):
for j in range(W1.shape[1]):
y_pred = X @ [W1[i, j], W2[i, j]]
mse = mean_squared_error(y, y_pred)
loss[i, j] = mse + alpha * (abs(W1[i, j]) + abs(W2[i, j]))
return W1, W2, loss
def plot_optimization_path(W1, W2, loss, path, feature_names):
"""Визуализирует траекторию оптимизации на фоне контурного графика."""
plt.figure(figsize=(12, 8))
plt.contour(W1, W2, loss, levels=20)
plt.plot(path[:, 0], path[:, 1], "ro-", markersize=3, alpha=0.7)
plt.scatter(path[0, 0], path[0, 1], c="blue", s=100, label="Старт")
plt.scatter(path[-1, 0], path[-1, 1], c="red", s=100, label="Финиш")
plt.title("Траектория градиентного спуска на поверхности потерь")
plt.xlabel(f"Коэффициент {feature_names[0]}")
plt.ylabel(f"Коэффициент {feature_names[1]}")
plt.legend()
plt.colorbar(label="Значение функции потерь")
plt.grid(True)
plt.show()
def gradient_descent(X, y, initial_weights, alpha, lr=0.001, n_iter=100):
"""Реализует алгоритм градиентного спуска для Lasso регрессии."""
w = initial_weights.copy()
path = [w]
for _ in range(n_iter):
y_pred = X @ w
grad_mse = 2 / X.shape[0] * X.T @ (y_pred - y)
grad_l1 = alpha * np.sign(w)
grad = grad_mse + grad_l1
w -= lr * grad
path.append(w.copy())
return np.array(path)
Использовать будем датасет diabetes
из sklearn
, но для облегчения оставим только два признака.
FEATURE_INDICES = [4, 1]
X, y, feature_names = load_and_prepare_data(FEATURE_INDICES)
Выберем alpha = 0.3
и посмотрим на градиентный спуск.
ALPHA = 0.3 # Параметр регуляризации L1
INITIAL_WEIGHTS = [120, 250] # Начальные веса для градиентного спуска
W1_RANGE = [-50, 500] # Границы для первого коэффициента
W2_RANGE = [-50, 250] # Границы для второго коэффициента
LEARNING_RATE = 5 # Скорость обучения
ITERATIONS = 5000 # Количество итераций
# 2. Расчет поверхности потерь
W1, W2, loss = calculate_loss_surface(X, y, ALPHA, W1_RANGE, W2_RANGE)
# 3. Оптимизация градиентным спуском
path = gradient_descent(X, y, INITIAL_WEIGHTS, ALPHA, lr=LEARNING_RATE, n_iter=ITERATIONS)
# 4. Визуализация результатов
plot_optimization_path(W1, W2, loss, path, feature_names)
Видим, что минимум у функции потерь (Loss) находится в окрестности точки (0, 300).
Выберем значние alpha
достаточно большим и посмотрим на градиентный спуск.
ALPHA = 20 # Параметр регуляризации L1
INITIAL_WEIGHTS = [100, 100] # Начальные веса для градиентного спуска
W1_RANGE = [-50, 120] # Границы для первого коэффициента
W2_RANGE = [-50, 120] # Границы для второго коэффициента
LEARNING_RATE = 0.1 # Скорость обучения
ITERATIONS = 500 # Количество итераций
W1, W2, loss = calculate_loss_surface(X, y, ALPHA, W1_RANGE, W2_RANGE)
path = gradient_descent(X, y, INITIAL_WEIGHTS, ALPHA, lr=LEARNING_RATE, n_iter=ITERATIONS)
plot_optimization_path(W1, W2, loss, path, feature_names)
Видим, что минимум находится в окрестности (0, 0), что значит, что из-за больших штрафов нашей модели выгодно занулять все веса.
Выберем значние alpha
маленьким и посмотрим на градиентный спуск.
ALPHA = 0.01 # Параметр регуляризации L1
INITIAL_WEIGHTS = [150, 300] # Начальные веса для градиентного спуска
W1_RANGE = [50, 500] # Границы для первого коэффициента
W2_RANGE = [0, 300] # Границы для второго коэффициента
LEARNING_RATE = 5 # Скорость обучения
ITERATIONS = 500 # Количество итераций
W1, W2, loss = calculate_loss_surface(X, y, ALPHA, W1_RANGE, W2_RANGE)
path = gradient_descent(X, y, INITIAL_WEIGHTS, ALPHA, lr=LEARNING_RATE, n_iter=ITERATIONS)
plot_optimization_path(W1, W2, loss, path, feature_names)
Видим, что коэффиценты не нулевые и минимум в области (50, 350)
❓ Вопрос ❓
Как использование L1-регуляризации влияет на область минимума функции потерь и почему это приводит к обнулению части параметров модели, в зависимости от коэффициента регуляризации alpha?
Ответ: