Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
iis-project/eda/cars_eda.py

718 строки
28 KiB
Python

# ---
# jupyter:
# jupytext:
# formats: ipynb,py:percent
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.17.3
# kernelspec:
# display_name: mpei-iis-project-99040779
# language: python
# name: mpei-iis-project-99040779
# ---
# %% [markdown]
# # Очистка и первичный анализ данных о подержанных автомобилях
# %% [markdown]
# **ОСТОРОЖНО**: Исполнение этого блокнота может тихо (пере)записать (и по умолчанию (пере)записывает) файлы очищенных/аугментированных
# данных. См. ниже параметры блокнота для papermill.
# %% [markdown]
# Блокнот использует файл сырых данных датасета о подержанных автомобилях ([источник](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars)). Блокнот записывает очищенные и аугментированные данные. См. ниже параметры блокнота для papermill.
# %%
from typing import Optional
# %% [markdown]
# Параметры блокнота для papermill:
# %% [markdown]
# (Ячейка с параметрами блокнота для papermill требует осторожного обращения; её содержимое парсится как текст очень простым парсером.)
# %% tags=["parameters"]
data_path: Optional[str] = None
# Полный путь к файлу (CSV) с исходным датасетом. Если не установлен, ищется файл в `data/<data_relpath>`.
data_relpath: str = 'cars.csv'
# Путь к файлу (CSV) с исходным датасетом относительно директории данных `data`. Игнорируется, если установлен data_path.
data_clean_csv: bool = True
# Сохранить ли очищенный датасет как CSV.
data_clean_csv_path: Optional[str] = None
# Полный путь к файлу (CSV) для сохранения очищенного датасета. Если не установлен, используется `data/<data_clean_csv_relpath>`.
data_clean_csv_relpath: str = 'cars.clean.csv'
# Путь к файлу (CSV) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_clean_csv_path.
data_clean_pickle: bool = False
# Сохранить ли очищенный датасет как pandas.DataFrame через pickle.
data_clean_pickle_path: Optional[str] = None
# Полный путь к файлу (pickle) для сохранения очищенного датасета. Если не установлен, используется `data/<data_clean_pickle_relpath>`.
data_clean_pickle_relpath: str = 'cars.clean.pickle'
# Путь к файлу (pickle) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_clean_pickle_path.
data_aug_csv: bool = False
# Сохранить ли аугментированный датасет как CSV.
data_aug_csv_path: Optional[str] = None
# Полный путь к файлу (CSV) для сохранения аугментированного датасета. Если не установлен, используется `data/<data_aug_csv_relpath>`.
data_aug_csv_relpath: str = 'cars.aug.csv'
# Путь к файлу (CSV) для сохранения аугментированного датасета относительно директории данных `data`. Игнорируется, если установлен data_aug_csv_path.
data_aug_pickle: bool = True
# Сохранить ли очищенный датасет как pandas.DataFrame через pickle.
data_aug_pickle_path: Optional[str] = None
# Полный путь к файлу (pickle) для сохранения очищенного датасета. Если не установлен, используется `data/<data_aug_pickle_relpath>`.
data_aug_pickle_relpath: str = 'cars.aug.pickle'
# Путь к файлу (pickle) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_aug_pickle_path.
# %%
# #%matplotlib ipympl
# %%
import os.path
import pathlib
import re
import sys
# %%
import bokeh.io
import bokeh.models
import bokeh.plotting
import bokeh.transform
import matplotlib.pyplot
import matplotlib.ticker
import numpy
import pandas
import seaborn
# %%
BASE_PATH = pathlib.Path('..')
# %%
CODE_PATH = BASE_PATH
sys.path.insert(0, str(CODE_PATH.resolve()))
# %%
import iis_project.plotting_utils
# %%
bokeh.io.output_notebook()
# %%
DATA_PATH = (
pathlib.Path(os.path.dirname(data_path))
if data_path is not None
else (BASE_PATH / 'data')
)
# %% [markdown] jp-MarkdownHeadingCollapsed=true
# ## Загрузка и обзор данных
# %%
df_orig = pandas.read_csv(data_path if data_path is not None else (DATA_PATH / data_relpath))
df_orig = df_orig.rename(columns=lambda s: re.sub(r'\s', '_', s.lower().replace(' ', '_')))
# %% [markdown]
# Обзор строк сырого датасета:
# %%
df_orig.head(0x10)
# %% [markdown]
# Размер сырого датасета:
# %%
len(df_orig)
# %% [markdown]
# Количество непустых значений и тип каждого столбца:
# %%
df_orig.info()
# %% [markdown]
# ## Первичный поиск аномалий и очистка данных
# %% [markdown]
# Подтверждение, что в датасете нет пустых значений:
# %%
all((len(s) == len(df_orig)) for _, s in df_orig.items())
# %%
quantitative_columns_orig = ['selling_price', 'present_price', 'driven_kms', 'year']
categorical_columns_orig = ['car_name', 'fuel_type', 'selling_type', 'transmission', 'owner']
# %%
for column in filter(lambda s: s not in ('car_name',), categorical_columns_orig):
# XXX: по идее, переименования категорий стоило бы делать после преобразования к типу категории,
# Series.cat.rename_categories. Однако... оно просто не работает.
if pandas.api.types.is_object_dtype(df_orig[column].dtype):
df_orig[column] = df_orig[column].map(
lambda s: ' '.join(map(lambda s2: s2.lower(), s.split()))
)
df_orig[column] = df_orig[column].astype('category')
# %%
def normalize_car_name(s):
return ' '.join(map(lambda s: s.lower(), s.split()))
# %% [markdown]
# Нормализация текстовых названий моделей автомобилей:
# %%
df_orig['car_name'] = df_orig['car_name'].apply(normalize_car_name)
# %% [markdown]
# Первичные статистики по количественным признакам:
# %%
df_orig[list(quantitative_columns_orig)].describe()
# %% [markdown]
# Категориальные признаки:
# %%
categorical_values_for_columns_orig = {
column: series.unique()
for column, series in df_orig[list(categorical_columns_orig)].items()
}
for column, values in categorical_values_for_columns_orig.items():
if len(values) <= 0x10:
values_str = ', '.join(map(repr, values))
else:
values_str = f'({len(values)} values)'
print(f'{column!r}: {values_str}')
# %% [markdown] raw_mimetype=""
# Просмотр распределений по отдельным признакам на предмет аномалий:
# %%
_fig, _axis = matplotlib.pyplot.subplots(2, 1, squeeze=True)
for i, (column, series) in enumerate(df_orig[['selling_price', 'present_price']].items()):
_ax = _axis[i]
_ax.set_title(str(column))
#_ax.set_xscale('symlog')
_ax.set_yscale('log')
_ax.grid(True)
_ = _ax.hist(series, bins=iis_project.plotting_utils.suggest_bins_num(len(series)))
_fig.tight_layout()
# %% [markdown]
# Есть 1 объект, вероятно, аномальный по `present_price`, но это неоднозначно.
# %%
for column, series in df_orig[['driven_kms']].items():
_fig, _ax = matplotlib.pyplot.subplots()
_ax.set_title(str(column))
#_ax.set_xscale('symlog')
_ax.set_yscale('log')
_ax.grid(True)
_ = _ax.hist(series, bins=iis_project.plotting_utils.suggest_bins_num(len(series)))
# %% [markdown]
# Есть 1 аномальный объект по `driven_kms` (аномально большой пробег).
# %%
for column, series in df_orig[['year']].items():
_fig, _ax = matplotlib.pyplot.subplots()
_ax.set_title(str(column))
_ax.set_yscale('log')
_ax.grid(True)
_ = _ax.hist(series, bins=iis_project.plotting_utils.suggest_bins_num(len(series)))
# %%
for column, series in df_orig[
list(filter(lambda s: s not in ('car_name',), categorical_columns_orig))
].items():
_fig, _ax = matplotlib.pyplot.subplots()
_ax.set_title(str(column))
_ax.set_yscale('log')
_ax.grid(True)
value_counts = series.value_counts()
_ = _ax.bar(tuple(map(str, value_counts.index)), value_counts)
# %% [markdown]
# Есть 2 исключительных объекта по топливу `fuel_type` (автомобиль на природном газе
# (CNG)) &mdash; и 1 исключительный объект по типу владельца `owner` (3).
# %% [markdown]
# Внимательное рассмотрение аномальных объектов:
# %%
labels_to_drop_from_orig = []
# %%
df_orig.loc[df_orig['owner'].isin((3,))]
# %% [markdown]
# Объект, исключительный по `owner` (3), исключается из датасета, не в последнюю очередь
# из-за неясности смысла значений `owner`.
# %%
labels_to_drop_from_orig.extend(df_orig.loc[df_orig['owner'].isin((3,))].index)
# %%
df_orig.loc[(df_orig['present_price'] >= 60.) | (df_orig['driven_kms'] >= 400000) | (df_orig['fuel_type'].isin(('CNG',)))]
# %% [markdown]
# Аномально большой пробег у автомобиля #196 ещё и является очень круглым числом. Этот объект
# исключается из датасета как вероятно недостоверный.
#
# 2 автомобиля на природном газе решено пока не исключать, т.к. с учётом и так малого размера
# датасета возможность хоть как-то предсказывать цены для автомобилей на природном газе сочтена
# сравнительно ценной.
#
# Автомобиль с исключительно высокой `present_price` не показывает других аномалий (у него и `selling_price` высокая в датасете), поэтому он оставлен.
# %%
labels_to_drop_from_orig.extend((196,))
# %%
df_clean = df_orig.drop(labels_to_drop_from_orig)
# %%
#bokeh_source_df_clean = bokeh.models.ColumnDataSource(df_clean)
# %% [markdown]
# По названиям и распределениям признаков `selling_price` и `present_price` есть большое подозрение,
# что это изначальная цена и цена продажи с пробегом, но перепутанные местами.
# %% [raw] raw_mimetype=""
# present_price_ratio = df_clean['selling_price'] / df_clean['present_price']
#
# _fig, _ax = matplotlib.pyplot.subplots()
# _ = _ax.set_xlabel('driven_kms')
# _ = _ax.set_ylabel('selling_price / present_price')
# _ax.set_xscale('log')
# _ax.grid(True)
# _ = _ax.scatter(df_clean['driven_kms'], present_price_ratio, alpha=0.5)
# _ = _ax.set_ylim((0., None))
# %%
_src = bokeh.models.ColumnDataSource({
**dict(df_clean[['car_name', 'driven_kms', 'year', 'selling_price', 'present_price']].items()),
'present_price_ratio': (df_clean['selling_price'] / df_clean['present_price']),
})
_fig = bokeh.plotting.figure(
x_axis_type='log',
x_axis_label='driven_kms', y_axis_label='selling_price / present_price',
)
_ = _fig.scatter(
'driven_kms', 'present_price_ratio',
source=_src,
size=12., alpha=0.5,
)
_fig.y_range.start = 0
_fig.add_tools(
bokeh.models.HoverTool(
tooltips=[
('present price', '@present_price'),
('selling price ', '@selling_price'),
('name', '@car_name'),
('year', '@year',),
('index', '$index',)
],
),
)
bokeh.plotting.show(_fig)
# %% [markdown]
# Указанное выше подозрение подтверждается, `selling_price` и `present_price` нужно поменять местами.
# %%
df_clean[['selling_price', 'present_price']] = df_clean[['present_price', 'selling_price']]
# %% [markdown] jp-MarkdownHeadingCollapsed=true
# ## Сохранение очищенных данных
# %%
if data_clean_csv:
df_clean.to_csv(
(
data_clean_csv_path
if data_clean_csv_path is not None
else (DATA_PATH / data_clean_csv_relpath)
),
index=False,
)
# %%
if data_clean_pickle:
import pickle
with open(
(
data_clean_pickle_path
if data_clean_pickle_path is not None
else (DATA_PATH / data_clean_pickle_relpath)
),
'wb',
) as out_file:
pickle.dump(df_clean, out_file)
# %%
df = df_clean.copy(deep=False)
# %% [markdown]
# TODO: Разделить блокнот на два &mdash; очистка данных и анализ.
# %% [markdown]
# ## Анализ и аугментация данных
# %%
quantitative_columns = ['selling_price', 'present_price', 'driven_kms', 'year']
categorical_columns = ['car_name', 'fuel_type', 'selling_type', 'transmission', 'owner']
# %% [markdown]
# Количество объектов:
# %%
len(df)
# %% [markdown]
# Количество непустых значений и тип каждого столбца:
# %%
df.info()
# %% [markdown]
# Добавим синтетический признак возраста. Для отсчёта предполагается актуальность датасета на 2019 год;
# учитывая, что год в датасете задан целым числом, это даёт возраст минимум 1 год для каждого объекта.
# %%
df['age'] = ((max(df['year']) + 1) - df['year']).astype(float)
quantitative_columns.append('age')
# %% [markdown]
# Вообще говоря, отношение `present_price / selling_price` может быть полезно при изучении данных
# или обучении моделей. Добавим такой признак `present_price_ratio`:
# %%
df['present_price_ratio'] = df['present_price'] / df['selling_price']
quantitative_columns.append('present_price_ratio')
# %% [markdown]
# Добавим некоторые потенциально полезные синтетические признаки - логарифмы некоторых количественных признаков:
# %%
for column in ('selling_price', 'present_price', 'driven_kms'):
df[f'log_{column}'] = numpy.log10(df[column])
df['log_age'] = numpy.log10(numpy.maximum(1., df['age']))
# %%
bokeh_source_df = bokeh.models.ColumnDataSource(df)
# %% [markdown]
# Первичные статистики по количественным признакам:
# %%
df[list(quantitative_columns)].describe()
# %% [markdown]
# Категориальные признаки:
# %%
categorical_values_for_columns = {
column: series.unique()
for column, series in df[list(categorical_columns)].items()
}
for column, values in categorical_values_for_columns.items():
if len(values) <= 0x10:
values_str = ', '.join(map(repr, values))
else:
values_str = f'({len(values)} values)'
print(f'{column!r}: {values_str}')
# %% [markdown]
# Матрица корреляций количественных признаков:
# %%
df_corr = df[[
'present_price', 'log_present_price',
'selling_price', 'log_selling_price',
'present_price_ratio',
'driven_kms', 'log_driven_kms',
'age', 'log_age',
]].corr('pearson', min_periods=2)
# %% [markdown]
# Из неочевидных выводов по матрице корреляций, существует слабая, но заметная прямая корреляция
# между изначальной ценой автомобиля и пробегом к дате последующей продажи.
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_im = _ax.imshow(df_corr.to_numpy(), cmap='RdBu', vmin=-1., vmax=+1., origin='upper')
_ax.tick_params(top=True, labeltop=True, bottom=False, labelbottom=False)
_ = _ax.set_xticks(range(len(df_corr.columns)), labels=df_corr.columns, rotation=90.)
_ = _ax.set_yticks(range(len(df_corr.index)), labels=df_corr.index)
if len(df_corr.columns) <= 10:
for i in range(len(df_corr.index)):
for j in range(len(df_corr.columns)):
if i == j:
continue
val = df_corr.iloc[(i, j)]
_ = _ax.text(
j, i, '{:+.2f}'.format(val),
ha='center', va='center',
color=('white' if abs(val) >= 0.5 else 'black'),
fontsize='small',
)
else:
_ = _fig.colorbar(_im)
# %% [markdown]
# Совместное распределение изначальной цены продажи и цены с пробегом:
# %% [raw] raw_mimetype=""
# _fig, _ax = matplotlib.pyplot.subplots()
# _ = _ax.set_xlabel('selling_price')
# _ = _ax.set_ylabel('present_price')
# _ax.grid(True)
# _ = seaborn.histplot(
# x=df['selling_price'], y=df['present_price'],
# bins=tuple(
# numpy.geomspace(
# min(series), max(series),
# (iis_project.plotting_utils.suggest_bins_num(len(series)) + 1),
# )
# for _, series in df[['selling_price', 'present_price']].items()
# ),
# #thresh=None, palette='Blues',
# cbar=True,
# #hue_norm=matplotlib.colors.LogNorm(0, 64),
# ax=_ax,
# )
# _ax.set_xscale('log')
# _ax.set_yscale('log')
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_ = _ax.set_xlabel('selling_price')
_ = _ax.set_ylabel('present_price')
_ax.set_xscale('log')
_ax.set_yscale('log')
_ax.grid(True)
_scatter = _ax.scatter(
df['selling_price'], df['present_price'], c=df['selling_type'].cat.codes,
alpha=0.5,
)
_ = _ax.legend(
*_scatter.legend_elements(
fmt=matplotlib.ticker.FuncFormatter(lambda i, _: df['selling_type'].cat.categories[int(i)]),
),
)
# %% [markdown]
# Существует очень сильное разделение по ценам при продаже подержанных автомобилей дилерами
# и частными лицами; частные лица обычно продают автомобили намного дешевле, чем дилеры (хотя этот
# вывод не учитывает возможное влияние других переменных).
# %% [markdown]
# Совместные распределения отношения цены с пробегом к изначальной, пробега, года выпуска:
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_ = _ax.set_xlabel('driven_kms')
_ = _ax.set_ylabel('present_price_ratio')
_ax.grid(True)
_ = seaborn.histplot(
x=df['driven_kms'], y=df['present_price_ratio'],
bins=(
numpy.geomspace(
min(df['driven_kms']), max(df['driven_kms']),
(iis_project.plotting_utils.suggest_bins_num(len(df['driven_kms'])) + 1),
),
iis_project.plotting_utils.suggest_bins_num(len(df['present_price_ratio'])),
),
#thresh=None, palette='Blues',
cbar=True,
#hue_norm=matplotlib.colors.LogNorm(0, 64),
ax=_ax,
)
_ax.set_xscale('log')
_ = _ax.set_ylim((0., None))
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_ = _ax.set_xlabel('age')
_ = _ax.set_ylabel('present_price / selling_price')
_ax.grid(True)
_ = seaborn.histplot(
x=df['age'], y=df['present_price_ratio'],
bins=(
iis_project.plotting_utils.suggest_bins_num(len(df['age'])),
iis_project.plotting_utils.suggest_bins_num(len(df['present_price_ratio'])),
),
#thresh=None, palette='Blues',
cbar=True,
#hue_norm=matplotlib.colors.LogNorm(0, 64),
ax=_ax,
)
#_ax.set_xscale('log')
_ = _ax.set_ylim((0., None))
# %% [raw] raw_mimetype=""
# _fig, _ax = matplotlib.pyplot.subplots()
# _ = _ax.set_xlabel('age')
# _ = _ax.set_ylabel('driven_kms')
# _ax.grid(True)
# _ = seaborn.histplot(
# x=df['age'], y=df['driven_kms'],
# bins=(
# iis_project.plotting_utils.suggest_bins_num(len(df['age'])),
# numpy.geomspace(
# min(df['driven_kms']), max(df['driven_kms']),
# (iis_project.plotting_utils.suggest_bins_num(len(df['driven_kms'])) + 1),
# ),
# ),
# #thresh=None, palette='Blues',
# cbar=True,
# #hue_norm=matplotlib.colors.LogNorm(0, 64),
# ax=_ax,
# )
# #_ax.set_xscale('log')
# _ax.set_yscale('log')
# %%
_fig = bokeh.plotting.figure(
x_axis_type='linear', y_axis_type='log',
x_axis_label='age', y_axis_label='driven_kms',
)
_mapper = bokeh.models.LinearColorMapper(
palette='Viridis256',
low=min(0., df['present_price_ratio'].min()), high=max(1., df['present_price_ratio'].max()),
)
_ = _fig.scatter(
'age', 'driven_kms',
color=bokeh.transform.transform('present_price_ratio', _mapper),
source=bokeh_source_df,
size=12., alpha=0.75,
)
_fig.add_tools(
bokeh.models.HoverTool(
tooltips=[
('present price', '@present_price'),
('selling price ', '@selling_price'),
('year', '@year',),
('index', '$index',),
],
),
)
_fig.add_layout(bokeh.models.ColorBar(color_mapper=_mapper), 'right')
bokeh.plotting.show(_fig)
# %% [markdown]
# В частности, существует сильная связь между и возрастом автомобиля и отношением цены с пробегом
# к изначальной. Довольно много объектов, но далеко не все, сосредоточены около одной точки
# в пространстве данных.
# %% [markdown]
# Распределения цен с пробегом по категориям:
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_ = _ax.set_yscale('log')
_ax.grid(True)
_ = df.boxplot('present_price', by='fuel_type', ax=_ax, patch_artist=False)
#_ax.set_ylim((0, None))
# %% [markdown]
# Автомобили на дизеле дороже автомобилей на бензине, а также реже продаются
# за крайне низкие цены.
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_ = _ax.set_yscale('log')
_ax.grid(True)
_ = df.boxplot('present_price', by='selling_type', ax=_ax, patch_artist=False)
#_ax.set_ylim((0, None))
# %% [markdown]
# Частные лица обычно продают автомобили намного дешевле, чем дилеры (хотя этот вывод не учитывает
# возможное влияние других переменных). Отмечены множество автомобилей, продаваемых дилерами
# по особо высоким ценам.
# %% [markdown]
# Проверка возможной систематической разницы в пробеге между категориями:
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_ = _ax.set_yscale('log')
_ax.grid(True)
_ = df.boxplot('driven_kms', by='fuel_type', ax=_ax, patch_artist=False)
#_ax.set_ylim((0, None))
# %%
_fig, _ax = matplotlib.pyplot.subplots()
_ = _ax.set_yscale('log')
_ax.grid(True)
_ = df.boxplot('driven_kms', by='selling_type', ax=_ax, patch_artist=False)
#_ax.set_ylim((0, None))
# %% [markdown]
# Большой систематической разницы в пробеге между категориями нет.
# %% [markdown]
# ## Сохранение аугментированных данных
# %%
if data_aug_csv:
df.to_csv(
(data_aug_csv_path if data_aug_csv_path is not None else (DATA_PATH / data_aug_csv_relpath)),
index=False,
)
# %%
if data_aug_pickle:
import pickle
with open(
(
data_aug_pickle_path
if data_aug_pickle_path is not None
else (DATA_PATH / data_aug_pickle_relpath)
),
'wb',
) as out_file:
pickle.dump(df, out_file)
# %% [markdown]
# ## Выводы по исследованию
# %% [markdown]
# Выполнена очистка датасета: удалены несколько аномальных объектов, переименованы некоторые
# ошибочно названные признаки. (Пропущенных значений в датасете нет.)
# %% [markdown]
# Датасет дополнен (аугментирован) потенциально полезными синтетическими признаками:
# отношение цены с пробегом к изначальной цене, возраст (предполагаемый на основе года выпуска
# автомобиля и распределения этих годов выпуска в датасете), логарифмы количественных величин.
# Аугментированная версия сохраняется отдельно.
# %% [markdown]
# Предварительно подтверждена возможность определения рыночной цены автомобиля с пробегом
# по использованным признакам, **в особенности** по следующим: исходная цена, возраст и пробег
# автомобиля, тип продающего лица (дилер или частное лицо), топливо (автомобили на дизельном
# топливе редко бывают дешёвыми).
#
# * Цена продажи с пробегом сильно линейно коррелирует с изначальной ценой.
#
# * Интересно, что возраст автомобиля является заметно лучшим предиктором снижения стоимости,
# чем пробег, при этом корреляция между возрастом и пробегом существенная, но не определяющая.
#
# * Существует огромная разница в ценах у дилеров и частных лиц (у частных лиц дешевле в разы).
#
# * Существует слабая, но заметная прямая корреляция между изначальной ценой автомобиля и пробегом
# к дате последующей продажи.
#
# * Датасет не очень однороден (у него есть "тяжёлый центр"), и с малым количеством объектов
# это может создать проблемы с устойчивостью предсказания цен. Рекомендуется применение
# робастных методов ограниченной сложности; однако прямая линейная регрессия для предсказания
# цены проодажи может всё-таки оказаться не лучшим методом.