среда, 2 марта 2022 г.

Машинное обучение с Python в розничной торговле : линейная регрессия

В качестве примера применение линейной регрессии рассмотрим задачу, часто возникающую в розничной торговле. Мы планируем месячные продажи нескольких товарных групп в нашей сети магазинов одежды и нам необходимо определить товарный запас по каждой товарной группе, который должен находится  в каждом магазине исходя из планового количества продаж. Для того, чтобы определить товарный запас по плану продаж в штуках надо задать плановую  оборачиваемость. 

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

Итак, у нас есть данные по продажам 70 магазинов одежды в марте 2021 года. Данные представлены в виде Data Frame с колонками : "Prod_gr" (товарная группа), "Turnover" (оборачиваемость), "Amount" (количество продаж). 

df = pd.DataFrame({

'Prod_gr': 

np.array(69*['Jeans']+['Jеans']+70*['Jacket']+70*['Socks']+70*['Belt']+70*['Shirt']+
70*['Pullover']+70*['Hoody']+70*['Underpants']+69*['T-shirt']+['T-shirt ']),

'Turnover': 

np.array([0.29,0.32,0.24,0.22,0.28,0.27,0.26,0.2,0.26,0.2,0.25,0.21,0.34,0.27,
                0.23,0.2,0.26,0.2,0.23,0.26,0.21,0.2,0.26,0.24,0.27,0.21,0.2,0.11,
                0.17,0.26,0.21,0.25,0.16,0.18,0.3,0.14,0.3,0.23,0.21,0.12,0.17,0.16,
                0.27,0.21,0.24,0.19,0.23,0.19,0.23,0.3,0.15,0.26,0.28,0.19,0.24,0.24,
                0.17,0.16,0.28,0.7,0.2,0.17,0.27,0.16,0.13,0.14,0.28,0.2,0.15,0.23,0.45,
                0.48,0.24,0.2,0.4,0.34,0.21,0.36,0.3,0.3,0.22,0.15,0.58,0.33,0.31,0.3,
                0.34,0.3,0.27,0.32,0.24,0.22,0.39,0.4,0.28,0.32,0.23,0.15,0.23,0.24,0.21,
                0.32,0.18,0.17,0.3,0.2,0.28,0.31,0.19,0.21,0.16,0.17,0.38,0.21,0.31,0.15,
                0.24,0.17,0.18,0.31,0.16,0.36,0.31,0.24,0.29,0.18,0.31,0.18,0.49,0.25,0.26,
                0.21,0.45,0.29,0.18,0.34,0.43,0.45,0.24,0.23,0.24,0.37,0.2,0.38,0.63,0.51,
                0.35,0.28,0.29,0.19,0.27,0.44,0.35,0.23,0.28,0.24,0.28,0.35,0.61,0.42,0.31,
                0.31,0.53,0.2,0.42,0.21,0.2,0.15,0.33,0.26,0.22,0.29,0.25,0.17,0.3,0.14,
                0.38,0.43,0.33,0.14,0.12,0.13,0.42,0.25,0.22,0.31,0.31,0.29,0.19,0.33,0.18,
                0.15,0.42,0.33,0.36,0.36,0.31,0.26,0.32,0.88,0.43,0.17,0.36,0.08,0.08,0.27,
                0.26,0.35,0.17,0.16,0.2,0.35,0.18,0.11,0.23,0.3,0.29,0.18,0.19,0.26,0.24,
                0.26,0.4,0.14,0.19,0.14,0.33,0.1,0.4,0.45,0.21,0.17,0.3,0.2,0.41,0.25,0.2,
                0.15,0.27,0.22,0.12,0.28,0.13,0.2,0.19,0.08,0.33,0.31,0.23,0.15,0.12,0.18,
                0.32,0.26,0.26,0.13,0.24,0.21,0.24,0.34,0.12,0.3,0.31,0.22,0.16,0.39,0.29,
                0.22,0.34,0.6,0.18,0.15,0.38,0.14,0.17,0.23,0.33,0.27,0.2,0.19,0.29,0.25,0.17,
                0.16,0.33,0.3,0.19,0.23,0.23,0.1,0.21,0.12,0.3,0.25,0.19,0.16,0.26,0.15,0.19,
                0.19,0.14,0.23,0.15,0.19,0.11,0.17,0.17,0.12,0.11,0.11,0.11,0.19,0.22,0.18,
                0.25,0.13,0.19,0.29,0.08,0.13,0.1,0.09,0.2,0.19,0.22,0.25,0.19,0.17,0.11,0.2,
                0.07,0.24,0.16,0.13,0.14,0.15,0.12,0.13,0.2,0.76,0.19,0.18,0.18,0.2,0.08,0.1,
                0.19,0.19,0.13,0.16,0.26,0.23,0.23,0.16,0.44,0.34,0.36,0.23,0.31,0.16,0.13,
                0.21,0.25,0.26,0.19,0.11,0.1,0.2,0.25,0.13,0.21,0.16,0.2,0.21,0.19,0.21,0.15,
                0.22,0.15,0.13,0.15,0.21,0.28,0.2,0.25,0.18,0.24,0.24,0.17,0.13,0.12,0.17,0.36,
                0.25,0.35,0.25,0.22,0.19,0.24,0.21,0.07,0.34,0.3,0.23,0.23,0.19,0.21,0.23,0.57,
                0.69,0.16,0.12,0.14,0.34,0.07,0.2,0.23,0.31,0.2,0.24,0.39,0.39,0.25,0.15,0.34,
                0.34,0.3,0.3,0.31,0.24,0.27,0.15,0.39,0.22,0.35,0.23,0.22,0.24,0.25,0.3,0.23,
                0.17,0.35,0.25,0.23,0.21,0.28,0.13,0.14,0.17,0.15,0.23,0.22,0.21,0.34,0.12,0.24,
                0.25,0.22,0.18,0.23,0.17,0.27,0.2,0.25,0.19,0.23,0.19,0.16,0.32,0.13,0.34,0.24,
                0.18,0.14,0.26,0.18,0.19,0.37,0.96,0.22,0.14,0.27,0.21,0.1,0.16,0.28,0.32,0.25,
                0.25,0.47,0.57,0.4,0.42,0.24,0.82,0.36,0.56,0.84,0.36,0.17,0.32,0.48,0.21,0.4,
                0.11,0.24,0.37,0.64,0.44,0.19,0.42,0.29,0.52,0.54,0.49,0.52,0.59,0.5,0.47,0.07,
                0.39,0.4,0.26,0.38,0.51,0.45,0.54,0.42,0.46,0.24,0.41,0.42,0.22,0.39,0.44,0.51,
                0.31,0.18,0.59,0.21,0.43,0.42,0.39,0.28,0.35,0.29,0.23,0.44,0.83,0.5,0.26,0.48,
                0.47,0.36,0.33,0.46,0.35,0.34,0.47,0.27,0.29,0.21,0.22,0.28,0.26,0.26,0.26,0.21,
                0.13,0.19,0.12,0.27,0.18,0.22,0.2,0.2,0.15,0.26,0.25,0.19,0.2,0.21,0.3,0.17,0.19,
                0.19,0.11,0.16,0.18,0.14,0.18,0.16,0.14,0.2,0.1,0.16,0.2,0.19,0.12,0.12,0.16,0.24,
                0.17,0.13,0.16,0.27,0.17,0.14,0.24,0.14,0.22,0.24,0.12,0.18,0.18,0.23,0.18,0.26,
                                         0.25,0.25,0.16,0.19,0.18,0.12,0.15,0.18,0.14,0.13,0.2]),

 'Amount': np.array([378,595,214,117,262,290,320,236,263,236,311,224,579,303,287,265,325,161,288,364,191,179,350,353,384,228,174,90,151,243,198,341,167,142,459,152,399,196,232,108,
121,129,348,186,229,132,276,222,212,457,134,348,324,178,190,244,206,126,355,429,154,
155,252,166,114,146,293,235,153,248,358,441,116,65,214,201,141,218,166,146,142,89,568,189,223,207,250,135,154,242,120,110,319,221,218,200,131,74,133,118,101,259,132,89,270,134,217,163,131,124,86,81,266,107,151,66,148,116,104,263,78,267,213,156,151,112,227,
92,398,284,147,118,248,193,97,225,290,333,184,156,206,439,109,157,480,362,215,210,193,108,198,210,468,171,217,185,245,180,377,330,169,126,441,157,332,101,115,63,165,150,
136,228,145,82,267,76,262,224,226,88,55,53,311,131,116,117,198,156,91,294,60,105,278,
169,169,185,213,109,252,236,218,79,220,61,31,168,186,266,95,122,54,102,30,16,60,71,60,38,36,46,46,37,137,28,43,32,78,17,75,111,36,27,83,48,62,36,35,23,43,39,20,61,28,30,56,14,73,55,49,29,17,27,81,39,42,16,44,37,32,94,15,65,60,33,23,67,53,29,90,69,31,23,59,25,23,
37,74,63,32,38,90,118,29,20,81,57,47,53,44,26,51,21,108,49,44,40,62,25,42,50,22,   39,55,64,24,33,32,22,26,21,23,43,39,36,80,25,43,46,15,22,16,16,55,31,35,33,38,42,21,56,
12,61,40,24,20,33,26,21,57,95,31,30,37,49,14,23,44,41,33,40,39,52,29,15,46,45,47,52,43,
26,20,25,54,34,29,20,15,20,43,20,28,17,33,62,33,35,24,37,28,20,21,48,55,24,49,31,37,39,
32,20,17,17,64,36,44,23,41,34,31,49,10,50,51,38,27,30,42,30,117,101,19,17,25,49,10,32,53,60,28,39,119,151,53,24,74,75,79,79,75,51,79,33,160,58,94,69,72,44,68,93,49,37,111,74,58,
51,63,20,36,36,26,80,62,42,107,28,64,47,58,45,38,28,79,39,57,21,61,48,35,107,26,98,74,46,25,52,52,31,123,60,44,28,58,46,18,37,76,97,59,62,84,140,46,30,39,59,66,58,62,37,24,32,
121,38,67,22,41,48,51,92,15,35,56,64,43,37,52,44,60,31,5,36,38,18,45,50,47,53,38,54,20,
23,46,20,31,24,61,48,20,100,23,47,32,47,18,24,31,19,64,30,52,19,53,43,25,29,42,35,34,51,
320,451,157,123,249,253,307,268,184,105,193,104,385,203,241,218,219,109,261,290,158,
153,269,221,186,136,146,62,140,158,113,197,129,86,254,78,155,131,147,98,84,107,256,
127,102,97,260,171,112,309,77,229,245,89,125,159,247,112,308,332,184,114,170,177,88,
122,150,132,131,190])
})

