Решаем задачу
Для начала возьмем достаточно классический
датасет.
Здесь у нас восемь столбцов։
- 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
В Python
NaNобычно представлен объектом
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() показывает следующее:

первые пять строк
Видим как числовые, так и категориальные признаки. Сначала разберемся с числовыми, потом придумаем что делать с категориальными.
Через
df.describe().applymap(lambda x: f"{x:.2f}")видим следующее:

статистические данные о числовых признаках
Итак, год выпуска от 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()

тепловая карта (
heatmap) корреляции числовых признаков
Результаты достаточно ожидаемы. Разберем по очереди
- year и selling_price (Корреляция +0.41)
Логично? Логично.Машина 2018 года выпуска при прочих равных стоит дороже, чем ее уставший аналог из 2008-го. Однако 0.41 говорит нам о том, что год, конечно же, влияет, но не определяет цену.
- km_driven и selling_price (Корреляция -0.19)
Связь слабая и отрицательная (обратная). Оказывается, пробег очень плохо объясняет цену.
- year и km_driven (Корреляция -0.42)
Сильная отрицательная связь между самими признаками. Чем старше машина (меньше год), тем больше её пробег. Однако история та же, что и в пункте 1.
Одними числовыми признаками задачу нормально не решить. Но это нас не остановит и мы создадим модель линейной регрессии на 2х признаках.
Однако, до этого поговорим о такой метрике, как

score. Сразу отмечу, что это не квадрат некого

, а просто обозначение. При виде

, не надо думать что это некое комплексное число

, с квадратом, равным.
Итак,
Коэффициент детерминации (или R-квардат) это обозначение величины

где

— реальное значение целевой переменной для

-го объекта.

— значение, которое предсказала модель (наш

)

— среднее арифметическое всех реальных значений

(вычисляется как

).
Если

, то наша модель предсказывает всё идеально
Если

, то модель вполне себе рабочий (например,

означает, что модель объясняет 85% вариации).
Если

, значит у нас получился шайтан-модель. Она предсказывает всегда просто среднее значение целевой переменной.
Если же

, то модель работает хуже, чем предсказание по среднему значению. Значит где-то что-то не так.
Теперь вернемся к делу, напишем нашу первую модель с двумя признаками и оценим полученные MSE и

.
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
Модель в среднем ошибается на

. это примерно ~500 000 (валютных единиц цены автомобиля).

тоже так себе... Наша модель, конечно, лучше той шайтан-модели, которая всегда возвращает среднее, но успехом это не назовешь.
Однако, такой результат был вполне ожидаемым.
Так подключим категориальные признаки!
Проблема в том, что кампуктеру всё равно машина называется 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()

теперь вместо конкретных моделей у нас компании производителей
Теперь
df_company['company'].nunique() говорит, что у нас 29 уникальных компаний. У нас матрица

имеет размеры

. Если сделаем OHE с
drop_first=True и удалим столбец с названиями компаний новая матрица будет иметь размеры

. Это всего 143 тысяч элементов. Для сравнения, если бы мы сделали OHE для колонки name, получили бы матрицу

(около 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()

средние цены у разных типов продавцов
Как видим, 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. Там дела у нас обстоят таким образом.

Что логично: чем меньше было владельцев, тем больше цена. Один к одному, как случай с продавцами. Чтож, сделаем очередной 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 таргет). Итоговый размер матрицы


. Примерно 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
Что можем сказать о результатах?

— модель объясняет около 68% изменчивости цен автомобилей. Для простой линейной модели это достаточно неплохой результат.

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

— уже более понятная метрика: модель в среднем ошибается примерно на 300 тысяч при предсказании цены автомобиля.
По сравнению с первой версией модели качество заметно выросло: feature engineering и регуляризация дали такой эффект.