Этот пост посвящен моему подходу к проблеме прогнозирования продаж Bigmart с использованием Python.

У меня было собеседование, где мне прислали эту проблему в качестве задачи для решения. Прежде всего, посмотрите на BigMart описание проблемы:

Исследователи данных из BigMart собрали данные о продажах 1559 товаров в 10 магазинах в разных городах за 2013 год. Также были определены определенные атрибуты каждого продукта и магазина. Цель состоит в том, чтобы построить прогностическую модель и выяснить продажи каждого товара в конкретном магазине.

Используя эту модель, BigMart попытается понять свойства продуктов и магазинов, которые играют ключевую роль в увеличении продаж.

Вы можете найти набор данных здесь

Итак, мы хотим прогнозировать продажи, используя информацию BigMart. Что у нас есть в качестве записей?

train.info()
#   Column                     Non-Null Count  Dtype  
---  ------                     --------------  -----  
 0   Item_Identifier            8523 non-null   object 
 1   Item_Weight                7060 non-null   float64
 2   Item_Fat_Content           8523 non-null   object 
 3   Item_Visibility            8523 non-null   float64
 4   Item_Type                  8523 non-null   object 
 5   Item_MRP                   8523 non-null   float64
 6   Outlet_Identifier          8523 non-null   object 
 7   Outlet_Establishment_Year  8523 non-null   int64  
 8   Outlet_Size                6113 non-null   object 
 9   Outlet_Location_Type       8523 non-null   object 
 10  Outlet_Type                8523 non-null   object 
 11  Item_Outlet_Sales          8523 non-null   float64

Мы можем видеть некоторые непрерывные атрибуты, такие как Item_Weight, Item_Visibility и Item_MRP. Item_Outlet_Sales — это то, что мы хотим предсказать. С другой стороны, хотя Outlet_Establishment_Year имеет тип int, он указывает год открытия магазина, поэтому мы будем считать его категоричным. Кроме того, мы видим, что Outlet_Size и Item_Weight имеют некоторые пропущенные значения. Мы поработаем над этим позже.

Кратко опишем каждый атрибут:

  • Item_Identifier: код для идентификации продукта.
  • Item_Weight: вес товара.
  • Item_Fat_Content: классификация продукта по жирности.
  • Item_Visibility: показатель, который относится к знанию товара
    о продукте у потребителя. Насколько легко можно найти продукт?
  • Item_MRP: максимальная розничная цена. Цена, рассчитанная производителем, с указанием максимальной цены, которую можно взимать за продукт.
  • Outlet_Identifier: идентификатор магазина.
  • Outlet_Establishment_Year: год открытия магазина.
  • Outlet_Size: размер магазина.
  • Outlet_Location_Type: классификация магазинов по расположению.
  • Outlet_Type: тип магазина.
  • Item_Outlet_Sales: продажи товара в каждом наблюдении.

Теперь мы знаем, какие атрибуты непрерывны, а какие категоричны. Давайте посмотрим, какие разные категории у нас есть в каждом категориальном атрибуте:

train["Item_Fat_Content"].unique().tolist()
train["Item_Type"].unique().tolist()
train["Outlet_Identifier"].unique().tolist()
train["Outlet_Size"].unique().tolist()
train["Outlet_Location_Type"].unique().tolist()
train["Outlet_Type"].unique().tolist()
train["Outlet_Establishment_Year"].unique().tolist()
['Low Fat', 'Regular', 'low fat', 'LF', 'reg']
['Dairy', 'Soft Drinks', 'Meat', 'Fruits and Vegetables', 'Household', 'Baking Goods', 'Snack Foods', 'Frozen Foods', 'Breakfast', 'Health and Hygiene', 'Hard Drinks', 'Canned', 'Breads', 'Starchy Foods', 'Others', 'Seafood']