df.head()

Prod_grTurnoverAmount
0Jeans0.29378
1Jeans0.32595
2Jeans0.24214
3Jeans0.22117
4Jeans0.28262

Выведем основные сведения по нашему датафрейму

df.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 630 entries, 0 to 629
Data columns (total 3 columns):
 #   Column    Non-Null Count  Dtype  
---  ------    --------------  -----  
 0   Prod_gr   630 non-null    object 
 1   Turnover  630 non-null    float64
 2   Amount    630 non-null    int32  
dtypes: float64(1), int32(1), object(1)
memory usage: 12.4+ KB

630 наблюдений по девяти товарным группам в 70-ти магазинах, пропусков нет.

Основные описательные статистики по числовым данным

df.describe().T

countmeanstdmin25%50%75%max
Turnover630.00.2562060.1213340.070.180.230.300.96
Amount630.0115.007937103.7818965.0038.0074.00165.75595.00

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

Часто в исходных данных , описывающих категории товара встречаются ошибки в написании, поэтому начнем с проверки на ошибки. Выведем список уникальных категорий товара :

df.Prod_gr.unique()

array(['Jeans', 'Jеans', 'Jacket', 'Socks', 'Belt', 'Shirt', 'Pullover',
       'Hoody', 'Underpants', 'T-shirt', 'T-shirt '], dtype=object)

