TL;DR
  • Автор берёт датасет Abalone и проводит подробный EDA: проверяет распределения, выбросы, мультиколлинеарность и видит выраженную гетероскедастичность целевой переменной.

  • Строится базовая линейная регрессия (c лог-преобразованием целевой), фильтруются выбросы, добавляются полиномиальные признаки — качество улучшается, но упирается в ограничения самой постановки.

  • Далее реализуется полносвязная нейросеть в PyTorch с подбором гиперпараметров, обучением на mini-batch и валидацией по RMSE.

  • Нейросеть даёт около 4% выигрыша по RMSE относительно лучшей линейной модели, но за счёт заметно большей сложности и меньшей интерпретируемости.

  • Вывод: на небольшом табличном датасете с шумной целевой глубокая модель не превращается в «серебряную пулю»; грамотный EDA и простые модели остаются сильной базой, а нейросети имеют смысл только при понятном приросте качества.

Практический PyTorch: строим трёхслойную нейросеть для множественной регрессии

Незадолго до того, как большие языковые модели (LLM) стали объектом всеобщего хайпа, между фреймворками для «классического» машинного обучения и фреймворками для глубокого обучения проходила почти видимая граница.

Про машинное обучение обычно говорили в контексте scikit-learn, XGBoost и им подобных, тогда как PyTorch и TensorFlow доминировали на сцене, когда речь заходила о глубоком обучении.

После взрывного роста интереса к ИИ я всё чаще вижу, что PyTorch заметно опережает TensorFlow по популярности. Оба фреймворка очень мощные и позволяют дата-сайентистам решать самые разные задачи, включая обработку естественного языка, что вновь подогрело интерес к глубокому обучению.

Однако в этой статье я не собираюсь говорить про NLP. Вместо этого мы разберём задачу множественной линейной регрессии с двумя целями:

  • Изучить, как создать модель с использованием PyTorch.

  • Поделиться нюансами линейной регрессии, о которых не всегда рассказывают в других туториалах.

Приступим.


Подготовка данных

Давайте я избавлю вас от ещё одной вычурной формальной дефиниции линейной регрессии. Скорее всего, вы уже видели её десятки раз в бесконечных туториалах по всему Интернету. Достаточно сказать так: когда у вас есть переменная Y, которую вы хотите предсказывать, и другая переменная X, которая может объяснить изменение Y с помощью прямой линии, — в сущности, это и есть линейная регрессия.

Набор данных

В этом упражнении мы будем использовать набор данных Abalone [1].

Nash, W., Sellers, T., Talbot, S., Cawthorn, A., & Ford, W. (1994). Abalone [Dataset]. UCI Machine Learning Repository. https://doi.org/10.24432/C55C7W

Согласно документации к набору данных, возраст морского ушка (абалон) определяют, разрезая раковину по конусу, затем окрашивая срез и считая количество колец под микроскопом — занятие скучное и очень трудоёмкое. Поэтому для предсказания возраста используют другие, более простые для измерения характеристики.

Итак, давайте загрузим данные. Дополнительно мы применим one-hot-кодирование к переменной Sex, так как это единственная категориальная переменная в наборе.

# Загрузка данных
from ucimlrepo import fetch_ucirepo
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_style('darkgrid')
from feature_engine.encoding import OneHotEncoder

# Загружаем набор данных
abalone = fetch_ucirepo(id=1)

# данные (в виде pandas DataFrame)
X = abalone.data.features
y = abalone.data.targets

# One-Hot кодирование признака Sex
ohe = OneHotEncoder(variables=['Sex'])
X = ohe.fit_transform(X)

# Объединяем признаки и целевую переменную в один DataFrame
df = pd.concat([X, y], axis=1)

Вот набор данных.

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

Исследуем данные

Первые шаги, которые я обычно выполняю при разведочном анализе данных, такие:

  1. Проверить распределение целевой переменной.

# Анализ распределения целевой переменной
plt.hist(y)
plt.title('Rings [Target Variable] Distribution')

