Analysez les ventes d'une librairie avec Python

Objectifs de la mission:

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.

Etape 1 - Chargement et exploration des donnés

Etape 1.1 - Import des bibliothèques Python nécessaires

In [ ]:
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

Etape 1.2 - Chargement des fichiers csv

In [ ]:
# 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=';')
In [ ]:
# Utiliser le style dark_background
plt.style.use('dark_background')

Etape 1.3 - Exploration du dataframe customers

In [ ]:
# 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
In [ ]:
# On affiche les 5 premières lignes du dataframe customers
customers.head()
Out[ ]:
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
In [ ]:
# On vérifie s'il y a des doublons
customers.duplicated().sum()
Out[ ]:
0
In [ ]:
# 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()
Out[ ]:
client_id sex birth
0 4410 f 1967
1 7839 f 1975
2 1699 f 1984
3 5961 f 1962
4 5320 m 1943
In [ ]:
# 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]
Out[ ]:
8621
In [ ]:
# Modifier le nom des modalités de la variable sex pour plus de clarté
customers['sex'] = customers['sex'].replace({'f': 'female', 'm': 'male'})
In [ ]:
# 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
In [ ]:
# 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
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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()
In [ ]:
# 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()
In [ ]:
# Afficher les effectifs de chaque genre
customers['sex'].value_counts()
Out[ ]:
female    4490
male      4131
Name: sex, dtype: int64

Etape 1.4 - Exploration du dataframe products

In [ ]:
# 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
In [ ]:
# Afficher les 5 premières lignes du dataframe products
products.head()
Out[ ]:
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
In [ ]:
# On vérifie s'il y a des doublons
products.duplicated().sum()
Out[ ]:
0
In [ ]:
# Vérifier l'unicité des clés primaires du dataframe, doit renvoyer 3286
products['id_prod'].unique().shape[0]
Out[ ]:
3286
In [ ]:
# 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
In [ ]:
# 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
In [ ]:
# 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
Out[ ]:
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

In [ ]:
# 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()
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# Distribution empirique de la variable price_group
products['price_group'].value_counts()
Out[ ]:
0-50       3013
50-100      150
100-150      79
150-200      29
200-250      13
250-300       2
Name: price_group, dtype: int64
In [ ]:
# Afficher la distribution empirique de la variable catégorie :
products['categ'].value_counts()
Out[ ]:
0    2308
1     739
2     239
Name: categ, dtype: int64
In [ ]:
# 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()
In [ ]:
# Afficher la proportion de chaque catégorie dans le nombre total de produits
products['categ'].value_counts(normalize=True)
Out[ ]:
0    0.702374
1    0.224893
2    0.072733
Name: categ, dtype: float64
In [ ]:
# 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()
In [ ]:
# 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)

Etape 1.4 - Exploration du dataframe transactions

In [ ]:
# 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
In [ ]:
# Afficher les 5 premières lignes du dataframe transactions
transactions.head()
Out[ ]:
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
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# On vérifie s'il y a des doublons
transactions.duplicated().sum()
Out[ ]:
0
In [ ]:
# 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]
Out[ ]:
3265
In [ ]:
# Afficher le nombre de clients uniques ayant effectué un achat
transactions['client_id'].unique().shape[0]
Out[ ]:
8600
In [ ]:
# Afficher le nombre de sessions uniques
number_of_unique_sessions = transactions['session_id'].unique().shape[0]
number_of_unique_sessions
Out[ ]:
345505
In [ ]:
# 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()
Out[ ]:
session_id nb_of_products_bought_during_session
0 1 1
1 2 1
2 3 3
3 4 2
4 5 1
In [ ]:
# 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')
Out[ ]:
Text(0, 0.5, 'Nombre de sessions')
In [ ]:
# Afficher le nombre maximum d'achats en une session
transactions_per_session['nb_of_products_bought_during_session'].max()
Out[ ]:
14
In [ ]:
# Ajouter une colonne au dataframe transactions
transactions = pd.merge(transactions, transactions_per_session, on='session_id')
transactions.head(15)
Out[ ]:
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

Etape 1.5 - Lister les produits et les clients jamais impliqués dans une transaction

In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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
Out[ ]:
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

Etape 1.5 - Fusion des dataframes et exploration du dataframe fusionné

In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# Afficher (nombre de lignes, nombre de colonnes) du dataframe fusionné
df.shape
Out[ ]:
(687534, 12)
In [ ]:
# Renommer les colonnes date et birth pour plus de clarté
df = df.rename(columns={'date': 'transaction_date', 'birth' : 'year_of_birth'})
df.head()
Out[ ]:
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.

In [ ]:
# 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)
In [ ]:
# On Visualise les anomalies
# pd.set_option('display.max_rows', 236)
df[df['anomalies'] == True].head(10)
Out[ ]:
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
In [ ]:
# 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)
In [ ]:
# On vérifie si les changements ont été effectués
df[df['anomalies'] == True].head()
Out[ ]:
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
In [ ]:
# Suppression de la colonne anomalies
df.drop(columns='anomalies', inplace=True)
In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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')
Out[ ]:
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

In [ ]:
# On récupère les identifiants des 4 outliers
outliers_id = best_customers.iloc[:4,0]
outliers_id
Out[ ]:
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.

Etape 2 - Indicateurs et chiffres clés autour du chiffre d'affaires

Etape 2.1 - Calcul du chiffre d'affaires réalisé sur la période étudiée

In [ ]:
# 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
In [ ]:
# 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 €.
In [ ]:
# Placer les dates en index
df = df.sort_values(by='transaction_date').set_index('transaction_date')
df.head()
Out[ ]:
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
In [ ]:
# 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
Out[ ]:
Year Revenue
0 2021 4944760.98
1 2022 6108681.81
2 2023 974220.31
In [ ]:
# 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

In [ ]:
# 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 €

Etape 2.2 - Evolution du chiffre d'affaires dans le temps

In [ ]:
# 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()
In [ ]:
# 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()
In [ ]:
# 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()
In [ ]:
# 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.

In [ ]:
# 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 €
In [ ]:
# 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}

Etape 2.3 - Répartition des ventes par catégorie

In [ ]:
# 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)
Out[ ]:
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
In [ ]:
# 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)
Out[ ]:
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
In [ ]:
# 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)
Out[ ]:
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
In [ ]:
# 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()
In [ ]:
# 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
Out[ ]:
{'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.

In [ ]:
# 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()
In [ ]:
# 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()
In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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..

Etape 2.4 - Tops et flops des produits commercialisés

In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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()
In [ ]:
# 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
Out[ ]:
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.

Etape 2.5 - Répartition du chiffre d'affaires par produit

In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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
Out[ ]:
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

In [ ]:
# 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
In [ ]:
# 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 !

In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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()
In [ ]:
# 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)
Out[ ]:
0    0.438710
1    0.372581
2    0.188710
Name: categ, dtype: float64

Etape 2.6 - Répartition du chiffre d'affaires par type de client

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.

In [ ]:
# Retirer nos 4 principaux outliers en se servant des id
df_without_outliers = df.loc[~df['client_id'].isin(outliers_id)]
In [ ]:
# Afficher le nombre de transactions post suppression de ces 4 outliers
df_without_outliers.shape[0]
Out[ ]:
640734
In [ ]:
# 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 €
In [ ]:
# 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()
In [ ]:
# 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()
In [ ]:
# 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
Out[ ]:
sex nb_of_unique_sessions
0 female 168546
1 male 153920
In [ ]:
# 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()
In [ ]:
# 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
Out[ ]:
{'female': 34.393726816460955, 'male': 34.73519964920725}
In [ ]:
# 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()
In [ ]:
# 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
Out[ ]:
gender total_sales
0 female 5796925.08
1 male 5346441.93
In [ ]:
# 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.

In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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()
In [ ]:
# Calculer l'âge moyen des clients
print('Âge moyen des clients:',int(df_without_outliers['age'].mean()),'ans')
Âge moyen des clients: 46 ans
In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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
Out[ ]:
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

In [ ]:
# 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.

In [ ]:
# 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
Out[ ]:
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

In [ ]:
# Afficher le nombre de ces clients
top_50_percent_customers['client_id'].nunique()
Out[ ]:
4282
In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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
Out[ ]:
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

Etape 3 - Analyse du comportement des clients

Etape 3.1 - Lien entre le genre d'un client et les catégories de livres achetés

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.

In [ ]:
# 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
Out[ ]:
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.

In [ ]:
# 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
In [ ]:
# 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
Out[ ]:
categ 0 1 2
sex
female 201574.0 114822.0 17096.0
male 185706.0 105782.0 15751.0
In [ ]:
# Afficher les valeurs observées histoire de comparer
observed_values = contingency_table.iloc[:-1, :-1]
observed_values
Out[ ]:
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.

In [ ]:
# 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
In [ ]:
# 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
Out[ ]:
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.

In [ ]:
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}'
In [ ]:
# Afficher le Khi-deux, la valeur critique et la p-valeur
Chi_square_critical_and_p_value(contingency_table, 0.05, Chi_square_statistic)
Out[ ]:
('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.

In [ ]:
# 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.

Etape 3.2 - Lien entre l'âge des clients et le montant total des achats

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.

In [ ]:
# On peut utiliser le dataframe sales_by_customer créé précédemment et auquel il manque juste la variable âge
sales_by_customer.head()
Out[ ]:
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
In [ ]:
# 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()
Out[ ]:
client_id age
0 1 69
1 2 48
2 3 45
3 4 60
4 5 30
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# Afficher les dimensions du dataframe sales_by_customer
sales_by_customer.shape
Out[ ]:
(8596, 3)
In [ ]:
# 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()
In [ ]:
# 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>
In [ ]:
# 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

In [ ]:
# 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}"
In [ ]:
# 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)
Out[ ]:
'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'
In [ ]:
# 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)
Out[ ]:
'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.

In [ ]:
# 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')
Out[ ]:
'Coefficient de corrélation de Spearman: -0.184, p-valeur: 0.0'
In [ ]:
# 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.

In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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()
In [ ]:
# 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.

In [ ]:
# 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')
Out[ ]:
'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.

In [ ]:
# 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}'
In [ ]:
# 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')
Out[ ]:
'Coefficient de corrélation de Pearson: -0.188, p-valeur: 0.0'

Etape 3.3 - Lien entre l'âge des clients et la fréquence d'achat

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..

In [ ]:
# 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()
Out[ ]:
client_id customer_number_of_sessions
0 1 34
1 2 100
2 3 36
3 4 96
4 5 49
In [ ]:
# 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'] 
In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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()
In [ ]:
# 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()
In [ ]:
# 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)
In [ ]:
# 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()
In [ ]:
# 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)
Out[ ]:
'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'
In [ ]:
# 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)
Out[ ]:
'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'
In [ ]:
# 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)
Out[ ]:
'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.

In [ ]:
# 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
In [ ]:
# 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
In [ ]:
# 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')
Out[ ]:
'Coefficient de corrélation de Pearson: -0.046, p-valeur: 2.1747084142154094e-05'
In [ ]:
# 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')
Out[ ]:
'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.

In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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)
In [ ]:
# # 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')
Out[ ]:
'Coefficient de corrélation de Spearman: -0.003, p-valeur: 0.97854118091275'
In [ ]:
# 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')
Out[ ]:
'Coefficient de corrélation de Spearman: -0.049, p-valeur: 0.6711786057747684'

