Разбираемся в ML без воды: от базы до Attention. Часть 3

Страницы:  1

Ответить
 

Professor Seleznov


Во второй части мы рассмотрели аналитическое решение задачи линейной регрессии и наткнулись на ряд неприятностей — сингулярность, плохая обусловленность, вычислительная сложность и т.д.
Логическим продолжением будет изучение (не побоюсь этого слова) сердца машинного обучения: градиентного спуска. Итак
Градиентный спуск
Так как лучше уже не придумаешь, начнем с классического рассказа о пьяном альпинисте:
Представим альпиниста, который на верхней точке горы знатно перебрал с согревающими напитками, после чего его накрыло туманом. Видимость — абсолютный ноль. Его единственная задача — сползти на самое дно ущелья.
Что он будет делать, чтобы спуститься вниз? Алгоритм максимально прост:
  • Он аккуратно выставляет ногу вперед, влево, вправо и прощупывает подошвой ботинка рельеф под собой, тем самым определяет в какую сторону идет уклон горы;
  • Когда понимает, где склон уходит вниз делает туда ровно один шаг.
  • Повторяет процесс заново
Вопрос։ При каких условиях альпинист не дойдет до ущелья (по крайней мере живым)?
Во-первых, гора должна быть достаточно гладкой.В противном случае получим задачу из школьной физики на тему свободного падения
Во-вторых, шаги альпиниста не должны быть километровыми. Какой смысл в том, что он каким-то способом сделает шаг и попадет на вершину соседней горы?
В-третьих, противоположный случай: если он сделает микроскопические шаги, пройдут года, наступит старость и т.д.
Ну вот, теперь мы полностью поняли, как устроен градиентный спуск. Всем пока, до новых встреч!
Перейдем к самой весёлой части. Альпинизм, безусловно, очень и очень интересное дело, но мы здесь собрались ради математики. Так что переведем этот рассказ в язык математики.
Определим градиент. Градиентом функции
pic
называется векторная функция вида
pic
Естественно, градиент (в том виде, в котором он нам интересен) существует не всегда: функция должна быть дифференцируемой. Это условие (почти) то же самое, что и гладкость горы для альпиниста.

Почему почти

В математике существует понятие гладкой функции. В данном контексте будем понимать под этим функции класса
pic
: множество функций, которые являются непрерывно дифференцируемыми.
Для функции многих переменных (что типично для ML) принадлежность к классу 
pic
на некотором множестве означает выполнение двух условий:
  • Существование: В каждой точке существуют все частные производные первого порядка.
  • Непрерывность: Все частные производные первого порядка сами по себе являются непрерывными функциями.