['OUT049', 'OUT018', 'OUT010', 'OUT013', 'OUT027', 'OUT045', 'OUT017', 'OUT046', 'OUT035', 'OUT019']
['Medium', nan, 'High', 'Small']

['Tier 1', 'Tier 3', 'Tier 2']

['Supermarket Type1', 'Supermarket Type2', 'Grocery Store', 'Supermarket Type3']

[1999, 2009, 1998, 1987, 1985, 2002, 2007, 1997, 2004]

Мы видим, что Item_Fat_Content имеет несоответствие в том, как загружались обычные и обезжиренные продукты. Давайте заменим «с низким содержанием жира», «с низким содержанием жира» на «LF» и «обычный», «reg» на «R».

train['Item_Fat_Content'] = train['Item_Fat_Content'].replace('Low Fat', 'LF').replace('low fat', 'LF').replace('reg', 'R').replace('Regular', 'R')

Непрерывные атрибуты

Теперь давайте проанализируем непрерывные атрибуты. В первую очередь полезно посмотреть их гистограммы.

Хотя Item_Visibility выглядит нормально, если мы обратим внимание на низкие значения видимости, мы заметим большое скопление элементов с нулевой видимостью. Это не имеет смысла, поскольку нулевая видимость означает, что мы не можем увидеть товар в магазине! Итак, Item_Visibility имеет пропущенные значения.

len(train['Item_Visibility'][train['Item_Visibility']==0]))
Zero visibility items 526

Теперь давайте разберемся с отсутствующими значениями в этих непрерывных атрибутах. У нас есть несколько стратегий для решения этой проблемы. Мы можем удалить записи с отсутствующими значениями, заменить их общим средним значением или заменить их более конкретным средним значением. Что я имею в виду под более конкретным значением? Посмотрим среднее значение Item_Visibility и Item_Weight по Item_Type:

Действительно, есть разница между средним содержанием жира и видимостью в зависимости от типа продукта. Чтобы заменить их полученным средним, мы могли бы использовать следующую функцию:

def MeanImputer_NA(data, data_t, atr, atr_filter):
    '''
    Mean Imputer for NA values
  
    Parameters
    ----------
    data : pandas.dataframe
      The training dataframe to process.
    data_t : pandas.dataframe
      The validaiton/test dataframe to process.
    atr: str
      Attribute that contains NA values.
    atr_filter : str
      Attribute used to filter data and compute mean value.
Returns
    -------
    data : pandas.dataframe
      Preprocessed data.
    data_t : pandas.dataframe
      Preprocessed data_t.
    '''
    key_filter = data[atr_filter].unique()
    for key in key_filter:
        mean = np.mean(data[atr][data[atr_filter] == key])
        data[atr][data[atr_filter] == key] = data[atr][data[atr_filter] == key].fillna(mean)
        data_t[atr][data_t[atr_filter] == key] = data_t[atr][data_t[atr_filter] == key].fillna(mean)
    return data, data_t

Хорошо, что мы можем сделать дальше? Возможно, мы могли бы попытаться проверить, будет ли какой-либо из непрерывных атрибутов важен для регрессии. Получим корреляционную матрицу.

Мы видим, что item_MRP имеет высокую корреляцию с Item_Outlet_Sales, поэтому, вероятно, это будет хорошая оценка. С другой стороны, Item_Visibility и Item_Weight не кажутся полезными, но так ли это? Давайте получим несколько диаграмм рассеяния.

Я не знаю, что вы, ребята, думаете, но я вижу, что товары с нормальным весом (я имею в виду не слишком тяжелые и не слишком легкие) покупаются больше. С другой стороны, предметы с низкой видимостью покупаются чаще, чем предметы с высокой видимостью, что довольно странно, если учесть описание этого атрибута.

Категориальные атрибуты

Теперь давайте разберемся с отсутствующими значениями в Outlet_Size. Прежде всего, я думаю, важно знать, что магазины с пропущенными значениями размера принадлежат трем разным торговым точкам:

