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

Некоторое время до того, как LLM стали популярными, существовала почти видимая граница, разделяющая фреймворки машинного обучения и фреймворки глубокого обучения.
Доклад был сосредоточен на Scikit-Learn, XGBoost и аналогичных технологиях для машинного обучения, в то время как PyTorch и TensorFlow доминировали, когда речь заходила о глубоком обучении.
Однако после взрывного развития искусственного интеллекта я наблюдаю, как PyTorch доминирует на сцене гораздо больше, чем TensorFlow. Оба фреймворка действительно мощные и позволяют специалистам по данным решать различные задачи, в том числе и задачи обработки естественного языка, что вновь повышает популярность глубокого обучения.
В этой статье я не собираюсь говорить об НЛП, а вместо этого поработаю с задачей многомерной линейной регрессии, преследуя две цели:
- Обучение созданию модели с использованием PyTorch
- Обмен знаниями о линейной регрессии, которые не всегда можно найти в других учебниках.
Давайте начнем.
Подготовка данных
Хорошо, позвольте мне избавить вас от замысловатого определения линейной регрессии. Вы, вероятно, уже не раз встречали его в бесчисленных руководствах по всему интернету. Итак, достаточно сказать, что если у вас есть переменная Y, которую вы хотите предсказать, и другая переменная X, которая может объяснить вариацию Y с помощью прямой линии, то это, по сути, линейная регрессия.
Набор данных
Для этого упражнения давайте воспользуемся набором данных Abalone [1].
Нэш, У., Селлерс, Т., Талбот, С., Коуторн, А. и Форд, У. (1994). Abalone [Набор данных]. Репозиторий машинного обучения Калифорнийского университета в Ирвайне. https://doi.org/10.24432/C55C7W.
Согласно документации набора данных, возраст морского ушка определяется путём разрезания раковины по конусу, окрашивания её и подсчёта количества колец под микроскопом — утомительное и трудоёмкое занятие. Для прогнозирования возраста используются другие, более простые измерения.
Итак, давайте загрузим данные. Кроме того, мы закодируем переменную «Пол» методом One Hot Encode, поскольку она единственная категориальная.
# Загрузка данных из 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) X = abalone.data.features y = abalone.data.targets # One Hot Encode Sex ohe = OneHotEncoder(variables=['Sex']) X = ohe.fit_transform(X) # Просмотр df = pd.concat([X,y], axis=1)
Вот набор данных.

Итак, чтобы создать лучшую модель, давайте исследуем данные.
Изучение данных
Первые шаги, которые я люблю выполнять при исследовании набора данных:
1. Проверка распределения целевой переменной.
# Смотрим на нашу целевую переменную plt.hist(y) plt.title('Распределение колец [целевая переменная]');
На графике показано, что целевая переменная распределена не нормально. Это может повлиять на регрессию, но обычно может быть исправлено с помощью степенного преобразования, например, логарифмического или преобразования Бокса-Кокса.

2. Посмотрите статистическое описание.
Статистика может показать нам важную информацию, такую как среднее значение, стандартное отклонение, и легко обнаружить некоторые расхождения в минимальных или максимальных значениях. Объясняющие переменные в целом неплохи, находятся в узком диапазоне и имеют тот же масштаб. Целевая переменная (кольца) имеет другой масштаб.
# Статистическое описание df.describe()

Далее проверим корреляции.
# Смотрим на корреляции (df .drop(['Sex_M', 'Sex_I', 'Sex_F'],axis=1) .corr() .style .background_gradient(cmap='coolwarm') )

Объясняющие переменные имеют среднюю или сильную корреляцию с числом колец. Мы также видим некоторую коллинеарность между Whole_weight, Shucked_weight, Viscera_weight и Shell_weight. Длина и диаметр также коллинеарны. Мы можем попробовать удалить их позже.
sns.pairplot(df);
Когда мы строим диаграммы рассеяния пар и смотрим на взаимосвязь переменных с помощью колец, мы можем быстро выявить некоторые проблемы.
- Нарушается предположение о гомоскедастичности. Это означает, что зависимость неоднородна с точки зрения дисперсии.
- Обратите внимание, как графики принимают форму конуса, увеличивая дисперсию Y по мере увеличения значений X. При оценке значения колец для более высоких значений переменных X оценка будет не очень точной.
- Переменная Height имеет по крайней мере два выброса, которые очень заметны, когда Height > 0,3.

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