Две категории повторяются, посмотрим на количество значений по этим категориям

df.Prod_gr.value_counts()

Underpants    70
Belt          70
Shirt         70
Jacket        70
Pullover      70
Socks         70
Hoody         70
Jeans         69
T-shirt       69
T-shirt        1
Jеans          1
Name: Prod_gr, dtype: int64

Явно два наименования категории записаны ошибочно, для подтверждения выведем
коды их символов

for pg in sorted(df.Prod_gr.unique()):
    print(f"{pg:>10s}", [ord(c) for c in pg])

 Belt [66, 101, 108, 116]
     Hoody [72, 111, 111, 100, 121]
    Jacket [74, 97, 99, 107, 101, 116]
     Jeans [74, 101, 97, 110, 115]
     Jеans [74, 1077, 97, 110, 115]
  Pullover [80, 117, 108, 108, 111, 118, 101, 114]
     Shirt [83, 104, 105, 114, 116]
     Socks [83, 111, 99, 107, 115]
   T-shirt [84, 45, 115, 104, 105, 114, 116]
  T-shirt  [84, 45, 115, 104, 105, 114, 116, 32]
Underpants [85, 110, 100, 101, 114, 112, 97, 110, 116, 115]

В категории "Jeans" не совпадают вторые символы, во втором варианте "е" написано 
кириллицей, а категории T-shirt в втором варианте в конце лишний пробел. Заменим эти значения.

