top of page

Prévision des démissions avec la régression logistique


Image faite avec DALL-E2


Aujourd’hui, les départements des ressources humaines consacrent beaucoup d’efforts à analyser et gérer le roulement du personnel (turnover). La problématique du roulement du personnel a pris de l’importance dans les organisations en raison de ses impacts négatifs, allant de la perte de productivité, aux perturbations dans la continuité des projets et aux stratégies de croissance à long terme. Aussi, remplacer des travailleurs expérimentés coûte cher. Malgré les efforts déployés par les entreprises pour développer et améliorer l’engagement de ses collaborateurs, le turnover reste un problème difficile à appréhender, notamment en raison de son caractère à priori imprévisible.


Dans toutes organisations, les décideurs se doivent donc découvrir les facteurs importants qui affectent l’attrition de son personnel afin de le prévenir. Pour découvrir ces facteurs, différentes méthodologies et techniques peuvent être utilisées. La méthode la plus simple consiste en l’administration de questionnaires et d’entretiens de sorties. Bien que facile à mettre en place, cette méthode à le défaut d’être grandement sujette aux différents biais cognitifs (à la fois par le collaborateurs qui sort mais aussi par les managers qui analysent les réponses). Une autre méthode, plus répandue, consiste en la construction de statistiques descriptives / indicateurs (comme le taux de démission ou le taux de turnover). Cette méthode permet d’avoir une vision globale des mouvements de sa force de travail mais échoue à la caractériser avec précision et ne peut être utilisée dans un cadre prédictif.


Dans ce post, j'examine un jeu de données RH fictif fournis par les data scientist d'IBM (parti 1) et propose la modélisation des démissions avec une régression logistique simple, puis, multiple (partie 2). Enfin je conclurais sur les possibles champs d'améliorations du modèle et les autres techniques pouvant être utilisé ici (partie 3).



 

Partie 1 : Analyse exploratoire des données


On commence par les classiques. On charge le jeu et regarde les premières statistiques.

df = pd.read_csv(".../HR-Employee-Attrition.csv")
df.info() 

Le jeu est composé de 35 colonnes (9 catégoriques, et 26 numériques) pour 1470 individus.

df.describe() # Stats descriptives des variables numériques
df.describe(include="O") # Stats descriptives des variables catégoriques

Je vous passe l'analyse des stats descriptives, cela serait trop long. Voici juste un sneak peak de ce que sortent respectivement les deux scripts :

Et :


On check s'il y a des NULL :

print(df.isnull().sum()) # Check s'il y a des NULL

Résultat, il ne manque rien. Noté qu'on peut aussi attester des valeurs manquantes avec .info(). En effet, vous avez 1470 individus, donc si vous ne voyez pas 1470 entrées dans une colonne, c'est qu'il manque des valeurs.


La variable "Attrition" est de type "object", soit catégorique. On peut la transformer en variable numérique binaire (1 / 0) pour faciliter l'EDA :

# Transforme l'attrition en dummy var et utilise drop_first pour éviter tout problème de colinéarité
dummy_target = pd.get_dummies(df['Attrition'], prefix='Attrition', drop_first=True)
df = pd.concat([df, dummy_target], axis=1)
df = df.drop('Attrition', axis=1)
df = df.rename(columns={"Attrition_Yes": "Attrition"})
# Drop les colonnes inutiles
df = df.drop(columns=['StandardHours', 
                          'EmployeeCount', 
                          'Over18'])

On regarde la distribution de l'attrition dans le jeu :

attrition_counts = df['Attrition'].value_counts(normalize=True) * 100

attrition_counts
# Résultats :
0    83.877551
1    16.122449
Name: Attrition, dtype: float64

On a donc 16.2% des individus qui ont démissionné contre 83.9% qui restent.

On peut visualiser avec un donut chart (uniquement pour celles et ceux qui aiment perdre leur temps !) :

fig, ax = plt.subplots()

ax.pie(attrition_counts, labels=attrition_counts.index, 
       autopct='%1.1f%%', startangle=90) # Créer le pie chart
