银行客户聚类实战:从数据清洗到KMeans分群完整指南

发布时间:2026/10/27 6:23:21
银行客户聚类实战:从数据清洗到KMeans分群完整指南

简介:这是一份面向机器学习初学者与银行业数据分析人员的客户聚类分析实战资源,围绕银行客户关系管理场景,基于K-Means等聚类算法对客户交易、账户、个人背景等多维数据进行分群,帮助理解无监督学习在客户精细分群、差异化服务与精准营销中的落地方式。资源共3个文件,压缩包约128KB,包含可直接运行的Python聚类脚本、客户样本CSV数据集及说明文档,分别对应建模代码、实验数据和算法选型与预处理思路讲解,结构精简便于快速上手。目前已有146人学习。通过该资源可系统掌握数据清洗、缺失值与异常值处理、特征归一化、K-Means聚类建模及簇结果业务化解读的完整流程,也可作为课程设计、毕业设计或银行客户分群任务的参考模板。

1. 银行客户聚类为什么值得你亲手跑一遍:不只是调库,是数据分群思路的完整闭环

很多工程师拿到“银行客户聚类分析算法(含数据集)”这类项目,第一反应是“不就是读CSV、跑个KMeans、画个散点图吗”。真上手你会发现,这个标题背后是一个典型的零售金融数据分群场景:几万条客户记录,包含年龄、收入、存款、贷款、消费行为等混合类型字段,目标是让算法自动把客户分成几类可解释的群体,支撑后续产品推荐、额度审批或风险识别。它看起来是入门级机器学习项目,实际做下来,数据清洗、特征工程、聚类选型、K值确定、结果验证每个环节都有坑。

这个方向最适合两类人:一类是刚学完机器学习算法原理、想找一个真实业务CEP验证聚类算法的人;另一类是在做用户画像或客户分群、但不想再用RFM手工打标签的从业者。它好在数据量适中、字段干净、业务语义清晰,没有图像和文本那种脏活累活,能把精力全部集中在“无监督学习的落地路径”上。而且银行客户聚类是金融风控和精准营销里被反复使用的技术底座,做透这一个项目,换到电商、保险、信贷场景都能平移思路。前提是,你得把“跑通代码”和“跑出可信结果”当成两件事。

2. 数据准备:银行客户表里藏着哪些坑,以及如何清洗出一份能直接喂给聚类算法的数据集

2.1 先看清银行客户数据的字段结构,别急着建模

一份典型的银行客户画像数据集,通常包含数值型字段如年龄、收入、账户余额、信用卡消费额,类别型字段如性别、职业、教育程度、婚姻状况,有时还带时间型字段如开户时长、最近一次交易时间。我在拿到这类数据的第一步永远是先做结构审查,而不是直接调sklearn。先回答三个问题:每列的数据类型是否和语义一致,缺失值集中在哪些列,类别字段的实际取值分布是否严重偏斜。

一个非常常见的翻车点是,某列被读进来是object类型,但实际上存的是数值——比如收入字段里混入了几十条“未知”或空值,导致整列被Pandas判定为字符串。另一个高频问题是年龄列里出现负数或超过120的异常值,这在银行数据里通常对应测试账号或录入错误。这些脏数据如果直接进入聚类算法,KMeans这种基于距离的算法会被离群点拉偏中心,导致分出来的群体毫无业务可解释性。所以数据清洗不是走流程,而是决定聚类结果上限的关键动作。

2.2 清洗脚本:处理缺失值、异常值和类型归一化

下面这个预处理脚本,是我处理这类表格型数据时反复用到的最小闭环。它完成三件事:字段类型纠正、缺失值填充、异常值截断。注意我只做了“保守处理”——异常值用分位数截断而不是直接删除,因为聚类任务里每一条样本都承载着客户语义,删数据要克制。