Возможно я путаюсь, и гладкими называются функции класса
pic
. Но на мою память у них название "бесконечно гладкие". Вроде как устойчивого определения не существует, и всё зависит от литературы. Знающие, если я ошибся, поправьте, пожалуйста.
Не будем выводить свойства градиента, но отмечу, зачем нам он нужен։ Оказывается, градиент показывает направление наибольшего роста функции. И, стало быть, тот же градиент со знаком минус (a.k.a. антиградиент) показывает направление, где функция убывает быстрее всего. Сохраняя аналогию, это тот самый ботинок альпиниста, которым он нащупывает гору.
Вот и математическое понимание градиентного спуска: Ищем антиградиент функции потерь, идём по нему, пока не дойдем до минимума.
В формуле вся эта логика «прощупывания и шага» превращается в одну простую строчку, по которой компьютер обновляет параметры (веса) модели на каждой итерации:
pic
Здесь
pic
— текущие (пока еще неточные) веса нашей модели. Это точка на склоне, где альпинист стоит прямо сейчас.
pic
— вектор градиента нашей функции потерь. Тот самый компас, показывающий на вершину горы ошибок.
Знак минус разворачивает нас спиной к вершине и лицом к ущелью (превращает градиент в антиградиент).
pic
коэффициент скорости обучения, он же learning rate (lr). Тот самый размер шага альпиниста. Она, кстати говоря является гиперпараметром (то есть параметром, который мы выбираем руками еще до старта, а не модель подбирает сама в процессе обучения). Также она может быть как фиксированной, так и динамически изменяемой (например, уменьшаться по мере приближения к минимуму, чтобы наш альпинист не проскочил ущелье на финише).
Каждую итерацию алгоритм берет старые веса, вычитает из них скорректированный на длину шага градиент и получает новые веса
pic
, с которыми наша модель ошибается чуточку меньше.
На всякий случай, добавлю, что при таком подходе функция потерь, естественно, должна иметь градиент. А также, нет никакой гарантии, что градиентный спуск приведет нас к глобальному минимуму (т.е. даст веса, при которых лосс вообще-вообще самый маленький). Есть большая вероятность, что мы попадем в локальный минимум.
Градиентный спуск для линейной регрессии
Для задачи линейной регрессии мы получаем джекпот — на этой поверхности наш альпинист застрахован от блуждания по ложным оврагам.
Дело в том, что функция потерь линейной регрессии (MSE) обладает очень хорошим свойством: она является выпуклой, а иногда (когда нет линейных зависимостей в фичах, т.е. при полном ранге матрицы признаков) — она строго выпуклая (имеет один минимум). Но, даже в случаях, когда минимумов несколько, все минимизаторы дают одно и то же значение функции потерь (все минимальные значения равны). Это значит, что в какое ущелье не попадет альпинист, там его ждут одинаковая пища/кровать и т.д.
Как и обещал, пора кодить!
Итак, на мой взгляд, мы окончательно добили линейную регрессию. Но теория без практики — удовольствие спорное. Кодим, товарищи!
Т.к. часть с кодом у нас встречается впервые, добавлю несколько пояснений.
Работать будем, как ни странно, в питоне (у меня 3.12). В первое время из библиотек нам понадобится классический джентльменский набор: numpy, pandas, scikit-learn, matplotlib и seaborn. В дальнейшем нам понадобятся еще как минимум торч и трансформеры, но до этого пока что далеко.
Примечание: для сохранения наглядности и простоты восприятия код не будет максимально коротким или оптимизированным “под прод”. Важно понимать, что учебный код пишется ради понимания сути алгоритма, а не ради экономии наносекунд процессорного времени. Тем не менее, я постараюсь сохранить базовую гигиену разработки и соблюдать основные принципы читаемости в стиле PEP 8: понятные имена переменных, аккуратные отступы и соблюдение базовых соглашений оформления кода. В целом, если вы разбираетесь в коде и вам интересна лишь математическая часть машинного обучения, можете смело пропустить этот блок.

Решаем задачу

Для начала возьмем достаточно классический датасет.
Здесь у нас восемь столбцов։
  • name — имя автомобиля
  • year — год выпуска
  • selling_price — цена (наш таргет)
  • km_driven — пробег в километрах
  • fuel — тип топлива (дизель / бензин / и т.д.)
  • seller_type — тип продавца (индивидуальный / дилер / и т.д.)
  • transmission — коробка передач
  • owner — количество владельцев
Чтобы закрепить информацию и набить руку, очень рекомендую после прочтения статьи самостоятельно построить аналогичную модель, используя "брата " нашего датасета — там собраны похожие данные, но уже по мотоциклам.
import pandas as pd # для работы с данными в виде таблиц
import numpy as np # для работы с массивами и числовыми операциями
import matplotlib.pyplot as plt # для визуализации данных
import seaborn as sns # опять же для визуализации (heatmap)
Далее идет загрузка данных из файла cars.csv.
Это наш датасет из kaggle, который я переименовал в cars.csv для удобства.
Также посмотрим на размер нашего датасета, и посмотрим сколько в них NaN.

Немного о NaN