ax.axis('equal')  # Assure que le pie chart soit bien un cercle.

# Créer un cercle blanc dans le pie chart 
centre_circle = plt.Circle((0, 0), 0.70, fc='white')
fig.gca().add_artist(centre_circle)

# Titre
plt.title("Pourcentage d'individus dans chaque catégorie d'attrition")
plt.show()

Cette information est importante ! Lorsque l'on tentera de faire des prévisions, il faudra faire très attention au choix de la méthode de split. Le jeu étant déséquilibré, on veut éviter que la variable cible se retrouve uniquement dans un des deux split. Une stratification sera nécessaire.


On va maintenant observer graphiquement les relations présentes dans le jeu données. Again, je ne vais passer en revue toutes les variables, cela serait trop long. Quoi qu'il en soit, le principe est le même pour chaque colonne, seul le fait qu'elles soient numériques ou catégoriques change la manière de visualiser.


Questions que l'on peut se poser : Quelle est la répartition par âge entre hommes et femmes ? Y a-t-il des différences significatives ? Quelle est la satisfaction professionnelle moyenne selon le statut d'attrition ? Un genre est-il plus insatisfait que l'autre ? Quel est le salaire moyen par genre ? Combien y a-t-il d'employés de chaque genre dans chaque département ?


Âge et attrition

fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(15,5))

# Plot the distribution of 'age' for males
male_line = df[df["Gender"] == "Male"]
sns.histplot(data=df[df['Gender'] == 'Male'], x='Age', ax=axes[0], kde=True, hue="Attrition")
axes[0].axvline(male_line['Age'].mean(), color="black", linestyle='--', label='Average Age')
axes[0].set(title="Âge des hommes et attrition", xlabel="Age", ylabel="Count")

# Plot the distribution of 'age' for females
female_line = df[df["Gender"] == "Female"]
sns.histplot(data=df[df['Gender'] == 'Female'], x='Age', ax=axes[1], kde=True, hue="Attrition")
axes[1].axvline(female_line['Age'].mean(), color="black", linestyle='--', label='Average Age')
axes[1].set(title="Âge des femmes et attrition", xlabel="Age", ylabel="Count")

# Plot the distribution of 'age' for all
sns.histplot(data=df, x='Age', ax=axes[2], kde=True, hue="Attrition")
axes[2].axvline(df['Age'].mean(), color="black", linestyle='--', label='Average Age')
axes[2].set(title="Âge et attrition", xlabel="Age", ylabel="Count")

plt.tight_layout()
plt.show()

Niveau hiérarchique, engagement et statisfaction dans le travail

fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(15,5))

# Plot the distribution of 'age' for males
sns.violinplot(data=df, y="JobLevel", x="Gender", ax=axes[0], kde=True, hue="Attrition", palette="Pastel1")
axes[0].set(title="Attrition par sexe et par niveau hiérarchique", xlabel="Gender", ylabel="Job level")

# Plot the distribution of 'age' for females
sns.violinplot(data=df, y="JobInvolvement", x="Gender", ax=axes[1], kde=True, hue="Attrition", palette="Pastel1")
axes[1].set(title="Attrition par sexe et par niveau d'engagement", xlabel="Gender", ylabel="JobInvolvement")
# Plot the distribution of 'age' for all
sns.violinplot(data=df, y="JobSatisfaction", x="Gender", ax=axes[2], kde=True, hue="Attrition", palette="Pastel1")
axes[2].set(title="Attrition par sexe et par niveau de satisfaction", xlabel="Gender", ylabel="JobSatisfaction")

plt.tight_layout()
plt.show()

Rémunération

fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(10,5))

sns.boxplot(data=df, y="MonthlyIncome", x="Gender", ax=axes[0], hue="Attrition", palette="Pastel1")
axes[0].set(title="Rémunération par sexe et par attrition")

sns.boxplot(data=df, y="MonthlyIncome", x="Attrition", ax=axes[1], palette="Pastel1")
axes[1].set(title="Rémunération par attrition")

plt.show()

Taux horaire