import pandas as pd import numpy as np # 读取原始数据,注意按实际文件名调整 df = pd.read_csv('bank_customers.csv', encoding='utf-8') # 1. 类型纠正:把看起来像数值的列强制转换,无法转换的置为NaN numeric_cols = ['age', 'annual_income', 'account_balance', 'credit_card_spend'] for col in numeric_cols: df[col] = pd.to_numeric(df[col], errors='coerce') # 2. 缺失值处理:数值列用中位数填充,类别列用众数填充 for col in numeric_cols: df[col].fillna(df[col].median(), inplace=True) categorical_cols = ['gender', 'education', 'marital_status'] for col in categorical_cols: df[col].fillna(df[col].mode()[0], inplace=True) # 3. 异常值截断:年龄限制在18-100,收入按0.5%和99.5%分位数截断 df = df[(df['age'] >= 18) & (df['age'] <= 100)] income_low, income_high = df['annual_income'].quantile([0.005, 0.995]) df['annual_income'] = df['annual_income'].clip(income_low, income_high) # 4. 衍生字段:开户年限比开户日期更适合直接参与距离计算 df['account_age_years'] = (pd.Timestamp('2024-12-31') - pd.to_datetime(df['account_open_date'])).dt.days / 365.0 print("清洗后数据量:", df.shape) print("剩余缺失值:", df.isnull().sum().sum())

这段脚本的逻辑重点在第三步和第四步。异常值截断用的是分位数而不是标准差,因为银行数据里的收入分布是典型的右偏长尾,用3倍标准差会把高净值客户直接判处死刑;开户时间这种日期字段不能直接参与距离计算,必须转换成“开户年限”这样的相对时长,否则算法学到的“身份证号大小”毫无含义。参数方面,分位数取0.005和0.995是经验值,如果你发现后续聚类结果里有一个群体的人均收入异常高且样本量极小,可以把上限分位再往下压。

2.3 标准化:为什么银行客户数据必须做Z-score归一化

聚类算法对量纲极其敏感,这是老生常谈但依然是最多人翻车的地方。年龄在18到100之间波动,收入却可能从2万到200万,账户余额可能从0到500万。如果你不做任何标准化直接喂给KMeans,距离计算会被收入或余额字段完全统治,年龄和教育程度对结果几乎没有贡献,最终聚出来的群体实际上是“按资产切了四刀”,而不是“按客户画像分群”。

我用的标准化方式是StandardScaler,也就是Z-score归一化,把每列变成均值为0、方差为1的分布。为什么不用MinMaxScaler?因为银行数据里离群值处理完仍然存在尖峰,MinMax会被极端值压扁正常区间;而Z-score对每个字段的贡献权重是一致的,这符合聚类分群的语义——我们想让每个特征都有平等投票权。下面的代码把类别字段做独热编码后,和数值字段一起拼接再进行标准化。

from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer # 区分数值列和类别列 num_features = ['age', 'annual_income', 'account_balance', 'credit_card_spend', 'account_age_years'] cat_features = ['gender', 'education', 'marital_status'] # ColumnTransformer统一处理:数值列标准化,类别列独热编码 preprocessor = ColumnTransformer([ ('num', StandardScaler(), num_features), ('cat', OneHotEncoder(handle_unknown='ignore'), cat_features) ]) X_scaled = preprocessor.fit_transform(df) print("预处理后特征矩阵维度:", X_scaled.shape)

这里有一个容易忽略的细节:handle_unknown='ignore'。当数据被拆成训练集和应用集时,应用集里可能出现训练时没见过的职业类别,如果不加这个参数,transform阶段会直接报错。虽然聚类通常是全量数据一次跑完,但真实场景里你经常要把模型保存下来去预测新客户,这个参数就是后悔药。标准化完成后,你可以顺手用pd.DataFrame(X_scaled, columns=preprocessor.get_feature_names_out()).head()看一眼特征是否正常,这一步能提前暴露特征拼接错误。

3. 聚类算法怎么选:KMeans、DBSCAN、层次聚类在银行客户场景的适用边界