Еще одно быстрое исследование, которое мы можем провести, — это построить несколько графиков для проверки взаимосвязи переменных при группировке по переменной «Пол».
Переменная Диаметр имеет наиболее линейную зависимость, когда Пол=I, но это все.
# Создать FacetGrid с диаграммами рассеяния sns.lmplot(x=»Diameter», y=»Rings», hue=»Sex», col=»Sex», order=2, data=df);

С другой стороны, Shell_weight имеет слишком большую дисперсию для высоких значений, что искажает линейную зависимость.
# Создать FacetGrid с диаграммами рассеяния sns.lmplot(x=»Shell_weight», y=»Rings», hue=»Sex», col=»Sex», data=df);

Всё это показывает, что модель линейной регрессии будет очень сложной для этого набора данных и, вероятно, не сработает. Но мы всё равно хотим её реализовать.
Кстати, я не помню, чтобы я видел пост, где мы бы подробно разбирали, что пошло не так. Так что, разобравшись с этим, мы тоже можем извлечь ценные уроки.
Моделирование: использование Scikit-Learn
Давайте запустим модель sklearn и оценим ее с помощью среднеквадратической ошибки.
из sklearn.linear_model импорт LinearRegression из sklearn.metrics импорт root_mean_squared_error df2 = df.query('Высота < 0,3 и кольца > 2 ').copy() X = df2.drop(['Кольца'], ось=1) y = np.log(df2['Кольца']) lr = LinearRegression() lr.fit(X, y) predicts = lr.predict(X) df2['Предсказания'] = np.exp(предсказания) print(root_mean_squared_error(df2['Кольца'], df2['Предсказания'])) 2.2383762717104916
Если взглянуть на заголовок, то можно подтвердить, что модель испытывает трудности с оценками для более высоких значений (например, строки 0, 6, 7 и 9).