# Joinplot pour faire le show
plt.figure(figsize=(4,3))
sns.jointplot(data=df, x="Age", y="HourlyRate", hue="Attrition", kind="kde")
plt.show()

En résumé, l'âge moyen des femmes est d'environ 37 ans, 36 pour les hommes. Les deux ont des distributions similaires. L'attrition est plus élevé pour un niveaux hiérarchique bas. Elle semble être plus élevé pour un niveau d'engagement de 3. L'attrition est répartie sur tout les niveaux de satisfaction. Au niveaux des salaires, la moyenne pour les deux sexes est quasiment la même, 6380.51 pour les hommes et 6686.57 pour les femmes. L'attrition se concentre sur les bas salaires. Le taux horaire ne semble pas être une variable influente.


Il faut faire attention à ce genre de visualisation. En effet, on ne calcul pas le pourcentage d'attrition par valeur contenue dans une variable, ce qui peut porter à confusion lors de certaine lecture graphique. Typiquement, si vous reprenez l'histogramme des âges le plus à droite (hommes et femmes confondus), difficile de savoir si les 23-24 ans ont plus d'attrition que les 25-26 ans. La fonctions pandas.crosstab() permet d'effectuer rapidement ce calcul.



Questions que l'on peut se poser part-2 : Quelle est la répartition de l'attrition selon le nombre de companie travaillé ? Existe t'il une tranche d'âge où l'attrition est plus forte ? Le niveau d'éducation a t'il une influence sur la démission ? Qu'en est il pour le revenu mensuel ? Pour les heures supplémentaires ?


pd.crosstab(df["NumCompaniesWorked"] ,df["Attrition"], 
                        normalize='index').round(2).astype(str) + "%"


cross_age = pd.crosstab(df["Age"] ,df["Attrition"], 
            normalize='index').round(2)

cross_age.plot(kind="bar", stacked=True, ylabel="Pourcentage")
plt.show()


cross_ed = pd.crosstab(df["Education"] ,df["Attrition"], 
            normalize='index').round(2)

cross_ed.plot(kind="bar", stacked=True, ylabel="Pourcentage")
plt.show()


df['Income_bins'] = pd.cut(df['MonthlyIncome'], bins=10)
count_data = df.groupby(['Income_bins', 'Attrition']).size().unstack()
normalized_count_data = count_data.div(count_data.sum(axis=1), axis=0)

normalized_count_data.plot(kind="bar", stacked=True)
plt.show()


cross_ed = pd.crosstab(df["OverTime"] ,df["Attrition"], 
            normalize='index').round(2)

cross_ed.plot(kind="bar", stacked=True, ylabel="Pourcentage")
plt.show()


Résumé : L'attrition augmente pour les individus qui ont travaillé dans un nombre de compagnie supérieur ou égale à 5. Il y'a un plus fort taux de démission chez les jeune de 18 à 29 ans que pour le reste. L'attrition baisse légèrement lorsque le niveau d'éducation s'élève. Pour mieux visualiser la relation entre démission et revenue on peut discrétiser la variable "MonthlyIncome". On peut observer que la propension à démissionner est plus forte pour les bas salaires. Celle-ci se réduit au fur et à mesure que le salaire augmente.

La variable "OverTime" semble avoir une influence non négligeable sur l'attrition. En effet, cette dernière est beaucoup plus élevé pour les individus effectuant des heures sup.



Partie 2 : Modélisation avec régression logistique


Avant de modéliser, on doit transformer nos variables pour qu'elles puissent être analysées par le modèle. En effet Scikit-learn n'arrive pas à lire les variables catégoriques.

# Drop income_bins
df = df.drop(["Income_bins"], axis=1)


# sauvegarde les colonnnes catégoriques dans une variable
dummy_col= df.select_dtypes(include=['object', 'category']).columns

# transforme les variable catérogiques en dummies
df = pd.get_dummies(df, columns=dummy_col, drop_first=True)

# Transforme les col uint8 en int64
uint8_cols = df.select_dtypes(include=['uint8']).columns
df[uint8_cols] = df[uint8_cols].astype('int64')


Matrice de corrélation

# Calcul la matrice de corrélation
correlation_matrix = df.corr()

# Créé la heatmap
sns.heatmap(correlation_matrix, cmap='coolwarm', vmin=-1, vmax=1, linewidths=0.5)

# Titre et graphe de la matrice
plt.title('Matrice de Corrélation')
plt.show()



La matrice nous montre qu'un certain nombre de variable sont corrélés entre elles (enn particulier au centre). Etant donnée le nombre important de variable dans le jeu (en particulier après avoir discrétisé les variables catégoriques), tout les labels n'apparaissent pas. Le mieux est d'itérer dessus et de capturer les corrélations supérieurs à un certains montant.


threshold = []

# Itère sur la matrice de corrélation
for i in range(len(correlation_matrix)):
    for j in range(i + 1, len(correlation_matrix)):
        # Check si la valeur de corrélation est supérieur ou égale à 0.7 et capture la variable
        if abs(correlation_matrix.iloc[i, j]) >= 0.70:
            threshold.append((correlation_matrix.index[i], 
                              correlation_matrix.columns[j], 
                              correlation_matrix.iloc[i, j]))


# Affiche les variable avec une corrélation supérieur ou égale à 0.70
print("Pairs with correlations greater than or equal to 0.70:")
for pair in threshold:
    print(f"{pair[0]} and {pair[1]}: {pair[2]:.2f}")
# Résultat :
Pairs with correlations greater than or equal to 0.70:
JobLevel and MonthlyIncome: 0.95
JobLevel and TotalWorkingYears: 0.78
MonthlyIncome and TotalWorkingYears: 0.77
PercentSalaryHike and PerformanceRating: 0.77
YearsAtCompany and YearsInCurrentRole: 0.76
YearsAtCompany and YearsWithCurrManager: 0.77
YearsInCurrentRole and YearsWithCurrManager: 0.71
BusinessTravel_Travel_Frequently and BusinessTravel_Travel_Rarely: -0.75
Department_Research & Development and Department_Sales: -0.91
Department_Research & Development and JobRole_Sales Executive: -0.73
Department_Sales and JobRole_Sales Executive: 0.81



correlation_matrix = df.corr().abs()

# Selectione la partie supérieur de la matrice
upper = correlation_matrix.where(np.triu(np.ones(correlation_matrix.shape), k=1).astype(bool))

# Capture les variables avec une corrélation >= 0.75
to_drop = [column for column in upper.columns if any(upper[column] > 0.75)]

to_drop

# Résultats :
 ['MonthlyIncome',
 'PerformanceRating',
 'TotalWorkingYears',
 'YearsInCurrentRole',
 'YearsWithCurrManager',
 'BusinessTravel_Travel_Rarely',
 'Department_Sales',
 'JobRole_Sales Executive']

# Drop les variables corrélées 
df.drop(to_drop, axis=1, inplace=True)

sns.heatmap(correlation_matrix, cmap='coolwarm', vmin=-1, vmax=1, linewidths=0.5)

plt.title('Matrice de Corrélation')
plt.show()

Maintenant que nous avons nettoyer le jeu des variables corrélés entre elles, regardons la corrélations avec la variable cible, l'attrition.


df.drop('Attrition', axis=1).corrwith(df["Attrition"]).sort_values().plot(kind='barh', figsize=(3, 6))
plt.show()

À ce stade, on pourrait d'ores et déjà supprimer un certain nombre de variable. Toutes celles qui ont une corrélation inférieure à +0.05 et -0.05 n'ont quasiment aucune influence sur l'attrition. C'est variables auront des p-valeurs élevés. je choisi ici de les garder pour l'exemple mais sachez que dans un véritable projet d'analyse il faudrait tester plusieurs techniques d'éliminations pour ne conserver que les variables d'intérêts. Cela permet aux modèle d'être plus performant (à la fois dans ses prévisions mais aussi dans sa rapidité d'exécution).


La régression logistique est modèle linéaire sensible aux différences d'échelles entre les valeurs. On standardise le jeu de données.

