Phystech@DataScience¶

Регуляризация¶

In [ ]:
# Bot check

# HW_ID: phds_sem3
# Бот проверит этот ID и предупредит, если случайно сдать что-то не то

# Status: not final
# Перед отправкой в финальном решении удали "not" в строчке выше
# Так бот проверит, что ты отправляешь финальную версию, а не промежуточную

Баллы за задание:

  • Задача 1 — 20 баллов
In [7]:
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¶

В данной задаче мы продолжим работать с датасетом о диабете, с которым вы уже знакомились ранее.

Загрузим данные

In [ ]:
data = load_diabetes()
X, y = data.data, data.target

Разбейте данные случайно на две части — обучающую и тестовую в соотношении 80:20.

In [ ]:
X_train, X_test, y_train, y_test = <...>

Масштабируйте признаки, с помощью StandardScaler

In [ ]:
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) $$

Особенности оптимизации:

  1. При $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} $$

  2. L1-штраф применяется только к весам ($w_j$). Градиент для смещения ($b$) содержит только производную от MSE

  3. Обновление параметров происходит по правилу: $$ 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 регрессию на практике:

In [ ]:
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 на наших данных

In [ ]:
# Создание и обучение кастомной модели
custom_model = MyLassoRegression(learning_rate=0.1, lambda_=0.1, n_iters=5000)
custom_model.fit(X_train, y_train)
In [ ]:
# Создание и обучение модели из sklearn
sklearn_model = Lasso(alpha=0.1, max_iter=5000)
sklearn_model.fit(X_train, y_train)

Распечатайте коэффициенты и сравните их с коэффициентами модели из sklearn.

In [ ]:
print("Кастомная модель коэффициенты:", custom_model.get_coef())
print("Sklearn Lasso коэффициенты:", sklearn_model.coef_)

Сравните качество моделей со на тестовой выборке, используя mean_squared_error и r2_score из sklearn.metrics .

In [ ]:
# Расчет и вывод 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-регуляризация

In [ ]:
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.

In [ ]:
# Создание и обучение кастомной модели
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)
In [ ]:
print("Кастомная модель коэффициенты:", custom_model.get_coef())
print("Sklearn Ridge коэффициенты:", sklearn_model.coef_)

Сравните качество моделей со на тестовой выборке, используя mean_squared_error и r2_score из sklearn.metrics .

In [ ]:
# Расчет и вывод 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-плоскость. Каждая линия объединяет точки с одинаковым значением функции потерь. Представьте, что:

  • Высокие "горные хребты" = высокие значения потерь (плохие предсказания)
  • "Долины" = низкие значения потерь (хорошие решения)

\

линии уровня.png

В пространстве 100+ признаков визуализация невозможна, но принципы остаются теми же. Профессионалы мысленно представляют эти ландшафты при настройке моделей!

Давайте запустим визуализацию и понаблюдаем за "бегом" градиента по линиям уровня:

Реализуем несколько функции для удобства.

In [1]:
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
In [2]:
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
In [3]:
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)
In [4]:
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) и понаблюдаем.

In [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])
In [ ]:
visualize_gradient_descent(
    booth_loss, booth_grad, lr=0.01, start_theta=[-2, -7.5], bounds=[[-10, 10], [-10, 10]]
)

Как видим, все хорошо. Градиент без проблем попал в минимум. Попробуем поменять параметр lr и как это скажется на траектории.

In [ ]:
visualize_gradient_descent(
    booth_loss, booth_grad, lr=0.1, start_theta=[-2, -7.5], bounds=[[-10, 10], [-10, 10]]
)

❓ Вопрос ❓

Как вы объясните такую траекторию градиентного спуска?
Чем она отличается от прошлой траектории ?

Ответ

Напишем уже пример функции посложнее, у которой будут локальные минимумы и глобальный в точке (0,0). Также запустим градиентный спуск и понаблюдаем.

In [10]:
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])
In [ ]:
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-регуляризация может занулять некоторые веса модели. Теперь предлагаем посмотреть, как именно зануляется градиент и как его выглядят траектории.

Напишем несколько вспомогательных функций.

In [12]:
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
In [13]:
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()
In [14]:
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
In [15]:
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()
In [16]:
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, но для облегчения оставим только два признака.

In [17]:
FEATURE_INDICES = [4, 1]
X, y, feature_names = load_and_prepare_data(FEATURE_INDICES)

Выберем alpha = 0.3 и посмотрим на градиентный спуск.

In [18]:
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)
In [ ]:
# 4. Визуализация результатов
plot_optimization_path(W1, W2, loss, path, feature_names)

Видим, что минимум у функции потерь (Loss) находится в окрестности точки (0, 300).

Выберем значние alpha достаточно большим и посмотрим на градиентный спуск.

In [ ]:
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 маленьким и посмотрим на градиентный спуск.

In [ ]:
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?

Ответ: