简介:一份面向银行客户细分场景的机器学习可视化项目,适合金融数据分析初学者、高校相关专业学生以及准备入门机器学习的读者。资源基于德国信用数据集,通过Python脚本完成数据清洗、特征编码、标准化、KMeans聚类与结果可视化,可帮助理解如何按收入水平、信用历史、贷款金额等维度划分客户群体,并利用散点图、热力图等图表直观呈现不同客群特征。压缩包共2个文件,包含可直接运行的py源代码和配套csv数据集,整体仅17KB,体积小巧、依赖简单,便于在本地快速复现完整分析流程。目前已有68人学习该资源。文件中还提供肘部法则选择聚类数等关键步骤,可作为客户画像、精准营销和信用风险评估等业务场景的参考模板,适合边做边学、二次改造。
1. 银行客户细分可视化:分群结果不画出来,业务方根本没法用
银行零售部或信用卡中心手里握着几十万甚至上百万客户数据,存量经营的第一步就是把客户分开:谁是高价值客群、谁是沉睡户、谁适合推理财、谁适合做交叉销售。机器学习里的聚类算法能完成这个粗分,但聚类输出的只是一列「群编号」——业务经理不会去看一个编号,他们要的是「这群人长什么样、有多少人、值不值得运营」。银行客户细分可视化,做的就是把这套聚类结果翻译成散点图、雷达图、热力图,最终落到一张业务能直接读的可视化大屏上。这篇文章面向的是刚接触机器学习的银行数据分析师和做数据报表的工程师,按「选型→建模→绘图→排坑」的顺序,把整套流程讲透。
2. 先定算法再谈可视化:为什么客户细分绕不开 K-Means 和 PCA 降维
2.1 客户细分的目标是「分得开」还是「讲得清」:先回答这两个问题再选算法
做银行客户细分前,我会先问业务方两个问题:分出来的群是要做差异化运营,还是要做风险分层?这决定了算法选型。银行场景里最常见的做法是用 K-Means 做聚类,原因很朴素:它快、稳定、每个群能算出一个「平均画像」。K-Means 的目标函数是让簇内样本到质心的距离平方和最小,聚类结果天然满足「同类相近、异类相远」,这让后续画雷达图、对比各群特征均值变得非常自然。
和层次聚类、GMM、DBSCAN 对比一下就有结论了。层次聚类要算全样本的距离矩阵,百万级客户直接内存爆炸;GMM 能给出概率归属,但训练慢、参数多,业务方听到「高斯混合」四个字就已经失去耐心;DBSCAN 在银行数据上最容易翻车——客户行为的密度分布极不均匀,普通客户的点挤在一起,高价值客户稀稀拉拉,DBSCAN 会把大量普通客户判成噪声点,而银行要的是「每个客户都能落到某个群」,不是把客户扔掉。所以 K-Means 不一定是最优算法,但它是银行客户细分里最容易向业务解释、最容易落地、也最容易可视化的一种。
聚类前有一个前置步骤不能省:特征标准化。K-Means 基于欧氏距离计算相似度,如果直接把「年龄」和「账户余额」放进模型,余额的量纲会彻底压过年龄。余额从几千到几百万,年龄只有 20 到 70,距离计算里余额贡献了绝大部分方差,聚类结果基本等于「按余额分段」。这个问题我会在后面的避坑章节再展开,但你现在就要记住:标准化不是可选步骤,是必需步骤。
2.2 高维聚类结果画不出来:PCA 降维不是炫技而是刚需
聚类模型通常在 8~15 个特征上训练,但人眼只能直接看二维平面,最多加一个颜色维度或大小维度看三维。要在图上呈现「客户被分成了几群」,必须把高维特征投影到二维或三维,PCA(主成分分析)就是做这件事的。PCA 做的事情是找到一组新的正交坐标轴,让数据在新坐标轴上的方差依次递减,第一个主成分捕获最大的信息量,第二个捕获剩余信息里最大的部分。
有人说 PCA 是「黑匣子」,这个说法不准确。PCA 的每个主成分是原始特征的线性组合,比如第一主成分可能是「0.7×余额 + 0.4×持有产品数 - 0.3×年龄」,只要打印出主成分的权重,就能解释这个坐标轴在说什么。相比之下,t-SNE 和 UMAP 虽然画出来的散点图「族群感」更强,点被拉开得更明显,但它们的坐标没有任何业务含义,参数(perplexity、n_neighbors)调一调图就变一个样,不适合用在银行项目的正式交付里。我的常见做法是:用 PCA 做主图,如果两个群在投影图上重叠严重、肉眼无法区分,再用 UMAP 做辅助探索图自己判断,但给业务方看的永远是 PCA。
还要提醒一个易混淆点:PCA 降维是给「可视化」用的,不是给「聚类」用的。有人先把 15 维特征 PCA 到 2 维再跑 K-Means,然后抱怨分群效果差——这等于把剩余 13 个维度的信息全扔了再聚类,能不差吗?正确流程是先对标准化后的全量特征做 K-Means,拿到聚类标签后,再用 PCA 投影到二维平面上给散点图上色。
2.3 可视化工具选型:Matplotlib 只配自己看,ECharts 才是交付级
做数据分析和可视化的工程师对 Matplotlib 都不陌生,但你要问自己一个问题:这张图是给谁看的?如果是自己在 Jupyter 里探索数据分布,Matplotlib 够用;如果是要放到周报里、塞进银行的可视化大屏、让业务经理自己拖拽筛选,Matplotlib 就远远不够了。静态 PNG 图没法悬停看具体客户信息,没法缩放看局部密度,更没法做联动筛选,这些都是银行客户细分可视化里的刚需。
我一般用的组合是 Python 端做聚类和数据处理,用 PyECharts 生成交互图表,最后拼成一张可视化大屏。PyECharts 是 ECharts 的 Python 封装,好处是你在 Python 里用字典和列表组织数据,它帮你生成一个 HTML 页面,里面自带缩放、悬停提示、图例筛选这些交互能力。对比同类的 Plotly、Bokeh,PyECharts 和中国大陆的开发者更熟,文档全、模板多,遇到「百度可视化大屏」这种展示需求,ECharts 生态几乎是默认答案。下一章我就用 PyECharts 走完整个流程。
3. 从原始客户表到聚类标签:一套可直接复现的银行客户分群代码
3.1 先看清字段再谈建模:银行客户表常见字段与特征选型
拿到一张银行客户表,第一步不是写聚类代码,而是先做字段盘点。常见做法是把字段分成三类:标识字段(customer_id、姓名、手机号)、特征字段(年龄、余额、信用分、持有产品数、开卡时长)、标签字段(如果是存量运营,可能还有历史响应记录,但客户细分本身是无监督的,不需要标签)。标识字段必须剔除,它们对聚类没有任何意义,反而会拉大距离。
| 特征字段 | 类型 | 说明 |
|---|---|---|
| age | 连续 | 年龄,注意缺失值和极端值 |
| balance | 连续 | 账户余额,量纲很大,必须标准化 |
| credit_score | 连续 | 信用评分,量纲中等 |
| num_products | 连续 | 持有产品数,通常 1~8 |
| has_credit_card | 二值 | 0/1,是否持有信用卡 |
| is_active | 二值 | 0/1,近 6 个月是否有动账 |
| tenure_months | 连续 | 开户时长(月) |
| occupation_category | 类别 | 职业类型,需要编码处理 |
这里有一个选型原则:能用连续变量表达的就不转成类别变量。「职业类型」这类字段在银行数据里通常是几大类,如果直接做 One-Hot 编码,会在后文引入一个很隐蔽的陷阱,我在第 3.2 节会讲处理办法。先读数据看结构和缺失情况:
import pandas as pd df = pd.read_csv("bank_customers.csv", encoding="utf-8") print(df.shape) print(df.info()) print(df.describe(percentiles=[0.25, 0.5, 0.75, 0.9]))df.shape先确认行数,银行客户表动辄几十万行,如果数据超过内存就需要考虑抽样。df.info()看每一列的非空值和数据类型,这一步能快速暴露两个问题:某个字段填空太多,或者导入后字段类型变成 object(通常是日期字段被读成了字符串)。df.describe()看量纲差异,正常情况下 balance 的标准差一定是 age 的几十倍,这就是后面要标准化的现实依据。
3.2 特征标准化与哑变量处理,让 K-Means 不欺负连续性变量
标准化用 StandardScaler,它把每列变成均值 0、标准差 1。对连续变量这是标准操作,但对 One-Hot 编码产生的哑变量,这里有个常见分歧:0/1 哑变量到底要不要一起标准化?我的做法是——同一变量拆分出来的哑变量,不参与标准化;没有拆分的二值字段(has_credit_card、is_active),也不标准化。因为 0/1 字段本身量纲固定,标准化之后反而会变成一个「原始占比」相关的连续值,可解释性变差。
至于职业类型这种多类别字段,我一般不会直接 One-Hot 后塞进聚类模型。原因很现实:如果职业有 5 类,One-Hot 会产生 5 列,这 5 列在 15 维特征空间里的权重被放大,聚类结果可能会从「按客户行为分群」变成「按职业分群」。职业标签应该在分群后的画像里看,而不是作为聚类输入。处理办法是把高基数的类别字段做「目标编码」或「频次编码」,更稳妥的做法是先不用它聚类,等到分群画像阶段再展示各群职业分布,这样既保留了信息的可解释性,又避免污染距离计算:
from sklearn.preprocessing import StandardScaler # 参与聚类的特征列 feature_cols = ["age", "balance", "credit_score", "num_products", "has_credit_card", "is_active", "tenure_months"] # 分两个步骤:先拷贝一份只含特征的数据 X = df[feature_cols].copy() # 缺失值处理:银行数据一般用中位数填充,避免均值被极端值拉偏 X = X.fillna(X.median()) # 标准化:只对连续列做 scaler = StandardScaler() cont_cols = ["age", "balance", "credit_score", "num_products", "tenure_months"] X[cont_cols] = scaler.fit_transform(X[cont_cols]) print(X.head())fillna(X.median())是我处理银行数据的默认选项。银行数据里 balance 长尾很重,个别千万级余额的客户会把均值拉高,用中位数填充缺失值更稳。标准化完成后可以再打印一次X.describe(),连续列的均值会接近 0,标准差接近 1,这时 K-Means 的距离计算才不会被单个字段绑架。
3.3 用「手肘 + 轮廓系数」双指标定 K,而不是拍脑袋
K 值是 K-Means 唯一的「超参数」,也是业务方最喜欢追着问的数字:为什么是 4 群而不是 5 群?我不能回答「拍的」,所以要给出两个佐证:肘部法则(ELBOW)和轮廓系数(Silhouette Score)。肘部法则看簇内平方和(WSS)随 K 增大而下降的曲线,下陡变缓的拐点就是合适的 K。轮廓系数算每个样本与自身簇内样本的平均距离、与最近其他簇样本的平均距离,取值范围 [-1, 1],接近 1 表示簇内紧凑且簇间分离。
实操里有个经验之谈:银行客户行为数据噪声很大,轮廓系数普遍偏低,一般 0.25~0.35 就算「能用的分群」,不要纠结于论文里说的 0.7 以上。看下面这段代码:
import numpy as np from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score K_range = range(2, 9) wss_list = [] sil_list = [] for k in K_range: model = KMeans(n_clusters=k, random_state=42, n_init=10) labels = model.fit_predict(X) wss_list.append(model.inertia_) sil_list.append(silhouette_score(X, labels)) # 打印出每个 K 的指标,等一下画图手动判读 for k, wss, sil in zip(K_range, wss_list, sil_list): print(f"K={k}, WSS={wss:.1f}, Silhouette={sil:.4f}")这里有两个参数值得说明。n_init=10是 KMeans 的多次初始化次数,K-Means 的初始质心是随机的,不同的初始质心可能收敛到不同的局部最优,n_init 会重复跑 10 次并保留结果最好的一次;银行数据量大时这个值可以降到 5 以节省时间,但不建议用默认的 1,那会随机得让人抓狂。random_state=42保证每次跑的初始点一致,这样你上午和下午跑出来的结果不会变——这个细节在给业务方复现结果时很重要。
画手肘图用 Matplotlib 看一眼即可,这张图不用交付。看拐点的方法不是找最陡的位置,而是找一个「再增加 K,WSS 下降幅度明显变小」的位置。轮廓系数则选最大值对应的 K,两个指标冲突时,我的取值口径是「偏保守选小 K」,因为群多了业务方运营不过来,群少了画不出差异,银行客户细分的 K 我见过最多的区间就是 3~6。
3.4 聚类回写与结果导出,给每一类客户贴上可读标签
选定 K 后重新跑一次完整的 K-Means,把标签写回原始 DataFrame,同时把每个群的特征均值算出来,导出成一张「分群画像表」。这张表就是你后面画雷达图、热力图的数据来源,也是和业务方沟通的核心材料:
K = 4 model = KMeans(n_clusters=K, random_state=42, n_init=10) df["cluster"] = model.fit_predict(X) # 分群画像:每个群的特征均值 profile = df.groupby("cluster")[feature_cols].mean().round(2) profile["客户数"] = df["cluster"].value_counts().sort_index().values # 保存画像表,后续可视化都靠它 profile.to_csv("cluster_profile.csv", encoding="utf-8-sig") print(profile)聚类出来的群编号是 0~3,这个编号本身没有任何含义,群 0 不等于「最差的群」。此时我先不看编号,看画像表里每一行的数值:如果群 0 余额均值最高、产品数最多、信用分最高,那它是高价值客群;如果群 1 余额极低、开户时间很长但近期没有动账,那它是沉睡客户群。这一步做完,你手上有了标签,也有了每个群的气质描述,接下来才算真正进入「可视化」阶段。
4. 把分群结果画出来:散点图、雷达图与热力图的 PyECharts 实现
4.1 主成分投影加散点图:让每个客户在二维平面上有坐标
拿到聚类标签后,第一张图我建议画「PCA 投影散点图」,把每个客户的位置画在平面上,用颜色区分群。这张图一眼能看出分群效果:群与群之间有没有重叠,每个群是紧凑的一坨还是散成一片。先做 PCA 投影:
from sklearn.decomposition import PCA # 对标准化后的特征做 PCA,降到 2 维用于画图 pca = PCA(n_components=2) coords = pca.fit_transform(X) df["pc1"] = coords[:, 0] df["pc2"] = coords[:, 1] # 打印主成分解释方差比例 print("Explained variance ratio:", pca.explained_variance_ratio_)explained_variance_ratio_是主成分的方差解释比例,比如输出[0.31, 0.18],表示第一主成分包含了 31% 的信息,两个主成分合计约 49%。这个数字不是越高越好——它低了说明二维投影有信息损失,但只要你不是拿它做预测,只是给业务看「群的大致分界」,解释力超过 40% 我就认为可以接受。如果低到 20%,后面直接画三维散点图会更靠谱。
接下来用 PyECharts 画散点图。几百万个点全部画上去浏览器会卡死,所以先做一个等比例抽样,每群抽 5000 个点,既保证图不卡,也能看出密度分布:
from pyecharts.charts import Scatter from pyecharts import options as opts # 等比例抽样:每个群最多抽 5000 个点 sample_df = df.groupby("cluster", group_keys=False).apply( lambda x: x.sample(n=min(5000, len(x)), random_state=42) ).reset_index(drop=True) # 配置颜色 color_map = ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"] groups = sorted(sample_df["cluster"].unique()) scatter = Scatter(init_opts=opts.InitOpts(width="1000px", height="600px")) for g in groups: sub = sample_df[sample_df["cluster"] == g] data_pairs = [[round(x, 2), round(y, 2)] for x, y in zip(sub["pc1"], sub["pc2"])] scatter.add_xaxis(data_pairs) # 实际按下方参数说明处理这里的代码写法在 PyECharts 里需要注意:Scatter.add_yaxis才是传数据的接口,add_xaxis接收的是类目轴值。正确习惯是把 x 和 y 组合成[[x1, y1], [x2, y2], ...]传入add_yaxis,x 轴设置为「数值型」,用opts.AxisOpts(type_="value")。我当时第一次写 PyECharts 就在这里绕了弯,先把数据准备好,后续小标题专门画图时再演示完整写法。
4.2 分群雷达图:业务方真正能看懂的是这一张
散点图证明「机器学习确实分出了几群」,但业务方不一定看得懂主成分坐标轴。真正让业务方点头的是雷达图:把每个群在「年龄」「余额」「产品数」「信用分」「活跃度」这些业务字段上的均值画成五边形或六边形,几张雷达图叠在一起对比。这个图回答的是「这群人和其他群比,到底哪里不一样」。
画雷达图前要先归一化。余额均值可能是 30 万,年龄均值是 45,直接画会让余额撑爆整个图形,其他特征全缩在圆心附近。归一化采用各群相对最大值的比例:
from pyecharts.charts import Radar # 选几个业务上最关心的维度 radar_features = ["age", "balance", "credit_score", "num_products", "tenure_months"] # 每列除以该列最大值,让数据落在 0~1 radar_data = profile[radar_features].copy() for col in radar_data.columns: radar_data[col] = radar_data[col] / radar_data[col].max()做完归一化后,Radar图表接收的是每个群一个列表。pyecharts 的 Radar 图把 indicator 定义成「维度名 + 最大值」,这里最大值就是 1。每个群对应一条折线,4 个群就是 4 条不同颜色的折线,业务方一眼就能看出:「深蓝色的群余额缩在中心,产品数也不高,是低价值客户」「绿色群信用分拉满,但余额也不低,是优质客户」。这比散点图直观得多,所以我做客户细分项目时,汇报 PPT 第一张图一定是雷达图,散点图只是辅助证明。
4.3 分群热力图:用全局视角验证「这些群有没有被分干净」
热力图是第三个「验证向」的图。雷达图适合看单个群的特征形状,热力图适合横向扫一眼所有群在所有特征上的高低关系——行是群,列是特征,颜色越深表示数值越高。画热力图之前要把 profile 表做一次 z-score 标准化(不是原始值了,是每个特征在各群之间做标准化),这样每列的颜色分布才有对比意义:
from pyecharts.charts import HeatMap heat_data = profile[feature_cols].copy() for col in heat_data.columns: std = heat_data[col].std() heat_data[col] = (heat_data[col] - heat_data[col].mean()) / std # 转成 pyecharts HeatMap 需要的 [x, y, value] 格式 heat_values = [] for i, row_idx in enumerate(heat_data.index): for j, col in enumerate(heat_data.columns): heat_values.append([j, i, round(heat_data.loc[row_idx, col], 2)])热力图能暴露出一个散点图和雷达图都不容易发现的问题:某两行(两个群)在所有列上的数值几乎没有差别。如果出现「群 0 和群 3 的 z-score 差异都小于 0.1」,说明这两个群实际上是一个群被拆成了两半,K 值选大了,应该退回第 3.3 节重新跑更小的 K。热力图也是一个「排查向」工具,它不直接给你结论,但能逼你审视聚类结果的有效性。
5. 银行客户细分可视化的五个常见坑:特征陷阱、降维陷阱与展示陷阱
5.1 不做标准化直接聚类,所有客户挤成一个大团
现象:跑了 K-Means 之后,散点图上所有客户不分颜色地堆成一团,几个群的重叠面积超过 80%,雷达图上各群均值几乎没有差异。
原因:这是最典型的翻车现场。balance 这一列的量纲比其他特征大几百倍,欧氏距离完全由 balance 主导,K-Means 实际上在按「余额大小」做一维切分,其他所有特征都成了噪声。群之间除了余额均值不同,其他画不出来。
解决:回到第 3.2 节,确保连续特征全部经过 StandardScaler 标准化。标准化之后再跑一次聚类,散点图会立即出现可辨识的簇。这个坑栽过的人最多,血泪经验建议做成检查项,每次建模前先打印X.describe()看量纲是否已被拉平。
5.2 One-Hot 编码后,聚类结果变成了「职业类型分组」
现象:分群画像里,每个群对应一个职业类型,群 0 全是公务员,群 1 全是企业职员,群 2 全是自由职业者,但余额、产品数这些「银行行为特征」在群间差异不大。
原因:职业这个类别特征被 One-Hot 拆成 5 列后,等于在特征空间里占了 5 个维度,距离计算时它的话语权被放大到接近连续特征的总和,K-Means 自然优先按职业切分。
解决:把高基数类别特征从聚类输入里拿掉,或者做降权处理。更常见做法是:聚类只用连续特征和二值特征,职业分布留在分群画像阶段再统计,作为「贴标签」的辅助信息,而不是「分群」的驱动力。
5.3 上百万个点在散点图上叠成一团,浏览器直接卡死
现象:PyECharts 渲染几百万个散点,页面打开要十几秒,缩放时拖动一卡一卡,散点重叠严重,根本看不出群边界。
原因:浏览器渲染 DOM 节点数量有限,百万级数据点超出了 ECharts 的舒适区;另外大量点叠加在同一个像素位置时,后画的点覆盖先画的点,密度信息全部丢失。
解决:画图前做抽样,每个群抽 3000~5000 个点足够展示分布形态。如果还想看密度,把散点图换成「六边形分箱热力图」(ECharts 的 heatmap 加 bmap 或 canvas 分箱),或者给散点加opacity=0.3,让重叠区域的颜色比稀疏区域更深,密度信息反而保留下来。
5.4 手肘图没有明显拐点,K 值纠结一整天
现象:WSS 曲线是一条平滑下降的线,你盯着它看了半天也说不清拐点在哪;轮廓系数在 K=2 到 K=6 之间一直在 0.2 上下浮动,没有一个明显的高峰。
原因:银行客户数据本身噪声大,内部结构不是清晰的球状簇,K-Means 在这种数据上很难给出「数学上唯一正确」的 K。这种情况不是你的代码有 bug,是数据本质如此。
解决:用业务约束替代数学最优。先问业务方运营团队能承接几个客群——一般 3~6 个,超过 6 个运营计划就写不过来了。然后在这个范围内挑轮廓系数相对较高的值,跑一遍看雷达图,如果相邻两个群的画像差异不明显,就减小 K。K 值不是纯数学问题,是数学结果和业务可实施性之间的折中。
5.5 群 0 不等于「最差的客户」,把群编号直接当价值排序汇报
现象:汇报材料上写「群 0 客户需要重点挽留」,但实际上群 0 是余额最高的高价值客户,只是因为簇编号是 0 就被想当然了——更常见的错误是直接把 K-Means 输出的标签号当成排名,0 当成差、3 当成好。
原因:K-Means 的簇编号是随机的,0 到 3 只是内部索引,不代表任何业务优先级。模型训练时根本没有「价值」这个概念,它只看到了特征之间的距离。
解决:在输出分群画像表后,先把每个群按关键业务指标(余额均值、产品数、近 6 个月动账比例)重新排序,然后手动给群重命名:「高价值活跃客群」「沉睡高潜力客群」「低价值流失风险群」「新开户成长群」。后续所有散点图、雷达图、大屏都显示这个名字,而不是「群 2」。
6. 从静态图到可交互筛选:把分群结果做成业务说得清的可视化大屏
做到第 5 章,四张图已经齐了:PCA 散点图、雷达图、热力图、画像表。但单张图是给「看」的,可视化大屏是给「用」的。业务经理在大屏上选中「高价值活跃客群」,散点图只高亮这群人,雷达图切换成这群人的画像,下面的明细表列出这群人的客户数、平均余额、平均产品数——这种联动筛选才是银行客户细分可视化的最终形态。PyECharts 实现联动不需要写前端,用 page 或 tab 把多个图组合起来,给每个图配置相同的图例组件即可;要做到真正点击筛选,常见做法是给散点图绑定事件,把选中的群维度传给其他图表。这个方案在 ECharts 的官方示例里叫「联动的 dataZoom + legend」,银行项目里最常见的大屏方案。
这里分享一个我自己的验证习惯:图做完先别急着上大屏,先做「业务校验」。把聚类结果和后续 3~6 个月的真实绩效数据关联,算每个群的产品购买率或留存率,如果高价值群的购买率确实明显高于低价值群,说明分群是有效的;如果各群绩效曲线完全重叠,说明特征没选对——回第 3 章,换特征重跑。我第一次做银行客户细分就只盯着散点图是否好看,结果业务方问「这群人是不是真的会买理财产品」,我答不上来,只能回去补这步校验。现在我的习惯是:任何分群项目,先问清楚「分完怎么验证」,再动手建模。希望帮到你。
本文还有配套的精品资源,点击获取