# Normalise le jeu
X = df.drop(["Attrition"], axis=1)
scaler = StandardScaler()

# fit_transform et garde les noms de col
X_scaled = pd.DataFrame(scaler.fit_transform(X), columns=X.columns, index=X.index)


Modélisation avec 1 variable (Statsmodel)

import statsmodels.api as sm
X = sm.add_constant(X_scaled["JobLevel"])
y = df['Attrition']
log_reg = sm.Logit(y, X)
results = log_reg.fit()
print(results.summary())
                       Logit Regression Results                           
===================================================================
Dep. Variable:          Attrition   No. Observations:          1470
Model:                      Logit   Df Residuals:              1468
Method:                       MLE   Df Model:                     1
Date:            Thu, 18 May 2023   Pseudo R-squ.:          0.03750
Time:                    17:13:35   Log-Likelihood:         -624.95
converged:                   True   LL-Null:                -649.29
Covariance Type:        nonrobust   LLR p-value:          2.996e-12
===================================================================
           coef    std err          z      P>|z|      [0.025 0.975]
-------------------------------------------------------------------
const    -0.6626      0.159     -4.163      0.000     -0.975 -0.351
JobLevel -0.5290      0.084     -6.303      0.000     -0.693 -0.364
===================================================================

Avec une seul variable, le modèle est mauvais. Le pseudo-R pointe 0.04, ce qui pourrait se traduire par : "0.04% de l'attrition est expliquée par la variable JobLevel". Néanmoins, on peut quand même retirer de la modélisation que cette variable est significative (p-val < 0.05).



Modélisation avec plusieurs variables (Statsmodel)

X = sm.add_constant(X_scaled)

log_reg = sm.Logit(y, X)
results = log_reg.fit()
print(results.summary())
                           Logit Regression Results                           
===================================================================
Dep. Variable:       Attrition   No. Observations:             1470
Model:                   Logit   Df Residuals:                 1432
Method:                    MLE   Df Model:                       37
Date:         Thu, 18 May 2023   Pseudo R-squ.:              0.3092
Time:                 17:16:40   Log-Likelihood:            -448.53
converged:                True   LL-Null:                   -649.29
Cov Type:            nonrobust   LLR p-value:             9.378e-63
===================================================================
            coef        std err       z      P>|z|   [0.025  0.975]
-------------------------------------------------------------------
const           -2.5227  0.128  -19.752     0.000    -2.773  -2.272
Age             -0.4058  0.111   -3.654     0.000    -0.623  -0.188
DailyRate       -0.1377  0.087   -1.583     0.113    -0.308   0.033
DistanceFromHome 0.3290  0.084    3.911     0.000     0.164   0.494

Je ne fait pas apparaître l'ensemble des variables, la table est beaucoup trop grande. L'important ici est de constater que le pseudo-R à nettement grimper, on passe de 0.04 à 0.3. Comme précédemment, on peut traduire cela par : "Les variables expliquent à 30% l'attrition". Si on regarde la table des coefficients, on trouve une p-valeur supérieur 0.05. Ce qui indique que la variable "DailyRate" n'est pas statistiquement significative.



Modélisation avec plusieurs variables et scikit learn

from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, recall_score, roc_auc_score, confusion_matrix
from sklearn.metrics import RocCurveDisplay
# Séparation du jeu de test et d'entrainement avec stratification
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.3,
                                                    stratify=y)
# On entraine le modèle
log_reg = LogisticRegression()
log_reg.fit(X_train, y_train)

# On fait les prévsions sur le jeu de test
y_pred = log_reg.predict(X_test)

# Calcul la matrice de confusion
conf_mat = confusion_matrix(y_test, y_pred)

plt.figure(figsize=(5,5))
sns.heatmap(conf_mat, annot=True, fmt='d', cmap='Blues')
plt.title('Matrice de confusion')
plt.xlabel('Prévision')
plt.ylabel('Test')
plt.show()

# Graphe de la courbe ROC
roc_display = RocCurveDisplay.from_estimator(log_reg, X_test, y_test)
plt.title('Courbe ROC')
plt.show()