3.1 三种算法的底层逻辑和业务匹配度对比

银行客户聚类没有“最好的算法”,只有“当前目标下最合适的算法”。KMeans的假设是群体呈球形分布,适合客户画像相对集中、群体边界清晰的情况,计算快、可解释性强,但它对离群点敏感且需要预设K。DBSCAN不需要预设簇数,能自动发现任意形状的簇并把异常点单独标出,在反欺诈场景很好用,但它的参数eps和min_samples对结果影响巨大,调参失败时要么全并成一坨要么全是噪声,而且高维数据下距离度量会失效。层次聚类不用预设K,能输出树状图辅助业务人员理解群体关系,但计算复杂度是O(n²),几万条数据还能跑,几十万条就会卡到怀疑人生。

我的选择逻辑是:如果目标是营销分群,KMeans是第一选择,因为它快速、稳定、结果容易被业务理解;如果目标是排查异常客户,DBSCAN更合适;如果样本量在1万以内且你想向领导展示“客户从高价值到低价值的谱系”,层次聚类更有说服力。但注意,银行客户数据集通常在几万条规模,层次聚类计算距离矩阵就需要几GB内存,不推荐直接全家桶。

表格能更直观地对三种算法做横向对比:

维度KMeansDBSCAN层次聚类
是否需要预设簇数是否否
对离群点的容忍度低高中
适用数据规模10万级5万级1万级以内
高维数据表现良好退化严重一般
业务可解释性高中高
计算速度快中慢

基于这个表,常规银行客户分群项目我首选KMeans,同时在旁边跑一版DBSCAN做交叉验证。不要只看内部指标,两种算法如果分出来的群体在业务含义上互相矛盾,你要警惕是特征工程出了问题而不是算法选型出了问题。

3.2 手写KMeans核心迭代逻辑,不只当调包侠

虽然sklearn一行KMeans(n_clusters=4).fit(X)就能出结果,但我坚持建议你至少手写一遍KMeans的核心迭代。原因有两个:第一,面试和答辩时“讲得清原理”远比“跑得出结果”重要;第二,手写过程能让你直观理解“迭代收敛”和“初始中心点敏感性”这两个黑匣子,后续调参才不是蒙的。下面这段是除了初始化和停止条件外最精简的迭代核心:

import numpy as np def kmeans_core(X, k, max_iter=100, tol=1e-4): # 随机选k个样本作为初始中心 rng = np.random.default_rng(42) centers = X[rng.choice(X.shape[0], k, replace=False)] for i in range(max_iter): # 计算每个样本到各中心的距离,分配到最近簇 distances = np.linalg.norm(X[:, None, :] - centers[None, :, :], axis=2) labels = np.argmin(distances, axis=1) # 更新中心为各簇均值 new_centers = np.array([X[labels == j].mean(axis=0) for j in range(k)]) # 中心移动量小于阈值时提前收敛 shift = np.linalg.norm(new_centers - centers) centers = new_centers if shift < tol: print(f"第{i+1}次迭代后收敛") break return labels, centers

这段代码的要点全在X[:, None, :] - centers[None, :, :]这一步,它利用广播机制一次性算出所有样本到所有中心的欧氏距离矩阵,避免了慢速循环。tol=1e-4是收敛阈值,如果设得太大,比如0.1,会在中心还没稳定时提前停止,聚类结果不稳定;如果设得太小,比如1e-8,会多跑很多次没意义的迭代。中心点的位置对结果影响极大,sklearn默认用k-means++初始化来缓解这个问题,手写代码里我用了随机抽样,你对比两种初始化方式跑同一份数据,会发现结果差异比想象中大。

3.3 用肘部法则配合轮廓系数确定K值,别再瞎猜“分四类”