График показывает, что целевая переменная не имеет нормального распределения. Это может повлиять на качество регрессии, но обычно проблему можно сгладить с помощью степенных преобразований, например логарифмирования или преобр��зования Бокса–Кокса.

Распределение целевой переменной
Распределение целевой переменной

2. Посмотреть статистическое описание.

Статистика позволяет заметить важные характеристики — среднее значение, стандартное отклонение, а также быстро увидеть аномалии в минимальных или максимальных значениях. Объясняющие переменные выглядят вполне корректно: находятся в небольшом диапазоне и на одной шкале. Целевая переменная (Rings) — на другой шкале.

# Статистическое описание данных
df.describe()
Статистическое описание
Статистическое описание

Теперь проверим корреляции.

# Анализ корреляций
(df
 .drop(['Sex_M', 'Sex_I', 'Sex_F'], axis=1)
 .corr()
 .style
 .background_gradient(cmap='coolwarm')
)
Корреляции
Корреляции

Объясняющие переменные имеют среднюю и сильную корреляцию с Rings. Также видно, что есть мультиколлинеарность между Whole_weight и Shucked_weight, Viscera_weight и Shell_weight. Length и Diameter тоже коллинеарны. Позже можно проверить, улучшится ли модель, если их убрать.

sns.pairplot(df);

Когда мы строим диаграммы рассеяния в парах и смотрим на связь переменных с Rings, можно быстро заметить несколько проблем.

  • Нарушено предположение гомоскедастичности. Это означает, что дисперсия ошибки непостоянна.

  • Посмотрите, как точки на графиках образуют форму конуса: по мере роста X увеличивается разброс значений Y. Поэтому при прогнозировании Rings для больших значений X погрешность будет выше.

  • Переменная Height имеет минимум два явных выброса при Height > 0.3.

Парные графики без трансформации
Парные графики без трансформации

Если удалить выбросы и преобразовать целевую переменную через логарифм, получим следующую диаграмму. Она лучше, но проблема гомоскедастичности всё равно остаётся.

Парные сюжеты после трансформации
Парные сюжеты после трансформации

Есть ещё один простой способ изучить данные — построить графики связи переменных, сгруппированные по Sex.

Переменная Diameter выглядит наиболее линейной при Sex = I, но на этом всё.

# Создаём FacetGrid с диаграммами рассеяния
sns.lmplot(x="Diameter", y="Rings", hue="Sex", col="Sex", order=2, data=df)
Диаметр x Количество колец
Диаметр x Количество колец

Переменная Shell_weight, напротив, при больших значениях показывает слишком сильный разброс, и это искажает линейную зависимость.

# Создаём FacetGrid с диаграммами рассеяния
sns.lmplot(x="Shell_weight", y="Rings", hue="Sex", col="Sex", data=df)
Shell_weight x Rings
Shell_weight x Rings

Все эти наблюдения показывают, что задача линейной регрессии для этого набора данных очень непростая и модель, вероятнее всего, покажет слабые результаты. Но мы всё равно попробуем.

Кстати, я не припомню статей, где подробно разбирается, что именно пошло не так. Так что, проходя через это, мы тоже чему-то научимся.

Моделирование: используем Scikit-Learn

Давайте запустим модель из sklearn и оценим её по RMSE (корню из среднеквадратичной ошибки).

from sklearn.linear_model import LinearRegression
from sklearn.metrics import root_mean_squared_error

df2 = df.query('Height < 0.3 and Rings > 2 ').copy()
X = df2.drop(['Rings'], axis=1)
y = np.log(df2['Rings'])

lr = LinearRegression()
lr.fit(X, y)

predictions = lr.predict(X)

df2['Predictions'] = np.exp(predictions)
print(root_mean_squared_error(df2['Rings'], df2['Predictions']))
2.2383762717104916

Если посмотреть на первые строки таблицы (head), можно убедиться, что модели сложно даются оценки для больших значений целевой переменной (например, строки 0, 6, 7 и 9).