Etape 3.4 - Lien entre l'âge des clients et la taille du panier moyen

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.

In [ ]:
# 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()
Out[ ]:
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
In [ ]:
# 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
Out[ ]:
40.402796386422054
In [ ]:
# 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

In [ ]:
# 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
In [ ]:
# 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}'
In [ ]:
# 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)
Out[ ]:
'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.

In [ ]:
#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}'''
In [ ]:
# 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)
Out[ ]:
'Statistique H : 4320.580835235676, Valeur critique : 9.487729036781154, P-valeur : 0.0'
In [ ]:
# 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.

In [ ]:
# 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
Out[ ]:
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
In [ ]:
# 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()

Etape 3.5 - Lien entre l'âge et la catégorie de produits achetés

In [ ]:
# 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

In [ ]:
# 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}'
In [ ]:
# Utiliser la fonction créée plus haut pour tester le lien entre les deux variables
Anova(df_without_outliers, 'categ', 'age', 0.05)
Out[ ]:
'Eta² = 0.1102713562648539 | F statistic = 39705.51969324281 | Critical Value = 2.995746280044053 | P-value= 1.1102230246251565e-16'
In [ ]:
# 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)
Out[ ]:
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.

Conclusion

Pour résumer, nous avons vu que:

  • la tendance haussière du chiffre d'affaires s'est inversée après le pic de mars 2022
  • les pics d'activité liés à la saisonnalité ont lieu chaque année en septembre (pic de ventes lié à la rentrée littéraire) et en octobre (chute des ventes, le soufflé rentrée littéraire retombe)
  • la catégorie la plus performante est la catégorie 1
  • la catégorie 2, catégorie de prédilection des clients jeunes, se distingue par ses prix élevés.
  • la catégorie 0 représente 70% des produits commercialisés mais seulement 34 % du CA
  • les femmes n'achètent pas plus que les hommes (et vice versa), mais elles sont légèrement plus nombreuses
  • les 40-49 ans sont les clients qui génèrent le plus de CA, suivis par les 20-29 et 30-39
  • le panier moyen des 20-29 ans est beaucoup plus élevé que dans les autres tranches d'âge mais ils achètent moins fréquemment
  • la répartition du CA entre les différents produits suit le principe de Pareto
  • 50% des clients génèrent 80% du CA

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:

  • les 20% de produits qui génèrent près de 80% du CA
  • les 50% de clients qui génèrent près de 80% du CA
  • les clients âgés entre 20 et 49 ans
- Vérifier si certains produits de catégorie 2 ne sont pas proposés à des prix exagérés, d'autant qu'ils sont achetés principalement par des jeunes (étudiants ?) et que ceux-ci n'ont globalement pas le même pouvoir d'achat que les clients des autres tranches d'âge.
- Considérer la question suivante : "Quels produits pourraient séduire les plus de 50 ans ?"

Analyses complémentaires

Lien entre tranche d'âge et catégorie de produits achetés, deux variables qualitatives.

In [ ]:
# # 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
Out[ ]:
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.

In [ ]:
# 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
Out[ ]:
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
In [ ]:
# Afficher le tableau des valeurs observées pour comparer avec celui au-dessus
observed_values = contingency_table.iloc[:-1, :-1]
observed_values
Out[ ]:
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.

In [ ]:
# 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
Out[ ]:
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.

In [ ]:
# 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)
Out[ ]:
('Statistique du Khi-deux: 209631.77',
 'Valeur critique : 15.51',
 'P-valeur : 0.0')
In [ ]:
# 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.

Lien entre âge et fréquence d'achat.

In [ ]:
# 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.