تحلیل خوشهبندی مشتریان بانک با K-Means
در این پروژه، داده های مشتریان و کمپین های بازاریابی بانک تحلیل شد تا الگوهای رفتاری مشابه شناسایی شوند. پس از پاک سازی، تبدیل ویژگی های دسته ای، و استانداردسازی داده ها، الگوریتم K-Means برای تقسیم مشتریان به دو خوشه استفاده شد. در ادامه، ارتباط ویژگی های مهم با خوشه ها و کیفیت تقسیم بندی با روش Elbow بررسی شد.
ابزارها

Python

Pandas

NumPy

Matplotlib

Seaborn

Scikit-learn
In [2]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
In [3]:
df = pd.read_csv('../DATA/bank-full.csv')
In [4]:
df.head()
Out[4]:
| age | job | marital | education | default | housing | loan | contact | month | day_of_week | ... | campaign | pdays | previous | poutcome | emp.var.rate | cons.price.idx | cons.conf.idx | euribor3m | nr.employed | subscribed | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 56 | housemaid | married | basic.4y | no | no | no | telephone | may | mon | ... | 1 | 999 | 0 | nonexistent | 1.1 | 93.994 | -36.4 | 4.857 | 5191.0 | no |
| 1 | 57 | services | married | high.school | unknown | no | no | telephone | may | mon | ... | 1 | 999 | 0 | nonexistent | 1.1 | 93.994 | -36.4 | 4.857 | 5191.0 | no |
| 2 | 37 | services | married | high.school | no | yes | no | telephone | may | mon | ... | 1 | 999 | 0 | nonexistent | 1.1 | 93.994 | -36.4 | 4.857 | 5191.0 | no |
| 3 | 40 | admin. | married | basic.6y | no | no | no | telephone | may | mon | ... | 1 | 999 | 0 | nonexistent | 1.1 | 93.994 | -36.4 | 4.857 | 5191.0 | no |
| 4 | 56 | services | married | high.school | no | no | yes | telephone | may | mon | ... | 1 | 999 | 0 | nonexistent | 1.1 | 93.994 | -36.4 | 4.857 | 5191.0 | no |
5 rows × 21 columns
In [5]:
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 41188 entries, 0 to 41187 Data columns (total 21 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 age 41188 non-null int64 1 job 41188 non-null object 2 marital 41188 non-null object 3 education 41188 non-null object 4 default 41188 non-null object 5 housing 41188 non-null object 6 loan 41188 non-null object 7 contact 41188 non-null object 8 month 41188 non-null object 9 day_of_week 41188 non-null object 10 duration 41188 non-null int64 11 campaign 41188 non-null int64 12 pdays 41188 non-null int64 13 previous 41188 non-null int64 14 poutcome 41188 non-null object 15 emp.var.rate 41188 non-null float64 16 cons.price.idx 41188 non-null float64 17 cons.conf.idx 41188 non-null float64 18 euribor3m 41188 non-null float64 19 nr.employed 41188 non-null float64 20 subscribed 41188 non-null object dtypes: float64(5), int64(5), object(11) memory usage: 6.6+ MB
In [6]:
df.describe().transpose()
Out[6]:
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| age | 41188.0 | 40.024060 | 10.421250 | 17.000 | 32.000 | 38.000 | 47.000 | 98.000 |
| duration | 41188.0 | 258.285010 | 259.279249 | 0.000 | 102.000 | 180.000 | 319.000 | 4918.000 |
| campaign | 41188.0 | 2.567593 | 2.770014 | 1.000 | 1.000 | 2.000 | 3.000 | 56.000 |
| pdays | 41188.0 | 962.475454 | 186.910907 | 0.000 | 999.000 | 999.000 | 999.000 | 999.000 |
| previous | 41188.0 | 0.172963 | 0.494901 | 0.000 | 0.000 | 0.000 | 0.000 | 7.000 |
| emp.var.rate | 41188.0 | 0.081886 | 1.570960 | -3.400 | -1.800 | 1.100 | 1.400 | 1.400 |
| cons.price.idx | 41188.0 | 93.575664 | 0.578840 | 92.201 | 93.075 | 93.749 | 93.994 | 94.767 |
| cons.conf.idx | 41188.0 | -40.502600 | 4.628198 | -50.800 | -42.700 | -41.800 | -36.400 | -26.900 |
| euribor3m | 41188.0 | 3.621291 | 1.734447 | 0.634 | 1.344 | 4.857 | 4.961 | 5.045 |
| nr.employed | 41188.0 | 5167.035911 | 72.251528 | 4963.600 | 5099.100 | 5191.000 | 5228.100 | 5228.100 |
In [10]:
sns.histplot(df, x = 'age', bins = 40,hue = 'loan')
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1075: FutureWarning: When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future version of pandas. Pass `(name,)` instead of `name` to silence this warning.
data_subset = grouped_data.get_group(pd_key)
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1075: FutureWarning: When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future version of pandas. Pass `(name,)` instead of `name` to silence this warning.
data_subset = grouped_data.get_group(pd_key)
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1075: FutureWarning: When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future version of pandas. Pass `(name,)` instead of `name` to silence this warning.
data_subset = grouped_data.get_group(pd_key)
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1075: FutureWarning: When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future version of pandas. Pass `(name,)` instead of `name` to silence this warning.
data_subset = grouped_data.get_group(pd_key)
Out[10]:
<Axes: xlabel='age', ylabel='Count'>
In [14]:
plt.figure(figsize=(12,6), dpi = 200)
sns.histplot(data= df[df['pdays']!=999], x = 'pdays')
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
Out[14]:
<Axes: xlabel='pdays', ylabel='Count'>
In [15]:
df['contact'].unique()
Out[15]:
array(['telephone', 'cellular'], dtype=object)
In [16]:
df['duration']
Out[16]:
0 261
1 149
2 226
3 151
4 307
...
41183 334
41184 383
41185 189
41186 442
41187 239
Name: duration, Length: 41188, dtype: int64
In [19]:
plt.figure(figsize=(12,6), dpi = 200)
sns.histplot(df, x = 'duration', hue = 'contact')
plt.xlim(0,1000)
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1075: FutureWarning: When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future version of pandas. Pass `(name,)` instead of `name` to silence this warning.
data_subset = grouped_data.get_group(pd_key)
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1075: FutureWarning: When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future version of pandas. Pass `(name,)` instead of `name` to silence this warning.
data_subset = grouped_data.get_group(pd_key)
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1075: FutureWarning: When grouping with a length-1 list-like, you will need to pass a length-1 tuple to get_group in a future version of pandas. Pass `(name,)` instead of `name` to silence this warning.
data_subset = grouped_data.get_group(pd_key)
Out[19]:
(0.0, 1000.0)
In [20]:
df['job'].unique()
Out[20]:
array(['housemaid', 'services', 'admin.', 'blue-collar', 'technician',
'retired', 'management', 'unemployed', 'self-employed', 'unknown',
'entrepreneur', 'student'], dtype=object)
In [25]:
df['job'].value_counts().index
Out[25]:
Index(['admin.', 'blue-collar', 'technician', 'services', 'management',
'retired', 'entrepreneur', 'self-employed', 'housemaid', 'unemployed',
'student', 'unknown'],
dtype='object', name='job')
In [26]:
sns.countplot(df, x = 'job', order = df['job'].value_counts().index)
plt.xticks(rotation = 90);
In [28]:
sns.countplot(df, x = 'education', order = df['education'].value_counts().index, hue = "default")
plt.xticks(rotation = 90);
In [30]:
df['default'].value_counts()
Out[30]:
default no 32588 unknown 8597 yes 3 Name: count, dtype: int64
In [32]:
df["loan"].value_counts()
Out[32]:
loan no 33950 yes 6248 unknown 990 Name: count, dtype: int64
In [33]:
sns.countplot(df, x = 'default')
Out[33]:
<Axes: xlabel='default', ylabel='count'>
In [34]:
sns.pairplot(df)
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
C:\Users\Zahra\anaconda3\Lib\site-packages\seaborn\_oldcore.py:1119: FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead.
with pd.option_context('mode.use_inf_as_na', True):
Out[34]:
<seaborn.axisgrid.PairGrid at 0x1fbf4320c10>
In [37]:
X = pd.get_dummies(df).astype(int)
In [38]:
X
Out[38]:
| age | duration | campaign | pdays | previous | emp.var.rate | cons.price.idx | cons.conf.idx | euribor3m | nr.employed | ... | day_of_week_fri | day_of_week_mon | day_of_week_thu | day_of_week_tue | day_of_week_wed | poutcome_failure | poutcome_nonexistent | poutcome_success | subscribed_no | subscribed_yes | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 56 | 261 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
| 1 | 57 | 149 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
| 2 | 37 | 226 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
| 3 | 40 | 151 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
| 4 | 56 | 307 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 41183 | 73 | 334 | 1 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 |
| 41184 | 46 | 383 | 1 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
| 41185 | 56 | 189 | 2 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
| 41186 | 44 | 442 | 1 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 |
| 41187 | 74 | 239 | 3 | 999 | 1 | -1 | 94 | -50 | 1 | 4963 | ... | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
41188 rows × 65 columns
In [39]:
#we need to scale our data because of the distace method
In [40]:
from sklearn.preprocessing import StandardScaler
In [41]:
scaler = StandardScaler()
In [42]:
scaled_X = scaler.fit_transform(X)
In [43]:
from sklearn.cluster import KMeans
In [44]:
kmeans_model = KMeans(n_clusters=2)
In [47]:
cluster_labels = kmeans_model.fit_predict(scaled_X)
In [48]:
cluster_labels
Out[48]:
array([1, 1, 1, ..., 0, 0, 0])
In [49]:
X['Cluster'] = cluster_labels
In [50]:
X
Out[50]:
| age | duration | campaign | pdays | previous | emp.var.rate | cons.price.idx | cons.conf.idx | euribor3m | nr.employed | ... | day_of_week_mon | day_of_week_thu | day_of_week_tue | day_of_week_wed | poutcome_failure | poutcome_nonexistent | poutcome_success | subscribed_no | subscribed_yes | Cluster | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 56 | 261 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 |
| 1 | 57 | 149 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 |
| 2 | 37 | 226 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 |
| 3 | 40 | 151 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 |
| 4 | 56 | 307 | 1 | 999 | 0 | 1 | 93 | -36 | 4 | 5191 | ... | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 41183 | 73 | 334 | 1 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
| 41184 | 46 | 383 | 1 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 |
| 41185 | 56 | 189 | 2 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 |
| 41186 | 44 | 442 | 1 | 999 | 0 | -1 | 94 | -50 | 1 | 4963 | ... | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 |
| 41187 | 74 | 239 | 3 | 999 | 1 | -1 | 94 | -50 | 1 | 4963 | ... | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 |
41188 rows × 66 columns
In [58]:
plt.figure(figsize=(12,6), dpi = 200)
X.corr()['Cluster'].iloc[:-1].sort_values().plot(kind = 'bar')
plt.xticks(rotation = 90);
In [59]:
ssd = []
for k in range(2,10):
kmean_model = KMeans(n_clusters=k)
kmean_model.fit(scaled_X)
ssd.append(kmean_model.inertia_) #SSD point -- > cluster center
In [60]:
ssd
Out[60]:
[2479086.128811826, 2390604.734047414, 2332846.9701420693, 2246019.022616529, 2156303.8257264467, 2218014.358154257, 2128418.071949139, 2068618.0740120136]
In [61]:
plt.plot(range(2,10), ssd, 'o--')
Out[61]:
[<matplotlib.lines.Line2D at 0x1fb89787410>]
In [62]:
pd.Series(ssd)
Out[62]:
0 2.479086e+06 1 2.390605e+06 2 2.332847e+06 3 2.246019e+06 4 2.156304e+06 5 2.218014e+06 6 2.128418e+06 7 2.068618e+06 dtype: float64
In [63]:
pd.Series(ssd).diff()
Out[63]:
0 NaN 1 -88481.394764 2 -57757.763905 3 -86827.947526 4 -89715.196890 5 61710.532428 6 -89596.286205 7 -59799.997937 dtype: float64
In [ ]: