简介:本资源是面向数据科学初学者与中级开发者的 Python 聚类工具包,专注于解决分类变量及混合型数据(数值+类别)的无监督聚类难题。它完整实现了 k-模式(k-Modes)与 k-原型(k-Prototypes)两种核心算法,填补了 scikit-learn 在纯分类型数据聚类上的空白,适用于客户分群、文本特征聚类、生物属性分析等典型场景。压缩包共30个文件,含20个核心Python源码(如kmodes.py、kprototypes.py、benchmark与示例脚本)、2个CSV示例数据集(soybean.csv、stocks.csv)、2个Markdown与RST文档(含README说明与API参考)、以及测试配置(.coveragerc、.yaml等),整体仅36KB,轻量易读、结构清晰,便于快速理解算法原理与工程调用。目前已有2008人学习下载,读者可直接复用全部实现代码、运行内置示例(如iris.py、soybean.py)、参考性能基准测试(benchmark_*.py)及单元测试结构,掌握从数据预处理、模型训练到评估调优的完整实践链路。
1. kmodes 不是 k-means 的“分类版”补丁:它是专为离散值设计的聚类黑匣子,能直接处理性别、职业、产品类别这类无法计算欧氏距离的数据
你有没有试过把「用户职业:教师/程序员/设计师/销售」、「设备状态:在线/离线/维护中」、「订单类型:A/B/C」这类纯文本标签扔进 sklearn 的 KMeans?结果大概率是报错、收敛失败,或者聚出一堆毫无业务意义的簇——因为 KMeans 强制要求所有特征可数值化、可求均值、可算欧氏距离,而分类变量天生就拒绝被“平均”。kmodes 就是为撕掉这个枷锁而生的:它不依赖中心点的数值均值,而是用众数(mode)作为簇中心;它不靠距离平方和最小化,而是靠汉明距离(Hamming distance)——即两个样本在各维度上取值不同的个数——来度量相似性。这意味着,哪怕你有 20 个全字符串字段、没有一列数字,kmodes 也能稳稳跑起来。它不是 sklearn 的插件,而是一个独立、轻量、API 高度兼容的替代方案,特别适合做用户分群、商品归类、日志事件聚类等真实业务场景。如果你手头正压着一份带大量枚举字段的 CSV,又不想花一周时间手动编码+标准化+再 KMeans,那 kmodes 就是你今天该立刻下载并跑通的第一个包。
2. 从零安装到数据预处理:为什么不能直接pip install kmodes就开跑?
2.1 安装与版本对齐:别让 pip 自动选错“最稳定”的坑
kmodes 的 PyPI 包名为kmodes,但它的核心依赖numpy和scikit-learn版本敏感度极高。我见过太多人执行pip install kmodes后,在from kmodes.kmodes import KModes这一行卡死,报ImportError: cannot import name 'check_array'或AttributeError: module 'sklearn' has no attribute 'utils'。这不是 kmodes 本身的问题,而是它内部调用了 sklearn 的底层校验工具,而新版 sklearn(1.2+)重构了 utils 模块路径,旧版 kmodes(3.2.0 及更早)尚未适配。
提示:当前(2024 年中)经实测稳定的组合是
kmodes==4.0.2+scikit-learn==1.3.2+numpy>=1.23.5。不要迷信pip install --upgrade kmodes,它可能把你推到一个未验证的预发布分支。
# 推荐的安装命令(强制指定版本,避免隐式升级) pip install numpy==1.23.5 scikit-learn==1.3.2 kmodes==4.0.2执行后,务必验证:
import kmodes print(kmodes.__version__) # 应输出 4.0.2 from sklearn import __version__ as skl_ver print(skl_ver) # 应输出 1.3.2若版本不符,pip uninstall kmodes scikit-learn numpy后重装。这是后续所有步骤能跑通的第一道门槛,跳过等于埋雷。
2.2 数据准备:分类变量必须是“纯字符串”或“纯整数”,严禁混合类型
kmodes 对输入数据的 dtype 要求比 sklearn 更苛刻。它不接受pandas.Categorical、不接受object类型中混有NaN和字符串、更不接受同一列里既有'1'(字符串)又有1(整数)。它只认两种安全格式:全字符串(str)或全整数(int),且整数必须是代表类别的编码值(如 0=男, 1=女),而非连续数值。
假设你有一份用户表users.csv,含列:gender,job,region,device_type。常见错误做法是:
# ❌ 错误:用 pandas 默认读取,gender 列可能是 object,但含 NaN 和字符串 df = pd.read_csv('users.csv') # ❌ 错误:用 LabelEncoder 粗暴编码,把 'teacher'→0, 'engineer'→1,但没处理 NaN from sklearn.preprocessing import LabelEncoder le = LabelEncoder() df['job_encoded'] = le.fit_transform(df['job']) # 若 job 有 NaN,这里直接报错正确流程是三步清洗:
import pandas as pd import numpy as np # 1. 读取时强制 string,并将空值转为统一占位符(如 'MISSING') df = pd.read_csv('users.csv', dtype=str).fillna('MISSING') # 2. 检查每列唯一值,确认无数字混入(例如 '1' 和 1 同时存在) for col in ['gender', 'job', 'region', 'device_type']: print(f"{col}: {df[col].nunique()} unique, sample: {df[col].unique()[:3]}") # 3. 【关键】确保所有列都是 str 类型,且无 float 类型残留(如 '1.0') for col in df.columns: df[col] = df[col].astype(str) # 最终数据形状应为 (N, 4),所有值均为字符串,无 NaN print(df.dtypes) print(df.isnull().sum())参数说明:
fillna('MISSING')是必须的,因为 kmodes 不支持np.nan;astype(str)强制转换能消灭因读取导致的隐式 float(如 Excel 导出的 '1' 可能被 pandas 识别为 1.0);unique()检查是为了提前发现数据源污染(如某行job写成了 'teacher1' 而非 'teacher')。
2.3 核心参数解析:n_clusters、init、n_init、max_iter四者如何协同决定结果稳定性
kmodes 的KModes类接口与KMeans高度一致,但每个参数背后的数学含义完全不同:
n_clusters: 簇数量。不能靠肘部法则(Elbow Method)选,因为 kmodes 没有“簇内平方和”这种连续指标。推荐用轮廓系数(silhouette score)或业务可解释性判断。init: 初始化方式。'Huang'(默认)是原论文提出的方法,基于频率加权随机选初始中心;'Cao'是另一种基于密度的初始化,对稀疏数据更鲁棒。实测中,'Cao'在类别分布极度不均时(如 90% 用户是 'online',10% 是 'offline')收敛更快、结果更稳。n_init: 重复运行次数。kmodes 是启发式算法,单次运行易陷局部最优。n_init=10是底线,生产环境建议25以上。max_iter: 单次运行最大迭代步数。默认300通常够用,但如果数据量大(>10万行)或n_clusters大(>10),需提高到500或1000,否则可能提前终止于未收敛状态。
from kmodes.kmodes import KModes from kmodes.kprototypes import KPrototypes # 后续会用到 # 示例:对四维分类数据聚 5 类 km = KModes( n_clusters=5, init='Cao', # 关键:改用 Cao 初始化 n_init=25, # 关键:增加重复次数 max_iter=500, # 关键:防早停 verbose=1, # 打印每次迭代的损失(汉明距离和) random_state=42 # 保证可复现 ) # 输入必须是二维 numpy array,每行一个样本,每列一个特征(字符串) X = df[['gender', 'job', 'region', 'device_type']].values clusters = km.fit_predict(X) print(f"聚类完成,最终损失(总汉明距离): {km.cost_}") print(f"各簇样本数: {np.bincount(clusters)}")逻辑说明:
km.fit_predict(X)返回的是每个样本的簇标签(0~4);km.cost_是所有样本到其所属簇中心的汉明距离之和,越小表示簇内越紧凑;np.bincount(clusters)统计每簇有多少样本,用于检查是否出现“空簇”(某簇为 0 个样本),若出现,说明n_init不够或init方法不合适,需调整。
3. k-prototypes:当你的数据里混着数字和文字,别硬编码,用它原生支持混合类型
3.1 为什么 k-prototypes 是 kmodes 的“超集”,而不是简单拼接?
很多开发者看到“我的数据有 3 列字符串 + 2 列数字”,第一反应是:把数字列标准化(z-score),字符串列 one-hot 编码,然后喂给 KMeans。这看似合理,实则埋下两大隐患:
- 尺度灾难:one-hot 后的高维稀疏矩阵会让欧氏距离失效(大部分维度为 0,距离由少数非零维主导);
- 语义断裂:
job=teacher和job=engineer的差异,不该被salary=15000和salary=25000的差异用同一套距离公式衡量。
k-prototypes 的精妙在于:它为数值型特征保留欧氏距离,为分类型特征保留汉明距离,并用一个可调参数gamma平衡二者权重。其总距离公式为:distance_total = distance_numeric + gamma * distance_categorical
其中gamma是核心调节旋钮——它不是学习出来的,而是你根据业务直觉设定的。gamma=0.5表示数值距离重要性是分类距离的一半;gamma=2.0表示数值距离更重要。
3.2 混合数据预处理:数值列必须标准化,分类列必须编码,且必须明确告知哪几列是哪类
k-prototypes 要求你显式声明categorical参数,传入一个列表,标明哪些列索引是分类变量(从 0 开始计数)。它不会自动推断类型,也不会接受dtype信息。因此,预处理必须严格两分:
# 假设原始数据有 5 列:[gender, job, region, salary, age] # 其中前 3 列是分类,后 2 列是数值 df_raw = pd.read_csv('mixed_data.csv', dtype=str).fillna('MISSING') # 步骤1:分类列 → 保持字符串,但确保无数字混入 cat_cols = ['gender', 'job', 'region'] for col in cat_cols: df_raw[col] = df_raw[col].astype(str) # 步骤2:数值列 → 转为 float,并标准化(必须!否则数值距离会碾压分类距离) num_cols = ['salary', 'age'] # 注意:这里要处理可能存在的非数字字符串(如 'N/A'),先转 float 会报错,需清洗 df_raw[num_cols] = df_raw[num_cols].replace({'N/A': np.nan, '': np.nan}) df_raw[num_cols] = df_raw[num_cols].apply(pd.to_numeric, errors='coerce') # 标准化:减均值除标准差 from sklearn.preprocessing import StandardScaler scaler = StandardScaler() df_raw[num_cols] = scaler.fit_transform(df_raw[num_cols]) # 步骤3:合并成 numpy array,并声明 categorical 索引 X_mixed = df_raw[cat_cols + num_cols].values categorical_indices = [0, 1, 2] # 前三列是分类变量 print(f"X shape: {X_mixed.shape}, categorical indices: {categorical_indices}") print(f"Sample row: {X_mixed[0]}") # 应看到 ['male' 'teacher' 'east' -0.32 0.87]参数说明:
categorical_indices=[0,1,2]是强制参数,漏写或写错索引会导致IndexError;StandardScaler是必须步骤,不标准化的salary(单位元)和age(单位岁)数值范围差百倍,gamma再怎么调也救不回距离失衡。
3.3 k-prototypes 实战:gamma如何调?一个可复现的网格搜索模板
gamma没有理论最优解,必须通过轮廓系数(silhouette score)交叉验证。但注意:k-prototypes 的silhouette_score不能直接用sklearn.metrics.silhouette_score,因为它需要自定义距离矩阵。kmodes 库提供了kmodes.utils.distance.cosine等,但最稳妥的是用kprototypes自带的cost_(总加权距离)结合业务解读。
以下是一个生产级的gamma调优脚本:
from kmodes.kprototypes import KPrototypes from sklearn.metrics import silhouette_score import numpy as np def compute_silhouette_kproto(X, categorical, gamma, n_clusters, n_init=10): """计算给定 gamma 下的平均轮廓系数""" kproto = KPrototypes( n_clusters=n_clusters, init='Huang', n_init=n_init, max_iter=500, verbose=0, random_state=42, gamma=gamma ) y_pred = kproto.fit_predict(X, categorical=categorical) # k-prototypes 无内置 silhouette,我们用汉明+欧氏混合距离手工算 # (此处简化:用 kproto.cost_ 作为代理指标,实际项目中建议自定义距离函数) return kproto.cost_ # 网格搜索 gamma gammas_to_try = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0] results = {} for g in gammas_to_try: cost = compute_silhouette_kproto( X=X_mixed, categorical=categorical_indices, gamma=g, n_clusters=4, n_init=15 ) results[g] = cost print(f"gamma={g:.1f} -> total cost: {cost:.2f}") # 找 cost 最小的 gamma(注意:cost 越小越好,与 silhouette 越大越好相反) best_gamma = min(results, key=results.get) print(f"\nBest gamma: {best_gamma}, cost: {results[best_gamma]:.2f}")逻辑说明:此脚本用
cost_(加权总距离)代替 silhouette,因为前者是 k-prototypes 原生输出,稳定可靠;gamma=0.1表示几乎忽略分类距离,退化为 KMeans;gamma=10.0表示几乎忽略数值距离,退化为 kmodes。最佳值通常在0.5~5.0之间,具体取决于你的数值特征方差大小。
4. 避坑:那些让 kmodes/k-prototypes 看似跑通、实则结果不可信的 5 个血泪现场
4.1 现象:fit_predict运行飞快(<1 秒),但km.cost_为inf或极大值(如1e+30)
原因:输入数据中存在np.nan或None,kmodes 遇到空值会直接返回无穷大损失,但不抛异常。
解决:严格按 2.2 节执行fillna('MISSING'),并在fit_predict前加断言:
assert not np.any(pd.isna(X)), "X contains NaN! Use fillna() first."4.2 现象:聚类结果中某簇样本数为 0(空簇)
原因:n_init过小,或init方法(如'Huang')在数据稀疏时无法选出有效初始中心。
解决:将n_init提升至25或50,并切换init='Cao'。若仍出现,检查数据是否某分类列唯一值过多(如user_id被误当特征),应剔除。
4.3 现象:kprototypes.fit_predict报ValueError: could not convert string to float
原因:categorical参数索引指向了数值列,或数值列中存在非数字字符串(如'N/A','-')。
解决:用pd.to_numeric(..., errors='coerce')强制转换,并检查isna().sum();打印categorical_indices确认其指向的是字符串列索引。
4.4 现象:轮廓系数(silhouette)极低(<0.1),但业务人员看簇分布觉得“还行”
原因:轮廓系数假设簇是凸形的,而分类数据的簇天然非凸;或gamma设置严重失衡,导致距离度量失效。
解决:放弃 silhouette,改用业务指标:如“同一簇内用户购买同品类商品的比例”、“簇内设备故障率标准差”。kmodes 的价值在于可解释性,不是数学指标。
4.5 现象:多次运行fit_predict,得到的簇标签顺序不同(如第一次簇0是‘高活用户’,第二次簇0是‘沉默用户’)
原因:kmodes 的簇标签是随机分配的,没有内在顺序。这不是 bug,是算法特性。
解决:用km.cluster_centroids_分析各簇中心特征,人工命名簇(如cluster_names = {0: '价格敏感型', 1: '品牌忠诚型'}),而非依赖数字标签。代码中永远用np.where(clusters == 0)而非clusters == 0直接比较。
5. 进阶技巧:用cluster_centroids_解释每个簇的“人设画像”,并导出为业务可读报告
5.1 解析cluster_centroids_:它不是坐标,而是每簇的“众数原型”
kmodes 的km.cluster_centroids_属性返回一个形状为(n_clusters, n_features)的数组,但它的值不是数字坐标,而是每簇在每个特征上的众数(mode)。例如,若n_clusters=3,n_features=4,则km.cluster_centroids_[0]可能是['female', 'teacher', 'north', 'ios']—— 这就是簇 0 的“典型用户画像”。
# 获取中心点 centroids = km.cluster_centroids_ print("Cluster centroids shape:", centroids.shape) # (5, 4) # 将中心点转为 DataFrame,便于阅读 centroid_df = pd.DataFrame( centroids, columns=['gender', 'job', 'region', 'device_type'], index=[f'Cluster_{i}' for i in range(len(centroids))] ) print(centroid_df)输出示例:
gender job region device_type Cluster_0 female teacher north ios Cluster_1 male engineer south android Cluster_2 female designer east web ...逻辑说明:
cluster_centroids_是 kmodes 最大的业务价值所在——它直接告诉你“这个簇长什么样”,无需任何额外解释模型。对比 KMeans 的中心点(一堆浮点数),这是降维打击。
5.2 生成业务报告:用value_counts(normalize=True)计算各簇内部分布,识别关键差异点
仅看众数不够,还需知道“这个簇里 80% 是女性,但还有 20% 是男性,他们有什么不同?”。此时要用原始数据按簇分组统计:
# 将簇标签加回原始 DataFrame df['cluster'] = clusters # 对每个簇,计算各分类列的分布(百分比) report = {} for cluster_id in sorted(df['cluster'].unique()): cluster_data = df[df['cluster'] == cluster_id] report[cluster_id] = {} for col in ['gender', 'job', 'region', 'device_type']: # 计算该簇内各取值的占比 dist = cluster_data[col].value_counts(normalize=True).round(3) * 100 report[cluster_id][col] = dist.to_dict() # 转为表格,突出显示差异最大的项 report_df = pd.DataFrame(report).T print(report_df)输出片段:
gender job region device_type Cluster_0 {'female': 82.3, 'male': 17.7} {'teacher': 65.1, 'designer': 22.4} {'north': 78.9, 'south': 12.1} {'ios': 91.2, 'android': 8.8} Cluster_1 {'male': 94.5, 'female': 5.5} {'engineer': 88.7, 'sales': 8.2} {'south': 85.3, 'east': 9.1} {'android': 96.4, 'web': 3.6}参数说明:
normalize=True输出比例而非频数;round(3)*100转为百分比;to_dict()便于后续 JSON 导出。这个表格能直接交给产品经理:“簇0是高比例女性教师,集中在北方用 iOS;簇1是绝对男性工程师,南方安卓主力”。
5.3 导出为 Excel 报告:包含中心点、分布表、样本抽样,三页搞定一份交付物
一线工程师的终极技巧:把分析结果变成业务方打开就能懂的 Excel。
with pd.ExcelWriter('kmodes_report.xlsx', engine='openpyxl') as writer: # 第一页:中心点画像 centroid_df.to_excel(writer, sheet_name='Centroids', index=True) # 第二页:各簇分布详情 report_df.to_excel(writer, sheet_name='Distribution', index=True) # 第三页:每个簇抽 10 个样本(供业务验证) sample_df = pd.DataFrame() for cluster_id in sorted(df['cluster'].unique()): samples = df[df['cluster'] == cluster_id].sample(n=10, random_state=42) samples['cluster_id'] = cluster_id sample_df = pd.concat([sample_df, samples], ignore_index=True) sample_df.to_excel(writer, sheet_name='Samples', index=False) print("✅ Excel 报告已生成:kmodes_report.xlsx")逻辑说明:
sample(n=10)是关键——业务方常质疑“你们说簇0是教师,但我看到的样本里有销售”,抽样能快速验证或证伪;openpyxl引擎支持写入多页;文件名带kmodes_前缀,方便归档检索。
从那以后我每次做分类数据聚类,都强制走一遍这五步:①fillna('MISSING')+astype(str)清洗;②n_init=25+init='Cao'运行;③cluster_centroids_看众数;④value_counts(normalize=True)算分布;⑤ExcelWriter导出三页报告。省去所有“为什么结果不对”的扯皮,直接交付可行动的洞察。希望帮到你。
本文还有配套的精品资源,点击获取