L’entreprise Lapage était originellement une librairie physique avec plusieurs points de vente. Mais devant le succès de certains de ses produits et l’engouement de ses clients, elle a ouvert, il y a deux ans, un site de vente en ligne. La direction aimerait maintenant faire le point sur les différents indicateurs et chiffres clés de l’entreprise, de façon à décider de la marche à suivre, par exemple décider si elle doit créer certaines offres, adapter certains prix ou cibler un certain type de clientèle etc.
L'analyse doit être découpée en deux parties :
-Une analyse des différents indicateurs de vente.
-Une analyse plus ciblée sur les clients : l’objectif est de comprendre le comportement des clients en ligne, pour pouvoir ensuite comparer avec la connaissance acquise via les librairies physiques.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.lines as mlines
import matplotlib.ticker as ticker
from matplotlib.ticker import PercentFormatter
import seaborn as sns
import datetime as dt
import statsmodels.api as sm
from statsmodels.tsa.seasonal import seasonal_decompose
from scipy import stats
# Les données étant séparées par des points virgules, on le précise avec un delimiter=';'
customers = pd.read_csv('BDD/customers.csv', delimiter=';')
products = pd.read_csv('BDD/products.csv', delimiter=';')
transactions = pd.read_csv('BDD/Transactions.csv', delimiter=';')
# Utiliser le style dark_background
plt.style.use('dark_background')
# On affiche le nombre de lignes, de colonnes, et de valeurs non-nulles dans chaque colonne
customers.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8621 entries, 0 to 8620 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 client_id 8621 non-null object 1 sex 8621 non-null object 2 birth 8621 non-null int64 dtypes: int64(1), object(2) memory usage: 202.2+ KB
# On affiche les 5 premières lignes du dataframe customers
customers.head()
client_id | sex | birth | |
---|---|---|---|
0 | c_4410 | f | 1967 |
1 | c_7839 | f | 1975 |
2 | c_1699 | f | 1984 |
3 | c_5961 | f | 1962 |
4 | c_5320 | m | 1943 |
# On vérifie s'il y a des doublons
customers.duplicated().sum()
0
# renommer les identifiants de clients et les convertir en entiers pour faciliter le tri par la suite
customers['client_id'] = customers['client_id'].str.slice(2) # retirer les deux premiers caractères
customers['client_id'] = customers['client_id'].astype(int)
customers.head()
client_id | sex | birth | |
---|---|---|---|
0 | 4410 | f | 1967 |
1 | 7839 | f | 1975 |
2 | 1699 | f | 1984 |
3 | 5961 | f | 1962 |
4 | 5320 | m | 1943 |
# On vérifie l'unicité des clés primaires de la table customers, doit renvoyer 8621 soit le nombre total de clients
customers.client_id.unique().shape[0]
8621
# Modifier le nom des modalités de la variable sex pour plus de clarté
customers['sex'] = customers['sex'].replace({'f': 'female', 'm': 'male'})
# Afficher l'année de naissance la plus lointaine, la plus proche, et le nombre d'années de naissance différentes
print('Plus lointaine:',customers['birth'].max())
print('Plus proche:',customers['birth'].min())
print('Nombre de valeurs uniques dans la colonne birth:',customers['birth'].unique().shape[0])
Plus lointaine: 2004 Plus proche: 1929 Nombre de valeurs uniques dans la colonne birth: 76
# Visualiser la distribution de la variable birth à l'aide d'un histogramme et afficher son skewness
# On peut voir que la distribution est étirée à gauche
print('Skewness:',customers['birth'].skew())
plt.figure(figsize=(10, 6))
ax = customers['birth'].hist(bins=76, edgecolor='black', color='darkred')
ax.spines[['top', 'right']].set_visible(False)
plt.title('Distribution des clients par année de naissance', fontsize=16)
plt.xlabel('Années')
plt.ylabel('Nombre de clients')
plt.grid(False)
plt.show()
Skewness: -0.36081388177536167
# Créer une colonne contenant l'âge de chaque client (à partir de la seule année de naissance, cela reste donc approximatif)
current_date_time = dt.datetime.now()
current_year = current_date_time.date().year
customers['age'] = current_year - customers['birth']
customers.head()
client_id | sex | birth | age | |
---|---|---|---|---|
0 | 4410 | female | 1967 | 57 |
1 | 7839 | female | 1975 | 49 |
2 | 1699 | female | 1984 | 40 |
3 | 5961 | female | 1962 | 62 |
4 | 5320 | male | 1943 | 81 |
# Créer une colonne qui indique la tranche d'âge à laquelle appartient le client ou cliente
def age_group(age):
if age < 30:
return '20-29'
elif age < 40:
return '30-39'
elif age < 50:
return '40-49'
elif age < 60:
return '50-59'
else:
return '60 and over'
customers['age_group'] = customers['age'].apply(age_group)
customers.head()
client_id | sex | birth | age | age_group | |
---|---|---|---|---|---|
0 | 4410 | female | 1967 | 57 | 50-59 |
1 | 7839 | female | 1975 | 49 | 40-49 |
2 | 1699 | female | 1984 | 40 | 40-49 |
3 | 5961 | female | 1962 | 62 | 60 and over |
4 | 5320 | male | 1943 | 81 | 60 and over |
# Visualiser la distribution des clients par tranche d'âge
plt.figure(figsize=(10, 6))
ax = customers['age_group'].value_counts().plot(kind='bar', color='darkred')
plt.title('Nombre de clients par tranche d\'âge', fontsize=16)
plt.xlabel('Tranche d\'âge')
plt.ylabel('Nombre de clients')
plt.grid(False)
plt.xticks(rotation=0)
ax.spines[['top', 'right']].set_visible(False)
plt.show()
# Visualiser la distribution des clients selon leur sexe
plt.figure(figsize=(10, 6))
ax = customers['sex'].value_counts(normalize=True).plot(kind='bar', color='darkred')
plt.title('Proportion des clients par genre', fontsize=16)
plt.xlabel('Genre')
plt.ylabel('Proportion')
# Ajouter les pourcentages sur les barres
for p in ax.patches:
ax.annotate("{:.2%}".format(p.get_height()), (p.get_x() + p.get_width() / 2., p.get_height() * 0.75), ha='center', va='center', color='white', fontweight='bold')
plt.grid(False)
plt.xticks(rotation=0)
ax.spines[['top', 'right']].set_visible(False)
plt.show()
# Afficher les effectifs de chaque genre
customers['sex'].value_counts()
female 4490 male 4131 Name: sex, dtype: int64
# Afficher le nombre de lignes, de colonnes et de valeurs non-nulles dans chaque colonne
products.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 3286 entries, 0 to 3285 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id_prod 3286 non-null object 1 price 3286 non-null float64 2 categ 3286 non-null int64 dtypes: float64(1), int64(1), object(1) memory usage: 77.1+ KB
# Afficher les 5 premières lignes du dataframe products
products.head()
id_prod | price | categ | |
---|---|---|---|
0 | 0_1421 | 19.99 | 0 |
1 | 0_1368 | 5.13 | 0 |
2 | 0_731 | 17.99 | 0 |
3 | 1_587 | 4.99 | 1 |
4 | 0_1507 | 3.99 | 0 |
# On vérifie s'il y a des doublons
products.duplicated().sum()
0
# Vérifier l'unicité des clés primaires du dataframe, doit renvoyer 3286
products['id_prod'].unique().shape[0]
3286
# Visualiser la distribution de la variable prix et son skewness
# la distribution est étirée à droite avec une majorité de valeurs inférieures à la moyenne
# et une minorité de valeurs extrêmes largement supérieures à celle-ci
print('Skewness:', products['price'].skew())
plt.figure(figsize=(10, 6))
ax = products['price'].hist(bins=57, color='darkred')
plt.title('Distribution empirique des prix', fontsize=16)
plt.xlabel('Prix en €')
plt.ylabel('Nombre de produits')
ax.spines[['top', 'right']].set_visible(False)
plt.grid(False)
plt.show()
Skewness: 3.79840808478482
# Afficher les indicateurs de tendance centrale et indicateurs de dispersion de la variable prix
def central_tendency_dispersion(df, df_variable):
values = {}
values['Nombre de valeurs non nulles'] = int(df[df_variable].count())
values['Nombre de valeurs uniques'] = int(df[df_variable].nunique())
values['min'] = round(df[df_variable].min(),2)
values['max'] = round(df[df_variable].max(),2)
values['étendue'] = round(values['max'] - values['min'],2)
values['mode'] = round(df[df_variable].mode().values[0],2)
values['médiane'] = round(df[df_variable].median(),2)
values['moyenne'] = round(df[df_variable].mean(),2)
values['variance'] = round(df[df_variable].var(),2)
values['écart-type'] = round(df[df_variable].std(),2)
results = pd.Series(values)
return results
print(central_tendency_dispersion(products, 'price'))
Nombre de valeurs non nulles 3286.00 Nombre de valeurs uniques 1454.00 min 0.62 max 300.00 étendue 299.38 mode 4.99 médiane 13.07 moyenne 21.86 variance 891.01 écart-type 29.85 dtype: float64
# Visualiser les outliers
def IQR_outliers(df, x):
Q1 = df[x].quantile(0.25)
Q3 = df[x].quantile(0.75)
IQ = Q3 - Q1
outliers = df[(df[x] < (Q1 - 1.5 * IQ)) | (df[x] > (Q3 + 1.5 * IQ))].sort_values(by=x, ascending=False)
return outliers
price_outliers = IQR_outliers(products, 'price')
price_outliers
id_prod | price | categ | |
---|---|---|---|
946 | 2_2 | 300.00 | 2 |
724 | 2_76 | 254.44 | 2 |
394 | 2_158 | 247.22 | 2 |
1435 | 2_167 | 236.99 | 2 |
2778 | 2_30 | 233.54 | 2 |
... | ... | ... | ... |
3168 | 1_626 | 47.35 | 1 |
2965 | 1_620 | 47.30 | 1 |
49 | 1_48 | 47.22 | 1 |
2265 | 1_569 | 46.99 | 1 |
1163 | 2_155 | 46.99 | 2 |
302 rows × 3 columns
# Visualiser les outliers à l'aide d'une boîte à moustaches
plt.figure(figsize=(12,6))
medianprops = dict(linestyle='-', linewidth=1, color='gold')
meanprops = dict(marker='o', markersize=8, markeredgecolor='black', markerfacecolor='green')
flierprops = dict(marker='o', markersize=8, markerfacecolor='darkorange', linestyle='none', alpha=0.7)
median_line = mlines.Line2D([], [], color='gold', label='Médiane')
mean_marker = mlines.Line2D([], [], color='green', marker='o', linestyle='None', markersize=8, label='Moyenne')
outliers_marker = mlines.Line2D([], [], color='darkorange', marker='o', linestyle='None', markersize=8, label='Outliers')
Q1 = products['price'].quantile(0.25)
Q3 = products['price'].quantile(0.75)
plt.annotate('Q1', xy=(Q1, 0), xytext=(Q1, -0.45), ha='center', va='center')
plt.annotate('Q3', xy=(Q3, 0), xytext=(Q3, -0.45), ha='center', va='center')
sns.boxplot(x='price', data=products, orient='h', color='red', medianprops=medianprops, meanprops=meanprops, showmeans=True, flierprops=flierprops)
plt.title("Visualisation des outliers",fontsize=16, fontfamily='Arial', loc='center', pad=50)
plt.xlabel('Prix d\'un produits en €')
xticks = np.arange(start=0, stop=products['price'].max(), step=25)
plt.xticks(xticks)
plt.legend(handles=[median_line, mean_marker, outliers_marker])
sns.despine()
plt.show()
# Discrétisation de la variable prix avec l'ajout d'une colonne price_group
bins = [0, 50, 100, 150, 200, 250, 300]
labels = ['0-50', '50-100', '100-150', '150-200', '200-250', '250-300']
products['price_group'] = pd.cut(products['price'], bins=bins, labels=labels, include_lowest=True)
products.head()
id_prod | price | categ | price_group | |
---|---|---|---|---|
0 | 0_1421 | 19.99 | 0 | 0-50 |
1 | 0_1368 | 5.13 | 0 | 0-50 |
2 | 0_731 | 17.99 | 0 | 0-50 |
3 | 1_587 | 4.99 | 1 | 0-50 |
4 | 0_1507 | 3.99 | 0 | 0-50 |
# Distribution empirique de la variable price_group
products['price_group'].value_counts()
0-50 3013 50-100 150 100-150 79 150-200 29 200-250 13 250-300 2 Name: price_group, dtype: int64
# Afficher la distribution empirique de la variable catégorie :
products['categ'].value_counts()
0 2308 1 739 2 239 Name: categ, dtype: int64
# Visualiser la distribution empirique de la variable catégorie à l'aide d'un barplot
plt.figure(figsize=(10, 6))
ax = products['categ'].value_counts().plot(kind='bar', color='darkred')
plt.title('Nombre de produits par catégorie', fontsize=16)
plt.xlabel('Catégories')
plt.ylabel('Nombre de produits')
# Ajouter les effectifs sur les barres
for p in ax.patches:
ax.annotate(str(p.get_height()), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
ax.spines[['top', 'right']].set_visible(False)
plt.xticks(rotation=0)
plt.grid(False)
plt.show()
# Afficher la proportion de chaque catégorie dans le nombre total de produits
products['categ'].value_counts(normalize=True)
0 0.702374 1 0.224893 2 0.072733 Name: categ, dtype: float64
# Visualiser la distribution empirique de la variable catégorie à l'aide d'un barplot
plt.figure(figsize=(10, 6))
ax = products['categ'].value_counts(normalize=True).plot(kind='bar', color='darkred')
plt.title('Proportion de chaque catégorie dans le nombre total des produits commercialisés', fontsize=16, pad=20)
plt.xlabel('Catégories')
plt.ylabel('Proportion')
# Ajouter les pourcentages sur les barres
for p in ax.patches:
ax.annotate("{:.2%}".format(p.get_height()), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
ax.spines[['top', 'right']].set_visible(False)
plt.xticks(rotation=0)
plt.grid(False)
plt.show()
# Changer le type de la variable categ en chaîne de caractères (vu qu'il s'agit d'une variable qualitative)
products['categ'] = products['categ'].astype(str)
# Afficher le nombre de lignes, de colonnes et de valeurs non-nulles dans chaque colonne
transactions.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 687534 entries, 0 to 687533 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id_prod 687534 non-null object 1 date 687534 non-null object 2 session_id 687534 non-null object 3 client_id 687534 non-null object dtypes: object(4) memory usage: 21.0+ MB
# Afficher les 5 premières lignes du dataframe transactions
transactions.head()
id_prod | date | session_id | client_id | |
---|---|---|---|---|
0 | 0_1259 | 2021-03-01 00:01:07.843138 | s_1 | c_329 |
1 | 0_1390 | 2021-03-01 00:02:26.047414 | s_2 | c_664 |
2 | 0_1352 | 2021-03-01 00:02:38.311413 | s_3 | c_580 |
3 | 0_1458 | 2021-03-01 00:04:54.559692 | s_4 | c_7912 |
4 | 0_1358 | 2021-03-01 00:05:18.801198 | s_5 | c_2033 |
# renommer les identifiants de sessions et les convertir en entiers
transactions['session_id'] = transactions['session_id'].str.slice(2) # retire les deux premiers caractères
transactions['session_id'] = transactions['session_id'].astype(int)
transactions.head()
id_prod | date | session_id | client_id | |
---|---|---|---|---|
0 | 0_1259 | 2021-03-01 00:01:07.843138 | 1 | c_329 |
1 | 0_1390 | 2021-03-01 00:02:26.047414 | 2 | c_664 |
2 | 0_1352 | 2021-03-01 00:02:38.311413 | 3 | c_580 |
3 | 0_1458 | 2021-03-01 00:04:54.559692 | 4 | c_7912 |
4 | 0_1358 | 2021-03-01 00:05:18.801198 | 5 | c_2033 |
# renommer les identifiants des clients et les convertir en entiers
transactions['client_id'] = transactions['client_id'].str.slice(2) # retirer les deux premiers caractères
transactions['client_id'] = transactions['client_id'].astype(int)
transactions.head()
id_prod | date | session_id | client_id | |
---|---|---|---|---|
0 | 0_1259 | 2021-03-01 00:01:07.843138 | 1 | 329 |
1 | 0_1390 | 2021-03-01 00:02:26.047414 | 2 | 664 |
2 | 0_1352 | 2021-03-01 00:02:38.311413 | 3 | 580 |
3 | 0_1458 | 2021-03-01 00:04:54.559692 | 4 | 7912 |
4 | 0_1358 | 2021-03-01 00:05:18.801198 | 5 | 2033 |
# On vérifie s'il y a des doublons
transactions.duplicated().sum()
0
# Afficher le nombre de produits uniques ayant fait l'objet d'une transaction, ne peut pas être supérieur à 3286
transactions['id_prod'].unique().shape[0]
3265
# Afficher le nombre de clients uniques ayant effectué un achat
transactions['client_id'].unique().shape[0]
8600
# Afficher le nombre de sessions uniques
number_of_unique_sessions = transactions['session_id'].unique().shape[0]
number_of_unique_sessions
345505
# créer un dataframe avec une colonne affichant le nombre de produits achetés par session
# En effet, une transaction = un achat = un seul produit => donc une session (qui englobe souvent plusieurs produits) = un panier
transactions_per_session = transactions.groupby('session_id')['date'].nunique()
transactions_per_session = transactions_per_session.reset_index()
transactions_per_session.columns = ['session_id', 'nb_of_products_bought_during_session']
transactions_per_session.head()
session_id | nb_of_products_bought_during_session | |
---|---|---|
0 | 1 | 1 |
1 | 2 | 1 |
2 | 3 | 3 |
3 | 4 | 2 |
4 | 5 | 1 |
# Afficher l'histogramme du nombre de produits achetés par session
plt.figure(figsize=(9,6))
transactions_per_session['nb_of_products_bought_during_session'].hist(bins=15, color='darkred')
plt.title('Distribution du nombre de produits achetés par session', fontsize=16)
plt.xlabel('Nombre de produits achetés lors d\'une session')
plt.grid(False)
plt.ylabel('Nombre de sessions')
Text(0, 0.5, 'Nombre de sessions')
# Afficher le nombre maximum d'achats en une session
transactions_per_session['nb_of_products_bought_during_session'].max()
14
# Ajouter une colonne au dataframe transactions
transactions = pd.merge(transactions, transactions_per_session, on='session_id')
transactions.head(15)
id_prod | date | session_id | client_id | nb_of_products_bought_during_session | |
---|---|---|---|---|---|
0 | 0_1259 | 2021-03-01 00:01:07.843138 | 1 | 329 | 1 |
1 | 0_1390 | 2021-03-01 00:02:26.047414 | 2 | 664 | 1 |
2 | 0_1352 | 2021-03-01 00:02:38.311413 | 3 | 580 | 3 |
3 | 0_1638 | 2021-03-01 00:10:37.223732 | 3 | 580 | 3 |
4 | 0_1110 | 2021-03-01 00:38:57.630675 | 3 | 580 | 3 |
5 | 0_1458 | 2021-03-01 00:04:54.559692 | 4 | 7912 | 2 |
6 | 1_310 | 2021-03-01 00:17:11.089942 | 4 | 7912 | 2 |
7 | 0_1358 | 2021-03-01 00:05:18.801198 | 5 | 2033 | 1 |
8 | 0_1073 | 2021-03-01 00:05:44.999018 | 6 | 4908 | 3 |
9 | 0_279 | 2021-03-01 00:07:48.507530 | 6 | 4908 | 3 |
10 | 0_1475 | 2021-03-01 00:16:16.649539 | 6 | 4908 | 3 |
11 | 0_1304 | 2021-03-01 00:07:04.371179 | 7 | 1609 | 2 |
12 | 0_1159 | 2021-03-01 00:11:57.832228 | 7 | 1609 | 2 |
13 | 1_445 | 2021-03-01 00:09:11.523122 | 8 | 7991 | 1 |
14 | 1_556 | 2021-03-01 00:10:20.265265 | 9 | 6171 | 1 |
# Récupérer les id des produits n'ayant fait l'objet d'aucune transaction
products_sold_at_least_once = transactions['id_prod'].unique()
products_not_sold = products.loc[~products['id_prod'].isin(products_sold_at_least_once)]
products_not_sold
id_prod | price | categ | price_group | |
---|---|---|---|---|
184 | 0_1016 | 35.06 | 0 | 0-50 |
279 | 0_1780 | 1.67 | 0 | 0-50 |
736 | 0_1062 | 20.08 | 0 | 0-50 |
793 | 0_1119 | 2.99 | 0 | 0-50 |
810 | 0_1014 | 1.15 | 0 | 0-50 |
845 | 1_0 | 31.82 | 1 | 0-50 |
1030 | 0_1318 | 20.92 | 0 | 0-50 |
1138 | 0_1800 | 22.05 | 0 | 0-50 |
1346 | 0_1645 | 2.99 | 0 | 0-50 |
1504 | 0_322 | 2.99 | 0 | 0-50 |
1529 | 0_1620 | 0.80 | 0 | 0-50 |
1542 | 0_1025 | 24.99 | 0 | 0-50 |
1708 | 2_87 | 220.99 | 2 | 200-250 |
1862 | 1_394 | 39.73 | 1 | 0-50 |
1945 | 2_72 | 141.32 | 2 | 100-150 |
2214 | 0_310 | 1.94 | 0 | 0-50 |
2407 | 0_1624 | 24.50 | 0 | 0-50 |
2524 | 2_86 | 132.36 | 2 | 100-150 |
2689 | 0_299 | 22.99 | 0 | 0-50 |
3030 | 0_510 | 23.66 | 0 | 0-50 |
3095 | 0_2308 | 20.28 | 0 | 0-50 |
# Récupérer les id des clients n'ayant jamais effectué un achat
customers_who_purchased = transactions['client_id'].unique()
customers_who_never_purchased = customers.loc[~customers['client_id'].isin(customers_who_purchased)]
customers_who_never_purchased
client_id | sex | birth | age | age_group | |
---|---|---|---|---|---|
801 | 8253 | female | 2001 | 23 | 20-29 |
2483 | 3789 | female | 1997 | 27 | 20-29 |
2734 | 4406 | female | 1998 | 26 | 20-29 |
2768 | 2706 | female | 1967 | 57 | 50-59 |
2850 | 3443 | male | 1959 | 65 | 60 and over |
3178 | 4447 | male | 1956 | 68 | 60 and over |
3189 | 3017 | female | 1992 | 32 | 30-39 |
3333 | 4086 | female | 1992 | 32 | 30-39 |
3720 | 6930 | male | 2004 | 20 | 20-29 |
3820 | 4358 | male | 1999 | 25 | 20-29 |
4723 | 8381 | female | 1965 | 59 | 50-59 |
4790 | 1223 | male | 1963 | 61 | 60 and over |
6111 | 6862 | female | 2002 | 22 | 20-29 |
6207 | 5245 | female | 2004 | 20 | 20-29 |
6337 | 5223 | male | 2003 | 21 | 20-29 |
6470 | 6735 | male | 2004 | 20 | 20-29 |
6584 | 862 | female | 1956 | 68 | 60 and over |
6827 | 7584 | female | 1960 | 64 | 60 and over |
7789 | 90 | male | 2001 | 23 | 20-29 |
7818 | 587 | male | 1993 | 31 | 30-39 |
8534 | 3526 | male | 1956 | 68 | 60 and over |
# On fusionne les 3 dataframes
# jointure interne entre products et transactions pour écarter les 21 produits qui ne figurent pas dans transactions
# puis nouvelle jointure interne pour ne récupérer que les clients ayant effectué au minimum une transaction.
df = pd.merge(products, transactions, on='id_prod', how='inner').merge(customers, on='client_id', how='inner')
df.sort_values(by='date').head()
id_prod | price | categ | price_group | date | session_id | client_id | nb_of_products_bought_during_session | sex | birth | age | age_group | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
642805 | 0_1259 | 11.99 | 0 | 0-50 | 2021-03-01 00:01:07.843138 | 1 | 329 | 1 | female | 1967 | 57 | 50-59 |
293202 | 0_1390 | 19.37 | 0 | 0-50 | 2021-03-01 00:02:26.047414 | 2 | 664 | 1 | male | 1960 | 64 | 60 and over |
29695 | 0_1352 | 4.50 | 0 | 0-50 | 2021-03-01 00:02:38.311413 | 3 | 580 | 3 | male | 1988 | 36 | 30-39 |
226546 | 0_1458 | 6.55 | 0 | 0-50 | 2021-03-01 00:04:54.559692 | 4 | 7912 | 2 | female | 1989 | 35 | 30-39 |
301488 | 0_1358 | 16.49 | 0 | 0-50 | 2021-03-01 00:05:18.801198 | 5 | 2033 | 1 | female | 1956 | 68 | 60 and over |
# Afficher (nombre de lignes, nombre de colonnes) du dataframe fusionné
df.shape
(687534, 12)
# Renommer les colonnes date et birth pour plus de clarté
df = df.rename(columns={'date': 'transaction_date', 'birth' : 'year_of_birth'})
df.head()
id_prod | price | categ | price_group | transaction_date | session_id | client_id | nb_of_products_bought_during_session | sex | year_of_birth | age | age_group | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0_1421 | 19.99 | 0 | 0-50 | 2021-03-01 04:13:00.107748 | 101 | 8533 | 3 | male | 1972 | 52 | 50-59 |
1 | 0_1421 | 19.99 | 0 | 0-50 | 2022-10-01 04:13:00.107748 | 276043 | 8533 | 3 | male | 1972 | 52 | 50-59 |
2 | 0_1421 | 19.99 | 0 | 0-50 | 2022-12-01 04:13:00.107748 | 305391 | 8533 | 3 | male | 1972 | 52 | 50-59 |
3 | 0_1421 | 19.99 | 0 | 0-50 | 2023-01-01 04:13:00.107748 | 320253 | 8533 | 3 | male | 1972 | 52 | 50-59 |
4 | 0_2199 | 12.99 | 0 | 0-50 | 2021-03-25 17:43:48.819074 | 11366 | 8533 | 5 | male | 1972 | 52 | 50-59 |
La conversion de la colonne transaction_date au format datetime renvoie une erreur. Certaines valeurs seraient incompatibles avec le format datetime.
Voyons cela de plus près.
# Fonction qui va permettre de filtrer le dataframe
def check_date(date_str):
# on s'assure que la valeur est bien une chaîne de caractères (car il y a quelques NaN correpondant aux clients n'ayant acheté aucun produit)
if isinstance(date_str, str):
# on récupére l'heure (11e et 12e caractères de la chaîne en partant de 0)
hour = date_str[11:13]
if hour == '24':
return True
return False
# On crée une colonne qui affiche True s'il existe une anomalie de format de date
df['anomalies'] = df['transaction_date'].apply(check_date)
# On Visualise les anomalies
# pd.set_option('display.max_rows', 236)
df[df['anomalies'] == True].head(10)
id_prod | price | categ | price_group | transaction_date | session_id | client_id | nb_of_products_bought_during_session | sex | year_of_birth | age | age_group | anomalies | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
81244 | 1_348 | 16.15 | 1 | 0-50 | 2021-10-20 24:01:38.673154 | 101450 | 2713 | 1 | female | 1970 | 54 | 50-59 | True |
85926 | 1_257 | 22.99 | 1 | 0-50 | 2021-10-03 24:20:42.539778 | 100545 | 1365 | 3 | male | 1983 | 41 | 40-49 | True |
85952 | 1_403 | 17.99 | 1 | 0-50 | 2021-10-03 24:20:24.463408 | 100545 | 1365 | 3 | male | 1983 | 41 | 40-49 | True |
86081 | 1_475 | 20.99 | 1 | 0-50 | 2021-10-03 24:20:05.463408 | 100545 | 1365 | 3 | male | 1983 | 41 | 40-49 | True |
95801 | 1_683 | 13.99 | 1 | 0-50 | 2021-10-05 24:35:07.554906 | 103368 | 195 | 1 | male | 1957 | 67 | 60 and over | True |
98376 | 1_683 | 13.99 | 1 | 0-50 | 2021-10-11 24:51:58.640773 | 105904 | 7925 | 2 | female | 1984 | 40 | 40-49 | True |
98418 | 1_730 | 22.65 | 1 | 0-50 | 2021-10-11 24:51:10.640773 | 105904 | 7925 | 2 | female | 1984 | 40 | 40-49 | True |
107095 | 1_249 | 22.99 | 1 | 0-50 | 2021-10-20 24:54:31.664543 | 103234 | 7399 | 1 | female | 1988 | 36 | 30-39 | True |
112799 | 1_396 | 18.60 | 1 | 0-50 | 2021-10-10 24:45:24.678543 | 104373 | 5632 | 3 | male | 1956 | 68 | 60 and over | True |
112805 | 1_282 | 23.20 | 1 | 0-50 | 2021-10-10 24:45:57.116331 | 104373 | 5632 | 3 | male | 1956 | 68 | 60 and over | True |
# Fonction pour transformer les 24 en 00 :
def correct_date(date_str):
# on s'assure que la valeur est bien une chaîne de caractères (car il y a quelques NaN correpondant aux clients n'ayant acheté aucun produit)
if isinstance(date_str, str):
# on récupére l'heure (11e et 12e caractères de la chaîne en partant de 0)
hour = date_str[11:13]
if hour == '24':
# on remplace 24 par 00 à l'aide d'une concaténation
date_str = date_str[:11] + '00' + date_str[13:]
return date_str
df['transaction_date'] = df['transaction_date'].apply(correct_date)
# On vérifie si les changements ont été effectués
df[df['anomalies'] == True].head()
id_prod | price | categ | price_group | transaction_date | session_id | client_id | nb_of_products_bought_during_session | sex | year_of_birth | age | age_group | anomalies | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
81244 | 1_348 | 16.15 | 1 | 0-50 | 2021-10-20 00:01:38.673154 | 101450 | 2713 | 1 | female | 1970 | 54 | 50-59 | True |
85926 | 1_257 | 22.99 | 1 | 0-50 | 2021-10-03 00:20:42.539778 | 100545 | 1365 | 3 | male | 1983 | 41 | 40-49 | True |
85952 | 1_403 | 17.99 | 1 | 0-50 | 2021-10-03 00:20:24.463408 | 100545 | 1365 | 3 | male | 1983 | 41 | 40-49 | True |
86081 | 1_475 | 20.99 | 1 | 0-50 | 2021-10-03 00:20:05.463408 | 100545 | 1365 | 3 | male | 1983 | 41 | 40-49 | True |
95801 | 1_683 | 13.99 | 1 | 0-50 | 2021-10-05 00:35:07.554906 | 103368 | 195 | 1 | male | 1957 | 67 | 60 and over | True |
# Suppression de la colonne anomalies
df.drop(columns='anomalies', inplace=True)
# Convertir la colonne transaction_date au format datetime
df['transaction_date'] = pd.to_datetime(df['transaction_date'])
# Vérifier si la variable transaction_date est maintenant de type datetime
df.dtypes
id_prod object price float64 categ object price_group category transaction_date datetime64[ns] session_id int32 client_id int32 nb_of_products_bought_during_session int64 sex object year_of_birth int64 age int64 age_group object dtype: object
# Afficher les 10 meilleurs clients
grouped_by_client_id = df.groupby('client_id')['price'].sum()
grouped_by_client_id = grouped_by_client_id.reset_index()
grouped_by_client_id.columns = ['client_id', 'customer_total_sales']
best_customers = grouped_by_client_id.sort_values(by='customer_total_sales', ascending=False).head(10)
best_customers
client_id | customer_total_sales | |
---|---|---|
1604 | 1609 | 326039.89 |
4944 | 4958 | 290227.03 |
6698 | 6714 | 153918.60 |
3446 | 3454 | 114110.57 |
1565 | 1570 | 5285.82 |
3256 | 3263 | 5276.87 |
2135 | 2140 | 5260.18 |
2893 | 2899 | 5214.05 |
7300 | 7319 | 5155.77 |
7939 | 7959 | 5135.75 |
# La méthode de l'équart interquartile renvoie 251 outliers mais ce sont surtout les 4 premiers qui se distinguent :
# le 4ème plus gros client a généré plus de 20 fois le chiffre d'affaires du 5ème
IQR_outliers(grouped_by_client_id, 'customer_total_sales')
client_id | customer_total_sales | |
---|---|---|
1604 | 1609 | 326039.89 |
4944 | 4958 | 290227.03 |
6698 | 6714 | 153918.60 |
3446 | 3454 | 114110.57 |
1565 | 1570 | 5285.82 |
... | ... | ... |
7226 | 7245 | 3659.80 |
381 | 383 | 3656.99 |
8179 | 8199 | 3655.61 |
3889 | 3899 | 3654.40 |
4171 | 4182 | 3653.22 |
251 rows × 2 columns
# On récupère les identifiants des 4 outliers
outliers_id = best_customers.iloc[:4,0]
outliers_id
1604 1609 4944 4958 6698 6714 3446 3454 Name: client_id, dtype: int64
La méthode de l'équart interquartile renvoie 251 outliers mais ce sont surtout les 4 premiers qui se distinguent :
le 4ème plus gros client a généré plus de 20 fois le chiffre d'affaires du 5ème. Ces 4 gros clients sont certainement des professionnels.
Il sera judicieux de les écarter lors de certaines analyses.
# Afficher la plus ancienne et la plus récente date de transaction
print(df['transaction_date'].min())
print(df['transaction_date'].max())
# Temps écoulé entre la première transaction et la dernière
print(df['transaction_date'].max() - df['transaction_date'].min())
2021-03-01 00:01:07.843138 2023-02-28 23:58:30.792755 729 days 23:57:22.949617
# Calculer le chiffre d'affaires total entre mars 2021 et février 2023
revenue = int(df['price'].sum())
print('Le chiffre d\'affaires total s\'élève à',revenue,'€.')
Le chiffre d'affaires total s'élève à 12027663 €.
# Placer les dates en index
df = df.sort_values(by='transaction_date').set_index('transaction_date')
df.head()
id_prod | price | categ | price_group | session_id | client_id | nb_of_products_bought_during_session | sex | year_of_birth | age | age_group | |
---|---|---|---|---|---|---|---|---|---|---|---|
transaction_date | |||||||||||
2021-03-01 00:01:07.843138 | 0_1259 | 11.99 | 0 | 0-50 | 1 | 329 | 1 | female | 1967 | 57 | 50-59 |
2021-03-01 00:02:26.047414 | 0_1390 | 19.37 | 0 | 0-50 | 2 | 664 | 1 | male | 1960 | 64 | 60 and over |
2021-03-01 00:02:38.311413 | 0_1352 | 4.50 | 0 | 0-50 | 3 | 580 | 3 | male | 1988 | 36 | 30-39 |
2021-03-01 00:04:54.559692 | 0_1458 | 6.55 | 0 | 0-50 | 4 | 7912 | 2 | female | 1989 | 35 | 30-39 |
2021-03-01 00:05:18.801198 | 0_1358 | 16.49 | 0 | 0-50 | 5 | 2033 | 1 | female | 1956 | 68 | 60 and over |
# Calculer le chiffre d'affaires de 2021, 2022 et 2023
df_resampled = df.resample('Y')['price'].sum()
df_resampled = df_resampled.reset_index()
df_resampled.columns = ['Year','Revenue']
df_resampled['Year'] = df_resampled['Year'].dt.year
df_resampled
Year | Revenue | |
---|---|---|
0 | 2021 | 4944760.98 |
1 | 2022 | 6108681.81 |
2 | 2023 | 974220.31 |
# Visualiser le chiffre d'affaires par année
plt.figure(figsize=(10, 6))
ax = df_resampled['Revenue'].plot(kind='bar', color='darkred')
plt.title('Chiffre d\'affaires par année', fontsize=16)
plt.xlabel('Années')
plt.ylabel('Chiffre d\'affaires millions d\'euros')
ax.spines[['top', 'right']].set_visible(False)
ax.set_xticklabels(df_resampled['Year'])
plt.xticks(rotation=0)
formatter = ticker.FuncFormatter(lambda x, pos: '{:,.0f}'.format(x/1e6))
plt.gca().yaxis.set_major_formatter(formatter)
plt.grid(False)
plt.show()
L'année 2021 est incomplète, il manque les mois de janvier et février, tout comme l'année 2023 dont les données se résument à ces deux mêmes mois (janvier et février). En faisant la somme du CA 2021 et 2023 pour avoir une année complète on a 5918981 € de CA versus 6108681 € en 2022. On en déduit que le chiffre d'affaires annuel moyen tourne autour des 6 millions d'euros
# Calcul du CA journalier, hebdomadaire et mensuel moyen
daily_revenue = revenue / 730
weekly_revenue = revenue / 104.28
monthly_revenue = revenue / 24
print('CA journalier moyen: ', int(daily_revenue),'€')
print('CA hebdomadaire moyen: ', int(weekly_revenue),'€')
print('CA mensuel moyen: ', int(monthly_revenue),'€')
CA journalier moyen: 16476 € CA hebdomadaire moyen: 115340 € CA mensuel moyen: 501152 €
# Afficher l'évolution du chiffre d'affaires journalier depuis le 1er mars 2021
daily_sales = df.loc['2021-03-01':'2023-02-28', 'price'].resample('D').sum()
rolling_daily_mean = daily_sales.rolling(window=7).mean()
weekly_sales = df.loc['2021-03-01':'2023-02-28', 'price'].resample('W').sum()
rolling_weekly_mean = weekly_sales.rolling(window=4).mean()
plt.figure(figsize=(24, 20))
plt.subplot(4,3,1)
sns.lineplot(data=daily_sales, label='CA journalier', color='darkred')
sns.lineplot(data=rolling_daily_mean, label='MM à 7 jours', color='gold')
plt.title('Évolution du chiffre d\'affaires journalier', fontsize=16, pad=20)
plt.xlabel('Date')
plt.ylabel('Chiffre d\'affaires en €')
plt.xticks(rotation=45)
plt.legend(bbox_to_anchor=(0.90, 1), loc='upper left')
sns.despine()
plt.subplot(4,3,2)
sns.lineplot(data=weekly_sales, label='CA hebdo', color='darkred')
sns.lineplot(data=rolling_weekly_mean, label='MM à 4 semaines', color='gold')
plt.title('Évolution du chiffre d\'affaires hebdomadaire', fontsize=16, pad=20)
plt.xlabel('Date')
plt.ylabel('Chiffre d\'affaires en €')
plt.xticks(rotation=45)
plt.legend(bbox_to_anchor=(0.90, 1), loc='upper left')
sns.despine()
plt.tight_layout(pad=3)
plt.show()
# Afficher l'évolution du chiffre d'affaires mensuel depuis le 1er mars 2021
# On remarque une baisse d'ampleur inhabituelle en février 2023 mais,, avec 24 mois de données, nous n'avons pas assez de recul pour en tirer des conclusions
monthly_sales = df.loc['2021-03-01':'2023-02-28', 'price'].resample('M').sum()
rolling_monthly_mean = monthly_sales.rolling(window=3).mean()
plt.figure(figsize=(12, 6))
sns.lineplot(data=monthly_sales, label='Chiffre d\'affaires mensuel', color='red')
sns.lineplot(data=rolling_monthly_mean, label='Moyenne mobile à 3 mois', color='gold')
plt.title('Évolution du chiffre d\'affaires mensuel', fontsize=16, pad=20)
plt.xlabel('Date')
plt.ylabel('Chiffre d\'affaires en €')
plt.legend()
sns.despine()
plt.show()
# Décomposition saisonnière de la variation du chiffre d'affaires (journalier) pour visualiser la part de la variation liée à la tendance de fond
# celle liée à la saisonnalité et enfin celle causée par le bruit
result_daily = seasonal_decompose(daily_sales, model='additive')
fig, axs = plt.subplots(4, figsize=(12, 8))
axs[0].plot(result_daily.observed, color='red')
axs[0].set_title('Observé - UT jour', fontsize=16)
axs[1].plot(result_daily.trend, color='red')
axs[1].set_title('Tendance', fontsize=16)
axs[2].plot(result_daily.seasonal, color='red')
axs[2].set_title('Saisonnalité', fontsize=16)
axs[3].plot(result_daily.resid, color='red')
axs[3].set_title('Bruit', fontsize=16)
plt.tight_layout(pad=3.0)
plt.show()
# Décomposition saisonnière en UT semaine
result_weekly = seasonal_decompose(weekly_sales, model='additive')
fig, axs = plt.subplots(4, figsize=(12, 8))
axs[0].plot(result_weekly.observed, color='red')
axs[0].set_title('Observé - UT semaine', fontsize=16)
axs[1].plot(result_weekly.trend, color='red')
axs[1].set_title('Tendance', fontsize=16)
axs[2].plot(result_weekly.seasonal, color='red')
axs[2].set_title('Saisonnalité', fontsize=16)
axs[3].plot(result_weekly.resid, color='red')
axs[3].set_title('Bruit', fontsize=16)
plt.tight_layout(pad=3.0)
plt.show()
En UT jour on remarque, dans la composante tendance, un pic haussier en septembre 2021 suivi par une forte chute du CA le mois suivant. Hypothèse: cette baisse soudaine du chiffre d'affaires coincide avec l'adoption par l’Assemblée nationale, le 6 octobre 2021, de la loi visant à améliorer l'économie du livre et à renforcer l'équité entre ses acteurs, loi qui a notamment contraint les librairies à imposer à leurs clients un prix minimum de frais de port pour les livres achetés en ligne, quel que soit le commerçant, de la petite librairie à la grande plateforme de e-commerce. Cela vise à rétablir une concurrence plus équilibrée entre les libraires indépendants et les grandes plateformes en ligne.
Cependant, si on décompose en UT semaine, ce "mouvement" n'est plus visible dans la partie tendance. Par contre, on retrouve ce même phénomène dans la composante saisonnière et il se répète l'année suivante. Les pics haussiers et baissiers de septembre 2021 et 2022 sont donc périodiques et certainement liés à la rentrée littéraire qui a lieu chaque année en septembre. Pendant cette période, de très nombreuses nouveautés sont publiées et de nombreux clients achètent ces livres puis le mois suivant on a le retour de baton; ces mêmes clients n'achètent plus, ils digèrent leurs achats.
# Il y a également un brusque décrochage en février 2023
# Comparons le CA de février 2022 à celui de février 2023
february_2022 = df.loc['2022-02-01':'2022-02-28', 'price'].sum()
february_2023 = df.loc['2023-02-01':'2023-02-28', 'price'].sum()
print('CA février 2022:', int(february_2022),'€')
print('CA février 2023:', int(february_2023),'€')
CA février 2022: 535571 € CA février 2023: 456679 €
# Voyons quelles catégories ont le plus contribué à la baisse
df_february_2022 = df.loc['2022-02-01':'2022-02-28']
categ_sales_february_2022 = {}
for categ in df_february_2022['categ'].unique():
categ_sales_february_2022[categ] = int(df_february_2022.loc[df_february_2022['categ']==categ, 'price'].sum())
df_february_2023 = df.loc['2023-02-01':'2023-02-28']
categ_sales_february_2023 = {}
for categ in df_february_2023['categ'].unique():
categ_sales_february_2023[categ] = int(df_february_2023.loc[df_february_2023['categ']==categ, 'price'].sum())
print('Ventes par catégorie février 2022:',categ_sales_february_2022)
print('Ventes par catégorie février 2023:',categ_sales_february_2023)
Ventes par catégorie février 2022: {'1': 213120, '0': 183197, '2': 139253} Ventes par catégorie février 2023: {'0': 162457, '1': 180347, '2': 113875}
# Afficher la distribution des produits par tranche de prix et par catégorie
# Les produits de catégorie 2 sont très chers...
pd.crosstab(products['categ'], products['price_group'], margins=True)
price_group | 0-50 | 50-100 | 100-150 | 150-200 | 200-250 | 250-300 | All |
---|---|---|---|---|---|---|---|
categ | |||||||
0 | 2308 | 0 | 0 | 0 | 0 | 0 | 2308 |
1 | 684 | 55 | 0 | 0 | 0 | 0 | 739 |
2 | 21 | 95 | 79 | 29 | 13 | 2 | 239 |
All | 3013 | 150 | 79 | 29 | 13 | 2 | 3286 |
# Calculer le panier moyen par catégorie
# Le montant d'un panier est le montant des transactions (ou achats) au cours d'une même session.
# Or, au cours d'une même session (et donc au sein d'un même panier) on peut trouver des produits appartenant à différentes catégories.
# Comme on peut le voir ci-dessous, la session 4 contient 2 transactions: l'achat d'un produit de catégorie 1 à 6.55 € et l'achat d'un produit de catégorie 2 à 14.20 €
df.sort_values(by='session_id').head(10)
id_prod | price | categ | price_group | session_id | client_id | nb_of_products_bought_during_session | sex | year_of_birth | age | age_group | |
---|---|---|---|---|---|---|---|---|---|---|---|
transaction_date | |||||||||||
2021-03-01 00:01:07.843138 | 0_1259 | 11.99 | 0 | 0-50 | 1 | 329 | 1 | female | 1967 | 57 | 50-59 |
2021-03-01 00:02:26.047414 | 0_1390 | 19.37 | 0 | 0-50 | 2 | 664 | 1 | male | 1960 | 64 | 60 and over |
2021-03-01 00:02:38.311413 | 0_1352 | 4.50 | 0 | 0-50 | 3 | 580 | 3 | male | 1988 | 36 | 30-39 |
2021-03-01 00:38:57.630675 | 0_1110 | 4.71 | 0 | 0-50 | 3 | 580 | 3 | male | 1988 | 36 | 30-39 |
2021-03-01 00:10:37.223732 | 0_1638 | 5.46 | 0 | 0-50 | 3 | 580 | 3 | male | 1988 | 36 | 30-39 |
2021-03-01 00:04:54.559692 | 0_1458 | 6.55 | 0 | 0-50 | 4 | 7912 | 2 | female | 1989 | 35 | 30-39 |
2021-03-01 00:17:11.089942 | 1_310 | 14.20 | 1 | 0-50 | 4 | 7912 | 2 | female | 1989 | 35 | 30-39 |
2021-03-01 00:05:18.801198 | 0_1358 | 16.49 | 0 | 0-50 | 5 | 2033 | 1 | female | 1956 | 68 | 60 and over |
2021-03-01 00:16:16.649539 | 0_1475 | 11.99 | 0 | 0-50 | 6 | 4908 | 3 | female | 1981 | 43 | 40-49 |
2021-03-01 00:07:48.507530 | 0_279 | 16.99 | 0 | 0-50 | 6 | 4908 | 3 | female | 1981 | 43 | 40-49 |
# On peut définir le panier moyen par catégorie comme étant le montant total des achats de produits appartenant à cette catégorie au sein d'une même session
# divisé par le nombre de sessions uniques dans cette catégorie
# Regrouper les transactions par catégorie puis par sessions puis calculer, pour chaque session, la somme des transactions appartenant à ladite catégorie
# Ainsi on peut voir que pour la categ 0, session 4, on a bien 6.55 €
df_categories = df.groupby(['categ','session_id']).agg({'price': 'sum', 'nb_of_products_bought_during_session':'first'})
df_categories = df_categories.reset_index()
df_categories.columns = ['categ', 'session_id', 'session_order_value', 'nb_of_products_bought_during_session']
df_categories.head(10)
categ | session_id | session_order_value | nb_of_products_bought_during_session | |
---|---|---|---|---|
0 | 0 | 1 | 11.99 | 1 |
1 | 0 | 2 | 19.37 | 1 |
2 | 0 | 3 | 14.67 | 3 |
3 | 0 | 4 | 6.55 | 2 |
4 | 0 | 5 | 16.49 | 1 |
5 | 0 | 6 | 42.97 | 3 |
6 | 0 | 7 | 13.85 | 2 |
7 | 0 | 11 | 12.82 | 1 |
8 | 0 | 12 | 11.12 | 3 |
9 | 0 | 13 | 3.19 | 1 |
# calcul du panier moyen pour chaque catégorie
categs = ['0', '1', '2']
average_order_value = {}
# Pour chaque catégorie
for categ in categs:
# On fait la somme des paniers de la catégorie que l'on divise par le nombre de sessions uniques de ladite catégorie
average_order_value[categ] = df_categories.loc[df_categories['categ']== categ, 'session_order_value'].mean()
df_average_order_value = pd.DataFrame(list(average_order_value.items()), columns=['Category', 'Average Order Value'])
# Visualiser les résultats à l'aide d'un barplot
plt.figure(figsize=(10, 6))
ax = sns.barplot(data=df_average_order_value, x='Category', y='Average Order Value', color='darkred')
plt.title('Panier moyen par catégorie', fontsize=16)
plt.xlabel('Catégories')
plt.ylabel('Panier moyen en €')
for p in ax.patches:
ax.annotate(str(f"{p.get_height():.2f}"), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
sns.despine()
plt.show()
# Afficher la quantité moyenne d'articles par session pour chaque catégorie
average_nb_of_products_bought_per_session = {}
# Pour chaque catégorie
for categ in categs:
# On fait la somme du nombre d'articles achetés pendant chaque session puis on la divise par le nombre de sessions uniques de ladite catégorie
average_nb_of_products_bought_per_session[categ] = round(df_categories.loc[df_categories['categ']== categ, 'nb_of_products_bought_during_session'].mean(),2)
average_nb_of_products_bought_per_session
{'0': 2.36, '1': 2.19, '2': 1.75}
Rappel: la répartition des produits dans les différentes catégories est la suivante :
categ 0 : 0.702374, categ 1: 0.224893, categ 2: 0.072733
Comparons avec la proportion de chaque catégorie dans le nombre total de transactions. On remarque ci-dessous que la catégorie 1 fait mieux que les autres car elle ne représente que 22% des produits proposés à la vente mais plus de 34% des produits vendus.
# Proportion de chaque catégorie dans le nombre total de transactions
plt.figure(figsize=(10, 6))
ax = df['categ'].value_counts(normalize=True).plot(kind='bar', color='darkred')
plt.title('Proportion de chaque catégorie dans le nombre total de produits vendus', fontsize=16, pad=20)
plt.xlabel('Catégories')
plt.ylabel('Proportion')
for p in ax.patches:
ax.annotate("{:.2%}".format(p.get_height()), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
ax.spines[['top', 'right']].set_visible(False)
plt.xticks(rotation=0)
ax.set_axisbelow(True)
plt.show()
# Visualiser le chiffre d'affaires par catégorie de produits (en millions d'euros):
sales_per_categ = df.pivot_table(index='categ', values='price', aggfunc='sum')
sales_per_categ = sales_per_categ.reset_index()
sales_per_categ.columns = ['category','total_sales']
plt.figure(figsize=(9,6))
palette = sns.color_palette('Reds', n_colors=3).as_hex()[::-1]
sns.barplot(data=sales_per_categ.sort_values(by='total_sales', ascending=False), x='total_sales', y='category', orient='h', palette=palette)
plt.title('Chiffre d\'affaires généré par chaque catégorie', fontsize=16, loc='center', pad=20)
plt.ylabel('Catégories')
plt.xlabel('Chiffre d\'affaires en millions d\'euros')
formatter = ticker.FuncFormatter(lambda x, pos: '{:,.0f}'.format(x/1e6))
plt.gca().xaxis.set_major_formatter(formatter)
plt.box(False)
plt.show()
# Calculer la représentation de chaque catégorie dans le CA total en %
sales_per_categ['percentage_of_Lapage_revenue'] = round((sales_per_categ['total_sales'] / sales_per_categ['total_sales'].sum())*100,2)
sales_per_categ
category | total_sales | percentage_of_Lapage_revenue | |
---|---|---|---|
0 | 0 | 4419730.97 | 36.75 |
1 | 1 | 4827657.11 | 40.14 |
2 | 2 | 2780275.02 | 23.12 |
# Visualiser le chiffre d'affaires par catégorie de produits en pourcentage du CA total :
plt.figure(figsize=(9,6))
palette = sns.color_palette('Reds', n_colors=3).as_hex()[::-1]
sns.barplot(data=sales_per_categ.sort_values(by='percentage_of_Lapage_revenue', ascending=False), x='percentage_of_Lapage_revenue', y='category', orient='h', palette=palette)
plt.title('Pourcentage du chiffre d\'affaires généré par chaque catégorie', fontsize=16, loc='center', pad=20)
plt.ylabel('Catégories')
plt.xlabel('Chiffre d\'affaires en %')
plt.box(False)
plt.show()
La catégorie 1 est donc celle qui contribue le plus au chiffre d'affaires..
# Créer un dataframe avec le top 20 des produits les plus vendus
# .groupby('id_prod').size() retourne une series où l’index est id_prod et la valeur est le nombre de fois que chaque produit (id_prod) apparaît dans le dataframe df
sales_by_product = df.groupby('id_prod').size()
top_20_products = sales_by_product.nlargest(20)
top_20_products = top_20_products.reset_index()
top_20_products.columns = ['id_prod', 'units_sold']
top_20_products
id_prod | units_sold | |
---|---|---|
0 | 1_369 | 2340 |
1 | 1_417 | 2269 |
2 | 1_414 | 2246 |
3 | 1_498 | 2202 |
4 | 1_425 | 2163 |
5 | 1_403 | 2040 |
6 | 1_413 | 2036 |
7 | 1_412 | 2014 |
8 | 1_406 | 2003 |
9 | 1_407 | 2001 |
10 | 1_396 | 1999 |
11 | 1_398 | 1975 |
12 | 1_395 | 1953 |
13 | 1_400 | 1906 |
14 | 1_392 | 1899 |
15 | 1_376 | 1870 |
16 | 1_385 | 1870 |
17 | 1_388 | 1858 |
18 | 1_397 | 1858 |
19 | 1_383 | 1857 |
# Visualisation du top 20 à l'aide d'un barplot
plt.figure(figsize=(9,6))
palette = sns.color_palette("Reds", n_colors=20).as_hex()[::-1]
sns.barplot(data=top_20_products, y='id_prod', x='units_sold', orient='h', palette=palette)
plt.title("Top 20 des meilleures ventes", fontsize=16, loc='center', pad=20)
plt.ylabel("Identifiant produit")
plt.xlabel('Nombre d\'unités vendues')
plt.box(False)
plt.show()
# Lister les 20 produits les moins vendus dans un dataframe et l'afficher
flop_20_products = sales_by_product.nsmallest(20)
flop_20_products = flop_20_products.reset_index()
flop_20_products.columns = ['id_prod', 'units_sold']
flop_20_products
id_prod | units_sold | |
---|---|---|
0 | 0_1151 | 1 |
1 | 0_1284 | 1 |
2 | 0_1379 | 1 |
3 | 0_1498 | 1 |
4 | 0_1539 | 1 |
5 | 0_1601 | 1 |
6 | 0_1633 | 1 |
7 | 0_1683 | 1 |
8 | 0_1728 | 1 |
9 | 0_2201 | 1 |
10 | 0_541 | 1 |
11 | 0_549 | 1 |
12 | 0_807 | 1 |
13 | 0_833 | 1 |
14 | 0_886 | 1 |
15 | 2_23 | 1 |
16 | 2_81 | 1 |
17 | 2_98 | 1 |
18 | 0_1116 | 2 |
19 | 0_1120 | 2 |
Sans trop de surprise, on retrouve dans le top 20 uniquement des produits de la catégorie 1, tandis que seul des produits des catégories 0 et 2 figurent dans le flop 20.
# Créer un dataframe pour analyser la répartition du CA par produit (en incluant les 4 outliers)
sales_by_product = df.groupby('id_prod').agg({'price': 'sum', 'categ': 'first'})
sales_by_product = sales_by_product.reset_index()
sales_by_product.rename(columns={'price': 'total_sales_of_product'}, inplace=True)
sales_by_product = sales_by_product.sort_values(by='total_sales_of_product')
sales_by_product.head()
id_prod | total_sales_of_product | categ | |
---|---|---|---|
595 | 0_1539 | 0.99 | 0 |
313 | 0_1284 | 1.38 | 0 |
719 | 0_1653 | 1.98 | 0 |
1784 | 0_541 | 1.99 | 0 |
665 | 0_1601 | 1.99 | 0 |
# Créer une colonne CA cumulé
sales_by_product['cumulative_sales'] = sales_by_product['total_sales_of_product'].cumsum()
# Ajouter une colonne proportion cumulée du CA, obtenue en divisant la colonne CA cumulé par le chiffre d'affaires total
sales_by_product['cumulative_proportion_of_sales'] = sales_by_product['cumulative_sales']/sales_by_product['total_sales_of_product'].sum()
sales_by_product
id_prod | total_sales_of_product | categ | cumulative_sales | cumulative_proportion_of_sales | |
---|---|---|---|---|---|
595 | 0_1539 | 0.99 | 0 | 0.99 | 8.231025e-08 |
313 | 0_1284 | 1.38 | 0 | 2.37 | 1.970458e-07 |
719 | 0_1653 | 1.98 | 0 | 4.35 | 3.616663e-07 |
1784 | 0_541 | 1.99 | 0 | 6.34 | 5.271182e-07 |
665 | 0_1601 | 1.99 | 0 | 8.33 | 6.925701e-07 |
... | ... | ... | ... | ... | ... |
3152 | 2_209 | 56971.86 | 2 | 11737290.11 | 9.758579e-01 |
3034 | 2_102 | 60736.78 | 2 | 11798026.89 | 9.809077e-01 |
3045 | 2_112 | 65407.76 | 2 | 11863434.65 | 9.863458e-01 |
3070 | 2_135 | 69334.95 | 2 | 11932769.60 | 9.921104e-01 |
3096 | 2_159 | 94893.50 | 2 | 12027663.10 | 1.000000e+00 |
3265 rows × 5 columns
# Créer une fonction courbe de lorenz qui va afficher les inégalités dans la répartition du CA entre les différents produits
# L'argument df_cum_y doit être une colonne de proportion (ou fréquence) cumulée
def lorenz_curve(df, df_cum_y, x_label, y_label, title):
# n = nombre d'éléments de la colonne du dataframe
n = len(df[df_cum_y])
# Créer un array avec autant de 0 qu'il y a d'éléments dans la colonne du dataframe.
# Chaque zéro est associé à son élément et signifie que l'élément (ou individu) ne possède (ou génère) rien.
# La courbe de lorenz commence à 0. On insère donc un 0 au début de chaque array, d'où le +1
perfect_inequality = np.zeros(n+1)
# numpy.linspace(start, stop, num=50, endpoint=True, retstep=False, dtype=None, axis=0) => Returns evenly spaced numbers over a specified interval.
# Lister autant de fractions qu'il y a d'éléments dans la colonne. Si n est 5, np.linspace(0, 1, n+1) renvoie un 1d array contenant 0.2, 0.4, 0.6, 0.8, et 1
# soit des fractions représentant une distribution parfaitement égale : le premier élément possède 20 %, le premier et le deuxième possèdent ensemble 40 %, etc....
perfect_equality = np.linspace(0, 1, n+1)
# Même principe qu'au-dessus.
fractions = np.linspace(0, 1, n+1)
cum_y_array = np.insert(df[df_cum_y].values, 0, 0) # on insère un 0 au début de l'array contenant les proportions cumulées
# print(cum_y_array)
lorenz_curve_y_values = df[df_cum_y].values # la méthode .values renvoie les valeurs dans un np.array
lorenz_curve_x_values = np.array(range(1, n+1)) / n
B = np.trapz(lorenz_curve_y_values, x = lorenz_curve_x_values) # aire sous la courbe de Lorenz
A = 0.5 - B # aire entre la courbe de Lorenz et la droite d'équirépartition (première bissectrice)
gini_index = round(A * 2,2) # indice de Gini
plt.figure(figsize=(9,6))
plt.plot(fractions, perfect_equality, color='green')
plt.plot(fractions, perfect_inequality, color='red')
plt.plot(fractions, cum_y_array, color='gold')
plt.title(title, fontsize=16, pad=20)
plt.xlabel(x_label)
plt.ylabel(y_label)
plt.grid()
plt.legend(['Répartition parfaitement égale','Parfaite inégalité',f'Courbe de Lorenz | Indice de Gini : {gini_index}'], bbox_to_anchor=(1.075, -0.15), ncol=4)
plt.show()
return gini_index
# Visualiser la courbe de Lorenz montrant la répartition du CA entre les produits
print(lorenz_curve(sales_by_product, 'cumulative_proportion_of_sales', 'Proportion de produits', 'Proportion du CA réalisé', 'Répartition du chiffre d\'affaires entre les produits'))
0.74
La répartition est très inégale : 80 % du chiffre d'affaires de l'entreprise est réalisé par seulement 20 % des produits...conformément au principe de Pareto !
# Générer un dataframe trié
sales_by_product_sorted = sales_by_product[['id_prod', 'total_sales_of_product', 'cumulative_sales']].sort_values(by='total_sales_of_product', ascending=False).copy()
percentage_of_sales = sales_by_product[['id_prod', 'total_sales_of_product']].copy()
percentage_of_sales['percentage_of_sales'] = percentage_of_sales['total_sales_of_product']/percentage_of_sales['total_sales_of_product'].sum()*100
percentage_of_sales.sort_values(by='percentage_of_sales', ascending=False, inplace=True)
percentage_of_sales['cum_percentage'] = percentage_of_sales['percentage_of_sales'].cumsum()
sales_by_product_sorted.set_index('id_prod', inplace=True)
percentage_of_sales.set_index('id_prod', inplace=True)
percentage_of_sales.head()
total_sales_of_product | percentage_of_sales | cum_percentage | |
---|---|---|---|
id_prod | |||
2_159 | 94893.50 | 0.788960 | 0.788960 |
2_135 | 69334.95 | 0.576462 | 1.365423 |
2_112 | 65407.76 | 0.543811 | 1.909234 |
2_102 | 60736.78 | 0.504976 | 2.414210 |
2_209 | 56971.86 | 0.473674 | 2.887883 |
# Afficher la courbe de Pareto pour les produits
fig, ax1 = plt.subplots(figsize=(12,8))
ax1.bar(sales_by_product_sorted.index, sales_by_product_sorted['total_sales_of_product'], color='darkred')
ax1.set_xlabel('Produits')
ax1.set_ylabel('Chiffre d\'affaire généré par produit en €')
ax2 = ax1.twinx()
ax2.plot(percentage_of_sales.index, percentage_of_sales['cum_percentage'], color='gold', ms=7)
ax2.set_ylabel('Chiffre d\'affaires en pourcentage cumulé')
ax2.yaxis.set_major_formatter(PercentFormatter())
# Il y a 3265 produits, 3265 * 0.2 = 653, la tranche des 20% de produits qui génèrent le plus de CA s'arrête donc au 654 ème produit
plt.xticks(np.arange(0, 3265, 653), np.arange(1, 3266, 653))
x_position = sales_by_product_sorted.index[653] # 654 ème xtick
ax1.axvline(x=x_position, color='lightgray', linestyle='--', label='Ligne verticale')
y_intersection = percentage_of_sales['cum_percentage'].iloc[653]
ax2.axhline(y=y_intersection, color='lightgray', linestyle='--', label='Ligne horizontale')
plt.show()
# Récupérer les 20 % de produits qui font quasiment 80 % du CA (environ 77% du CA pour être plus précis)
# et visualiser la proportion de chaque catégorie
sales_by_product.sort_values(by='cumulative_proportion_of_sales', ascending=False, inplace=True)
top_20_percent_products = sales_by_product[sales_by_product['cumulative_proportion_of_sales'] >= 0.23]
top_20_percent_products['categ'].value_counts(normalize=True)
0 0.438710 1 0.372581 2 0.188710 Name: categ, dtype: float64
Désormais il est préférable d'exclure nos 4 clients "pas comme les autres" pour éviter de biaiser les résultats d'analyse.
# Retirer nos 4 principaux outliers en se servant des id
df_without_outliers = df.loc[~df['client_id'].isin(outliers_id)]
# Afficher le nombre de transactions post suppression de ces 4 outliers
df_without_outliers.shape[0]
640734
# Calculer le panier moyen tous clients confondus
mean_order = df_without_outliers.groupby('session_id')['price'].sum().mean()
print('Le panier moyen s\'élève à', round(mean_order,2),'€')
Le panier moyen s'élève à 34.56 €
# Visualiser le nombre de transactions réalisées par les hommes et les femmes
plt.figure(figsize=(10, 6))
ax = df_without_outliers['sex'].value_counts().plot(kind='bar', color='darkred')
plt.title('Nombre de produits vendus par genre', fontsize=16)
plt.xlabel('Genre')
plt.ylabel('Nombre de produits vendus')
for p in ax.patches:
ax.annotate(str(p.get_height()), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
ax.spines[['top', 'right']].set_visible(False)
plt.xticks(rotation=0)
ax.set_axisbelow(True)
plt.show()
# Visualiser la proportion des deux sexes dans le nombre total de transactions
plt.figure(figsize=(10, 6))
ax = df_without_outliers['sex'].value_counts(normalize=True).plot(kind='bar', color='darkred')
plt.title('Proportion de chaque genre dans le nombre de ventes', fontsize=16, pad=20)
plt.xlabel('Genre')
plt.ylabel('Proportion')
for p in ax.patches:
ax.annotate("{:.2%}".format(p.get_height()), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
ax.spines[['top', 'right']].set_visible(False)
plt.xticks(rotation=0)
ax.set_axisbelow(True)
plt.show()
# Créer un dataframe affichant le nombre de connexions (sessions) par sexe
sessions_by_gender = df_without_outliers.groupby('sex')['session_id'].nunique()
sessions_by_gender = sessions_by_gender.reset_index()
sessions_by_gender.columns = ['sex', 'nb_of_unique_sessions']
sessions_by_gender
sex | nb_of_unique_sessions | |
---|---|---|
0 | female | 168546 |
1 | male | 153920 |
# Visualiser le nombre de connexions (sessions) par sexe
plt.figure(figsize=(10, 6))
ax = sns.barplot(data= sessions_by_gender, x='sex', y='nb_of_unique_sessions', color='darkred')
plt.title('Nombre de sessions par genre', fontsize=16)
plt.xlabel('Genre')
plt.ylabel('Nombre de sessions')
for p in ax.patches:
ax.annotate(str(f"{p.get_height():.0f}"), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
plt.xticks(rotation=0)
ax.set_axisbelow(True)
sns.despine()
plt.show()
# Créer un dataframe contenant le montant de chaque panier
average_order_value_of_gender = df_without_outliers.groupby(['sex', 'session_id'])['price'].sum()
average_order_value_of_gender = average_order_value_of_gender.reset_index()
average_order_value_of_gender.columns = ['sex','session_id', 'session_order_value']
average_order_values = {}
for gender in average_order_value_of_gender['sex'].unique():
average_order_values[gender] = average_order_value_of_gender.loc[average_order_value_of_gender['sex'] == gender, 'session_order_value'].mean()
average_order_values
{'female': 34.393726816460955, 'male': 34.73519964920725}
# Convertir le dictionnaire en dataframe
average_order_values = pd.DataFrame(list(average_order_values.items()), columns=['Gender', 'Average_order_value'])
# Visualiser les résultats à l'aide d'un barplot
plt.figure(figsize=(10, 6))
ax = sns.barplot(data=average_order_values, x='Gender', y='Average_order_value', color='darkred')
plt.title('Panier moyen par genre', fontsize=16)
plt.xlabel('Genre')
plt.ylabel('Panier moyen en €')
for p in ax.patches:
ax.annotate(str(f"{p.get_height():.2f}"), (p.get_x() + p.get_width() / 2., p.get_height() * 0.50), ha='center', va='center', color='white', fontweight='bold')
sns.despine()
plt.show()
# Afficher le chiffre d'affaires total par sexe :
sales_by_gender = df_without_outliers.pivot_table(index='sex', values='price', aggfunc=sum)
sales_by_gender = sales_by_gender.reset_index()
sales_by_gender.rename(columns={'sex': 'gender', 'price': 'total_sales'}, inplace=True)
sales_by_gender
gender | total_sales | |
---|---|---|
0 | female | 5796925.08 |
1 | male | 5346441.93 |
# Visualiser le CA par sexe à l'aide d'un diagramme en secteurs
labels = ['Femme', 'Homme']
palette = sns.color_palette("Blues", n_colors=20).as_hex()[::-1]
first_blue = palette[0]
plt.figure(figsize=(9,6), facecolor='black')
wedges, texts, autotexts = plt.pie(x=sales_by_gender['total_sales'], labels=labels, autopct='%1.0f%%', pctdistance = 0.75, startangle=90, colors=['darkred', first_blue])
plt.title('Répartition du chiffre d\'affaires par genre', fontsize=16, loc='center', pad=20)
plt.setp(autotexts, color='white', weight='bold', size=14)
plt.setp(texts, color='white', family='Arial')
plt.show()
Le nombre d'achats est plus ou moins équitablement réparti entre hommes et femmes, tout comme le chiffre d'affaires.
Cela nous indique que le montant moyen d'un achat ne varie pas ou peu selon le sexe du client.
# Créer un dataframe qui affiche le CA réalisé par tranche d'âge
sales_by_age_group = df_without_outliers.pivot_table(index='age_group', values='price', aggfunc=sum)
sales_by_age_group = sales_by_age_group.reset_index()
sales_by_age_group.rename(columns={'price': 'total_sales'}, inplace=True)
sales_by_age_group['percentage_of_Lapage_revenue'] = round((sales_by_age_group['total_sales'] / sales_by_age_group['total_sales'].sum())*100,2)
sales_by_age_group = sales_by_age_group.sort_values(by='age_group')
sales_by_age_group
age_group | total_sales | percentage_of_Lapage_revenue | |
---|---|---|---|
0 | 20-29 | 2419357.60 | 21.71 |
1 | 30-39 | 2420859.27 | 21.72 |
2 | 40-49 | 2789124.63 | 25.03 |
3 | 50-59 | 1719738.81 | 15.43 |
4 | 60 and over | 1794286.70 | 16.10 |
# Visualiser la répartition du chiffre d'affaires par tranche d'âge
plt.figure(figsize=(10, 6))
ax = sales_by_age_group['percentage_of_Lapage_revenue'].plot(kind='bar', color='darkred')
plt.title('Répartition du chiffre d\'affaires par tranche d\'âge', fontsize=16, pad=20)
plt.xlabel('Tranches d\'âge')
plt.ylabel('Chiffre d\'affaires en %')
ax.spines[['top', 'right']].set_visible(False)
plt.xticks(rotation=0)
ax.set_xticklabels(sales_by_age_group['age_group'])
plt.show()
# Calculer l'âge moyen des clients
print('Âge moyen des clients:',int(df_without_outliers['age'].mean()),'ans')
Âge moyen des clients: 46 ans
# Créer une colonne CA cumulé
sales_by_age_group['cumulative_sales'] = sales_by_age_group['total_sales'].cumsum()
# Ajouter une colonne proportion cumulée du CA, obtenue en divisant la colonne CA cumulé par le chiffre d'affaires total
sales_by_age_group['cumulative_proportion_of_sales'] = sales_by_age_group['cumulative_sales']/sales_by_age_group['total_sales'].sum()
# Afficher le dataframe
sales_by_age_group
age_group | total_sales | percentage_of_Lapage_revenue | cumulative_sales | cumulative_proportion_of_sales | |
---|---|---|---|---|---|
0 | 20-29 | 2419357.60 | 21.71 | 2419357.60 | 0.217112 |
1 | 30-39 | 2420859.27 | 21.72 | 4840216.87 | 0.434359 |
2 | 40-49 | 2789124.63 | 25.03 | 7629341.50 | 0.684653 |
3 | 50-59 | 1719738.81 | 15.43 | 9349080.31 | 0.838982 |
4 | 60 and over | 1794286.70 | 16.10 | 11143367.01 | 1.000000 |
# Calculer le chiffre d'affaires généré par chaque client sur les deux ans
sales_by_customer = df_without_outliers.pivot_table(index='client_id', values='price', aggfunc=sum)
sales_by_customer = sales_by_customer.reset_index()
sales_by_customer.rename(columns={'price': 'total_sales_of_customer'}, inplace=True)
sales_by_customer = sales_by_customer.sort_values(by='total_sales_of_customer')
sales_by_customer.head()
client_id | total_sales_of_customer | |
---|---|---|
8326 | 8351 | 6.31 |
8116 | 8140 | 8.30 |
8090 | 8114 | 9.98 |
4632 | 4648 | 11.20 |
4462 | 4478 | 13.36 |
# Créer une colonne CA cumulé
sales_by_customer['cumulative_sales'] = sales_by_customer['total_sales_of_customer'].cumsum()
# Ajouter une colonne proportion cumulée du CA, obtenue en divisant la colonne CA cumulé par le chiffre d'affaires total
sales_by_customer['cumulative_proportion_of_sales'] = sales_by_customer['cumulative_sales']/sales_by_customer['total_sales_of_customer'].sum()
# Afficher le dataframe
sales_by_customer
client_id | total_sales_of_customer | cumulative_sales | cumulative_proportion_of_sales | |
---|---|---|---|---|
8326 | 8351 | 6.31 | 6.31 | 5.662561e-07 |
8116 | 8140 | 8.30 | 14.61 | 1.311094e-06 |
8090 | 8114 | 9.98 | 24.59 | 2.206694e-06 |
4632 | 4648 | 11.20 | 35.79 | 3.211776e-06 |
4462 | 4478 | 13.36 | 49.15 | 4.410696e-06 |
... | ... | ... | ... | ... |
7296 | 7319 | 5155.77 | 11122330.09 | 9.981122e-01 |
2892 | 2899 | 5214.05 | 11127544.14 | 9.985801e-01 |
2134 | 2140 | 5260.18 | 11132804.32 | 9.990521e-01 |
3255 | 3263 | 5276.87 | 11138081.19 | 9.995257e-01 |
1565 | 1570 | 5285.82 | 11143367.01 | 1.000000e+00 |
8596 rows × 4 columns
# Visualiser la courbe de Lorenz montrant la répartition du CA entre les clients
print(lorenz_curve(sales_by_customer, 'cumulative_proportion_of_sales', 'Proportion de clients', 'Proportion du CA réalisé', 'Répartition du chiffre d\'affaires entre les clients'))
0.4
La courbe de Lorenz ci-dessus montre que 80 % des clients réalisent environ 55% du chiffre d'affaires, et les 20 % de clients restant génèrent 45% du CA.
# Récupérer les 50 % de clients qui font quasiment 80 % du CA
sales_by_customer.sort_values(by='cumulative_proportion_of_sales', ascending=False, inplace=True)
top_50_percent_customers_ids = sales_by_customer.loc[sales_by_customer['cumulative_proportion_of_sales'] >= 0.22, 'client_id'].tolist()
top_50_percent_customers = df_without_outliers.loc[df_without_outliers['client_id'].isin(top_50_percent_customers_ids)]
top_50_percent_customers
id_prod | price | categ | price_group | session_id | client_id | nb_of_products_bought_during_session | sex | year_of_birth | age | age_group | |
---|---|---|---|---|---|---|---|---|---|---|---|
transaction_date | |||||||||||
2021-03-01 00:01:07.843138 | 0_1259 | 11.99 | 0 | 0-50 | 1 | 329 | 1 | female | 1967 | 57 | 50-59 |
2021-03-01 00:02:26.047414 | 0_1390 | 19.37 | 0 | 0-50 | 2 | 664 | 1 | male | 1960 | 64 | 60 and over |
2021-03-01 00:02:38.311413 | 0_1352 | 4.50 | 0 | 0-50 | 3 | 580 | 3 | male | 1988 | 36 | 30-39 |
2021-03-01 00:04:54.559692 | 0_1458 | 6.55 | 0 | 0-50 | 4 | 7912 | 2 | female | 1989 | 35 | 30-39 |
2021-03-01 00:05:18.801198 | 0_1358 | 16.49 | 0 | 0-50 | 5 | 2033 | 1 | female | 1956 | 68 | 60 and over |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
2023-02-28 23:49:03.148402 | 1_508 | 21.92 | 1 | 0-50 | 348444 | 3573 | 1 | female | 1996 | 28 | 20-29 |
2023-02-28 23:51:29.318531 | 2_37 | 48.99 | 2 | 0-50 | 348445 | 50 | 1 | female | 1994 | 30 | 30-39 |
2023-02-28 23:53:18.929676 | 1_695 | 26.99 | 1 | 0-50 | 348446 | 488 | 1 | female | 1985 | 39 | 30-39 |
2023-02-28 23:58:00.107815 | 0_1547 | 8.99 | 0 | 0-50 | 348447 | 4848 | 1 | male | 1953 | 71 | 60 and over |
2023-02-28 23:58:30.792755 | 0_1398 | 4.52 | 0 | 0-50 | 348435 | 3575 | 6 | female | 1981 | 43 | 40-49 |
496754 rows × 11 columns
# Afficher le nombre de ces clients
top_50_percent_customers['client_id'].nunique()
4282
# Afficher la répartition des achats de ces clients par sexe et tranche d'âge
gender_age = pd.crosstab(top_50_percent_customers['age_group'], top_50_percent_customers['sex'])
gender_age
sex | female | male |
---|---|---|
age_group | ||
20-29 | 23822 | 21548 |
30-39 | 62827 | 62518 |
40-49 | 91417 | 84435 |
50-59 | 44067 | 38643 |
60 and over | 36273 | 31204 |
# Afficher la répartition de leurs achats par tranche de prix et par catégorie
price_group_categ = pd.crosstab(top_50_percent_customers['price_group'], top_50_percent_customers['categ'])
price_group_categ
categ | 0 | 1 | 2 |
---|---|---|---|
price_group | |||
0-50 | 307202 | 161493 | 4679 |
50-100 | 0 | 1440 | 16527 |
100-150 | 0 | 0 | 3265 |
150-200 | 0 | 0 | 1423 |
200-250 | 0 | 0 | 707 |
250-300 | 0 | 0 | 18 |
Les deux variables sont qualitatives. Il s'agit de démontrer s'il existe une corrélation entre le genre d'un client et son attrait pour une catégorie de livres en particulier. Autrement dit si le choix d'une catégorie dépend du sexe ou non. Nous utiliserons le test du Khi-deux pour tester l'indépendance des deux variabes.
# Afficher le tableau de contingence entre sex et categ où l'on peut voir le nombre de transactions par genre et par catégorie
contingency_table = pd.crosstab(df_without_outliers['sex'], df_without_outliers['categ'], margins=True)
contingency_table
categ | 0 | 1 | 2 | All |
---|---|---|---|---|
sex | ||||
female | 200793 | 115721 | 16980 | 333494 |
male | 186488 | 104884 | 15868 | 307240 |
All | 387281 | 220605 | 32848 | 640734 |
Les conditions de validité pour effectuer le test sont respectées: les transactions sont indépendantes et les effectifs conjoints dans le tableau de contingence sont tous supérieurs à 5.
# Créer une fonction qui renvoie le tableau des valeurs attendues (effectifs théoriques)
def expected_values(contingency_table):
# On récupère pour chaque ligne la valeur qui se trouve dans la dernière colonne (colonne total), renvoie autant de valeurs qu'il y a de lignes
row_totals = contingency_table.iloc[:-1, -1].values
# On récupère pour chaque colonne la valeur qui se trouve dans la dernière ligne (ligne total), renvoie autant de valeurs qu'il y a de colonnes
col_totals = contingency_table.iloc[-1, :-1].values
# On récupère le total des effectifs
grand_total = contingency_table.values[-1,-1]
# On initialise un tableau vide (rempli de 0)
expected_values = np.zeros(contingency_table.shape)
# pour chaque élément de row_totals
for i in range(len(row_totals)):
# et chaque élément de col_totals
for j in range(len(col_totals)):
# on multiplie le premier élément de row_totals par chaque élément de col_totals et on divise à chaque fois par l'effectif total
expected_value = int(np.dot(row_totals[i], col_totals[j]) / grand_total)
# On ajoute la valeur attendue calculée au numpy array expected_values
expected_values[i,j] = expected_value
expected_values_table = pd.DataFrame(expected_values,
index=contingency_table.index,
columns=contingency_table.columns)
return expected_values_table
# Utiliser la fonction pour générer le tableau des effectifs théoriques
expected_table = expected_values(contingency_table)
# La fonction renvoie également la colonne total et la ligne total, on les retire
expected = expected_table.iloc[:-1, :-1]
expected
categ | 0 | 1 | 2 |
---|---|---|---|
sex | |||
female | 201574.0 | 114822.0 | 17096.0 |
male | 185706.0 | 105782.0 | 15751.0 |
# Afficher les valeurs observées histoire de comparer
observed_values = contingency_table.iloc[:-1, :-1]
observed_values
categ | 0 | 1 | 2 |
---|---|---|---|
sex | |||
female | 200793 | 115721 | 16980 |
male | 186488 | 104884 | 15868 |
On pose nos hypothèses:
H0 = Les variables catégorie de livres et genre du client sont indépendantes.
H1 = Le genre du client a une influence sur le choix de la catégorie de livres achetés.
A présent calculons la statistique du Khi-deux.
# Créer une fonction qui calcule et renvoie la statistique du khi-deux et qui affiche la heatmap
def Chi_square_and_heatmap(contingency_table, expected_values_table, map_xlabel, map_ylabel):
# Dépouiller les deux tableaux des marges (ligne Total et colonne Total)
contingency_table = contingency_table.iloc[:-1, :-1]
expected_values_table = expected_values_table.iloc[:-1, :-1]
# Calculer le tableau des erreurs quadratiques moyennes
# Pour chaque case (effectifs conjoints) du tableau des valeurs observées on calcule : (valeur observée - valeur attendue)² / valeur attendue
mean_squared_errors = (observed_values - expected)**2 / expected
# Calculer la somme des erreurs quadratiques moyennes, soit notre statistique Khi-deux
# mean_squared_errors.sum() calcule la somme des valeurs pour chaque colonne, le deuxième sum() fait la somme de ces sommes
Chi_square_statistic = mean_squared_errors.sum().sum()
# Calcul des contributions à la non-indépendance
# On divise chaque erreur quadratique moyenne par la somme des erreurs quadratiques moyennes (donc par la statistique Khi-deux)
# afin d'avoir la proportion de chaque erreur dans la contribution totale à la non-indépendance des deux variables qualitatives
contributions = mean_squared_errors / Chi_square_statistic
# Générer la heatmap
plt.figure(figsize=(9,6))
sns.heatmap(contributions, annot=True, fmt=".2f", cmap="Reds")
plt.title('Contributions à la non-indépendance des deux variables', fontsize=16, pad=20)
plt.xlabel(map_xlabel)
plt.ylabel(map_ylabel)
plt.show()
return Chi_square_statistic
# Afficher la heatmap et la statistique du Khi-deux
Chi_square_statistic = Chi_square_and_heatmap(contingency_table, expected_table, 'Catégories de produits', 'Genre')
Chi_square_statistic
22.637123734531478
Il faut à présent déterminer la valeur critique à laquelle nous allons comparer la statistique Khi-deux. Si cette dernière est supérieure à la valeur critique cela signifie que les écarts
entre les valeurs observées et les valeurs attendues sont assez significatifs pour rejeter l'hypothèse nulle selon laquelle les deux varibales sont indépendantes.
def Chi_square_critical_and_p_value(contingency_table, alpha, chi_square_statistic):
# Dépouiller le tableau de contingence de ses marges (ligne total et colonne total)
observed_values = contingency_table.iloc[:-1, :-1]
# Calcul des degrés de liberté : (nombre de lignes -1) * (nombre colonnes -1)
degrees_of_freedom = (observed_values.shape[0]-1) * (observed_values.shape[1]-1)
# Niveau de significativité choisi
alpha = alpha
# Déterminer, à l'aide de la bibliothèque stats de scipy, la valeur critique pour un niveau de confiance 1 - alpha et n degrés de liberté
# Alternative : utiliser la table du Khi-deux pour déterminer la valeur critique
critical_value = stats.chi2.ppf(1 - alpha, degrees_of_freedom)
# Calculer la p-valeur, la probabilité d'obtenir une statistique au moins aussi extrême que celle observée (ici 22.64) si l'hypothèse nulle était vraie
# Plus la p-valeur est faible plus le résultat obtenu est inhabituel et peu probable sous l'hypothèse nulle
# Si la p-valeur est inférieure à alpha on rejette cette hypothèse nulle
p_value = stats.chi2.sf(Chi_square_statistic, degrees_of_freedom)
return f'Statistique du Khi-deux: {round(Chi_square_statistic,2)}',f'Valeur critique : {round(critical_value,2)}',f'P-valeur : {p_value}'
# Afficher le Khi-deux, la valeur critique et la p-valeur
Chi_square_critical_and_p_value(contingency_table, 0.05, Chi_square_statistic)
('Statistique du Khi-deux: 22.64', 'Valeur critique : 5.99', 'P-valeur : 1.2145378058412343e-05')
Nous allons vérifier ces résultats en utilisant la fonction scipy.stats.chi2_contingency(). Il peut y avoir de légères différences dues au fait que je convertis les valeurs en integers dans la fonction expected_values créée plus haut.
# Vérifier le résultat en utilisant la fonction scipy.stats.chi2_contingency()
# On ne récupère que les valeurs du dataframe
observed_values_array = observed_values.values
chi2, p, dof, expected = stats.chi2_contingency(observed_values_array)
print(f"chi2 statistic: {chi2}")
print(f"p-value: {p}")
print(f"degrees of freedom: {dof}")
print("expected frequencies:") # Pour comparer avec les valeurs attendues générées plus haut par la fonction expected_values()
print(expected)
chi2 statistic: 22.66856665178056 p-value: 1.1955928116587024e-05 degrees of freedom: 2 expected frequencies: [[201574.89662481 114822.13191434 17096.97146086] [185706.10337519 105782.86808566 15751.02853914]]
La statistique du Khi-deux (22.64) est supérieure à la valeur critique (5.99), et la p-valeur, probabilité d'observer une statistique aussi extrême si l'hypothèse nulle était vraie, est très faible (ce qui rend la chose hautement improbable) et inférieure au seuil de signification.
On rejette l'hypothèse nulle. Il semblerait que le sexe d'un client ait une influence sur le choix de la catégorie des livres achetés.
L'âge des clients et le montant total des achats sont deux variables quantitatives. Nous évaluerons leur relation en utilisant le coefficient de corrélation de Pearson si les conditions suivantes sont respectées:
- Chaque variable suit une distribution normale
- Au moins 20 individus dans l'échantillon (recommandé)
Autrement nous utiliserons le coefficent de corrélation de rangs de Spearman.
# On peut utiliser le dataframe sales_by_customer créé précédemment et auquel il manque juste la variable âge
sales_by_customer.head()
client_id | total_sales_of_customer | cumulative_sales | cumulative_proportion_of_sales | |
---|---|---|---|---|
1565 | 1570 | 5285.82 | 11143367.01 | 1.000000 |
3255 | 3263 | 5276.87 | 11138081.19 | 0.999526 |
2134 | 2140 | 5260.18 | 11132804.32 | 0.999052 |
2892 | 2899 | 5214.05 | 11127544.14 | 0.998580 |
7296 | 7319 | 5155.77 | 11122330.09 | 0.998112 |
# Récupérer l'âge de chaque client
client_age = df_without_outliers.pivot_table(index='client_id', values='age')
client_age = client_age.reset_index()
client_age.head()
client_id | age | |
---|---|---|
0 | 1 | 69 |
1 | 2 | 48 |
2 | 3 | 45 |
3 | 4 | 60 |
4 | 5 | 30 |
# Fusion des deux dataframes
sales_by_customer = pd.merge(client_age, sales_by_customer, on='client_id', how='inner')
sales_by_customer = sales_by_customer.sort_values(by='total_sales_of_customer')
sales_by_customer.drop(columns=['cumulative_sales', 'cumulative_proportion_of_sales'], inplace=True)
sales_by_customer.head()
client_id | age | total_sales_of_customer | |
---|---|---|---|
8326 | 8351 | 56 | 6.31 |
8116 | 8140 | 53 | 8.30 |
8090 | 8114 | 62 | 9.98 |
4632 | 4648 | 20 | 11.20 |
4462 | 4478 | 54 | 13.36 |
# Afficher les dimensions du dataframe sales_by_customer
sales_by_customer.shape
(8596, 3)
# Visualiser la corrélation entre l'âge des client et le montant total moyen des achats par client à l'aide d'un nuage de points
# on distingue non sans peine une corrélation négative (le montant total des achats tend à diminuer à mesure que l'âge des clients augmente)
plt.figure(figsize=(9,6))
plt.scatter(sales_by_customer['age'], sales_by_customer['total_sales_of_customer'], color='darkred')
plt.title('Corrélation entre l\'âge d\'un client et le montant total de ses achats', fontsize=16, pad=20)
plt.ylabel('Montant total des achats en €')
plt.xlabel('Âge du client')
plt.gca().spines[['right', 'top']].set_visible(False)
plt.show()
# Visualiser les distributions des deux variables pour déterminer si elles suivent une gaussienne
plt.figure(figsize=(16,16))
plt.subplot(4,3,1)
ax = sales_by_customer['age'].hist(color='darkred')
plt.title('Distribution des âges', fontsize=16)
plt.xlabel('Âges')
plt.ylabel('Nombre de clients')
plt.grid(False)
ax.spines[['top', 'right']].set_visible(False)
plt.subplot(4,3,2)
ax = sales_by_customer['total_sales_of_customer'].hist(color='darkred')
plt.title('Distribution du montant total des achats', fontsize=16)
plt.xlabel('Montant total des achats d\'un client')
plt.ylabel('Nombre de clients')
plt.grid(False)
ax.spines[['top', 'right']].set_visible(False)
plt.show()
plt.tight_layout(pad=3.0)
<Figure size 432x288 with 0 Axes>
# Visualiser les écarts par rapport à une distribution normale avec un Q-Q plot
# On constate que les observations (en bleu) des deux variables ne s'alignent pas avec la droite
# leur distributions s'écartent de la normalité, c'est plus flagrant pour la variable montant total des achats
plt.figure(figsize=(16,16))
plt.subplot(4,3,1)
stats.probplot(sales_by_customer['age'], dist='norm', plot=plt)
plt.xlabel("Quantiles de la distribution normale")
plt.ylabel("Quantiles de la variable age")
plt.title("Q-Q plot de age")
plt.subplot(4,3,2)
stats.probplot(sales_by_customer['total_sales_of_customer'], dist='norm', plot=plt)
plt.xlabel("Quantiles de la distribution normale")
plt.ylabel("Quantiles de la variable total_sales_of_customer")
plt.title("Q-Q plot de total_sales_of_customer")
plt.tight_layout(pad=3.0)
plt.show()
Les données contiennent plus de 20 observations mais les deux distributions ne semblent pas suivre une distribution normale.
Nous allons effectuer le test de Kolmogorov-Smirnov puis celui de Shapiro-Wilk pour nous en assurer. Cela dit, que l'on utilise l'un ou l'autre, les deux tests sont sensibles à la taille de l'échantillon. Plus la taille de l'échantillon est grande moins ces tests sont robustes....
Hypothèses:
H0 : age et total_sales_of_customer suivent une distribution normale
H1 : les deux distributions ne suivent pas une distribution normale
# Créer une fonction test de normalité qui utilise scipy.stats.kstest() pour effectuer le test de Kolmogorov-Smirnov
def normality_test(df, df_variable, alpha):
skewness = df[df_variable].skew()
# Renvoie l'excès de kurtosis (= kurtosis - 3)
kurtosis = df[df_variable].kurtosis()
ks_statistic, ks_p_value = stats.kstest(df[df_variable], 'norm')
# Le test de Shapiro-Wilk est inefficace si taille de l'échantillon est supérieure à 5000
if len(df[df_variable]) > 5000:
random_sample = df[df_variable].sample(1000, random_state=42)
sw_statistic, sw_p_value = stats.shapiro(random_sample)
if ks_p_value > alpha:
print(f'Test de Kolmogorov-Smirnov: la variable {df_variable} semble suivre une gaussienne (on ne rejette pas H0)')
else:
print(f'Test de Kolmogorov-Smirnov: la variable {df_variable} ne semble pas suivre une gaussienne (on rejette H0)')
if sw_p_value > alpha:
print(f'Test de Shapiro-Wilk: la variable {df_variable} semble suivre une gaussienne (on ne rejette pas H0)')
else:
print(f'Test de Shapiro-Wilk: la variable {df_variable} ne semble pas suivre une gaussienne (on rejette H0)')
return f"Skewness: {skewness:.3f} | Excès de Kurtosis: {kurtosis:.3f} | KS Statistique: {ks_statistic:.3f} | KS P-valeur: {ks_p_value:.3f} | SW Statistique: {sw_statistic:.3f} | SW P-valeur: {sw_p_value:.3f}"
# Application des tests de Kolmogorov-Smirnov et Shapiro-Wilk à la colonne 'age'
normality_test(sales_by_customer, 'age', 0.05)
Test de Kolmogorov-Smirnov: la variable age ne semble pas suivre une gaussienne (on rejette H0) Test de Shapiro-Wilk: la variable age ne semble pas suivre une gaussienne (on rejette H0)
'Skewness: 0.362 | Excès de Kurtosis: -0.633 | KS Statistique: 1.000 | KS P-valeur: 0.000 | SW Statistique: 0.968 | SW P-valeur: 0.000'
# Application des tests de Kolmogorov-Smirnov et Shapiro-Wilk à la colonne total_sales_of_customer
normality_test(sales_by_customer, 'total_sales_of_customer', 0.05)
Test de Kolmogorov-Smirnov: la variable total_sales_of_customer ne semble pas suivre une gaussienne (on rejette H0) Test de Shapiro-Wilk: la variable total_sales_of_customer ne semble pas suivre une gaussienne (on rejette H0)
'Skewness: 1.160 | Excès de Kurtosis: 1.094 | KS Statistique: 1.000 | KS P-valeur: 0.000 | SW Statistique: 0.895 | SW P-valeur: 0.000'
Etant donné les résultats du test de Kolmogorov-Smirnov, et vu que le coefficient de corrélation de Pearson suppose que les deux variables sont normalement distribuées et qu'il existe une relation linéaire entre les deux, on utilisera plutôt le coefficient de Spearman pour évaluer leur corrélation.
# Créer une fonction qui calcule le coefficient de corrélation des rangs de Spearman et la p-valeur associée
def spearman_correlation_test(df, df_x, df_y):
n = len(df)
# on calcule le rang de chaque valeur de la variable df_x
df['ranks_x'] = df[df_x].rank(method='average')
# Même chose pour la variable df_y
df['ranks_y'] = df[df_y].rank(method='average')
# on fait la somme des carrés des écarts entre les rangs de x et les rangs de y
sum_of_squared_differences = ((df['ranks_x'] - df['ranks_y'])**2).sum()
# Calcul du coef de spearman = (6 * la somme des carrés des écarts) divisée par n³-n
spearman_r = 1 - (6*sum_of_squared_differences)/(n*(n**2-1))
# calcul de la statistique t utilisée pour calculer la p-valeur
t_statistic = spearman_r * np.sqrt((n-2)/(1-spearman_r**2))
# On supprime la colonne créée dans notre dataframe puisqu'elle n'est plus utile
df.drop(columns=['ranks_x', 'ranks_y'], inplace=True)
# Calcul de la p-valeur associée au test, à comparer au seuil de signification (0.05)
p_value_two_sided = 2 * (1 - stats.t.cdf(abs(t_statistic), n - 2))
return f'Coefficient de corrélation de Spearman: {round(spearman_r,3)}, p-valeur: {p_value_two_sided}'
# Utiliser la fonction pour calculer le coefficient de spearman et renvoyer une p-valeur
spearman_correlation_test(sales_by_customer, 'age', 'total_sales_of_customer')
'Coefficient de corrélation de Spearman: -0.184, p-valeur: 0.0'
# Même calcul en utlisant cette fois la fonction scipy.stats.spearmanr()
spearman_corr, p_value = stats.spearmanr(sales_by_customer['age'], sales_by_customer['total_sales_of_customer'])
print('Coefficient de corrélation de Spearman: %.3f' % spearman_corr)
print('p-valeur: %.3f' % p_value)
Coefficient de corrélation de Spearman: -0.185 p-valeur: 0.000
Le coefficient de corrélation de Spearman montre une corrélation négative assez faible, mais dont la significativité est toutefois confirmée par la faiblesse de la p-valeur qui est très proche de 0. Il semble donc y avoir un lien, bien que faible, entre l'âge et les dépenses en livres.
Comment l'interpréter ? Une interprétation possible est que l'on achète beaucoup de livres lorsque l'on fait des études (plus par nécessité), que l'on en achète davantage quand on entre dans la vie active et jusqu'à la cinquantaine (plutôt par plaisir), mais qu'au-delà des 50 ans d'autres centres d'intérêts prennent le pas sur la lecture.
# Regrouper les données par âge et calculer, pour chaque âge, le montant total des achats des clients ayant cet âge
sales_by_age = sales_by_customer.groupby('age').agg({'total_sales_of_customer': 'sum'})
sales_by_age = sales_by_age.reset_index()
sales_by_age.columns = ['age', 'total_sales_of_clients_this_age']
sales_by_age.head()
age | total_sales_of_clients_this_age | |
---|---|---|
0 | 20 | 616418.15 |
1 | 21 | 180680.89 |
2 | 22 | 186791.83 |
3 | 23 | 181233.57 |
4 | 24 | 167388.08 |
# Visualiser la corrélation entre l'âge et le montant total des achats de l'ensemble des clients ayant cet âge
# On remarque que les clients de 20 ans se distinguent nettement, probablement parce qu'ils sont très nombreux
plt.figure(figsize=(9,6))
plt.scatter(sales_by_age['age'], sales_by_age['total_sales_of_clients_this_age'], color='darkred')
plt.title('Corrélation entre l\'âge d\'un client le montant total de ses achats', fontsize=16, pad=20)
plt.ylabel('Montant des achats')
plt.xlabel('Âge')
plt.gca().spines[['right', 'top']].set_visible(False)
plt.show()
# Même chose en utilisant le montant moyen des achats
# Cette fois on voit clairement une corrélation négative
average_sales_by_age = sales_by_customer.groupby('age').agg({'total_sales_of_customer': 'mean'})
average_sales_by_age = average_sales_by_age.reset_index()
average_sales_by_age.columns = ['age', 'average_sales_of_clients_this_age']
plt.figure(figsize=(9,6))
plt.scatter(average_sales_by_age['age'], average_sales_by_age['average_sales_of_clients_this_age'], color='darkred')
plt.title('Corrélation entre l\'âge d\'un client et le montant total moyen des achats', fontsize=16, pad=20)
plt.ylabel('Montant des achats en €')
plt.xlabel('Âge')
plt.gca().spines[['right', 'top']].set_visible(False)
plt.show()
Après avoir groupé les lignes par âge et calculé le montant total moyen des achats des clients pour chaque âge, on effectue un nouveau test et constate une corrélation négative beaucoup plus forte et toujours satistiquement significative.
# Calculer le coefficient de corrélation des rangs de spearman sur le dataframe average_sales_by_age
spearman_correlation_test(average_sales_by_age, 'age', 'average_sales_of_clients_this_age')
'Coefficient de corrélation de Spearman: -0.728, p-valeur: 9.658940314238862e-14'
Il est fréquent d'entendre que la non normalité d'une distribution n'est pas un frein à l'utilisation des tests paramétriques tant que le nombre d'individus dans les échantillons est grand. De plus, lorsque la taille d'un échantillon est grande (plus de 30 observations), la distribution de l’échantillon se rapproche de la distribution normale grâce au théorème central limite. Par conséquent, nous allons également utiliser le coefficient de Pearson pour évaluer la corrélation.
H0 = le coefficient de corrélation est égal à 0, les variables âge et montant total des achats sont indépendantes
H1 = le coefficient de corrélation est différent de 0, les variables âge et montant total des achats ne sont pas indépendantes
Si le coefficient de Pearson est différent de 0 et que la p-valeur associée au test est inférieure au seuil de signification (0.05), on rejette l'hypothèse nulle.
# Créer une fonction qui renvoie le coefficient de corrélation de Pearson et la p-valeur associée (qui mesure la significativité du coefficient)
def pearson_correlation_test(df, df_x, df_y):
n = len(df[df_x])
x_mean = df[df_x].mean()
y_mean = df[df_y].mean()
x_standard_deviation = df[df_x].std()
y_standard_deviation = df[df_y].std()
covariance_x_y = ((df[df_x] - x_mean) * (df[df_y] - y_mean)).sum() / len(df)
pearson_coef = covariance_x_y / (x_standard_deviation * y_standard_deviation)
# calcul de la statistique t utilisée pour calculer la p-valeur
t_statistic = pearson_coef * np.sqrt((n-2)/(1-pearson_coef**2))
# Calcul de la p-valeur associée au test, à comparer au seuil de signification (0.05)
# Le test t de Student est bilatéral car le coefficient de Pearson, compris entre -1 et 1, peut être positif ou négatif
p_value_two_sided = 2 * (1 - stats.t.cdf(abs(t_statistic), n - 2))
return f'Coefficient de corrélation de Pearson: {round(pearson_coef,3)}, p-valeur: {p_value_two_sided}'
# Utiliser la fonction pour évaluer la corrélation entre l'âge d'un client et le montant total de ses achats
pearson_correlation_test(sales_by_customer, 'age', 'total_sales_of_customer')
'Coefficient de corrélation de Pearson: -0.188, p-valeur: 0.0'
A présent, évaluons la corrélation entre l'âge des clients et la fréquence d'achat, de nouveau 2 variables quantitatives. Dans ce contexte on peut définir la fréquence d'achat comme suit:
- Par le nombre total de sessions d'un client (puisqu'en l'occurrence une session débouche sur un achat au minimum) .
- Par le nombre total d'achats effectués par un client.
Nous prendrons en compte la deuxième définition et utiliserons, comme pour la corrélation précédente, le coefficient de corrélation de Pearson si les conditions suivantes sont respectées:
- Chaque variable suit une distribution normale
- Au moins 20 individus dans l'échantillon (recommandé)
Autrement nous utiliserons le coefficent de corrélation de rangs de Spearman..
# Créer un dataframe session affichant pour chaque client le nombre total de sessions dudit client
sessions = df_without_outliers.groupby('client_id')['session_id'].nunique()
sessions = sessions.reset_index()
sessions.columns = ['client_id', 'customer_number_of_sessions']
sessions.head()
client_id | customer_number_of_sessions | |
---|---|---|
0 | 1 | 34 |
1 | 2 | 100 |
2 | 3 | 36 |
3 | 4 | 96 |
4 | 5 | 49 |
# Grouper les données par client puis calculer le nombre total de produits achetés et récupérer les données dans un dataframe
total_number_of_products_bought = df_without_outliers.groupby('client_id').agg({'nb_of_products_bought_during_session': 'sum', 'age_group': 'first'})
total_number_of_products_bought = total_number_of_products_bought.reset_index()
total_number_of_products_bought.columns = ['client_id', 'total_number_of_purchases', 'age_group']
# Fusionner sessions avec sales_by_customer pour obtenir un dataframe contenant à la fois le nombre de sessions d'un client, son âge et le CA total généré par ce même client
purchase_frequency = pd.merge(sessions, sales_by_customer, on='client_id').merge(total_number_of_products_bought, on='client_id')
purchase_frequency.head()
client_id | customer_number_of_sessions | age | total_sales_of_customer | total_number_of_purchases | age_group | |
---|---|---|---|---|---|---|
0 | 1 | 34 | 69 | 629.02 | 79 | 60 and over |
1 | 2 | 100 | 48 | 2340.72 | 470 | 40-49 |
2 | 3 | 36 | 45 | 1006.55 | 186 | 40-49 |
3 | 4 | 96 | 60 | 1987.63 | 146 | 60 and over |
4 | 5 | 49 | 30 | 2819.30 | 403 | 30-39 |
# Visualiser la corrélation entre l'âge et le nombre total d'achats/transactions d'un client à l'aide d'un nuage de points
plt.figure(figsize=(9,6))
plt.scatter(purchase_frequency['age'], purchase_frequency['total_number_of_purchases'], color='darkred')
plt.title('Corrélation entre l\'âge le nombre total d\'achats d\'un client', fontsize=16, pad=20)
plt.ylabel('Montant total de transactions')
plt.xlabel('Âge du client')
plt.gca().spines[['right', 'top']].set_visible(False)
plt.show()
# Visualiser la corrélation entre l'âge et le nombre total de sessions d'un client à l'aide d'un nuage de points
plt.figure(figsize=(9,6))
plt.scatter(purchase_frequency['age'], purchase_frequency['customer_number_of_sessions'], color='darkred')
plt.title('Corrélation entre l\'âge le nombre de sessions d\'un client', fontsize=16, pad=20)
plt.ylabel('Nombre de sessions')
plt.xlabel('Âge du client')
plt.gca().spines[['right', 'top']].set_visible(False)
plt.show()
# On a vu précédemment que la variable âge ne suit pas une distribution normale
# Visualisons la distribution de la variable nombre total d'achats
plt.figure(figsize=(9,6))
ax = purchase_frequency['total_number_of_purchases'].hist(color='darkred')
plt.title('Distribution du nombre total d\'achats par client', fontsize=16)
plt.xlabel('Nombre total d\'achats')
plt.ylabel('Nombre de clients')
plt.grid(False)
ax.spines[['top', 'right']].set_visible(False)
plt.tight_layout(pad=3.0)
# On visualise également celle de la variable nombre total de sessions
plt.figure(figsize=(9,6))
ax = purchase_frequency['customer_number_of_sessions'].hist(color='darkred')
plt.title('Distribution du nombre total de sessions par client', fontsize=16)
plt.xlabel('Nombre de sessions')
plt.ylabel('Nombre de clients')
plt.grid(False)
ax.spines[['top', 'right']].set_visible(False)
plt.show()
# Tester la normalité de la distribution de la variable age
normality_test(purchase_frequency, 'age', 0.05)
Test de Kolmogorov-Smirnov: la variable age ne semble pas suivre une gaussienne (on rejette H0) Test de Shapiro-Wilk: la variable age ne semble pas suivre une gaussienne (on rejette H0)
'Skewness: 0.362 | Excès de Kurtosis: -0.633 | KS Statistique: 1.000 | KS P-valeur: 0.000 | SW Statistique: 0.962 | SW P-valeur: 0.000'
# Tester la normalité de la distribution de total_number_of_purchases
normality_test(purchase_frequency, 'total_number_of_purchases', 0.05)
Test de Kolmogorov-Smirnov: la variable total_number_of_purchases ne semble pas suivre une gaussienne (on rejette H0) Test de Shapiro-Wilk: la variable total_number_of_purchases ne semble pas suivre une gaussienne (on rejette H0)
'Skewness: 1.900 | Excès de Kurtosis: 3.816 | KS Statistique: 0.995 | KS P-valeur: 0.000 | SW Statistique: 0.784 | SW P-valeur: 0.000'
# Tester la normalité de la distribution de customer_number_of_sessions
normality_test(purchase_frequency, 'customer_number_of_sessions', 0.05)
Test de Kolmogorov-Smirnov: la variable customer_number_of_sessions ne semble pas suivre une gaussienne (on rejette H0) Test de Shapiro-Wilk: la variable customer_number_of_sessions ne semble pas suivre une gaussienne (on rejette H0)
'Skewness: 1.356 | Excès de Kurtosis: 1.334 | KS Statistique: 0.990 | KS P-valeur: 0.000 | SW Statistique: 0.845 | SW P-valeur: 0.000'
Les deux variables ne sont pas normalement distribuées. On utilisera à nouveau le coefficient de Spearman pour évaluer la corrélation.
# Evaluer, à l'aide du coefficient de corrélation des rangs de Spearman, la corrélation entre l'âge d'un client et le nombre total de produits achetés
# puis celle entre l'âge d'un client et le nombre de sessions
spearman_corr, p_value = stats.spearmanr(purchase_frequency['age'], purchase_frequency['total_number_of_purchases'])
print('Lien entre âge et nombre total de produits achetés => coef de Spearman: %.3f' % spearman_corr, '| p-valeur: %.3f' % p_value)
spearman_corr, p_value = stats.spearmanr(purchase_frequency['age'], purchase_frequency['customer_number_of_sessions'])
print('Lien entre âge et nombre total de sessions => Coef de Spearman: %.3f' % spearman_corr, '| p-valeur: %.3f' % p_value)
Lien entre âge et nombre total de produits achetés => coef de Spearman: 0.034 | p-valeur: 0.002 Lien entre âge et nombre total de sessions => Coef de Spearman: 0.212 | p-valeur: 0.000
# Même chose en utilisant la fonction créée précédemment
print('âge et nombre total de produits achetés:', spearman_correlation_test(purchase_frequency, 'age', 'total_number_of_purchases'))
print('âge et nombre total de sessions:', spearman_correlation_test(purchase_frequency, 'age', 'customer_number_of_sessions'))
âge et nombre total de produits achetés: Coefficient de corrélation de Spearman: 0.034, p-valeur: 0.001416904674418351 âge et nombre total de sessions: Coefficient de corrélation de Spearman: 0.212, p-valeur: 0.0
# On vérifie ce que cela donne avec le coefficient de corrélation de Pearson
pearson_correlation_test(purchase_frequency, 'age', 'total_number_of_purchases')
'Coefficient de corrélation de Pearson: -0.046, p-valeur: 2.1747084142154094e-05'
# On vérifie ce que cela donne avec le coefficient de corrélation de Pearson mais en utilisant le nombre total de sessions
pearson_correlation_test(purchase_frequency, 'age', 'customer_number_of_sessions')
'Coefficient de corrélation de Pearson: 0.164, p-valeur: 0.0'
Le coefficient de corrélation de Spearman montre une corrélation positive très faible dans le premier cas et faible dans le deuxième. Cependant, dans les deux cas, la p-valeur est très largement inférieure au seuil de signification de 5 %. Par conséquent, et bien que très faible, on peut dire qu'il existe une corrélation statistiquement significative entre l'âge et la fréquence d'achat.
Voyons à présent si la corrélation est visuellement plus parlante en groupant par âge.
# Regrouper les données par âge et calculer, pour chaque âge, le nombre total moyen de sessions et d'achats ainsi que le montant total moyen dépensé des clients ayant cet âge
sessions_purchases_grouped_by_age = purchase_frequency.groupby('age').agg({'customer_number_of_sessions': 'mean', 'total_number_of_purchases': 'mean', 'total_sales_of_customer': 'mean'})
sessions_purchases_grouped_by_age = sessions_purchases_grouped_by_age.reset_index()
sessions_purchases_grouped_by_age.columns = ['age', 'average_nb_of_sessions_of_clients_this_age', 'average_nb_of_purchases_of_clients_this_age', 'average_sales_of_clients_this_age' ]
sessions_purchases_grouped_by_age.head()
age | average_nb_of_sessions_of_clients_this_age | average_nb_of_purchases_of_clients_this_age | average_sales_of_clients_this_age | |
---|---|---|---|---|
0 | 20 | 20.036613 | 94.347826 | 1410.567849 |
1 | 21 | 18.034483 | 80.075862 | 1246.075103 |
2 | 22 | 18.386207 | 86.331034 | 1288.219517 |
3 | 23 | 19.904000 | 93.088000 | 1449.868560 |
4 | 24 | 18.205882 | 95.882353 | 1230.794706 |
# Visualiser la corrélation entre l'âge et la fréquence d'achat, exprimée par le nombre total d'achats des clients ayant un âge donné
# puis par le nombre total de sessions de ces mêmes clients
plt.figure(figsize=(18,18))
plt.subplot(4,3,1)
plt.scatter(sessions_purchases_grouped_by_age['age'], sessions_purchases_grouped_by_age['average_nb_of_purchases_of_clients_this_age'], color='darkred')
plt.title('Corrélation entre l\'âge et le nombre moyen \nde produits achetés par un client', fontsize=16, pad=20)
plt.ylabel('Nombre moyen d\'achats')
plt.xlabel('Âge')
plt.gca().spines[['right', 'top']].set_visible(False)
plt.subplot(4,3,2)
plt.scatter(sessions_purchases_grouped_by_age['age'], sessions_purchases_grouped_by_age['average_nb_of_sessions_of_clients_this_age'], color='darkred')
plt.title('Corrélation entre l\'âge et le nombre moyen \nde sessions d\'un client', fontsize=16, pad=20)
plt.ylabel('Nombre moyen de sessions')
plt.xlabel('Âge')
plt.gca().spines[['right', 'top']].set_visible(False)
plt.tight_layout(pad=3.0)
# # Si on applique le test de Spearman, le résultat montre que l'on ne peut pas rejeter l'hypothèse nulle selon laquelle les variables âge et nombre moyen de sessions
# sont independantes. En effet, le coefficient est proche de 0 et la p-valeur est proche de 1
spearman_correlation_test(sessions_purchases_grouped_by_age, 'age', 'average_nb_of_sessions_of_clients_this_age')
'Coefficient de corrélation de Spearman: -0.003, p-valeur: 0.97854118091275'
# Même constat entre les variables âge et nombre moyen de produits achetés : le coefficient est proche de 0 et la p-valeur est élevée
spearman_correlation_test(sessions_purchases_grouped_by_age, 'age', 'average_nb_of_purchases_of_clients_this_age')
'Coefficient de corrélation de Spearman: -0.049, p-valeur: 0.6711786057747684'
Etant donné qu'une transaction (ou achat) = un produit, et qu'une session peut englober plusieurs achats/produits, on peut dire qu'une session correspond à un panier. Nous allons donc calculer, pour chaque client, le montant de son panier moyen. Concernant l'âge, nous allons utiliser les tranches d'âge pour évaluer la corrélation.
La variable tranche d'âge est qualitative et la variable taille du panier moyen est quantitative. Nous visualiserons la corrélation à l'aide d'un boxplot et testerons la significativité de celle-ci avec l'ANOVA si les conditions de validité du test sont remplies, autrement nous effectuerons les test de Kruskal-Wallis.
# Calcul du panier moyen de chaque client en divisant le chiffre d'affaires total du client par le nombre de sessions (de paniers) de ce même client
purchase_frequency['average_order_value'] = purchase_frequency['total_sales_of_customer'] / purchase_frequency['customer_number_of_sessions']
purchase_frequency.head()
client_id | customer_number_of_sessions | age | total_sales_of_customer | total_number_of_purchases | age_group | average_order_value | |
---|---|---|---|---|---|---|---|
0 | 1 | 34 | 69 | 629.02 | 79 | 60 and over | 18.500588 |
1 | 2 | 100 | 48 | 2340.72 | 470 | 40-49 | 23.407200 |
2 | 3 | 36 | 45 | 1006.55 | 186 | 40-49 | 27.959722 |
3 | 4 | 96 | 60 | 1987.63 | 146 | 60 and over | 20.704479 |
4 | 5 | 49 | 30 | 2819.30 | 403 | 30-39 | 57.536735 |
# Calcul du panier moyen tous clients confondus (à l'exception bien sûr des 4 gros outliers)
average_basket = purchase_frequency['average_order_value'].mean()
average_basket
40.402796386422054
# Visualiser la corrélation entre le groupe d'âge et la taille du panier moyen
plt.figure(figsize=(12, 8))
palette = sns.color_palette("Reds", n_colors=5).as_hex()[::]
meanprops = dict(marker='o', markersize=8, markeredgecolor='black', markerfacecolor='red')
mean_marker = mlines.Line2D([], [], color='red', marker='o', linestyle='None', markersize=8, label='Moyenne')
flierprops = dict(marker='o', markersize=5, markerfacecolor='darkorange', linestyle='none', alpha=0.7)
outliers_marker = mlines.Line2D([], [], color='darkorange', marker='o', linestyle='None', markersize=5, label='Outliers')
sns.boxplot(x='age_group', y='average_order_value', data=purchase_frequency, order=['20-29', '30-39', '40-49', '50-59', '60 and over'], palette=palette, meanprops=meanprops, showmeans=True, flierprops=flierprops)
plt.title('Corrélation entre tranche d\'âge et taille du panier moyen', fontsize=16, pad=20)
plt.xlabel('Tranches d\'âge')
plt.ylabel('Taille du panier moyen en €')
sns.despine()
plt.legend(handles=[mean_marker, outliers_marker])
plt.show()
On remarque des différences dans la variance de la taille des paniers moyens d'une tranche d'âge à une autre. Le panier moyen des clients âgés de 20 à 29 ans est globalement plus élevé que dans toutes les autres tranches d'âge. Viennent ensuite les 30-39 ans. Il semble donc y avoir une corrélation négative entre l'âge (ici exprimé en tranche d'âge) et la taille du panier moyen. Mais cette corrélation est-elle statistiquement significative ? Comparons les paniers moyens de ces différentes tranches d'âge par l'ANOVA à un facteur.
On pose les hypothèses suivantes:
H0 = Les paniers moyens sont identiques et par conséquent l'âge d'un client n'a aucun impact sur le montant moyen du panier.
H1 = Le panier moyen d'au moins une tranche d'âge diffère des autres paniers moyens et par conséquent on rejette l'hypothèse nulle.
Il nous faut tester les conditions de validité suivantes avant d'effectuer l'ANOVA :
-Les mesures sont indépendantes
-Les échantillons ont des variances égales
-Les résidus suivent une distribution normale (supposée ou vérifiée)
-Au moins 20 individus dans le dispositif, ou normalité des résidus supposée ou vérifiée
Les mesures sont indepéndantes. Nous allons tester l'égalité des variances avec le test de Levene et la normalité des résidus avec Shapiro-Wilk. .
Hypothèses avec un seuil de signification de 0.05:
H0 = Les variances sont égales | les résidus suivent une distribution normale
H1 = Au moins une variance diffère des autres | les résidus ne suivent pas une distribution normale
# Créez une liste de listes, où chaque sous-liste contient toutes les valeurs de 'average_order_value' pour un âge donné
samples = [list(purchase_frequency.loc[purchase_frequency['age_group'] == age_group, 'average_order_value']) for age_group in purchase_frequency['age_group'].unique()]
# Appliquez le test de Levene du module scipy.stats
w, p_value = stats.levene(*samples)
print(f"Levene's Test W-statistic : {w}")
print(f"Levene's Test P-value : {p_value}")
Levene's Test W-statistic : 359.3600864847125 Levene's Test P-value : 1.5025187799646024e-286
# créer une fonction qui calcule les résidus et effectue le test de normalité
def intra_class_residuals(df, categ, categ_values, alpha):
total_count = len(df)
# récupérer dans un array les résidus (en l'occurrence les écarts à la moyenne intra groupe)
residuals = np.empty(total_count)
for group in df[categ].unique():
group_df = df.loc[df[categ] == group]
group_mean = group_df[categ_values].mean()
# Calculer les résidus pour ce groupe
group_residuals = group_df[categ_values] - group_mean
# Ajouter les résidus de ce groupe au tableau de résidus
residuals[group_df.index] = group_residuals
if len(residuals) > 5000:
random_sample = np.random.choice(residuals, 2000, replace=False)
statistic, p_value = stats.shapiro(random_sample)
if p_value > alpha:
print(f'Test de Shapiro-Wilk: les résidus semblent suivre une gaussienne (on ne rejette pas H0)')
else:
print(f'Test de Shapiro-Wilk: les résidus ne semblent pas suivre une gaussienne (on rejette H0)')
return f'Statistique: {statistic} | P-valeur: {p_value}'
# tester la normalité des résidus avec la fonction
intra_class_residuals(purchase_frequency, 'age_group', 'average_order_value', 0.05)
Test de Shapiro-Wilk: les résidus ne semblent pas suivre une gaussienne (on rejette H0)
'Statistique: 0.8483839631080627 | P-valeur: 3.993798714218231e-40'
Le test de Levene renvoie deux valeurs : la statistique W et la p-valeur. Plus la statistique W est élevée plus la différence entre les variance est grande.
Si la p-valeur, probabilité d'observer une statistique W aussi extrême sous l'hypothèse nulle, est inférieure au seuil de signification (ici = 0,05), on rejette l’hypothèse nulle selon laquelle les variances sont égales. Dans notre cas la p-valeur est très proche de 0.
Concernant les résidus, la p-valeur renvoyée par le test de Shapiro-Wilk est là encore inférieure au seuil de signification alpha. Par conséquent on rejette l’hypothèse nulle dans les deux cas. On utilisera le test de Kruskal-Wallis.
#Créer une fonction qui effectue le test de Kruskal-Wallis
def kruskal_wallis(df, variable_a, variable_b, alpha):
# On attribue un rang à chaque valeur de la colonne variable_b après que celles-ci aient été triées par ordre croissant (la fonction rank effectue le tri)
# Si plusieurs valeurs sont égales on leur attribue un rang égal à la moyenne des rangs
# Ex : Si les valeurs aux rangs 3, 4 et 5 sont égales, elles auront toutes les trois le rang 4 car (3+4+5)/3 = 4
df['ranks'] = df[variable_b].rank(method='average')
# Calcul de la somme des rangs pour chaque groupe
Ri_sum = df.groupby(variable_a)['ranks'].sum()
df = df.drop(columns='ranks')
# Nombre total d'observations dans l'ensemble des groupes
N = len(df)
# K est le nombre de valeurs uniques de la variable indépendante
k = df[variable_a].nunique()
# Calcul la statistique H
H = (12 / (N * (N + 1))) * sum((Ri_sum[i] ** 2) / len(df.loc[df[variable_a] == i]) for i in df[variable_a].unique()) - 3 * (N + 1)
# Calcul des degrés de liberté
degrees_of_freedom = k - 1
# Le niveau de signification choisi
alpha = alpha
# Calcul, à l'aide du module stats de scipy, de la valeur critique pour un niveau de confiance de 1 - alpha et n degrés de liberté
critical_value = stats.chi2.ppf(1 - alpha, degrees_of_freedom)
# calcul de la p-valeur
p_value = 1 - stats.chi2.cdf(H, degrees_of_freedom)
return f'''Statistique H : {H}, Valeur critique : {critical_value}, P-valeur : {p_value}'''
# Utiliser la fonction et comparer avec le résultat de la fonction stats.kruskal de scipy
kruskal_wallis(purchase_frequency, 'age_group', 'average_order_value', 0.05)
'Statistique H : 4320.580835235676, Valeur critique : 9.487729036781154, P-valeur : 0.0'
# Vérifier les résultats en utilisant la fonction scipy.stats.kruskal()
# Créer des sous-ensembles de données pour chaque tranche d'âge
group1 = purchase_frequency[purchase_frequency['age_group'] == '20-29']['average_order_value']
group2 = purchase_frequency[purchase_frequency['age_group'] == '30-39']['average_order_value']
group3 = purchase_frequency[purchase_frequency['age_group'] == '40-49']['average_order_value']
group4 = purchase_frequency[purchase_frequency['age_group'] == '50-59']['average_order_value']
group5 = purchase_frequency[purchase_frequency['age_group'] == '60 and over']['average_order_value']
result = stats.kruskal(group1,group2,group3,group4,group5)
print("Statistique H de Kruskal-Wallis :", result.statistic)
print("Valeur p :", result.pvalue)
Statistique H de Kruskal-Wallis : 4320.580836256011 Valeur p : 0.0
La statistique H est très largement supérieure à la valeur critique du test. Surtout, la p-valeur renvoyée est très proche de 0 (ici arrondie à 0 par l'ordinateur).
On rejette l'hypothèse nulle selon laquelle il n'y a pas de différence significative entre la taille des paniers moyens d'une tranche d'âge à une autre. L'âge semble avoir un effet sur la taille du panier moyen d'un client.
# Créer un dataframe contenant la taille du panier moyen de chaque tranche d'âge
grouped_by_age_group = purchase_frequency.groupby('age_group')['average_order_value'].mean()
grouped_by_age_group = grouped_by_age_group.reset_index()
grouped_by_age_group.columns = ['age_group', 'age_group_average_order_value']
grouped_by_age_group
age_group | age_group_average_order_value | |
---|---|---|
0 | 20-29 | 69.648262 |
1 | 30-39 | 43.479534 |
2 | 40-49 | 33.002153 |
3 | 50-59 | 28.709195 |
4 | 60 and over | 26.494877 |
# Visualiser la taille du panier moyen par tranche d'âge
plt.figure(figsize=(10, 6))
palette = sns.color_palette("Reds", n_colors=5).as_hex()[::-1]
sns.barplot(data=grouped_by_age_group, x='age_group', y='age_group_average_order_value', palette=palette)
plt.title('Panier moyen par tranche d\'âge')
plt.xlabel('Tranches d\'âge')
plt.ylabel('Panier moyen en €')
plt.box()
plt.show()
# visualiser la corrélation entre l'âge et la catégorie de livres achetés à l'aide d'un boxplot
# Les acheteurs de produits de catégorie 2 sont généralement plus jeunes, les trois quarts ont à peine plus de 30 ans
plt.figure(figsize=(10, 6))
color1 = sns.color_palette("Reds", n_colors=3).as_hex()[::-1][0]
color2 = sns.color_palette("Reds", n_colors=3).as_hex()[::-1][1]
color3 = sns.color_palette("Reds", n_colors=3).as_hex()[::-1][2]
palette = {'0' : color2, '1' : color1, '2' : color3}
meanprops = dict(marker='o', markersize=8, markeredgecolor='black', markerfacecolor='red')
mean_marker = mlines.Line2D([], [], color='red', marker='o', linestyle='None', markersize=8, label='Moyenne')
flierprops = dict(marker='o', markersize=5, markerfacecolor='darkorange', linestyle='none', alpha=0.7)
outliers_marker = mlines.Line2D([], [], color='darkorange', marker='o', linestyle='None', markersize=5, label='Outliers')
sns.boxplot(x='categ', y='age', data=df_without_outliers, palette=palette, meanprops=meanprops, showmeans=True, flierprops=flierprops)
plt.title('Corrélation entre l\'âge et la catégorie de produits achetés', fontsize=16, pad=20)
plt.xlabel('Catégorie')
plt.ylabel('Âge')
sns.despine()
plt.legend(handles=[mean_marker,outliers_marker], bbox_to_anchor=(0.90, 1), loc='upper left')
plt.show()
Cette dernière corrélation implique la variable quantitative âge et la variable qualitative catégorie. Il s'agit d'évaluer le lien entre les variables en comparant l'âge moyen des clients dans les 3 catégories. Nous avons donc 3 moyennes à comparer. Sur le boxplot ci-dessus on constate de gros écarts entre ces moyennes. Il faut découvrir si ces écarts sont statistiquement significatifs. A nouveau, le choix entre le test Anova et le test de Kruskal-Wallis s'impose. Mais ici nous choisirons le test Anova indépendamment des conditions de validité car il est fréquemment admis que l'ANOVA est robuste à la violation de la condition de non-normalité si la taille de l'échantillon est suffisamment grande.
Hypothèses:
H0 : la moyenne d'âge des clients est identique dans les trois catégories.
H1 : Au moins une moyenne diffère des autres.
Seuil de signification : 0.05
# Créer une fonction Anova qui renvoie le rapport de corrélation eta², la statistique F et la p-valeur
def Anova(df, categ, categ_values, alpha):
# On calcule la moyenne générale
grand_mean = df[categ_values].mean()
inter_class_sum_of_squares = 0
intra_class_sum_of_squares = 0
total_sum_of_squares = 0
total_count = len(df)
group_counts = {}
for group in df[categ].unique():
group_df = df.loc[df[categ] == group]
group_mean = group_df[categ_values].mean()
group_size = len(group_df)
group_counts[group] = group_size
inter_class_sum_of_squares += group_size * (group_mean - grand_mean)**2
intra_class_sum_of_squares += ((group_df[categ_values] - group_mean)**2).sum()
total_sum_of_squares += ((group_df[categ_values] - grand_mean)**2).sum()
# Calculer les degrés de libertés à mettre au dénominateur pour calculer les variances inter et intra groupes
dof_inter_class = len(group_counts) - 1
dof_intra_class = total_count - len(group_counts)
inter_class_mean_squares = inter_class_sum_of_squares / dof_inter_class
intra_class_mean_squares = intra_class_sum_of_squares / dof_intra_class
f_statistic = inter_class_mean_squares / intra_class_mean_squares
p_value = 1 - stats.f.cdf(f_statistic, dof_inter_class, dof_intra_class)
# Proportion de la variance inter groupes par rapport à la variance totale
eta_squared = inter_class_sum_of_squares / total_sum_of_squares
critical_value = stats.f.ppf(1-alpha, dof_inter_class, dof_intra_class)
return f'Eta² = {eta_squared} | F statistic = {f_statistic} | Critical Value = {critical_value} | P-value= {p_value}'
# Utiliser la fonction créée plus haut pour tester le lien entre les deux variables
Anova(df_without_outliers, 'categ', 'age', 0.05)
'Eta² = 0.1102713562648539 | F statistic = 39705.51969324281 | Critical Value = 2.995746280044053 | P-value= 1.1102230246251565e-16'
# Utiliser la fonction scipy.stats.f_oneway() pour vérifier si les résultats correspondent
categ0 = df_without_outliers[df_without_outliers['categ'] == '0']['age']
categ1 = df_without_outliers[df_without_outliers['categ'] == '1']['age']
categ2 = df_without_outliers[df_without_outliers['categ'] == '2']['age']
stats.f_oneway(categ0, categ1, categ2)
F_onewayResult(statistic=39705.51969324279, pvalue=0.0)
La statistique F, rapport entre la variance inter-groupe et la variance intra-groupe, est très largement supérieure à la valeur critique ce qui nous pousse à rejeter l'hypothèse nulle. La p-valeur très proche de 0 confirme la significativité du résultat et soutient le choix du rejet.
En fonction de leur âge, les clients feront plus d'achats dans une catégorie plutôt qu'une autre.
Pour résumer, nous avons vu que:
Eu égard aux informations récoltées, voici quelques recommandations qui pourraient bénéficier à l'entreprise:
- Diminuer le nombre de produits de catégorie 0 proposés, retirer les moins performants.
- Se focaliser davantage sur ce qui fonctionne:
# # Afficher le tableau de contingence entre age_group et categ où l'on peut voir le nombre de transactions par tranche d'âge et par catégorie
contingency_table = pd.crosstab(df_without_outliers['age_group'], df_without_outliers['categ'], margins=True)
contingency_table
categ | 0 | 1 | 2 | All |
---|---|---|---|---|
age_group | ||||
20-29 | 13365 | 23616 | 23630 | 60611 |
30-39 | 105507 | 38700 | 6763 | 150970 |
40-49 | 157479 | 51264 | 754 | 209497 |
50-59 | 65377 | 46917 | 743 | 113037 |
60 and over | 45553 | 60108 | 958 | 106619 |
All | 387281 | 220605 | 32848 | 640734 |
Les conditions de validité pour effectuer le test sont respectées: les transactions sont indépendantes et les effectifs conjoints dans le tableau de contingence sont tous supérieurs à 5.
# Utiliser la fonction créée précédemment pour générer le tableau des effectifs théoriques
expected_table = expected_values(contingency_table)
expected = expected_table.iloc[:-1, :-1]
expected
categ | 0 | 1 | 2 |
---|---|---|---|
age_group | |||
20-29 | 36635.0 | 20868.0 | 3107.0 |
30-39 | 91251.0 | 51979.0 | 7739.0 |
40-49 | 126626.0 | 72129.0 | 10740.0 |
50-59 | 68323.0 | 38918.0 | 5794.0 |
60 and over | 64444.0 | 36708.0 | 5465.0 |
# Afficher le tableau des valeurs observées pour comparer avec celui au-dessus
observed_values = contingency_table.iloc[:-1, :-1]
observed_values
categ | 0 | 1 | 2 |
---|---|---|---|
age_group | |||
20-29 | 13365 | 23616 | 23630 |
30-39 | 105507 | 38700 | 6763 |
40-49 | 157479 | 51264 | 754 |
50-59 | 65377 | 46917 | 743 |
60 and over | 45553 | 60108 | 958 |
Hypothèses:
H0 = Les variables catégorie de livres et groupe d'âge sont indépendantes.
H1 = L'âge a une influence sur le choix de la catégorie de livres achetés.
A présent, calculons la statistique du Khi-deux.
# Calcul du Khi-deux avec la fonction chi_square_and_heatmap créée précédemment
# Les effectifs conjoints de catégorie 2 et 20-29 ans semblent contribuer fortement à la non indépendance de âge et catégorie
Chi_square_statistic = Chi_square_and_heatmap(contingency_table, expected_table,'Product category', 'Age groups')
Chi_square_statistic
209631.7664643104
Il faut à présent déterminer la valeur critique à laquelle nous allons comparer la statistique Khi-deux. Si cette dernière est supérieure à la valeur critique cela signifie que les écarts
entre les valeurs observées et les valeurs attendues sont assez significatifs pour rejeter l'hypothèse nulle selon laquelle les deux varibales sont indépendantes.
# On réutilise la fonction créée plus haut pour calculer la valeur critique et la p-valeur
Chi_square_critical_and_p_value(contingency_table, 0.05, Chi_square_statistic)
('Statistique du Khi-deux: 209631.77', 'Valeur critique : 15.51', 'P-valeur : 0.0')
# De nouveau, on Vérifie le résultat en utilisant la fonction scipy.stats.chi2_contingency()
# On ne récupère que les valeurs du dataframe
observed_values_array = observed_values.values
chi2, p, dof, expected = stats.chi2_contingency(observed_values_array)
print(f"chi2 statistic: {chi2}")
print(f"p-value: {p}")
print(f"degrees of freedom: {dof}")
print("expected frequencies:") # Pour comparer avec les valeurs attendues générées plus haut par la fonction expected_values()
print(expected)
chi2 statistic: 209615.22101896504 p-value: 0.0 degrees of freedom: 8 expected frequencies: [[ 36635.30995858 20868.3941464 3107.29589502] [ 91251.30330215 51979.03786907 7739.65882878] [126626.97415308 72129.9098924 10740.11595451] [ 68323.33292287 38918.68916742 5794.97790971] [ 64444.07966332 36708.9689247 5465.95141197]]
La statistique du Khi-deux étant très nettement supérieure à 15.51, et étant donné la très faible p-valeur (ici = 0), on rejette l'hypothèse nulle. Il semblerait que l'âge d'un client ait une influence sur le choix de la catégorie des livres achetés.
# Visualiser la corrélation entre la variable tranche d'âge et le nombre d'achats à l'aide d'un boxplot
plt.figure(figsize=(18,18))
palette = sns.color_palette("Reds", n_colors=5).as_hex()[::]
meanprops = dict(marker='o', markersize=8, markeredgecolor='black', markerfacecolor='red')
mean_marker = mlines.Line2D([], [], color='red', marker='o', linestyle='None', markersize=8, label='Moyenne')
plt.subplot(4,3,1)
sns.boxplot(x='age_group', y='customer_number_of_sessions', data=purchase_frequency, order=['20-29', '30-39', '40-49', '50-59', '60 and over'], palette=palette, meanprops=meanprops, showmeans=True)
plt.title('Corrélation entre la tranche d\'âge d\'un client \net le nombre total de sessions', pad=20)
plt.xlabel('Tranche d\'âge')
plt.ylabel('Nombre de sessions')
sns.despine()
plt.subplot(4,3,2)
sns.boxplot(x='age_group', y='total_number_of_purchases', data=purchase_frequency, order=['20-29', '30-39', '40-49', '50-59', '60 and over'], palette=palette, meanprops=meanprops, showmeans=True)
plt.title('Corrélation entre la tranche d\'âge d\'un client \net le nombre total d\'achats', pad=20)
plt.xlabel('Tranche d\'âge')
plt.ylabel('Nombre d\'achats')
sns.despine()
plt.subplot(4,3,3)
sns.boxplot(x='age_group', y='total_sales_of_customer', data=purchase_frequency, order=['20-29', '30-39', '40-49', '50-59', '60 and over'], palette=palette, meanprops=meanprops, showmeans=True)
plt.title('Corrélation entre la tranche d\'âge d\'un client \net le montant total des achats', pad=20)
plt.xlabel('Tranche d\'âge')
plt.ylabel('Montant des achats')
sns.despine()
plt.legend(handles=[mean_marker])
plt.tight_layout(pad=3.0)
On remarque que la fréquence d'achat des clients de la tranche 20-29 ans est beaucoup plus faible que dans les autres tranches d'âge, mais globalement le montant total de leurs achats reste élevé, quasiment de même ampleur que celui des 40-49. Quant au plus de 60 ans, bien qu'ils se connectent assez régulièrement, ils achètent à peine plus de livres que les 20-29 ans. La tranche d'âge qui se distingue des autres par sa constance en termes de sessions, de nombre d'achats et de chiffre d'affaires généré est celle des 40-49 ans.
Quelle conclusion en tirer ? Une interprétation possible est que les client jeunes se connectent principalement par obligation, pour acheter les livres dont ils ont besoin pour leurs études, les 30-50 ans le font davantage pour le plaisir, mais qu'à partir de la cinquantaine d'autres centres d'intérêts prennent le pas sur la lecture.