Заголовок с прогнозами
Заголовок с прогнозами

Шаг назад: пробуем другие трансформации

Итак, что мы можем сделать дальше?

Скорее всего, стоит удалить ещё часть выбросов и попробовать снова. Попробуем применить несупервизируемый алгоритм, чтобы найти дополнительные выбросы. Мы воспользуемся методом Local Outlier Factor и отбросим 5% наблюдений, признанных выбросами.

Заодно уберём мультиколлинеарность, удалив признаки Whole_weight и Length.

from sklearn.neighbors import LocalOutlierFactor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

# загрузим набор данных
abalone = fetch_ucirepo(id=1)

# данные (как pandas DataFrame)
X = abalone.data.features
y = abalone.data.targets

# One Hot Encode для признака Sex
ohe = OneHotEncoder(variables=['Sex'])
X = ohe.fit_transform(X)

# удалим Whole_weight и Length (мультиколлинеарность)
X.drop(['Whole_weight', 'Length'], axis=1, inplace=True)

# посмотрим на датафрейм
df = pd.concat([X,y], axis=1)

# создадим Pipeline для масштабирования данных и поиска выбросов методом Local Outlier Factor (на основе k ближайших соседей)
steps = [
('scale', StandardScaler()),
('LOF', LocalOutlierFactor(contamination=0.05))
]
# обучим модель и получим предсказания (метки выбросов)
outliers = Pipeline(steps).fit_predict(X)

# добавим столбец с метками выбросов
df['outliers'] = outliers

# моделирование
df2 = df.query('Height < 0.3 and Rings > 2 and outliers != -1').copy()
X = df2.drop(['Rings', 'outliers'], axis=1)
y = np.log(df2['Rings'])

lr = LinearRegression()
lr.fit(X, y)

predictions = lr.predict(X)

df2['Predictions'] = np.exp(predictions)
print(root_mean_squared_error(df2['Rings'], df2['Predictions']))
2.238174395913869

Результат тот же. Хм…

Ладно, мы можем продолжать экспериментировать с переменными и заниматься генерацией признаков (feature engineering) — и увидим небольшие улучшения. Например, если добавить квадраты Height, Diameter и Shell_weight. В сочетании с обработкой выбросов это снизит RMSE до 2.196.

# признаки второй степени
X['Diameter_2'] = X['Diameter'] ** 2
X['Height_2'] = X['Height'] ** 2
X['Shell_2'] = X['Shell_weight'] ** 2

Важно отметить: каждый новый признак, добавляемый в модель линейной регрессии, влияет на R² и иногда искусственно «раздувает» его, создавая ложное ощущение, что модель стала лучше, хотя это не так. В данном случае модель действительно улучшается, потому что мы добавляем в неё нелинейные компоненты за счёт признаков второй степени. Это можно подтвердить, посчитав скорректированный R²: он вырос с 0.495 до 0.517.

# Скорректированный R²
from sklearn.metrics import r2_score


r2 = r2_score(df2['Rings'], df2['Predictions'])
n= df2.shape[0]
p = df2.shape[1] - 1
adj_r2 = 1 - (1 - r2) * (n - 1) / (n - p - 1)
print(f'R²: {r2}')
print(f'Adjusted R²: {adj_r2}')

С другой стороны, если вернуть признаки Whole_weight и Length, метрики можно немного улучшить, но я бы не рекомендовал этого делать. В этом случае мы снова добавляем мультиколлинеарность и завышаем значимость коэффициентов некоторых переменных, что может привести к ошибкам оценок в будущем.

Моделирование: использование PyTorch

Итак. Теперь, когда у нас есть базовая модель, наша цель — построить линейную модель с использованием глубокого обучения и попробовать превзойти RMSE 2.196.

Для начала сразу оговорюсь: модели глубокого обучения лучше работают с масштабированными данными. Однако наши признаки X уже находятся на одной шкале, поэтому об этом можно не беспокоиться и двигаться дальше.

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