Шаг назад: пробуем другие трансформации
Хорошо. Что же нам теперь делать?
Вероятно, стоит удалить ещё выбросы и повторить попытку. Давайте попробуем использовать неконтролируемый алгоритм для поиска ещё выбросов. Применим коэффициент локального выброса , отбросив 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) X = abalone.data.features y = abalone.data.targets # One Hot Encode Sex ohe = OneHotEncoder(variables=['Sex']) X = ohe.fit_transform(X) # Отбрасывание всего веса и длины (мультиколинейность) X.drop(['Whole_weight', 'Length'], axis=1, inplace=True) # Представление df = pd.concat([X,y], axis=1) # Давайте создадим Pipeline для масштабирования данных и поиска выбросов с помощью классификатора KNN steps = [ ('scale', StandardScaler()), ('LOF', LocalOutlierFactor(contamination=0.05)) ] # Подгонка и прогнозирование выбросов = Pipeline(steps).fit_predict(X) # Добавление столбца df['outliers'] = выбросы # Моделирование df2 = df.query('Height < 0.3 and Rings > 2 and throws != -1').copy() X = df2.drop(['Rings', 'outliers'], axis=1) y = np.log(df2['Rings']) lr = LinearRegression() lr.fit(X, y) predicts = lr.predict(X) df2['Predictions'] = np.exp(predictions) print(root_mean_squared_error(df2['Rings'], df2['Predictions'])) 2.238174395913869
Тот же результат. Хм…
Хорошо. Мы можем продолжить экспериментировать с переменными и конструированием признаков, и мы начнём замечать улучшения, например, при добавлении квадратов высоты, диаметра и веса оболочки. В сочетании с обработкой выбросов это снизит среднеквадратичное отклонение до 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² из sklearn.metrics import r2_score r2 = r2_score(df2['Кольца'], df2['Предсказания']) 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'Скорректированный R²: {adj_r2}')
С другой стороны, возвращение Whole_weight и Length может немного улучшить результаты, но я бы не рекомендовал этого делать. В этом случае мы добавим мультиколлинеарность и завысим значимость коэффициентов некоторых переменных, что может привести к ошибкам оценки в будущем.
Моделирование: использование PyTorch
Итак. Теперь, когда у нас есть базовая модель, идея состоит в том, чтобы создать линейную модель с использованием глубокого обучения и попытаться превзойти среднеквадратичное отклонение (RMSE) 2,196.
Хорошо. Для начала, позвольте мне сразу заявить: модели глубокого обучения лучше работают с масштабированными данными. Однако, поскольку все наши переменные X имеют одинаковый масштаб, нам не нужно об этом беспокоиться. Итак, продолжим.
импортировать torch импорт torch.nn как nn импорт torch.optim как optim из torch.utils.data импорт DataLoader, TensorDataset
Нам необходимо подготовить данные для моделирования с помощью PyTorch. Здесь нам потребуется внести некоторые изменения, чтобы сделать данные приемлемыми для фреймворка PyTorch, поскольку он не поддерживает обычные фреймы данных Pandas.
- Давайте используем тот же фрейм данных из нашей базовой модели.
- Разделить X и Y
- Преобразуем переменную Y в логарифм
- Преобразуйте оба в массивы numpy, поскольку PyTorch не принимает фреймы данных.
df2 = df.query('Высота < 0,3 и Количество колец > 2 и выбросы != -1').copy() X = df2.drop(['Кольца', 'выбросы'], ось=1) y = np.log(df2[['Кольца']]) # X и Y в Numpy X = X.to_numpy() y = y.to_numpy()
Затем, используя TensorDataset, мы превращаем X и Y в объекты Tensor и выводим результат.
# Подготовка с помощью TensorData # TensorData помогает нам преобразовать набор данных в объект Tensor dataset = TensorDataset(torch.tensor(X).float(), torch.tensor(y).float()) input_sample, label_sample = dataset[0] print(f'** Входной образец: {input_sample}, n** Образец метки: {label_sample}') ** Входной образец: tensor([0.3650, 0.0950, 0.2245, 0.1010, 0.1500, 1.0000, 0.0000, 0.0000, 0.1332, 0.0090, 0.0225]), ** Образец метки: 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__() гарантирует, что класс будет вести себя как объект-фонарик.
- Метод прямого действия описывает, что происходит с входными данными при передаче их в модель .
Здесь мы пропускаем его через линейные слои, которые мы определили в методе 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()
Далее давайте впервые попробуем модель, используя скрипт, имитирующий случайный поиск.
- Создать критерий ошибки для оценки модели
- Создайте список для хранения данных из лучшей модели и задайте для best_loss высокое значение, чтобы оно заменялось лучшими показателями потерь во время итерации.
- Установите диапазон скорости обучения. Мы будем использовать коэффициенты мощности от -2 до -4 (например, от 0,01 до 0,0001).
- Установите диапазон импульса от 0,9 до 0,99.
- Получить данные
- Обнулите градиент, чтобы очистить расчеты градиента из предыдущих итераций.
- Подогнать модель
- Рассчитайте потери и зарегистрируйте показатели лучшей модели.
- Вычислите веса и смещения с помощью обратного прохода.
- Повторите N раз и выведите лучшую модель.
# Среднеквадратическая ошибка (MSE) является стандартной для критерия регрессии = nn.MSELoss() # Значения случайного поиска = [] best_loss = 999 для idx в диапазоне (1000): # Случайным образом выбирается фактор скорости обучения от 2 до 4 factor = np.random.uniform(2,5) lr = 10 ** -factor # Случайным образом выбирается импульс от 0,85 до 0,99 momentum = np.random.uniform(0.90, 0.99) # 1. Получить признак данных, target = dataset[:] # 2. Нулевые градиенты: очистить старые градиенты перед обратным проходом optimizer = optim.SGD(model.parameters(), lr=lr, momentum=momentum) optimizer.zero_grad() # 3. Прямой проход: вычислить прогноз y_pred = model(feature) # 4. Вычислить потери loss = criteria(y_pred, target) # 4.1 Зарегистрировать лучший убыток, если убыток < лучший_убыток: лучший_убыток = убыток лучший_lr = lr лучший_моментум = импульс лучший_idx = idx # 5. Обратный проход: вычислить градиент убытка относительно W и b' потерь.backward() # 6. Обновить параметры: скорректировать W и b, используя вычисленные градиенты optimizer.step() значения.append([idx, lr, импульс, убыток]) print(f'n: {idx},lr: {lr}, импульс: {моментум}, убыток: {убыток}') n: 999,lr: 0.004782946959508322, импульс: 0.9801209929050066, убыток: 0.06135804206132889
Как только мы достигнем оптимальной скорости обучения и импульса, мы сможем двигаться дальше.
# — 3. Функция потерь и оптимизатор — # Среднеквадратическая ошибка (MSE) является стандартным критерием для регрессии = nn.MSELoss() # Стохастический градиентный спуск (SGD) с малой скоростью обучения (lr) оптимизатор = optim.SGD(model.parameters(), lr=0.004, momentum=0.98)
Затем мы повторно обучим эту модель, используя те же шаги, что и раньше, но на этот раз сохраняя ту же скорость обучения и импульс.
Подгонка модели PyTorch требует более длинного скрипта, чем обычный метод fit() из Scikit-Learn. Но это не так уж и важно. Структура всегда будет похожа на следующие шаги:
- Активируйте режим model.train()
- Создайте цикл с нужным количеством итераций. Каждая итерация называется эпохой.
- Обнулите градиенты из предыдущих проходов с помощью optimizer.zero_grad().
- Получите пакеты из загрузчика данных.
- Вычислите прогнозы с помощью model(X)
- Рассчитайте убыток, используя критерий (y_pred, target).
- Выполните обратный проход для вычисления весов и смещения: loss.backward()
- Обновите веса и смещение с помощью 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. Получить признак данных, цель = данные # 2. Нулевые градиенты: очистить старые градиенты перед обратным проходом optimizer.zero_grad() # 3. Прямой проход: вычислить прогноз y_pred = model(feature) # 4. Вычислить потери loss = criteria(y_pred, target) loss_history.append(loss) # Получить лучшую модель, если 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) # Выведите статус каждые 50 эпох, если epoch % 200 == 0: print(epoch, loss.item()) print(f'Best Loss: {best_loss}') 0 0,061786893755197525 Лучший убыток: 0,06033024191856384 200 0,036817338317632675 Лучший убыток: 0,03243456035852432 400 0,03307393565773964 Лучший убыток: 0,03077109158039093 600 0,032522525638341904 Лучший убыток: 0,030613820999860764 800 0,03488151729106903 Лучший убыток: 0,029514113441109657 1000 0,0369877889752388 Лучший убыток: 0,029514113441109657
Отлично. Модель обучена. Теперь пора оценить.
Оценка
Давайте проверим, превзошла ли эта модель результаты обычной регрессии. Для этого я переведу модель в режим оценки с помощью model.eval(), чтобы PyTorch знал, что нужно изменить поведение модели, полученное в ходе обучения, и перейти в режим вывода. Например, это отключит нормализацию слоёв и исключения элементов.
# Получить признаки features, target = dataset[:] # Получить прогнозы model.eval() с torch.no_grad(): predicts = model(features) # Добавить в фрейм данных df2['Predictions'] = np.exp(predictions.detach().numpy()) # RMSE print(root_mean_squared_error(df2['Rings'], df2['Predictions'])) 2.1108551025390625
Улучшение было скромным, около 4% .
Давайте рассмотрим некоторые прогнозы каждой модели.