В PythonNaNобычно представлен объектом numpy.nanи имеет типfloat. Вообще говоря, он не равен 0.0, поэтому код ниже выведет True:
from numpy import nan
x = nan
if x:
print(True)
Несмотря на “утиную” философию Python, где различные “пустые” значения (0,""[]и т.д.) ведут себя одинаково в логическом контексте, сNaNтакая штука не работает.
Самое главное, не путать NaN с None (единственным объектом типа NoneType)
Самое интересное: np.nan == np.nan тоже даст False. Дело в том, что np.nan стоит воспринимать как неизвестность/неопределенность.
pd.set_option('display.float_format', '{:.2f}'.format) # просто формат отображения
df = pd.read_csv('cars.csv')
print(df.shape)
print(df.isna().sum())
>>> (4340, 8)
name 0
year 0
selling_price 0
km_driven 0
fuel 0
seller_type 0
transmission 0
owner 0
Окей, 4340 строк (это то, что мы обозначили за k). Нулевое количество nan. 7 фич, один таргет.
Посмотрим содержимое. вызов df.head() показывает следующее:
pic
первые пять строк
Видим как числовые, так и категориальные признаки. Сначала разберемся с числовыми, потом придумаем что делать с категориальными.
Через df.describe().applymap(lambda x: f"{x:.2f}")видим следующее:
pic
статистические данные о числовых признаках
Итак, год выпуска от 1992 до 2020, цены от 20000 до почтим 9млн пробег от 1км до 800к.
Также видим средние, квантили, медианы и т.д.
Посмотрим как они коррелируют между собой.
plt.figure(figsize=(8, 6))
sns.heatmap(df[['year', 'km_driven', 'selling_price']].corr(),
annot=True, cmap='coolwarm', fmt='.2f')
plt.title('Correlation Matrix of Numerical Features')
plt.show()
pic
 тепловая карта (heatmap) корреляции числовых признаков
Результаты достаточно ожидаемы. Разберем по очереди
  •  year и selling_price (Корреляция +0.41)
    Логично? Логично.Машина 2018 года выпуска при прочих равных стоит дороже, чем ее уставший аналог из 2008-го. Однако 0.41 говорит нам о том, что год, конечно же, влияет, но не определяет цену.
  •  km_driven и selling_price (Корреляция -0.19)
    Связь слабая и отрицательная (обратная). Оказывается, пробег очень плохо объясняет цену.
  • year и km_driven (Корреляция -0.42)
    Сильная отрицательная связь между самими признаками. Чем старше машина (меньше год), тем больше её пробег. Однако история та же, что и в пункте 1.
