среда, 13 апреля 2022 г.

Машинное обучение с Python в розничной торговле : кластеризация магазинов по ассортиментной матрице продаж

Рассмотрим задачу кластеризации розничных магазинов одежды по ассортиментной матрице продаж. В качестве исходных данных возьмем ассортиментные матрицы продаж 72 магазинов одежды в осенне-зимний сезон  (массив данных сгенерирован с помощью sklearn.datasets.samples_generator). Доли в продажах разделены на 11 комбинаций по полу-товарной группе-сезон. В реальности различия в ассортиментных матрицах могут быть связаны с разными климатическими условиями (магазины могут находятся в разных городах), а также с разницей в расположении магазинов в городе и в торговом центре. В результате анализа необходимо разделить магазины на кластеры, чтобы в дальнейшем более правильно организовать их снабжение товарами.


Загружаем данные из файла Excel и проводим предварительный анализ :

df = pd.read_excel('am_shop.xlsx',index_col='shop')

# Размер данных
df.shape

(72, 11)

#Тип данных
df.dtypes

female pants universal       float64
female jacket demi-season    float64
female jacket winter         float64
female sweater winter        float64
female sweater universal     float64
male pants universal         float64
male jacket demi-season      float64
male jacket winter           float64
male sweater winter          float64
male sweater universal       float64
male hoody universal         float64
dtype: object

Наш датафрейм содержит 72 строки, выведем первые и последние пять строк

pd.concat([df.head().T, df.tail().T],axis=1)


sh1sh2sh3sh4sh5sh68sh69sh70sh71sh72
female pants universal0.13510.16090.16260.20290.15810.05540.08160.07690.06570.0567
female jacket demi-season0.14990.06840.05340.04120.05620.03060.04410.03880.03160.0340
female jacket winter0.08940.11880.12990.16220.19110.26840.24070.26310.25740.2225
female sweater winter0.03290.02880.04300.03550.04250.01320.01960.02960.02630.0197
female sweater universal0.05120.04530.04890.05640.04310.00950.01820.02270.01760.0134
male pants universal0.17750.21360.22710.19150.17780.12610.10330.10490.11540.1381
male jacket demi-season0.14410.08500.08310.06780.06540.07400.05880.04680.04270.0524
male jacket winter0.07150.10380.10910.11160.11360.31820.32420.32370.33790.3572
male sweater winter0.03090.03480.04210.03350.03880.02010.02600.02640.03290.0293
male sweater universal0.03350.05050.03300.02870.03690.03060.02980.02900.02010.0304
male hoody universal0.08410.09000.06770.06850.07650.05400.05350.03810.05240.0464

Сделаем копию введенных данных и наименование колонок

df_orig = df.copy()
colname=df_orig.columns.values.tolist()

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


corr_mat = df.corr()

for x in range(corr_mat.shape[0]):
    corr_mat.iloc[x,x] = 0.0
    
corr_mat

female pants universalfemale jacket demi-seasonfemale jacket winterfemale sweater winterfemale sweater universalmale pants universalmale jacket demi-seasonmale jacket wintermale sweater wintermale sweater universalmale hoody universal
female pants universal0.00000.2054-0.69370.45560.64390.66690.1764-0.80250.19050.33980.4886
female jacket demi-season0.20540.0000-0.26180.01970.4025-0.01660.7095-0.5104-0.13000.17570.2676
female jacket winter-0.6937-0.26180.0000-0.3881-0.5272-0.8632-0.42270.7438-0.3969-0.5925-0.7436
female sweater winter0.45560.0197-0.38810.00000.55260.2676-0.1644-0.46140.69180.40620.4126
female sweater universal0.64390.4025-0.52720.55260.00000.29190.3717-0.73240.25270.45310.5513
male pants universal0.6669-0.0166-0.86320.26760.29190.00000.1946-0.68350.33870.49290.5926
male jacket demi-season0.17640.7095-0.4227-0.16440.37170.19460.0000-0.5417-0.14810.21640.4420
male jacket winter-0.8025-0.51040.7438-0.4614-0.7324-0.6835-0.54170.0000-0.3110-0.5596-0.7395
male sweater winter0.1905-0.1300-0.39690.69180.25270.3387-0.1481-0.31100.00000.36190.4562
male sweater universal0.33980.1757-0.59250.40620.45310.49290.2164-0.55960.36190.00000.5978
male hoody universal0.48860.2676-0.74360.41260.55130.59260.4420-0.73950.45620.59780.0000