Нужно подготовить данные для моделирования в PyTorch. Здесь нам потребуется преобразовать данные в формат, который PyTorch «понимает», так как он не работает напрямую с pandas DataFrame.

  • Возьмём тот же датафрейм, что и для базовой модели.

  • Разделим X и Y.

  • Преобразуем целевую переменную Y с помощью логарифма.

  • Преобразуем обе части в массивы numpy, так как PyTorch не принимает DataFrame.

df2 = df.query('Height < 0.3 and Rings > 2 and outliers != -1').copy()
X = df2.drop(['Rings', 'outliers'], axis=1)
y = np.log(df2[['Rings']])


# Преобразуем X и Y в Numpy
X = X.to_numpy()
y = y.to_numpy()

Далее, используя TensorDataset, мы превращаем X и Y в тензоры и выводим пример.

# Подготовка с помощью TensorDataset
# TensorDataset помогает преобразовать набор данных в объект Tensor
dataset = TensorDataset(torch.tensor(X).float(), torch.tensor(y).float())


input_sample, label_sample = dataset[0]
print(f'** Input sample: {input_sample}, \n** Label sample: {label_sample}')
** Input sample: tensor([0.3650, 0.0950, 0.2245, 0.1010, 0.1500, 1.0000,
 0.0000, 0.0000, 0.1332, 0.0090, 0.0225]),
 ** Label sample: tensor([2.7081])

Затем, с пом��щью функции DataLoader, мы можем разбить данные на батчи. Это означает, что нейросеть будет обрабатывать данные порциями размера batch_size.

# Теперь воспользуемся DataLoader
batch_size = 500
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)

Модели в PyTorch лучше всего определять в виде классов.

  • Класс наследуется от nn.Module — базового класса PyTorch для нейросетей.

  • В методе init мы задаём слои модели. 

    • Вызов super().__init__() гарантирует, что класс будет вести себя как torch-объект.

  • Метод forward описывает, что происходит с входными данными при прохождении через модель.

Здесь мы пропускаем их через линейные слои, определённые в init, и используем функции активации ReLU, чтобы добавить нелинейность при прямом проходе.

# 2. Создаём класс
class AbaloneModel(nn.Module):
  def __init__(self):
    super().__init__()
    self.linear1 = nn.Linear(in_features=X.shape[1], out_features=128)
    self.linear2 = nn.Linear(128, 64)
    self.linear3 = nn.Linear(64, 32)
    self.linear4 = nn.Linear(32, 1)


  def forward(self, x):
    x = self.linear1(x)
    x = nn.functional.relu(x)
    x = self.linear2(x)
    x = nn.functional.relu(x)
    x = self.linear3(x)
    x = nn.functional.relu(x)
    x = self.linear4(x)
    return x


# Создаём экземпляр модели
model = AbaloneModel()

Далее попробуем модель в деле, используя скрипт, который имитирует Random Search по гиперпараметрам.

  • Создадим функцию ошибки для оценки модели.

  • Создадим список для хранения данных о лучших вариантах модели и зададим best_loss большим числом, чтобы его можно было постепенно заменять более низкими значениями в ходе итераций.

  • Зададим диапазон для шага обучения: будем использовать степени от −2 до −4 (то есть от 0.01 до 0.0001).

  • Зададим диапазон для параметра momentum от 0.9 до 0.99.

  • Получим данные.

  • Обнулим градиенты, чтобы очистить результаты вычислений с предыдущих шагов.

  • Обучим модель.

  • Посчитаем функцию потерь и запишем показатели для лучшей модели.

  • Посчитаем градиенты весов и смещений на обратном проходе.

  • Повторим эти шаги N раз и в конце выведем лучшую модель.

# Среднеквадратичная ошибка (MSE) — стандартная функция потерь для задач регрессии
criterion = nn.MSELoss()