df.loc[df.Prod_gr.isin(['Jeans', 'Jеans']), 'Prod_gr'] = 'Jeans'
df.loc[df.Prod_gr.isin(['T-shirt', 'T-shirt ']), 'Prod_gr'] = 'T-shirt'
df.Prod_gr.value_counts()

Underpants    70
Jeans         70
Belt          70
Shirt         70
T-shirt       70
Jacket        70
Socks         70
Pullover      70
Hoody         70
Name: Prod_gr, dtype: int64

Проверим числовые показатели, оборачиваемость и количество продаж не должно быть      меньше или рано нулю

((df.Turnover <= 0) | (df.Amount <= 0)).any()

False

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

sns.lmplot(x='Amount', y='Turnover', data =df,col='Prod_gr',col_wrap=3)



Зависимость для всех товарных групп близка к линейной, но во-первых, коэффициенты линейной регрессии явно отличаются, и во-вторых, в данных явно присутствуют 
выбросы. 
Выведем другой график, который это подтвердит.

plt.figure(figsize=(15, 5))
sns.set_style('whitegrid')
sns.boxplot('Prod_gr', 'Turnover', data=df)




Это так называемые ящичные диаграммы с усами. Каждый ящик представляет                значения, располагающиеся между первым и третьим квартилями, усы охватывают 
значения в пределах 1,5 межквартильных размахов от границ ящика, все значения за их пределами считаем выбросами.

Для того, чтобы убрать выбросы используем функцию outliers модуля stats из пакета         dautil, она выдает картеж с значениями нижних и верхних усов.

t_min,t_max=stats.outliers(df.query('Prod_gr == "Jeans"')['Turnover'])
df_new=df[((df.Prod_gr == "Jeans")&(df.Turnover<=t_max))|(df.Prod_gr != "Jeans")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "Jacket"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "Jacket")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "Jacket")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "Socks"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "Socks")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "Socks")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "Belt"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "Belt")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "Belt")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "Shirt"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "Shirt")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "Shirt")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "Pullover"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "Pullover")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "Pullover")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "Hoody"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "Hoody")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "Hoody")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "Underpants"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "Underpants")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "Underpants")]