train[‘Outlet_Identifier’][train[‘Outlet_Size’].isna()].value_counts()
OUT045    929
OUT017    926
OUT010    555

Почему это важно? Посмотрим позже. А пока давайте сосредоточимся на том, как мы справляемся с этим. Опять же, мы можем удалить экземпляры с отсутствующими значениями, заменить их категорией общего среднего или попробовать более разумный подход. Мы обучим классификатор для определения размера этих магазинов. Обучающий набор будет состоять из всех записей без пропущенных значений, а набор обобщения будет состоять из записей с пропущенными значениями. Я выбрал KNN для этого и перекрестную проверку для поиска по гиперпараметрам.

from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import GridSearchCV
KNN=KNeighborsClassifier()
idx=list(np.arange(5, 100, 5))
param_KNN = [{'n_neighbors': idx}]
search_KNN = GridSearchCV(KNN, 
                          param_KNN, 
                          cv=5, 
                          scoring='balanced_accuracy', #se podría decir que hay un poco de data imbalance
                          return_train_score=True,
                         verbose=1)
search_KNN.fit(x_train_dummies, y_train_aux)

X_train_dummies — это тренировочный набор, в котором я сделал один горячий кодировщик для категориальных атрибутов, а y_train_aux — это размер выхода. После обучения прогнозируемый размер розетки магазинов с отсутствующими значениями:

Outlet size 010:
Medium    274
Small     252
High       29
Name: Outlet_Size, dtype: int64

Outlet size 045:
Medium    561
Small     346
High       22
Name: Outlet_Size, dtype: int64

Outlet size 017:
Medium    542
Small     348
High       36
Geneneral outlet sizes:
Medium    2793
Small     2388
High       932

Обратите внимание, что я также напечатал общие размеры розеток. Это необходимо для того, чтобы учесть соотношение между различными размерами. Теперь мы уверены, что торговые точки 017 и 045 среднего размера. С другой стороны, магазин 010 может быть магазином малого или среднего размера.

Мы могли бы заполнить пропущенные значенияoutlet_size предсказаниями классификатора, но было бы бессмысленно, если бы один и тот же магазин имел три разных размера. Следовательно, нам нужно заполнить output_size магазинов 017 и 045 значением «Средний». Что касается магазина 010, нам нужно больше расследовать. Мы видим, что есть два магазина, которые являются продуктовыми магазинами: 010 и 019. По здравому смыслу продуктовые магазины — это небольшие магазины. Чтобы быть более уверенным, давайте посмотрим размер хранилища 019:

train['Outlet_Identifier'][train['Outlet_Type']=='Grocery Store'].value_counts()
Stores that are grocerys:
OUT010    555
OUT019    528
Name: Outlet_Identifier, dtype: int64
train['Outlet_Size'][train['Outlet_Identifier']=='OUT019'].value_counts()
Size of 019:
Small    528

Кроме того, мы можем проверить продажи торговых точек по магазинам и по типам магазинов.

Опять же, кажется правильным, что магазин 010 — это небольшой магазин. Поэтому давайте заменим Outlet_size магазинов 017 и 045 на «Средний», а Outlet_size магазина 010 на «Малый».

Обучение

Прежде всего, давайте определим некоторые общие функции, которые помогут нам реализовать всю необходимую предварительную обработку:

def preprocessing(dataframe, dataframe_t, droppable_columns, integer_columns, categorical_columns, objective, transform=None, one_hot=False):
    '''
    Preprocessing function for the proposed dataframe.
This functions modifies the dataframe from of the training set and the dataframe of the validation/test set to get categorical columns from strings,
    dropping non-informative attributes, and rescaling continuous attributes.
Parameters
    ----------
    dataframe : pandas.dataframe
      The training dataframe to process.
    dataframe_t : pandas.dataframe
      The validaiton/test dataframe to process.
    droppable_columns : list
      List of droppable column strings.
    continuous_columns : list
      List of continuous column strings.
    categorical_columns : list
      List of categorical column strings.
Returns
    -------
    dataframe : pandas.dataframe
      Preprocessed dataframe.
    dataframe_t : pandas.dataframe
      Preprocessed dataframe_t.
    '''