K的取值是银行客户聚类里最容易被糊弄过去的步骤。拍脑袋选K等于4或5,结果汇报时说不清为什么是这个数,评审一句话就能让你重做。我一般用两套指标交叉验证:肘部法用簇内误差平方和(inertia),轮廓系数衡量样本与自身簇内样本的紧密度和最近簇的分离度之间的差值。肘部法的逻辑是随着K增大,inertia单调下降,但下降速度会有一个明显拐点;轮廓系数取值在-1到1之间,越大表示聚类质量越好,但K过大会让单个簇变小,轮廓系数反而可能上升,所以要和业务解释性一起看。

from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import matplotlib.pyplot as plt inertia_list, sil_list = [], [] K_range = range(2, 9) for k in K_range: km = KMeans(n_clusters=k, init='k-means++', random_state=42, n_init=10) labels = km.fit_predict(X_scaled) inertia_list.append(km.inertia_) sil_list.append(silhouette_score(X_scaled, labels)) # 打印每个K对应的指标 for k, inertia, sil in zip(K_range, inertia_list, sil_list): print(f"K={k}, inertia={inertia:.0f}, silhouette={sil:.4f}") # 画肘部图 plt.plot(K_range, inertia_list, 'o-') plt.xlabel('K') plt.ylabel('Inertia') plt.show()

n_init=10这个参数被很多人忽略,它表示用10次不同初始中心跑KMeans、取最好结果,能显著降低随机性。当你看到K=3到K=5的inertia下降明显变缓,同时silhouette在某个K取值最高,这就是你该选的K。但我要泼一盆冷水:这两个指标只是参考,真正的验收标准是“聚类结果能否被业务解读成有区分度的客户群体”。我有一次跑出silhouette最高在K=2,但分出来的两个群体是“有钱人”和“普通人”,这种结果给业务看等于没分;反而K=4时轮廓系数略低一点,但群体画像分别是高价值稳健型、年轻成长型、高负债风险型、普通大众型,这才是可交付的结果。

4. 完整训练流程:从KMeans建模到客户分群画像的落地命令与参数解读

4.1 训练-解释-落地的三步流水线

选定K之后,训练模型本身是最没有技术含量的一步,但训练完之后你一定要做两件事:把聚类结果合并回原始DataFrame,按照群体维度聚合统计各个特征的均值、中位数和分布,让每个簇“说得出人话”;然后用降维可视化看群体是否真的分离。这一步做不好,模型就是黑匣子,领导问“第四类客户到底是什么样的人”,你答不上来。

下面的代码展示了完整的三步:训练、回填标签、生成群体画像表。注意我在聚合统计时使用了groupby加agg,汇总了各个聚类群体在关键特征上的均值和中位数,这是输出业务报告的底稿。降维可视化我习惯用PCA而不是t-SNE,因为PCA是线性变换,结果稳定且可解释,t-SNE的随机性会导致每次画图都不一样,不适合做正式汇报。

from sklearn.cluster import KMeans from sklearn.decomposition import PCA import pandas as pd # 第一步:用选定的K训练模型 best_k = 4 km = KMeans(n_clusters=best_k, init='k-means++', random_state=42, n_init=10) cluster_labels = km.fit_predict(X_scaled) # 第二步:把标签回填到原始数据框 df['cluster'] = cluster_labels # 生成群体画像:各簇在关键字段上的均值 profile = df.groupby('cluster').agg({ 'age': 'mean', 'annual_income': 'mean', 'account_balance': 'median', 'credit_card_spend': 'mean', 'account_age_years': 'mean', 'gender': lambda x: x.mode()[0], # 取众数 'education': lambda x: x.mode()[0] }).round(1) print("客户分群画像:") print(profile) print("\n各群样本量占比:") print(df['cluster'].value_counts(normalize=True).round(3)) # 第三步:PCA降到2维做可视化检查 pca = PCA(n_components=2) coords = pca.fit_transform(X_scaled) df['pc1'] = coords[:, 0] df['pc2'] = coords[:, 1]