#Выведем пары с максимальными корреляциями c их значениями
pc=corr_mat.abs().idxmax()
cormax=corr_mat.loc[pc.index,pc.values]
df_cor=pd.DataFrame({'colname ': pc,'cor': np.diagonal(cormax)})
df_cor.sort_values(by=['cor'])


colnamecor
female jacket wintermale pants universal-0.8632
male pants universalfemale jacket winter-0.8632
female pants universalmale jacket winter-0.8025
male jacket winterfemale pants universal-0.8025
male hoody universalfemale jacket winter-0.7436
female sweater universalmale jacket winter-0.7324
male sweater universalmale hoody universal0.5978
female sweater wintermale sweater winter0.6918
male sweater winterfemale sweater winter0.6918
female jacket demi-seasonmale jacket demi-season0.7095
male jacket demi-seasonfemale jacket demi-season0.7095

Проведем некоторую предобработку данных: приведем данные к одному масштабу 

from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
df = scaler.fit_transform(df)
df=pd.DataFrame(df,columns=colname)



Визуализируем зависимости в данных

sns.set_context('notebook')
sns.set_style('white')
sns.pairplot(df)





Далее используем метод главных компонент для уменьшения размерности наших данных.

from sklearn.decomposition import PCA

pca_list = list()
feature_weight_list = list()

# Цикл по количеству компонент

for n in range(1, 12):
    
    # Создаем модель
    PCAmod = PCA(n_components=n)
    PCAmod.fit(df)
    
    # Сохраняем модель и дисперсию
    pca_list.append(pd.Series({'n':n, 'model':PCAmod,
                               'var': PCAmod.explained_variance_ratio_.sum()}))
    
    # Расчет и сохранение важности переменных
    abs_feature_values = np.abs(PCAmod.components_).sum(axis=0)
    feature_weight_list.append(pd.DataFrame({'n':n, 
                                             'features': df.columns,
                                             'values':abs_feature_values/abs_feature_values.sum()}))
    
#Выводим суммарную долю объясненной дисперсии по компонентам
pca_df = pd.concat(pca_list, axis=1).T.set_index('n')
pca_df

modelvar
n
1PCA(n_components=1)0.5530
2PCA(n_components=2)0.6870
3PCA(n_components=3)0.7863
4PCA(n_components=4)0.8653
5PCA(n_components=5)0.9171
6PCA(n_components=6)0.9454
7PCA(n_components=7)0.9656
8PCA(n_components=8)0.9808
9PCA(n_components=9)0.9920
10PCA(n_components=10)0.9999
11PCA(n_components=11)1.0000


Диаграмма долей объясненной дисперсии по компонентам