# Random Search
values = []
best_loss = 999
for idx in range(1000):
  # Случайным образом выбираем показатель степени для шага обучения в диапазоне от 2 до 4
  factor = np.random.uniform(2,5)
  lr = 10 ** -factor


  # Случайным образом выбираем momentum в диапазоне от 0.90 до 0.99
  momentum = np.random.uniform(0.90, 0.99)


  # 1. Получаем данные
  feature, target = dataset[:]
  # 2. Обнуляем градиенты, чтобы очистить результаты прошлых обратных проходов
  optimizer = optim.SGD(model.parameters(), lr=lr, momentum=momentum)
  optimizer.zero_grad()
  # 3. Прямой проход: считаем предсказание
  y_pred = model(feature)
  # 4. Считаем функцию потерь
  loss = criterion(y_pred, target)
  # 4.1. Регистрируем лучшую (минимальную) ошибку
  if loss < best_loss:
    best_loss = loss
    best_lr = lr
    best_momentum = momentum
    best_idx = idx


  # 5. Обратный проход: считаем градиенты функции потерь по W и b
  loss.backward()
  # 6. Обновляем параметры: корректируем W и b с учётом вычисленных градиентов
  optimizer.step()
  values.append([idx, lr, momentum, loss])


print(f'n: {idx},lr: {lr}, momentum: {momentum}, loss: {loss}')
n: 999,lr: 0.004782946959508322, momentum: 0.9801209929050066, loss: 0.06135804206132889

Когда мы нашли лучший шаг обучения и momentum, можно двигаться дальше.

# --- 3. Функция по��ерь и оптимизатор ---


# Среднеквадратичная ошибка (MSE) — стандартная функция потерь для задач регрессии
criterion = nn.MSELoss()


# Стохастический градиентный спуск (SGD) с небольшим шагом обучения (lr)
optimizer = optim.SGD(model.parameters(), lr=0.004, momentum=0.98)

Дальше мы переобучим модель, используя те же шаги, что и раньше, но теперь фиксируя шаг обучения и momentum.

Обучение модели в PyTorch требует более развёрнутого кода, чем простой вызов метода fit() в Scikit-Learn. Но это не проблема: структура почти всегда будет такой:

  1. перевести модель в режим обучения model.train();

  2. создать цикл по числу итераций (эпох), которое вы хотите выполнить;

  3. в каждой итерации обнулять градиенты через optimizer.zero_grad();

  4. получать батчи данных из dataloader;

  5. считать предсказания y_pred = model(X);

  6. вычислить функцию потерь loss = criterion(y_pred, target);

  7. выполнить обратный проход loss.backward(), чтобы посчитать градиенты весов и смещений;

  8. обновить веса и смещения с помощью optimizer.step().

Мы будем обучать эту модель в течение 1000 эпох (итераций). Дополнительно добавим шаг по сохранению лучшей версии модели (с минимальной ошибкой), чтобы в конце использовать именно её.

# 4. Обучение
torch.manual_seed(42)
NUM_EPOCHS = 1001
loss_history = []
best_loss = 999


# Переводим модель в режим обучения
model.train()


for epoch in range(NUM_EPOCHS):
  for data in dataloader:


    # 1. Получаем данные
    feature, target = data


    # 2. Обнуляем градиенты перед обратным проходом
    optimizer.zero_grad()


    # 3. Прямой проход: считаем предсказание
    y_pred = model(feature)


    # 4. Считаем функцию потерь
    loss = criterion(y_pred, target)
    loss_history.append(loss)


    # Сохраняем лучшую модель
    if loss < best_loss:
      best_loss = loss
      best_model_state = model.state_dict()  # сохраняем состояние лучшей модели


    # 5. Обратный проход: считаем градиенты функции потерь по W и b
    loss.backward()


    # 6. Обновляем параметры: корректируем W и b с учётом вычисленных градиентов
    optimizer.step()


    # Загружаем лучшее состояние модели перед тем, как использовать её для предсказаний
    model.load_state_dict(best_model_state)


  # Печатаем прогресс каждые 200 эпох
  if epoch % 200 == 0:
    print(epoch, loss.item())
    print(f'Best Loss: {best_loss}')