聚合统计表里,收入看均值、余额看中位数,这不是随手写的。余额字段右偏极长,均值会被少数超高净值客户拉高,中位数才能代表群体典型水平;收入相对稳定所以均值可用。如果你发现某个簇的年龄均值是28岁、收入均值12万、余额中位数3000,这在业务上叫“年轻成长型”,产品推荐逻辑是推信用卡和消费贷;如果另一个簇是年龄45岁、收入80万、余额中位数150万,这是“高价值稳健型”,推理财和私人银行。聚类算法的价值不是算出数学分群,而是让这些业务标签自动浮现。

4.2 评估聚类效果的两条硬指标和一条软指标

硬指标除了前面算过的轮廓系数,还要看每个簇的样本量是否均衡到离谱的程度。比如K=5时,一个簇占了70%,其余四个簇瓜分30%,这种情况聚类基本没有区分度,说明特征空间里没有清晰的多簇结构,或者标准化出了问题。软指标是看群画像特征差异是否显著——如果两个簇的收入、年龄、学历几乎相同,只是余额差几百块,那它们很可能应该被合并;聚类出来的群体必须能在至少两三个关键特征上有肉眼可见的差异,才能支撑后续业务动作。

这里有一个我在多个数据集上验证过的经验:银行客户聚类最常见的失败形态是“收入主导型分群”——高收入者一个簇、低收入者一个簇、中间收入按年龄劈开。这说明特征工程时收入字段的信息量过大,压制了其他特征。解决方式不是删掉收入,而是对收入做分箱或取对数变换,让它的分布从长尾变成近似正态,降低对距离计算的统治力。

import matplotlib.pyplot as plt # 分群散点图:用PCA前两维做可视化 plt.figure(figsize=(8, 6)) for cluster_id in sorted(df['cluster'].unique()): subset = df[df['cluster'] == cluster_id] plt.scatter(subset['pc1'], subset['pc2'], label=f'Cluster {cluster_id}', s=5, alpha=0.6) plt.legend() plt.xlabel('Principal Component 1') plt.ylabel('Principal Component 2') plt.show()

可视化不是锦上添花,它是对聚类结果的最后体检。如果图上四个簇互相嵌套、没有清晰的分离边界,说明K选大了或特征不够强;如果有一个孤零零的小簇飘在角落,那通常是异常客户群,需要回去查原始数据看它们是谁,而不是直接接受结果。

5. 聚类分析避坑指南:银行客户场景里的4个血泪教训

5.1 现象:聚类结果每次跑都不一样,领导要你复现时无法交差

原因几乎都是忘了固定随机种子。KMeans的初始中心选择是随机的,random_state不固定,每次运行结果可能不同;PCA、train_test_split这些涉及随机过程的步骤同理。我在第一次交付时没固定种子,汇报现场重新跑了一遍代码,客户分群比例从28%变到了22%,当场被问“哪个结果是对的”。

解决:在代码开头统一设置全局随机种子。KMeans里显式传入random_state=42,其他涉及随机的模块设置np.random.seed(42),如果用了PyTorch还要额外设torch.manual_seed(42)。seed选什么数字不重要,重要的是整个项目团队统一。

5.2 现象:轮廓系数很高但聚类结果业务上完全不可用

原因:标准化的特征集合里包含了一些业务上不该参与分群的字段,比如客户ID、开户网点编号。这些字段数值分布可能恰好能制造出漂亮的数学分群,但分出来的群体对业务毫无意义。我在一个项目里发现轮廓系数高达0.72,聚类结果是按“客户经理编号”分的群,模型学到的是内部渠道差异而不是客户画像差异。

解决:在预处理阶段剔除纯标识字段。保留的字段必须满足两个条件,一是语义上能描述客户特征,二是业务上对后续动作有指导意义。建议每保留一个字段都问一句“这个字段能指导营销话术或风控策略吗”,不能就直接排除。

5.3 现象:KMeans分群结果里出现了一个只有十几人的微型簇