for c in droppable_columns:
        dataframe = dataframe.drop(c, axis="columns")
        dataframe_t = dataframe_t.drop(c, axis="columns")
    for c in categorical_columns:
        if c == 'Item_Fat_Content':
            dataframe[c] = dataframe[c].replace('Low Fat', 'LF').replace('low fat', 'LF').replace('reg', 'R').replace('Regular', 'R')
            dataframe_t[c] = dataframe_t[c].replace('Low Fat', 'LF').replace('low fat', 'LF').replace('reg', 'R').replace('Regular', 'R')
        if c == 'Outlet_Size':
            dataframe[c], dataframe_t[c] = MeanImputer_Size(dataframe, dataframe_t, 'Outlet_Size', 'Outlet_Identifier')            
        dataframe[c] = dataframe[c].astype("category", copy=False)
        dataframe_t[c] = dataframe_t[c].astype("category", copy=False)
    for c in continuous_columns:
        if c == 'Item_Weight':
            dataframe[c], dataframe_t[c] = MeanImputer_NA(dataframe, dataframe_t, 'Item_Weight', 'Item_Type')
        if c == 'Item_Visibility':
            dataframe[c], dataframe_t[c] = MeanImputer_0(dataframe, dataframe_t, 'Item_Visibility', 'Item_Type')
        dataframe[c], dataframe_t[c] = StandarScaler(dataframe[c].astype('float64', copy=False), dataframe_t[c].astype('float64', copy=False))
    if one_hot:
        dataframe = pd.get_dummies(dataframe, categorical_columns)
        dataframe_t = pd.get_dummies(dataframe_t, categorical_columns)
    if transform:
        return dataframe.drop(objective, axis="columns"), dataframe_t.drop(objective, axis="columns"), transform(dataframe[objective]), transform(dataframe_t[objective])
    return dataframe.drop(objective, axis="columns"), dataframe_t.drop(objective, axis="columns"), dataframe[objective], dataframe_t[objective]

Теперь давайте используем LightGBM в качестве регрессора для этой задачи. Я выполнил поиск по гиперпараметрам путем перекрестной проверки. Кроме того, я разделил набор поездов, предоставленный BigMart, на поезд и тестовый набор. Я использовал RMSE в качестве функции потерь.

from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.metrics import mean_squared_error as mse
from sklearn.model_selection import KFold
import lightgbm as lgb
train = pd.read_csv('data/Train_BigMart.csv')
droppable_columns = ['Item_Identifier'] 
continuous_columns = ['Item_Weight', 'Item_Visibility', 'Item_MRP']
categorical_columns = ['Item_Type',  'Outlet_Location_Type', 'Outlet_Type', 'Outlet_Size', 'Outlet_Identifier', 'Item_Fat_Content']
objective = 'Item_Outlet_Sales'
train, test = train_test_split(train, test_size=1000)
x_train, x_test, y_train, y_test = preprocessing(train, test, droppable_columns, continuous_columns, categorical_columns, objective)
model = lgb.LGBMRegressor(random_state=42, 
                             objective="rmse",
                             silent=True
                             )
parameters = {
    'task': ['train'],
    'boosting_type': ['gbdt'],
    'objective': ['regression'],
    'metric': ['rmse'],
    'learning_rate': [0.005],
    'feature_fraction': [0.8, 0.9, 1],
    'bagging_fraction': [0.7, 0.85, 1],
    'bagging_freq': [10],
    'verbose': [0],
    "num_leaves": [128],  
    "max_bin": [512],
    "max_depth": [6, 8, 10],
    "silent": [True],
    "n_estimators": [1000],
    'n_iterations:': [10000]
}
n_splits = 5
kfold_5 = KFold(shuffle = True, n_splits = n_splits, random_state=42)
search = GridSearchCV(model, 
                           parameters, 
                           cv=kfold_5, 
                           scoring='neg_root_mean_squared_error', 
                           return_train_score=True, 
                           n_jobs=-1,
                           verbose=True)
