Логистическая регрессия в PyTorch

Давайте теперь немного усложним пример и построим бинарный классификатор, определяющий верятность некого события на основе многих факторов. Базовой моделью для этого классификатора станет всё та же линейная регрессия из предыдущего поста, в неё будут внесены всего лишь два небольших изменения, которые превратят линейную регрессию в логистическую. В качестве источника данных будет использовано соревнование Telco Customer Churn, в рамках которого нужно было предсказать вероятность оттока пользователей некого сервиса или, иными словами, вероятность несовершения пользователями неких действий за определённое время, свидетельствующих о том, что они всё ещё пользуются этим сервисом. Похожую задачу я решал в Qiwi, когда предсказывал вероятность совершения покупки пользователями карты Совесть в течении 30 дней от некоторой даты. Эту модель вполне можно рассматривать как систему, рекомендующую применить меры по удержанию пользователей.

Итак, импортируем необходимы библиотеки и загрузим данные.

import pandas as pd import torch from torch import nn, optim from torch.functional import F import math import numpy as np import math import statsmodels.api as sm import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import roc_auc_score from sklearn.linear_model import LogisticRegression df_churn = pd.read_csv("http://nagornyy.me/datasets/telco-customer-churn.zip") df_churn.shape
(7043, 21)
df_churn.head(2)
customerID gender SeniorCitizen Partner Dependents tenure PhoneService MultipleLines InternetService OnlineSecurity ... DeviceProtection TechSupport StreamingTV StreamingMovies Contract PaperlessBilling PaymentMethod MonthlyCharges TotalCharges Churn
0 7590-VHVEG Female 0 Yes No 1 No No phone service DSL No ... No No No No Month-to-month Yes Electronic check 29.85 29.85 No
1 5575-GNVDE Male 0 No No 34 Yes No DSL Yes ... Yes No No No One year No Mailed check 56.95 1889.5 No

2 rows × 21 columns

Как мы видим, данные содержат 21 переменную, одну из которых, под названием Churn, нам нужно предсказать. Большинство переменных категориальные, однако некоторые метрические ("tenure", "MonthlyCharges", "TotalCharges") — давайте этими тремя пока и ограничимся. Сконвертируем их в тензоры...

df_churn["TotalCharges"] = pd.to_numeric(df_churn.TotalCharges, errors="coerce") df_churn.dropna(inplace=True) X = df_churn[["tenure", "MonthlyCharges", "TotalCharges"]] y = df_churn["Churn"].replace({"No": 0, "Yes": 1}) y_tensor = torch.from_numpy(y.values.reshape(-1, 1)).float() X_tensor = torch.from_numpy(X.values).float()

В качестве базовой модели используем логистическую регрессию из пакета scikit-learn. Построим простую модель и посчитаем AUC ROC [1].

log_reg_sklearn = LogisticRegression(solver="liblinear") log_reg_sklearn.fit(X, y); predicted = log_reg_sklearn.predict(X) roc_auc_score(y, predicted)
0.6810574003380642

Не так уже плохо для таких ограниченных данных. Теперь реализуем модели логистической регрессии в PyTorch и сравним результаты.

Для этого нужно вспмнить механизм работы логистической регрессии. Она относится к классу обобщённых линейных моделей, которые в основе своей представляют из себя обычную линейную модель, выход которой передаётся в связывающую функцию (link function), преобразующую этот выход в нужный формат — в данном случае в вероятность. В логистической регресии для этого служит, незапно, логистическая фукнция, относящаяся к классу сигмоидальных функций.

Таким образом, всё что нужно для преобразования линейной регресии в логистическую — это применить к её выходу логистическую функцию, а также использовать функцию потерь, которая больше подходит к задаче классификации. Обычно для этого используют функцию бинарной кросс-энтропии [2].

def logistic(z): return 1 / (1 + torch.exp(-z)) def logistic_reg_model(X, w, b): return logistic(X @ w.t() + b) def binary_cross_entropy(predicted, true): return -(true * predicted.log() + (1 - true) * (1 - predicted).log()).mean()