0 0.061786893755197525
Best Loss: 0.06033024191856384
200 0.036817338317632675
Best Loss: 0.03243456035852432
400 0.03307393565773964
Best Loss: 0.03077109158039093
600 0.032522525638341904
Best Loss: 0.030613820999860764
800 0.03488151729106903
Best Loss: 0.029514113441109657
1000 0.0369877889752388
Best Loss: 0.029514113441109657

Отлично. Модель обучена. Теперь пора её оценить.


Оценка качества

Давайте проверим, стала ли эта модель лучше по сравнению с обычной линейной регрессией. Для этого я переведу модель в режим оценки с помощью model.eval(), чтобы PyTorch понял, что нужно изменить поведение: из режима обучения перейти в режим инференса. Например, в этом режиме отключаются слои Dropout и batch normalization переводится в режим оценки.

# Получаем признаки и целевую переменную
features, targets = dataset[:]


# Получаем предсказания
model.eval()
with torch.no_grad():
  predictions = model(features)


# Добавляем предсказания в датафрейм
df2['Predictions'] = np.exp(predictions.detach().numpy())


# RMSE
print(root_mean_squared_error(df2['Rings'], df2['Predictions']))
2.1108551025390625
2.1108551025390625

Улучшение получилось скромным — примерно 4%.

Давайте посмотрим на некоторые предсказания обеих моделей.

Прогнозы обеих моделей
Прогнозы обеих моделей

Обе модели дают очень похожие результаты. Им становится сложнее справляться с предсказаниями по мере роста значения Rings. Причина — в «конусной» форме распределения целевой переменной.

Если немного об этом подумать:

  • По мере увеличения числа колец (Rings) растёт и разброс, вносимый объясняющими переменными.

  • Морское ушко с 15 кольцами может иметь гораздо более широкий диапазон значений признаков, чем экземпляр с 4 кольцами.

  • Это сбивает модель с толку, потому что ей приходится проводить одну-единственную линию посередине данных, которые по сути не особенно линейны.

Заключение

В этом проекте мы разобрали много всего:

  • Как исследовать данные.

  • Как понять, подходит ли линейная модель для конкретной задачи.

  • Как создать модель на PyTorch для задачи многомерной линейной регрессии.

В итоге мы увидели, что неоднородная целевая переменная, даже после степенных преобразований, может приводить к низкой эффективности модели. Наша модель всё равно лучше, чем просто предсказывать одно и то же среднее значение для всех наблюдений, но ошибка остаётся высокой — порядка 20 % от среднего значения.

Мы попытались использовать глубокое обучение, чтобы улучшить результат, но всей этой мощности оказалось недостаточно, чтобы заметно снизить ошибку. Я бы, вероятно, выбрал модель на Scikit-Learn: она проще и лучше интерпретируется.

Другой путь улучшения качества — собрать кастомный ансамбль, например Random Forest + линейная регрессия. Но эту часть я оставляю вам, если захотите поэкспериментировать.

Ссылки

Научиться работать с важнейшими моделями машинного обучения, NLP, DL, рекомендательными системами на практике с реальными данными можно на курсе "Machine Learning. Professional". Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.

А если не хватает «живых» кейсов и хочется увидеть, как ML ведёт себя в продакшене, приходите на демо-уроки:

  • 11 декабря 20:00 — Ускоряем работу с видеопотоками при помощи TorchCodec. Записаться

  • 17 декабря 18:00 — Оптимизируем построение модели через Pipeline. Записаться

  • 22 декабря 18:00 — ML как основа современного AI. Записаться

Комментарии (1)


  1. holodoz
    10.12.2025 19:51

    Даже на train test не разбил, на чём тренируем, на том и проверяем. И это автор двух книг, если верить его сайту, 18 лет опыта. Пойду синьёр дата саентистом куда-нибудь