search.fit(x_train, y_train)

Мы будем использовать все атрибуты, кроме Item_Identifier. В нем более 1600 категорий, что делает его бесполезным из-за проклятия размерности. Зачем использовать остальные атрибуты? Ну, мы можем думать примерно так:

  • Item_Weight: мы видели, что люди склонны покупать товары среднего веса.
  • Item_Fat_Content: гипотезой может быть то, что люди хотят заботиться о своем здоровье и покупать продукты с меньшим содержанием жира.
  • Item_Visibility: мы заметили, что люди, как правило, покупают товары с низкой видимостью, что было странно.
  • Item_Type: гипотеза может состоять в том, что люди, как правило, покупают предметы первой необходимости, такие как еда, и менее роскошные предметы, такие как крепкие напитки.
  • Item_MRP: мы увидели корреляцию между этим атрибутом и продажами.
  • Outlet_Identifier: мы пока оставляем это, потому что у меня нет веских причин отказываться от этого.
  • Outlet_Establishment_Year: можно предположить, что люди склонны покупать больше в известных магазинах.
  • Outlet_Size: можно предположить, что люди склонны покупать больше в крупных магазинах, где есть разнообразие товаров.
  • Outlet_Location_Type: гипотеза может состоять в том, что люди, как правило, покупают больше в магазинах в центре города, чем на окраинах города.
  • Outlet_Type: это может сжать информацию о Outlet_location_type, Outlet_size и Outlet_stitution_year.

После тренировки мы получили такие результаты:

Total predicted test: 2178685; Total real test: 2192608
Total predicted training: 16401934, Total real training: 16398516
RMSE test: 1046.73

Мы видим, что, хотя сумма прогнозируемого количества продаж похожа на реальную сумму продаж, мы недооцениваем товары, которые покупаются много.

Давайте посмотрим, какие функции важны для LightGBM.

Мы видим, чтоoutlet_type,outlet_location_type и item_fat_contentменее важны, чем остальные атрибуты, так что давайте опустим их. Спойлер: результаты не лучше.

Total predicted test: 2217288, Total real test: 2210842
Total predicted training: 16368628, Total real training: 16380282
RMSE test: 1055.84

И у нас все еще есть проблема недооценки вещей, которые покупают много. Мы могли бы попробовать трюк с логарифмом (x+1), чтобы решить асимметричное распределениеoutlet_item_sales и проверить, решает ли это проблему недооценки. Опять же, спойлер: это не так.

Total predicted test: 1907749, Total real test: 2186165
Total predicted training: 14413580, Total real training: 16404959
RMSE test: 2202.43

На самом деле результаты хуже, общая сумма отличается больше, чем без преобразования log(x+1), а RMSE больше.

Вывод

  • Item_Fat_Content
  • Item_Weight, Item_Visibility и Outlet_Size: представлены отсутствующие значения. Item_Weight и Item_Visibility зависели от Item_Type. С другой стороны, отсутствующие значения Outlet_Size относятся к 3 конкретным магазинам. Разумно было бы считать, что магазины 017 и 045 средние, а магазин 010 — маленький.
  • LightGBM недооценивает товары, которые покупают много
  • Лучший показатель RMSE для тестового набора составил 1045.
  • Наиболее важными атрибутами были: Item_MRP, Item_Weight, Outlet_Establishment_Year, Outlet_Identifier, Item_Visibility, Item_Type, Outlet_Size.

Проверить код можно здесь