Обе модели дают очень похожие результаты. С ростом числа колец они становятся всё более затруднительными. Это связано с конусообразной формой целевой переменной.
Если задуматься на мгновение:
- По мере увеличения количества колец увеличивается дисперсия объясняющей переменной.
- Морское ушко с 15 кольцами будет иметь гораздо более широкий диапазон значений, чем такое же с 4 кольцами.
- Это сбивает модель с толку, поскольку ей приходится рисовать одну линию посередине данных, которая не столь линейна.
Прежде чем уйти
В ходе этого проекта мы многому научились:
- Как исследовать данные.
- Как проверить, будет ли линейная модель хорошим вариантом.
- Как создать модель PyTorch для многомерной линейной регрессии.
В итоге мы увидели, что неоднородная целевая переменная, даже после степенных преобразований, может привести к получению неэффективной модели. Наша модель всё ещё лучше, чем среднее значение для всех прогнозов, но погрешность всё ещё высока, оставаясь около 20% от среднего значения.
Мы попытались улучшить результат с помощью глубокого обучения, но всей этой мощи оказалось недостаточно, чтобы значительно снизить погрешность. Я бы, пожалуй, выбрал модель Scikit-Learn, поскольку она проще и понятнее.
Другой вариант улучшить результаты — создать собственную ансамблевую модель с использованием случайного леса и линейной регрессии. Но это задача, которую я оставляю вам, если вы того пожелаете.
Если вам понравился этот контент, найдите меня на моем сайте.
https://gustavorsantos.me
Репозиторий GitHub
Код для этого упражнения.
https://github.com/gurezende/Linear-Regression-PyTorch
Ссылки
[1. Набор данных Abalone – Репозиторий UCI, лицензия CC BY 4.0.] https://archive.ics.uci.edu/dataset/1/abalone
[2. Режим оценки] https://stackoverflow.com/questions/60018578/what-does-model-eval-do-in-pytorch
https://docs.pytorch.org/docs/stable/generated/torch.nn.Module.html#torch.nn.Module.eval
[3. Документация PyTorch] https://docs.pytorch.org/docs/stable/nn.html
[4. Блокнот Kaggle] https://www.kaggle.com/code/samlakhmani/s4e4-deeplearning-with-oof-strategy
[5. Репозиторий GitHub] https://github.com/gurezende/Linear-Regression-PyTorch
Источник: towardsdatascience.com



