Одними числовыми признаками задачу нормально не решить. Но это нас не остановит и мы создадим модель линейной регрессии на 2х признаках.
Однако, до этого поговорим о такой метрике, как
pic
score. Сразу отмечу, что это не квадрат некого
pic
, а просто обозначение. При виде
pic
, не надо думать что это некое комплексное число
pic
, с квадратом, равным.
Итак, Коэффициент детерминации (или R-квардат) это обозначение величины
pic
где
pic
— реальное значение целевой переменной для
pic
-го объекта.
pic
— значение, которое предсказала модель (наш
pic
)
pic
— среднее арифметическое всех реальных значений
pic
(вычисляется как
pic
).
Если
pic
, то наша модель предсказывает всё идеально
Если
pic
, то модель вполне себе рабочий (например, 
pic
означает, что модель объясняет 85% вариации).
Если
pic
, значит у нас получился шайтан-модель. Она предсказывает всегда просто среднее значение целевой переменной.
Если же
pic
, то модель работает хуже, чем предсказание по среднему значению. Значит где-то что-то не так.
Теперь вернемся к делу, напишем нашу первую модель с двумя признаками и оценим полученные MSE и
pic
.
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.preprocessing import StandardScaler
# выделяем целевую переменную и признаки
X = df[['year', 'km_driven']] # признаки
y = df['selling_price'] # целевая переменная
# разделяем данные на обучающую и тестовую выборки соотношением 4 к 1.
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# стандартизуем признаки для улучшения качества модели
# (год у нас в среднем 2013, а пробег в среднем 60000.
# это может негативно влиять на обучение модели)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
# создаем стандартную модель линейной регрессии и обучаем её на обучающей выборке
model = LinearRegression()
model.fit(X_train_scaled, y_train)
# делаем предсказания на тестовой выборке
y_pred = model.predict(X_test_scaled)
# оцениваем качество модели с помощью метрик MSE и R2
mse = mean_squared_error(y_test, y_pred)
r2 = r2_score(y_test, y_pred)
print(f'Mean Squared Error: {mse:.2f}')
print(f'R2 Score: {r2:.2f}')
>>> Mean Squared Error: 255707328588.58
>>> R2 Score: 0.16
Модель в среднем ошибается на
pic
. это примерно ~500 000 (валютных единиц цены автомобиля).
pic
тоже так себе... Наша модель, конечно, лучше той шайтан-модели, которая всегда возвращает среднее, но успехом это не назовешь.
Однако, такой результат был вполне ожидаемым.
Так подключим категориальные признаки!
Проблема в том, что кампуктеру всё равно машина называется Lada 2101, или Lamborghini GT3. Он у нас хоть и умный, но понимает только числа. Остается одно: привести их в числовой формат.
Начнем с колонки name. df['name'].nunique() говорит, что у нас 1491 уникальных типов автомобилей.
Казалось бы, Label encoding (т.е. просто если их пронумеруем 1,2,3,...1491), решит наш вопрос. Но в реальности, он создаст другую проблему: Если Lada 2101 станет (условно) 666, Lamborghini GT3 — 1005, то как компьютер потом поймет что из них "лучше" ? Потому, отложим label encoding для тех категориальных признаков, которые сравнимы между собой.
Что насчет One Hot Encoding? В таком виде он раздует нашу таблицу до соседнего города.

One Hot Encoding (OHE)

Разберем на примере этот способ работы с категориальными признаками.
Допустим у нас в датасете есть colors в котором данные либо red, либо green, либо blue. Вместо столбца colors сделаем 3 новых: is_red, is_green, is_blue, с значениям 0/1 (True/False). Более того, is_red нам не нужен, ибо его значение получается путем
is_red =(1 - is_green)*(1 - is_blue).Тем самым у нас не возникнет лишняя корреляция, да и лишних столбцов будет меньше.
Сделаем вот что: так как в каждой строке name у нас первым делом указаны компании производящие конкретные автомобили, поменяем фичу name на фичу company.
df_company = df.copy() # создаем копию датасета, чтобы не изменять оригинальные данные
df_company['company'] = df_company['name'].apply(lambda x: x.split()[0])
df_company.drop('name', axis=1, inplace=True) # удаляем ненужный нам столбец 'name'
df_company.head()
pic
теперь вместо конкретных моделей у нас компании производителей
Теперь df_company['company'].nunique() говорит, что у нас 29 уникальных компаний. У нас матрица
pic
имеет размеры
pic
. Если сделаем OHE с drop_first=True и удалим столбец с названиями компаний новая матрица будет иметь размеры
pic
. Это всего 143 тысяч элементов. Для сравнения, если бы мы сделали OHE для колонки name, получили бы матрицу
pic
(около 6.5млн элементов).
Делается всё в одну строчку:
df_company = pd.get_dummies(df_company, columns=['company'], drop_first=True, dtype=int)
C первым признаком разобрались. Далее, аналогично глянем признал fuel. df['fuel'].nunique()говорит, что типов у него всего 5. Метод уже знаем, так что повторим:
df_fuel = pd.get_dummies(df_company, columns=['fuel'], drop_first=True, dtype=int)
Далее посмотрим на seller-ов. Там всего 3 уникальных значений: 'Individual', 'Dealer', 'Trustmark Dealer'. Здесь OHE тоже подходит, но, для разнообразия, поступим чуть иначе. По идее, диллеры завышают цены, частник продаст ту же машину дешевле. Попробуем вызвать df.groupby('seller_type')['selling_price'].mean()
pic
средние цены у разных типов продавцов
Как видим, seller_type СИЛЬНО связан с ценой. В среднем, у Individual selling_price меньше, у Trustmark dealer — больше. Обозначим Individual за 0, Dealer за 1, Trustmark Dealer за 2.
Это немного плохо, потому что теперь у нас есть одинаковое "расстояние" между тремя типами, что вводит в небольшое заблуждение: грубо говоря, у нас теперь разница между Trustmark Dealer и Dealer эквивалентно разницы Dealer-а с Individual-ом. Более грамотным вариантом было бы внести коэффициенты хотя бы масштабируя средние, хотя и там свои подводные камны. Но всё же, для эксперимента и разнообразия, сделаем разочек LabelEncoding
mapping = {
'Individual': 0,
'Dealer': 1,
'Trustmark Dealer': 2
}
df_seller = df_fuel.copy()
df_seller['seller_type'] = df_seller['seller_type'].map(mapping)
Теперь Transmisson. Там всего 2 типа: 'Manual', 'Automatic'. Берем первое за 0, второе за 1.
mapping = {
'Manual': 0,
'Automatic': 1
}
df_transmission = df_seller.copy()
df_transmission['transmission'] = df_transmission['transmission'].map(mapping)
Остается owner. Там дела у нас обстоят таким образом.
pic
Что логично: чем меньше было владельцев, тем больше цена. Один к одному, как случай с продавцами. Чтож, сделаем очередной label encoding и получим финальный датафрейм с готовыми для линейной регрессии данными.
mapping = {
'Test Drive Car': 0,
'First Owner': 1,
'Second Owner': 2,
'Third Owner': 3,
'Fourth & Above Owner': 4
}
final_df = df_transmission.copy()
final_df['owner'] = final_df['owner'].map(mapping)
В конце у нас вышло 38 столбцов (37 фич / 1 таргет). Итоговый размер матрицы
pic
pic
. Примерно 160 тысяч чисел.
Сделаем теперь линейную регрессию.
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler
X = final_df.drop('selling_price', axis=1) # признаки (все столбцы кроме 'selling_price')
y = final_df['selling_price'] # целевая переменная (столбец 'selling_price')
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
random_state=100500)
# сделаем линейную регрессию и обучим модель на наших данных.
# не забудим про масштабирование данных для лучшей работы модели.
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
model = Ridge(alpha=1.0)
# alpha - это коэффициент регуляризации,
# который контролирует степень штрафа за большие коэффициенты (гиперпараметр)
# у нас он был обозначен через лямбду.
model.fit(X_train_scaled, y_train)
y_pred = model.predict(X_test_scaled)
mse = mean_squared_error(y_test, y_pred)
r2 = r2_score(y_test, y_pred)
print(f'Mean Squared Error: {mse:.2f}')
print(f'R^2 Score: {r2:.2f}')
rmse = mse ** 0.5 # корень из MSE, сколько в среднем ошибались в цене
print(f'RMSE: {rmse:.2f}')
>>> Mean Squared Error: 89174913379.40
>>> R^2 Score: 0.68
>>> RMSE: 298621.69
Что можем сказать о результатах?
  • pic
    — модель объясняет около 68% изменчивости цен автомобилей. Для простой линейной модели это достаточно неплохой результат.
  • pic
    — средняя квадратичная ошибка. Число большое, потому что ошибка измеряется в квадрате денежных единиц. Само по себе интерпретируется плохо.
  • pic
    — уже более понятная метрика: модель в среднем ошибается примерно на 300 тысяч при предсказании цены автомобиля.
    По сравнению с первой версией модели качество заметно выросло: feature engineering и регуляризация дали такой эффект.
Подведем итоги
В этой части мы разобрались с градиентным спуском, закончили с линейной регрессией и написали немного кода. В следующей части перейдем к задачам классификации. Там нас ждет много интересного: от KNN и почему он иногда хорош, до деревьев и лесов.-Источник
 
Loading...
Error