# Calcul des indicateurs d'évaluation
accuracy = accuracy_score(y_test, y_pred)
f1 = f1_score(y_test, y_pred)
recall = recall_score(y_test, y_pred)

# Print les indicateurs
print('Accuracy:', accuracy.round(2))
print('F1-Score:', f1.round(2))
print('Recall:', recall.round(2))

# ROC AUC Score
roc_auc = roc_auc_score(y_test, log_reg.predict_proba(X_test)[:, 1])
print('ROC-AUC Score:', roc_auc.round(2))
#Résultats
Accuracy: 0.88
F1-Score: 0.51
Recall: 0.38
ROC-AUC Score: 0.77

Petit rappel des métriques en classification :

  • L'accuracy (ou précision) est une mesure de performance qui indique la proportion de prédictions correctes réalisées par le modèle par rapport à l'ensemble total des observations. Une accuracy de 1.0 signifie que toutes les prédictions du modèle sont correctes, tandis qu'une accuracy de 0 signifie qu'aucune des prédictions n'est correcte. Accuracy = (Nombre de prédictions correctes) / (Nombre total de prédictions)

  • Le "recall" (aussi connu sous le nom de sensibilité ou taux de vrais positifs) est la proportion de vrais positifs (VP) parmi l'ensemble des vrais positifs et des faux négatifs (FN). En d'autres termes, parmi toutes les instances positives réelles, combien notre modèle a-t-il réussi à prédire correctement ? Un recall parfait (1.0) signifie que le système de classification a réussi à identifier tous les vrais positifs, tandis qu'un recall de 0 signifie qu'il a manqué tous les vrais positifs. Recall = VP / (VP + FN)

  • Le score F1 est une mesure de performance qui combine la précision et le rappel (recall) pour fournir une seule mesure globale. Le score F1 est la moyenne harmonique de la précision et du rappel, donnant une mesure qui équilibre ces deux dimensions. La moyenne harmonique est utilisée plutôt que la moyenne arithmétique simple parce qu'elle tend à être plus faible si l'un des deux termes est nettement inférieur à l'autre, ce qui signifie que vous ne pouvez pas obtenir un score F1 élevé sans avoir une précision et un rappel tous deux solides. F1 = 2 * (précision * rappel) / (précision + rappel)

  • ROC-AUC, qui signifie "Receiver Operating Characteristic - Area Under the Curve", est une mesure de performance utilisée pour évaluer la qualité d'un modèle de classification binaire. ROC est une courbe qui illustre la performance d'un modèle de classification à tous les seuils de classification. Elle trace le taux de vrais positifs (sensibilité) en fonction du taux de faux positifs (1 - spécificité). AUC signifie "Area Under the Curve". C'est l'aire sous la courbe ROC. L'AUC donne une mesure globale de la performance du modèle à travers tous les seuils possibles. Un AUC de 1 indique que le modèle a une performance parfaite : il classe correctement tous les exemples positifs et négatifs. Un AUC de 0.5 indique que le modèle est équivalent à un tirage au sort aléatoire. En général, plus l'AUC est proche de 1, meilleur est le modèle.


Partie 3 : Conclusion


On a une bonne précision (88%) ainsi qu'un bon score ROC-AUC (77%). Néanmoins, le recall n'est pas terrible (38%). Le modèle n'est globalement pas mauvais mais il est clairement perfectible. Il peut manquer des variables importantes dans notre jeu, ou bien, la démission revêt une particularité que la variable retranscrit mal. Ce qui est sûrement le cas ici. En effet, la littérature identifie deux forme de démissions. Les démissions volontaires (dûs à des facteurs internes liés au travail comme une mauvaise rémunération ou des conditions de travail mauvaises) et les démissions involontaires (dûs à des facteurs externe tels que suivre son conjoint lors d'une mutation ou la naissance d'un enfant). Ici, nous n'avons aucune indication sur le fait qu'il s'agisse d'une démission volontaire ou involontaire.


Comentarios


bottom of page