原因:数据清洗不彻底,离群点没有被截断或删除。标准缩放后,极端离群点在高维空间里距离其他样本非常远,会被单独划成一簇。看起来是自动发现异常客户,但这个簇太小且不可解释,汇报时反而显得模型不稳定。

解决:回到数据清洗阶段检查有没有泄漏进来的异常值。银行客户里最常见的离群源是测试账号(年龄个位数或收入为0)、休眠户(多年零交易)、以及超高净值客户(余额超过分位数上限)。测试账号直接删除,休眠户单独标记但先不进模型,超高净值客户保留但在业务解释时单独叫“私行层”。

5.4 现象:标准化之后类别型字段成了距离计算的干扰项

原因:独热编码后,教育程度从“小学到博士”这种有序语义被打散成多个0/1列,原本的等级信息丢失了;更重要的是,银行数据里性别、婚姻这类二值字段独热编码后只有0和1,它们对欧氏距离的贡献被压缩到远小于收入等数值字段。结果就是类别字段对聚类几乎失去话语权。

解决:有序类别做标签编码而不是独热编码。教育程度按照学历层级映射成1到5的整数,再把这一列同样加入标准化流程,保留它的顺序语义;无序类别如性别保持独热。这个细节我一开始没注意,后来发现分群模型几乎只按收入和年龄切分,加回教育等级编码后,群画像里学历差异才显现出来。

提示:上述四条教训有一个共性——聚类结果必须回到业务语义里接受检验,不能只看数学指标。跑出任何一版结果,先问自己“这个分群能讲出什么客户故事”。

6. 进阶验证技巧:用聚类稳定性检查判断结果可信度,规避“一次运气好”

K值选完、聚类跑完还不算结束。你还需要验证一件事:这版聚类结果对样本扰动是否稳定。一个靠谱的聚类模型,应该在去掉5%样本后或换种子重跑时,多数客户仍留在原簇里;如果每次跑出来群成员大范围换血,说明聚类结构脆弱,换数据源或增量更新时结果会漂移,业务方会失去信任。

具体做法是Bootstrap验证:从原数据中有放回抽样生成多份样本集,每份样本量与原数据相同,分别跑同一K值下相同参数的KMeans,然后计算每对样本集之间的簇标签匹配度或调整兰德指数(Adjusted Rand Index,ARI)。ARI越接近1,说明聚类结果越稳定。下面的代码是我常用的一种轻量级检验方式,用不同随机种子跑10次KMeans,两两比较标签一致性:

from sklearn.metrics import adjusted_rand_score from sklearn.cluster import KMeans n_runs = 10 all_labels = [] for seed in range(n_runs): km = KMeans(n_clusters=best_k, init='k-means++', random_state=seed, n_init=10) all_labels.append(km.fit_predict(X_scaled)) # 两两计算ARI,取平均值作为稳定性指标 ari_scores = [] for i in range(n_runs): for j in range(i+1, n_runs): ari_scores.append(adjusted_rand_score(all_labels[i], all_labels[j])) print(f"平均ARI={np.mean(ari_scores):.4f}, 标准差={np.std(ari_scores):.4f}")

平均ARI大于0.85说明聚类结果非常稳定,0.7到0.85之间属于可用,低于0.7就要回头检查特征工程或重新考虑K。这个检验对汇报尤其重要,因为它能让“为什么信你这版聚类结果”从主观判断变成量化证据。

最后说一个我从这个项目里带出来的工作习惯:每次聚类交付,我都把预处理参数(填充策略、截断分位、K值、种子)写进一个配置文件或Notebook开头,方便三个月后复现。无监督学习项目最容易吃“当时能跑、现在跑不出来”的亏,固定参数和种子就是这个方向的后悔药。银行客户聚类不是看一次轮廓系数就能交付的项目,它需要数据清洗、特征构造、算法调参、业务解释形成完整闭环,每个环节都要留痕。希望这份笔记能帮你在自己的数据集上少踩几个坑,把聚类分析真正做成能被业务采纳的工具。

本文还有配套的精品资源,点击获取