# --- # 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: 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: 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: 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: 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: 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)) — и 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: Разделить блокнот на два — очистка данных и анализ. # %% [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] # Предварительно подтверждена возможность определения рыночной цены автомобиля с пробегом # по использованным признакам, **в особенности** по следующим: исходная цена, возраст и пробег # автомобиля, тип продающего лица (дилер или частное лицо), топливо (автомобили на дизельном # топливе редко бывают дешёвыми). # # * Цена продажи с пробегом сильно линейно коррелирует с изначальной ценой. # # * Интересно, что возраст автомобиля является заметно лучшим предиктором снижения стоимости, # чем пробег, при этом корреляция между возрастом и пробегом существенная, но не определяющая. # # * Существует огромная разница в ценах у дилеров и частных лиц (у частных лиц дешевле в разы). # # * Существует слабая, но заметная прямая корреляция между изначальной ценой автомобиля и пробегом # к дате последующей продажи. # # * Датасет не очень однороден (у него есть "тяжёлый центр"), и с малым количеством объектов # это может создать проблемы с устойчивостью предсказания цен. Рекомендуется применение # робастных методов ограниченной сложности; однако прямая линейная регрессия для предсказания # цены проодажи может всё-таки оказаться не лучшим методом.