Кстати, небольшой лайфхак. Нейронные сети крайне чуствительны к шкалам, по которой измеренны данные. Очень желательно, чтобы основные параметры переменных, такие как среднее и страндартное отклонение, не сильно отличались между собой. В противном случае, метод градиентного спуска не сработает, особенно такой тупой, как у нас реализован сейчас, т.к. он попадёт в "овраг", образованный "большими" шкалам.

Самый простой способ нормировать и шкалировать данные — это вычесть среднее и нормировать на стандартное отклонение.

X_tensor = (X_tensor - X_tensor.mean(axis=0)) / X_tensor.std(axis=0)

Можем приступать к обучению.

epochs = 1000 learning_rate = 0.001 # инициализируем параметры нулями # и начинаем записывать историю вычислений weight = torch.zeros(y_tensor.shape[1], X_tensor.shape[1], requires_grad=True) bias = torch.zeros(y_tensor.shape[1], requires_grad=True) for epoch in range(epochs): # forward pass — вычисляем функцию потерь predictions = logistic_reg_model(X_tensor, weight, bias) loss = binary_cross_entropy(predictions, y_tensor) # backward paass — вычисляем градиент loss.backward() # вычитаем производные из параметров # записывать историю вычислений уже не нужно (no_grad) with torch.no_grad(): weight -= weight.grad * learning_rate bias -= bias.grad * learning_rate # обнуляем производные weight.grad.zero_() bias.grad.zero_() predictions = logistic_reg_model(X_tensor, weight, bias) print(roc_auc_score(y, predictions.detach().numpy()))
0.7888779765726146

Модель успешно обучилась (а, возможно, и переобучилась) и её AUC превысил таковой у нашей базовой модели.

Здесь, однако, нужно сделать важную ремарку. Я заполнил начальные значения параметров нулями, поскольку из-за логистической функции при случайной инициализации наш примитивный градиентный спуск не мог сойтись и предсказания модели сваливалась NaN'ы. Инициализация параметров — вообще на удивление важная и тонкая вещь, к которой нужно относится с огромным вниманием. В данном случае эту проблему удалось легко решить, однако в будущем количество таких нюансов будет расти.

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

Например, в PyTorch имеется модуль, реализующий линейную модель. Взгляните на его код, он довольно прост. Модуль берёт на себя заботу об инициализации параметров и вызывает функцию F.linear для вычисления функции в методе forward. Можно убедиться, что формула, для расчёта линейной модели ничем не отличается от той, что использовали мы.

Чтобы посмотреть код эти функций в jupyter, можно выполнить torch.nn.Linear?? и torch.functional.F.linear??

Итак, перепишим нашу модель более конвенциональным способом, используя встроенные в PyTorch инструменты.

class LogisticRegressionTorch(nn.Module): def __init__(self, input_size, output_size): super(LogisticRegressionTorch, self).__init__() self.linear = nn.Linear(input_size, output_size) def forward(self, X): predictions = self.linear(X) return torch.sigmoid(predictions) model = LogisticRegressionTorch(X_tensor.shape[1], y_tensor.shape[1]) # определяем функцию потерь — бинарную кросс-энтропию criterion = nn.BCELoss() # определяем алгоритм оптимизации Adam optimizer = optim.Adam(model.parameters(), lr=learning_rate) for epoch in range(epochs): optimizer.zero_grad() predictions = model(X_tensor) loss = criterion(predictions, y_tensor) # вычисляем градиенты loss.backward() # обновляем параметры optimizer.step() predictions = model(X_tensor) print(roc_auc_score(y, predictions.detach().numpy()))
0.8038308551597795

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

В следующий раз мы продолжим развивать тему линейных моделей, научив их по-максимуму использовать категориальные переменные и взаимодействия между ними при помощи алгоритма под названием факторизационные машины [3].

Комментарии