plt.bar(range(1,12),PCAmod.explained_variance_ratio_)
plt.title('Percentage of Variance Explained')
plt.xlabel('Number of Components')
plt.ylabel('Percentage of Variance Explained'





Диаграмма суммарной доли объясненной дисперсии по компонентам

sns.set_context('talk')
ax = pca_df['var'].plot(kind='bar')

ax.set(xlabel='Number of Components',
       ylabel='Percent explained variance',
       title='Explained Variance vs Components')




Выведем веса показателей  по компонентам

features_df = (pd.concat(feature_weight_list)
               .pivot(index='n', columns='features', values='values'))
features_df.columns=colname
features_df

female pants universalfemale jacket demi-seasonfemale jacket winterfemale sweater winterfemale sweater universalmale pants universalmale jacket demi-seasonmale jacket wintermale sweater wintermale sweater universalmale hoody universal
n
10.09780.03080.13140.06960.08970.11960.04660.13610.05860.10240.1174
20.06230.08370.08060.12170.06510.07700.11950.11310.12850.08030.0683
30.05010.09280.09010.13050.10170.12950.09540.09270.10360.06190.0516
40.08990.07550.07070.11610.09810.10750.09280.08580.09160.10380.0680
50.08960.06940.06690.09600.09400.08780.09750.07090.11090.13490.0822
60.07830.08730.06510.09140.09650.08130.09260.06560.11010.11980.1120
70.07860.08410.08710.07940.11210.07110.08950.08400.09620.10790.1099
80.07080.08830.09200.08980.11360.06910.08590.08490.10320.09510.1072
90.07720.09280.09020.10170.10160.07330.09230.08130.10520.08670.0977
100.08910.09860.08430.09350.10290.08010.10160.07820.09890.08300.0898
110.09050.09980.09160.08740.09760.08650.09960.09100.09240.07730.0862

Визуализируем состав и доли данных по компонентам

ax = features_df.plot(kind='bar', figsize=(13,8))
ax.legend(loc='upper right')
ax.set(xlabel='Number of dimensions',
       ylabel='Relative importance',
       title='Feature importance vs Dimensions')


Обычно предпочтительнее выбирать такое количество измерений, которое
соответствует достаточно большой порции дисперсии (например 95%).
Но так как мы хотим визуализировать расположение кластеров, уменьшим размер данных до двух компонент, как было видно ранее, они объясняют ~69% дисперсии

pca = PCA(n_components=2, random_state=11)
pca.fit(df)
df_pca = pca.transform(df)
df_pca = pd.DataFrame(df_pca,columns=['Component1', 'Component2'])
df_pca.shape

(72, 2)


Выведем первые и последние пять строк полученного датафрейма

pd.concat([df_pca.head(), df_pca.tail()])


Component1Component2
00.94310.7945
10.98180.2189
20.8057-0.0369
30.65210.0999
40.6104-0.0842
67-0.74780.2571
68-0.60980.1086
69-0.6888-0.0883
70-0.7305-0.1522
71-0.6697-0.0407

Перейдем к кластеризации методом k-средних. В качестве оценки качества
кластеризации используем коэффициент силуэта, который показывает, насколько
среднее расстояние до объектов в нашем кластере отличается от среднего расстояния до объектов в других кластерах. Коэффициент силуэта ограничен диапазоном
от -1 до 1. Значения, близкие к -1, соответствуют плохой (разбросанной) кластеризации,
значения, близкие к нулю, указывают на то, что кластеры пересекаются и
перекрываются, значения, близкие к 1, соответствуют «плотные» четко очерченные
скопления. Таким образом, чем крупнее силуэт, тем отчетливее выделяются
скопления, и они представляют собой компактные, плотно сгруппированные облака
точек. Рассмотрим варианты с количеством кластеров от 2 до 8 и
рассчитаем коэффициент силуэта для каждого варианта.


plt.figure(figsize=(20, 20))
plt.subplot(4, 2, 1)
plt.title('Instances')
plt.scatter(df_pca.loc[:,'Component1'],df_pca.loc[:,'Component2'])

colors = ['b', 'g', 'r', 'c', 'm', 'y', 'k', 'b']
markers = ['>', 's', 'D', 'v', '^', 'p', '*', '+']
tests = [2, 3, 4, 5, 6,7,8]
subplot_counter = 1
for t in range(2,9):
    subplot_counter += 1
    plt.subplot(4, 2, subplot_counter)
    kmeans_model = KMeans(n_clusters=t).fit(df_pca)
    for i, l in enumerate(kmeans_model.labels_):
        plt.plot(df_pca.loc[i,'Component1'],df_pca.loc[i,'Component2'], 
        color=colors[l], marker=markers[l],ls='None')
        plt.title('K = %s, Silhouette Coefficient = %.03f' % 
        (t,metrics.silhouette_score(df_pca, kmeans_model.labels_,
        metric='euclidean')))
        plt.scatter(kmeans_model.cluster_centers_[:,0],
        kmeans_model.cluster_centers_[:,1],s=200,c='black',marker='*')

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

s = []
x=[]
for n_clusters in range(2,9):
    kmeans = KMeans(n_clusters=n_clusters)
    kmeans.fit(df_pca)
    labels = kmeans.labels_
    centroids = kmeans.cluster_centers_
    s.append(metrics.silhouette_score(df_pca, labels, metric='euclidean'))
    x.append(n_clusters)
plt.plot(x,s)
plt.ylabel("Silouette")
plt.xlabel("n_clusters")
plt.title("Silouette for K-means")
plt.grid(True)
sns.despine()




Для количественной оценки качества также кластеризации используется

 внутрикластерное значение SSE (искажение). В случае применения библиотеки 

scikit-leam внутрикластерное значение SSE после подгонки модели КМеаns доступно

через атрибут inertia_.На основе внутрикластерного значения SSE построен метод локтя - графический инструмент для оценки оптимального количества кластеров k.  Идея 

метода заключается  в том, чтобы идентифицировать значение k, при котором

 искажение начинает  увеличиваться быстрее всего, что прояснится, если построить

 график искажения  для разных значений k.

from sklearn.cluster import KMeans
distortions = []
for i in range(1, 11):
    km = KMeans(n_clusters=i, 
                init='k-means++', 
                n_init=10, 
                max_iter=300, 
                random_state=0)
    km.fit(df_pca)
    distortions.append(km.inertia_)
plt.plot(range(1, 11), distortions, marker='o')
plt.xlabel('Number of clusters')
plt.ylabel('Distortion')
plt.tight_layout()
plt.grid(True)




Как видно на графике  локоть находится в точке k = 3, свидетельствуя о том, 
что k = 3 - хороший выбор для этого набора данных.

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

Построим  дендограмму  с помощью библиотеки SciPy.
В SciPy используется функция, которая принимает массив данных X в качестве
 аргумента и вычисляет массив связей (linkage array) с записанными сходствами между
 кластерами. Затем  этот массив передается функции SciPy dendrogram, чтобы
построить дендрограмму  

# импортируем функцию dendrogram и функцию кластеризации ward из SciPy
from scipy.cluster.hierarchy import dendrogram, ward
# применяем кластеризацию ward к массиву данных X
# функция SciPy ward возвращает массив с расстояниями
# вычисленными в ходе выполнения агломеративной кластеризации
linkage_array = ward(df)
#строим дендрограмму для массива связей, содержащего расстояния
# между кластерами
dendrogram(linkage_array)

# делаем отметки на дереве, соответствующие двум или трем кластерам
ax = plt.gca()

bounds = ax.get_xbound()
ax.plot(bounds, [0.7, 0.7], '--', c='k')
ax.plot(bounds, [0.45, 0.45], '--', c='k')
ax.plot(bounds, [0.3, 0.3], '--', c='k')
ax.text(bounds[1], 0.7, ' two clusters', va='center', fontdict={'size': 15})
ax.text(bounds[1], 0.45, ' three clusters', va='center', fontdict={'size': 15})
ax.text(bounds[1], 0.3, ' four clusters', va='center', fontdict={'size': 15})
plt.xlabel("observation index")
plt.ylabel("cluster distance")




В библиотеке scikit-learn имеется также реализация AgglomerativeClustering,
позволяющая выбирать количество кластеров, которые желательно возвратить.
Класс AgglomerativeClustering полезен, если мы хотим подрезать иерархическое
дерево кластеров. Устанавливая число кластеров n_cluster=3 и еще два параметра :
метрика, используемая для вычисления связи affinity ='euclidean' и linkage='ward'
 критерий связи, определяет, какое расстояние использовать между наборами
 наблюдений 
 Алгоритм объединит пары кластеров, которые минимизируют этот критерий,
'ward' минимизирует дисперсию объединяемых кластеров. Добавим, что они также
установлены по умолчанию.

from sklearn.cluster import AgglomerativeClustering h_clusters= AgglomerativeClustering(n_clusters=3,affinity='euclidean',linkage='ward') h_clusters.fit(df_pca)

Выведем полученный коэффициент силуэта :

metrics.silhouette_score(df_pca, h_clusters.labels_, metric='euclidean')

0.5120138550006529

Он получился выше, чем у KMeans

Также выведем номера кластеров

np.unique(h_clusters.labels_)

array([0, 1, 2], dtype=int64)

Визуализируем кластеры

plt.figure(figsize=(10, 5))
plt.scatter(df_pca.loc[h_clusters.labels_==0,'Component1'],
df_pca.loc[h_clusters.labels_==0,'Component2'],
            c='lightblue',edgecolor='black',marker='o',s=40,label='cluster 1')
plt.scatter(df_pca.loc[h_clusters.labels_==1,'Component1'],
df_pca.loc[h_clusters.labels_==1,'Component2'],
            c='red',edgecolor='black',marker='o',s=40,label='cluster 2')
plt.scatter(df_pca.loc[h_clusters.labels_==2,'Component1'],
df_pca.loc[h_clusters.labels_==2,'Component2'],
            c='green',edgecolor='black',marker='o',s=40,label='cluster 3')
plt.legend()




Добавим в наши данные номера кластеров

df_orig["cluster"] = h_clusters.labels_
df_orig.head().T

shopsh1sh2sh3sh4sh5
female pants universal0.13510.16090.16260.20290.1581
female jacket demi-season0.14990.06840.05340.04120.0562
female jacket winter0.08940.11880.12990.16220.1911
female sweater winter0.03290.02880.04300.03550.0425
female sweater universal0.05120.04530.04890.05640.0431
male pants universal0.17750.21360.22710.19150.1778
male jacket demi-season0.14410.08500.08310.06780.0654
male jacket winter0.07150.10380.10910.11160.1136
male sweater winter0.03090.03480.04210.03350.0388
male sweater universal0.03350.05050.03300.02870.0369
male hoody universal0.08410.09000.06770.06850.0765
cluster2.00002.00002.00002.00002.0000

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

df_by_cl = df_orig.groupby('cluster')
df_cl=pd.DataFrame(df_by_cl.mean())
df_cl['cluster']=df_cl.index
df_cl.T

cluster012
female pants universal0.11010.08040.1477
female jacket demi-season0.04730.03840.0667
female jacket winter0.20790.25520.1334
female sweater winter0.03030.02470.0342
female sweater universal0.02830.01890.0425
male pants universal0.17600.12630.2230
male jacket demi-season0.06290.05040.0846
male jacket winter0.20770.30410.1141
male sweater winter0.03520.02870.0364
male sweater universal0.03470.02700.0403
male hoody universal0.05980.04590.0771
cluster0.00001.00002.0000


Перед тем, как выводить показатели кластеров в разных разрезах переведем данные
из широкого формата в длинный

df_cl_new=pd.melt(df_cl,id_vars=['cluster'],
        value_vars=['female pants universal','female jacket demi-season',
        'female jacket winter','female sweater winter','female sweater universal',
        'male pants universal','male jacket demi-season','male jacket winter',
        'male sweater winter','male sweater universal','male hoody universal'])

df_cl_new.head()

clustervariablevalue
00female pants universal0.1101
11female pants universal0.0804
22female pants universal0.1477
30female jacket demi-season0.0473
41female jacket demi-season0.0384

Выделим в отдельные колонки показатели пола, товарной группы и сезона

df_cl_new[['sex','tg','season']] =df_cl_new['variable'].
apply(lambda x: pd.Series(str(x).split()))

df_cl_new.head()

clustervariablevaluesextgseason
00female pants universal0.1101femalepantsuniversal
11female pants universal0.0804femalepantsuniversal
22female pants universal0.1477femalepantsuniversal
30female jacket demi-season0.0473femalejacketdemi-season
41female jacket demi-season0.0384femalejacketdemi-season

Первая группировка по сезону

df_gr_season=df_cl_new.groupby(['cluster','season'])
df_gr_season.sum()

value
clusterseason
0demi-season0.1101
universal0.4088
winter0.4810
1demi-season0.0887
universal0.2985
winter0.6127
2demi-season0.1513
universal0.5305
winter0.3181




По сезону кластеры условно можно разделить : 0 - центральный регион, 
1 - северный (большая зимнего товара) и 2 - южный регион

Вторая группировка по полу :

df_gr_sex=df_cl_new.groupby(['cluster','sex'])
df_gr_sex.sum()

value
clustersex
0female0.4239
male0.5762
1female0.4176
male0.5824
2female0.4246
male0.5754



Принципиальной разницы в долях мужского и женского товара нет

Третья группировка по товарным группам

df_gr_sex=df_cl_new.groupby(['cluster','tg'])
df_gr_sex.sum()


value
clustertg
0hoody0.0598
jacket0.5257
pants0.2861
sweater0.1284
1hoody0.0459
jacket0.6480
pants0.2067
sweater0.0994
2hoody0.0771
jacket0.3988
pants0.3707
sweater0.1534



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

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











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

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