t_min,t_max=stats.outliers(df.query('Prod_gr == "T-shirt"')['Turnover'])
df_new=df_new[((df_new.Prod_gr == "T-shirt")&(df_new.Turnover<=t_max))|
(df_new.Prod_gr != "T-shirt")]

Данные в новом датафрейме df_new без выбросов

plt.figure(figsize=(15, 5))
sns.set_style('whitegrid')
sns.boxplot('Prod_gr', 'Turnover', data=df_new)



Теперь можно переходить к моделям регрессии для каждой товарной группы.
Импортируем необходимые функции 

from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression

Для оценки точности будем использовать Средняя абсолютная процентная ошибка
(MAPE), определим для ее расчета функцию

def mean_absolute_percentage_error(y_true, y_pred): 
    y_true, y_pred = np.array(y_true), np.array(y_pred)
    return round(np.mean(np.abs((y_true - y_pred) / y_true)) * 100,2)

Функция для получения MAPE на обучающей и тестовой выборке

def get_train_test_mape( model,X_train,X_test,y_train,y_test ): 
     y_train_pred = model.predict( X_train ) 
     mape_train = round(mean_absolute_percentage_error( y_train,y_train_pred),2) 
     y_test_pred = model.predict( X_test ) 
     mape_test = round(mean_absolute_percentage_error( y_test,y_test_pred),2) 
     print( 'mape train: ', mape_train, ' mape test:', mape_test )

Также определим функцию для линейной регрессии 

def turnover(df,prod_gr):
    str_query='Prod_gr =='+prod_gr
    X=df.query(str_query)['Amount'] 
    y=df.query(str_query)['Turnover'] 
     X=X[:,np.newaxis]
    #Разделяем данные на обучающую и тестовую части 
     X_train, X_test, y_train, y_test = train_test_split(X,y,random_state=42)
    #Определяем модель линейной регрессии 
     reg = LinearRegression() 
     #Обучаем модель на обучающих данных 
      reg.fit(X_train, y_train)
    #Делаем прогноз по тестовым данным
    y_test_pred = reg.predict(X_test)
    #Выводим результаты
    print(prod_gr)
    print(f"Turnover = {reg.intercept_:.2f} + {reg.coef_[0]:.4f} Amount")
    get_train_test_mape(reg,X_train,X_test,y_train,y_test)
    test_pred_df = pd.DataFrame( { 
    'amount':  X_test.flatten(),      
    'turn_act': y_test,
    'turn_pred': np.round( y_test_pred, 2 ),
    'balance_act' : np.round(X_test.flatten()/y_test,0),
    'balance_pred' : np.round(X_test.flatten()/y_test_pred,0),    
    'mape_turn': np.round((y_test - y_test_pred)*100 / y_test,2)} )                              
    print(test_pred_df.sample(10))


Вот так она работает для товарной группы джинсы

"Jeans"
Turnover = 0.12 + 0.0004 Amount
mape train:  10.7  mape test: 7.64
    amount  turn_act  turn_pred  balance_act  balance_pred  mape_turn
10     311      0.25       0.25       1244.0        1252.0       0.65
5      290      0.27       0.24       1074.0        1211.0      11.31
57     126      0.16       0.17        788.0         742.0      -6.17
33     142      0.18       0.18        789.0         804.0       1.85
60     154      0.20       0.18        770.0         847.0       9.12
35     152      0.14       0.18       1086.0         840.0     -29.22
9      236      0.20       0.22       1180.0        1090.0      -8.27
47     222      0.19       0.21       1168.0        1054.0     -10.85
12     579      0.34       0.36       1703.0        1599.0      -6.49
30     198      0.21       0.20        943.0         988.0       4.56

В таблице balance_act и balance_pred - это расчетный остаток, необходимый для 
тестовых продаж, рассчитанный по фактической и расчетной (регрессионной) 
оборачиваемости.




 











Комментариев нет:

Отправить комментарий