简介:面向高校宿舍分配场景的K均值聚类算法Python源码与视频演示资料,适合数据挖掘入门者、算法学习者及需完成宿舍分配项目的开发者。项目基于Python与sklearn库,围绕学生年龄、性别、专业、生活习惯等特征构建数据集,依次执行数值化转换、缺失值填充和标准化处理,再利用K均值模型完成聚类分组。资源包含可运行的Python源码主程序,以及聚类中心占比、最终聚类结果两份CSV结果数据,便于核对分组质量;使用说明和Markdown文档从算法原理、参数选择到运行步骤做了详细讲解;两份MP4演示视频分别录下程序实际运行过程和文档操作,即使零基础也能跟随上手。压缩包共13个文件,涵盖Python源码、结果CSV、说明文档、演示视频及项目配置等,整体约10.71MB,结构清晰便于检索。已有820人浏览学习,读者可借此复现宿舍分配实验,并进一步理解肘部法则、轮廓系数、KMeans++初始化等聚类优化方法,为扩展更多个性化分寝策略打下基础。
1. 基于KMeans聚类算法的高校宿舍分配:从开盲盒式分寝到可解释的聚类分组
每到开学季,宿管办就被换寝申请淹没。早睡的和夜猫子住一间,爱干净的和乱扔袜子的住一起,矛盾从宿舍蔓延到班级群,最后都变成辅导员的工作量。传统宿舍分配按学号或报到顺序机械分组,结果全凭运气。基于KMeans聚类算法的高校宿舍分配,本质是把每个学生的作息、性格、卫生习惯量化成数值特征,用聚类算法把特征相似的人聚成一组,再一组落到一间宿舍。它适合高校信息化管理人员、宿管老师和拿这个题目做课设毕设的学生。下面从特征设计、源码实现、参数调优到避坑,把这条路完整走一遍。
2. KMeans分宿舍的原理前提:怎么把“合不合得来”变成可计算的向量
KMeans本身是个黑匣子。你喂进去什么特征,它就只能按什么特征分组。我第一次做这个项目时,把“是否抽烟”“是否熬夜打游戏”“睡觉时间”“性格测试分数”一起丢进去,聚类结果出来宿舍里全按“睡觉时间”分堆了——熬夜打游戏这个0/1特征在欧氏距离里几乎不起作用,但“睡觉时间”的连续值跨度大,反而主导了分组。这里有个关键认知:宿舍分配聚类没有标准答案,特征设计决定了结果上限,KMeans只是把这个上限兑现出来。
2.1 学生特征向量设计:作息、性格、卫生习惯的量化口径
我在实际项目里常用的特征集是四个维度:作息节律(起床时间和睡觉时间,用连续值记录)、卫生习惯(打扫频率,1-5等级)、性格维度(内向-外向,1-5等级)、生活习惯(是否熬夜打游戏,0/1二元)。
作息节律是宿舍冲突的第一大来源。起床时间用6.0到10.0的连续值,睡觉时间用22.0到24.0的连续值。注意这里不要用“早睡”“晚睡”这种文字标签,KMeans只认数值。卫生习惯和性格用李克特量表式的1-5分,比如1分是“从不主动打扫”,5分是“每天打扫且注重整洁”。这种量化方式问卷设计简单,学生填起来也快。
特征不是越多越好。加入过多弱相关特征,比如“高考数学成绩”“是否喜欢某个明星”,会让聚类结果被无关维度拉偏。一个判别标准:每个特征必须能讲出一个“宿舍冲突故事”——睡觉时间差异大导致关灯冲突、打扫频率差异大导致卫生矛盾、性格差异大导致沟通问题。讲不出故事的特征,直接删掉。
2.2 从直觉到算法:KMeans怎么把相似的人聚到一起
KMeans的迭代过程分两步,非常简单但常被误解。第一步,随机初始化K个质心,也就是K个宿舍“虚拟代表”;第二步,把每个学生分配到离他最近的质心所在的簇;第三步,每个簇重新计算质心,也就是簇内所有学生的平均特征向量;第四步,重复第二步和第三步,直到质心不再变化或变化小于阈值。
这个过程中距离度量用的是欧氏距离,也就是多维空间里的直线距离。宿舍分配场景里为什么欧氏距离够用?因为这些特征都是连续数值或有序等级,“1分”到“5分”的差就是4,没有高维稀疏问题。如果你用了大量独热编码,把专业、学院、生源地都展开成0/1列,特征维度会膨胀到几十上百,欧氏距离在高维空间会失去区分度——那时就该考虑余弦相似度或高斯混合模型GMM了。但在宿舍分配这个场景,KMeans加欧氏距离就好,参数少、速度快、结果易解释,这也是它比GMM更常被选用的原因。
2.3 特征标准化:不做这步,聚类结果等于没做
这是KMeans项目里最常见的翻车点。直接把原始特征丢进KMeans,“起床时间”的取值范围是6.0到10.0,跨度4;“性格分数”是1到5,跨度也是4,但分布形态完全不同。欧氏距离计算时,起床时间几个小时的差异会完全淹没性格分数一两分的差异。所以你跑出来的聚类结果,与其说是“综合画像分组”,不如说是“按起床时间切了K刀”。
解决方案是标准化。sklearn里有两种常用手段,MinMaxScaler把特征压到[0,1]区间,StandardScaler把特征转换成均值0、标准差1。我一般选MinMaxScaler,原因是宿舍分配的特征里有0/1二元变量和1-5的等级变量,MinMax会把它们都映射到[0,1],语义清晰——0就是完全不熬夜,1就是天天熬夜。StandardScaler对正态分布数据效果更好,但学生的作息习惯分布往往是双峰的(早睡型和夜猫型),用z-score反而会把这种双峰结构抹平。
提示:scaler需要用训练数据fit,再用同一个scaler去transform后续的新生数据,不能每次重新fit,否则新老生的特征尺度不一致,聚类结果没法复现。
3. Python源码实现:从模拟数据到宿舍分配名单的最小闭环
网上免费的python源码大全里,聚类相关的多半是Iris数据集跑个KMeans就完事。宿舍分配这个场景特殊在两点:一是特征要自己设计,二是聚类结果必须满足性别、学院这类硬约束。所以源码的核心不是调库,而是把“约束修正”这件事写对。下面这套流程我用Python和sklearn完整跑通过,先看最小闭环。
3.1 最小可跑通的KMeans分配脚本:数据生成到聚类输出
代码先固定随机种子,再生成模拟学生数据,做标准化,按性别和专业分组聚类,最后输出分配预览。
import numpy as np import pandas as pd from sklearn.preprocessing import MinMaxScaler from sklearn.cluster import KMeans # 固定随机种子,保证演示视频和源码复现一致 np.random.seed(42) # 生成200个模拟学生,4个特征 # 起床时间6-10,睡觉时间22-24,卫生习惯1-5,性格内向-外向1-5 n_students = 200 df = pd.DataFrame({ 'wake_time': np.random.uniform(6, 10, n_students).round(1), 'sleep_time': np.random.uniform(22, 24, n_students).round(1), 'hygiene': np.random.randint(1, 6, n_students), 'extrovert': np.random.randint(1, 6, n_students), }) # 给每个学生加上性别和专业,用于后面的硬约束分组 df['gender'] = np.random.choice(['M', 'F'], n_students, p=[0.5, 0.5]) df['major'] = np.random.choice(['CS', 'EE', 'LAW', 'MED'], n_students) # 标准化特征,让四个特征在距离计算中权重均衡 scaler = MinMaxScaler() X_scaled = scaler.fit_transform(df[['wake_time', 'sleep_time', 'hygiene', 'extrovert']]) # 先按性别+专业分组,组内独立做KMeans df['cluster'] = -1 for (gender, major), group_idx in df.groupby(['gender', 'major']).groups.items(): idx = group_idx.to_numpy() if len(idx) < 4: continue # 不足一间宿舍的人,留到最后跨组二次分配 n_rooms = len(idx) // 4 # 目标宿舍间数,4人一间 km = KMeans(n_clusters=n_rooms, random_state=42, n_init=10) labels = km.fit_predict(X_scaled[idx]) df.loc[idx, 'cluster'] = labels # 按性别、专业、簇排序,生成分配预览 result = df[df['cluster'] >= 0].sort_values(['gender', 'major', 'cluster']) print(result.head(16))这段代码的逻辑核心是用groupby(['gender', 'major'])把学生按性别和专业切分,再在每组内部做KMeans。这样KMeans的聚类结果天然不会出现男女生混住一个簇的情况,因为聚类根本没跨性别运行。
参数说明:np.random.seed(42)是关键中的关键。演示视频里跑出来一套宿舍名单,源码复现时每次数据都得一样,靠的就是这个种子。n_init=10表示KMeans从10组随机初始化里选最优结果,避免第一次初始化糟糕导致聚类跑偏,sklearn较新版本默认行为类似,但显式写出来更稳。random_state=42同时传给KMeans,保证算法内部的随机过程也可复现。n_rooms = len(idx) // 4是用组内人数除以每间人数向下取整,多出来不足4人的尾巴学生,cluster保持-1,留给后面的二次分配。
3.2 K值怎么定:固定宿舍规格与肘部法则的取舍
K值在宿舍分配场景有两种确定方式。第一种,宿舍规格是硬性的,比如学校规定4人间,那么“组内人数除以4”就是K,不用纠结。第二种,学校允许弹性安排,比如4到6人间都可以,这时用肘部法则在数据里找自然的簇数。聚类结果会更贴合学生真实分布,但宿管排床位的复杂度会上升。
肘部法则的代码很短,核心是画出SSE随K变化的曲线:
import matplotlib.pyplot as plt from sklearn.cluster import KMeans # 计算不同K值下的簇内平方和SSE sse = [] K_range = range(2, 20) for k in K_range: km = KMeans(n_clusters=k, random_state=42, n_init=10) km.fit(X_scaled) sse.append(km.inertia_) # inertia_即簇内平方和,越小说明簇越紧凑 plt.plot(K_range, sse, 'o-') plt.xlabel('K') plt.ylabel('SSE') plt.title('Elbow Method for Optimal K') plt.show()逻辑说明:随着K增大,SSE必然下降,因为簇越来越多、每个簇越来越小。真正的拐点在SSE下降速率骤减的地方,那个K就是自然的簇数。但现实里拐点经常不明显,这时候我会再看轮廓系数。
轮廓系数的计算方式是对每个样本,算它与同簇其他样本的平均距离a,再算它与最近邻簇所有样本的平均距离b,轮廓系数等于(b-a)/max(a,b),最终取全样本均值。范围[-1,1],越接近1越好。宿舍分配场景下,特征本身就是人工设计的问卷评分,噪声大是常态,轮廓系数到0.25到0.35已经算合格,不用追求0.5往上。如果肘部法则拐点在K=8,轮廓系数在K=6和K=8都差不多,我选小的K,因为宿舍数量越少、后续排床位越简单。
3.3 硬约束修正:性别、学院分组聚类与尾巴学生的贪心二次分配
KMeans不认性别、学院,它只认数值。宿舍分配如果没有约束修正,算法会产出“早睡男生和晚睡女生住一间”这种宿舍办绝对不可能批准的名单。常见做法是先按性别加学院分组,组内再做KMeans。这一步必须在标准化之后、聚类之前完成,顺序反了会出现跨组特征尺度不一致的问题。
每个分组整除后剩下的1到3个尾巴学生,需要跨组拼宿舍。拼宿舍的原则不是随便塞,而是把这些人的特征向量和待拼入宿舍的现有成员做距离计算。维护一个“未满宿舍列表”,每个宿舍记录当前成员的平均特征向量,新来的学生算一下自己和每个未满宿舍平均向量的欧氏距离,选最近的。代码如下:
from scipy.spatial.distance import euclidean # 假设df里已经有cluster列,cluster==-1的是尾巴学生 tail = df[df['cluster'] == -1].copy() unfilled = df[df['cluster'] >= 0].groupby(['gender', 'major', 'cluster']) unfilled_list = [] for (gender, major, cluster), sub in unfilled: if len(sub) < 4: # 不足4人的簇也放进拼宿舍池 unfilled_list.append({ 'gender': gender, 'major': major, 'cluster': cluster, 'member_ids': sub.index.tolist(), 'centroid': sub[['wake_time', 'sleep_time', 'hygiene', 'extrovert']].mean().to_numpy() }) for _, stu in tail.iterrows(): vec = stu[['wake_time', 'sleep_time', 'hygiene', 'extrovert']].to_numpy(dtype=float) # 只允许同性别、同专业的学生拼进同一间宿舍 candidates = [u for u in unfilled_list if u['gender'] == stu['gender'] and u['major'] == stu['major']] if not candidates: continue # 实在凑不够时,跨专业兜底的逻辑这里先省略 # 贪心:选择质心距离最近的一个未满宿舍 closest = min(candidates, key=lambda u: euclidean(vec, u['centroid'])) closest['member_ids'].append(stu.name) closest['centroid'] = np.mean( df.loc[closest['member_ids'], ['wake_time', 'sleep_time', 'hygiene', 'extrovert']].to_numpy(), axis=0 )逻辑说明:这个片段的本质是贪心算法,每来一个尾巴学生,找当前所有未满宿舍中特征质心离他最近的,塞进去。缺点是不是全局最优,但宿舍分配场景里贪心已经够用,实现简单、辅导员也好解释。
参数说明:euclidean来自scipy.spatial.distance,等价于自己写np.linalg.norm(vec1 - vec2)。程序的硬过滤是同性别的同专业才允许拼宿舍,实际项目里如果组内真的凑不满,才允许跨专业合并,但性别这条线绝对不能破。
4. 从聚类结果到宿舍名单:分配策略与结果验证
KMeans输出的是“簇”,不是“宿舍”。簇是特征空间里的点集合,宿舍是物理房间里的床位集合,两者之间还差一个落位步骤。这个步骤做不好,前面的聚类等于白算。
4.1 簇内落位:大簇拆分、小簇合并的贪心策略
聚类出来的簇大小不会精确等于4。实际分配时我做三层处理:第一层,正好4人的簇,直接成一间;第二层,大于4人的簇,按簇内成员到质心距离排序,把特征最接近的人优先配对,拆成多个4人组;第三层,小于4人的簇和分组余数学生,汇总进拼宿舍池用贪心法补满。
大簇拆分这一步很容易被忽略。同一个簇里可能同时存在“极早睡”和“稍早睡”,让他们住一起没问题,但如果你随手按顺序切4人份,可能把特征最像的人拆开。所以要先算每个成员到簇质心的欧氏距离,按距离排序后连续切段:
# 以大簇为例,按成员到质心的距离排序后拆成4人组 def split_large_cluster(cluster_df, size=4): # cluster_df包含该簇所有学生的特征列 feature_cols = ['wake_time', 'sleep_time', 'hygiene', 'extrovert'] centroid = cluster_df[feature_cols].mean().to_numpy() # 计算每个成员到质心的距离,按距离排序 distances = cluster_df[feature_cols].apply( lambda row: euclidean(row.to_numpy(dtype=float), centroid), axis=1 ) sorted_df = cluster_df.loc[distances.sort_values().index] # 按每4人一段切分,生成宿舍编号 groups = [sorted_df.iloc[i:i+size] for i in range(0, len(sorted_df), size)] return groups逻辑说明:排序后连续切段,保证每个4人组内部成员的特征差异最小。如果出现最后一段不足4人,这段自动落入拼宿舍池,交给贪心补充逻辑。排序是关键,不排序直接切,可能把离质心最近和最远的成员凑一起,宿舍内部差异被无谓放大。
参数说明:size=4是宿舍容量,改成6就是6人间。实际项目中宿舍容量很少是固定的,把size参数化后,同一个函数可以支持不同宿舍楼栋的差异化配置。
4.2 验证聚类质量:轮廓系数、簇内距离和分配合理性检查
聚类完成后不要急着出名单,先算三个指标。第一个是轮廓系数,衡量簇内紧密度和簇间分离度;第二个是每个簇的平均内距,也就是簇内成员到质心的平均欧氏距离;第三个是离群点比例,距离超过簇平均距离两倍的成员标记为离群点。
from sklearn.metrics import silhouette_score # 全局轮廓系数 sil_score = silhouette_score(X_scaled, df['cluster']) print(f"Silhouette Score: {sil_score:.3f}") # 每个簇的平均内距与离群点检查 feature_cols = ['wake_time', 'sleep_time', 'hygiene', 'extrovert'] for cluster_id in df['cluster'].unique(): if cluster_id < 0: continue sub = df[df['cluster'] == cluster_id] centroid = sub[feature_cols].mean().to_numpy() dists = sub[feature_cols].apply( lambda row: euclidean(row.to_numpy(dtype=float), centroid), axis=1 ) avg_dist = dists.mean() outliers = (dists > avg_dist * 2).sum() print(f"Cluster {cluster_id}: n={len(sub)}, avg_dist={avg_dist:.3f}, outliers={outliers}")逻辑说明:轮廓系数看的是整体分组质量,我只把它作为参考,不设死门槛。真正要盯的是离群点比例。宿舍分配场景下,一个离群点就是一个“和谁都不合拍”的学生,这种学生即使分进去,后续换寝概率也高。发现离群点后,我的处理方式是把ta重新放回拼宿舍池,用4.1的贪心逻辑再做一次匹配。
提示:轮廓系数高不代表分配合理。如果特征集里混入了“高考成绩”这类弱相关列,算法会按成绩聚类,轮廓系数照样好看,但宿舍矛盾该爆发还是爆发。所以做完聚类后,强烈建议随抽几个簇打印成员特征,用人的常识判断一下分组是否讲得通。
4.3 可视化与名单导出:聚类散点图和Excel分配表
最后一步是把结果可视化并导出。可视化建议选两个最具区分度的特征做二维散点图,给辅导员看的时候直观。导出用pandas的to_excel,注意把同一宿舍的学生放在连续行,方便打印张贴。
import matplotlib.pyplot as plt # 二维投影图:横轴起床时间,纵轴睡觉时间 fig, ax = plt.subplots(figsize=(10, 6)) scatter = ax.scatter( df['wake_time'], df['sleep_time'], c=df['cluster'], cmap='tab20', s=50, alpha=0.7 ) ax.set_xlabel('wake_time') ax.set_ylabel('sleep_time') ax.set_title('Student Clusters by Sleep Schedule') plt.colorbar(scatter, label='cluster id') plt.show() # 导出分配表:每个学生一行,标注宿舍号和床位 df['dorm_id'] = df['gender'] + df['major'] + '-' + df['cluster'].astype(str) df.sort_values(['dorm_id', 'wake_time']).to_excel('dorm_assignment.xlsx', index=False)逻辑说明:散点图只是辅助验证手段,4个特征投影到2维必然丢失信息,图上簇重叠不代表聚类失败。真正的交付物是Excel分配表,dorm_id列把性别、专业、簇编号拼成宿舍号,然后按宿舍号排序,同一个宿舍的学生自然落在一起。
注意导出前要检查dorm_id是否有重复。如果两个不同的簇拼出来一样的宿舍号,说明聚类阶段就有问题——最常见的错误是分组聚类后没有重置簇编号,导致不同专业组里都出现“cluster 0”。
5. 宿舍分配聚类的避坑指南:5个必踩的坑与排查方法
这部分是血泪经验。从数据生成到名单导出,每一环都有对应的坑,按现象、原因、解决三个步骤说清楚。
5.1 量纲没归一化,聚类等于按单个特征切几刀
现象:聚类结果里学生几乎只按起床时间分组,卫生习惯和性格完全没有参与。
原因:起床时间取值范围6到10,跨度是4;卫生习惯1到5,跨度也是4,但两个特征的方差结构完全不同。欧氏距离计算时,起床时间的微小差异被放大,性格、卫生这类低方差特征被淹没。KMeans本质是在做“最小化欧氏距离总和”,哪个特征方差大,哪个特征就主导分组。
解决:训练前统一做MinMaxScaler或StandardScaler。跑完标准化后打印一下各特征的标准差,确认尺度在同一量级再进KMeans。如果某列标准差接近0,说明这个特征几乎没有区分度,直接删掉。
5.2 KMeans强制分K簇,但簇人数严重不均
现象:有的簇15人,有的簇3人,排宿舍时大簇拆分麻烦,小簇又拼不满。
原因:KMeans只优化簇内平方和,不做人数均衡。当学生特征分布本身集中时,大量学生会被拉进同一个簇,少数离群学生形成小簇,这是算法特性,不是bug。
解决:聚完先看每簇人数。大簇用4.1的排序切段拆小;小簇进拼宿舍池。如果数据分布偏得厉害,比如80%的学生作息都集中在同一区间,考虑把宿舍容量约束提到6人间,减轻均衡压力。必要时可以换DBSCAN,但DBSCAN的eps参数调起来更费劲,宿舍分配场景我更推荐KMeans加后处理。
5.3 先全局聚类再按性别拆,结果全乱
现象:全局聚类出来的簇成员特征确实相似,但男女混在一起。按性别拆分后,每个性别子集里的成员特征不再相似,宿舍分配质量反而降了。
原因:KMeans不识别性别,全局聚类得到的“作息相近”小组可能横跨性别。硬拆是按外部规则破坏聚类结构,结果自然不伦不类。
解决:必须先按性别加学院分组,组内独立做KMeans。这一步是流程顺序问题,不是算法调参能解决的。很多课设源码翻车,翻在这。
5.4 演示视频和源码复现的结果对不上
现象:演示视频里宿舍A住的是甲乙丙丁,自己跑源码出来变成了戊己庚辛,甚至每次跑都不一样。
原因:源码没有固定随机种子。数据生成用的是np.random.uniform,聚类初始化用KMeans默认随机,跑一次一个结果。演示视频里录到的只是若干次随机里的一次。
解决:在数据生成前加np.random.seed(42),KMeans里传random_state=42。注意数据生成的seed要放在所有随机调用之前,如果把seed放在数据生成之后,等于没固定。另外,pandas的sample、train_test_split这类操作也要传random_state,全链路都固定住,复现才对得上。
5.5 轮廓系数很高但实际分组不合理
现象:轮廓系数0.6,结果辅导员看了一眼就说“这间宿舍三个人作息完全不一样”。
原因:特征集里混入了和宿舍冲突无关的弱相关特征,比如高考成绩、身高、体重。算法确实按这些特征把学生分开了,轮廓系数衡量的是“按这些特征分得好不好”,不是“分得合不合理”。指标骗了你,因为喂进去的数据本身有问题。
解决:特征设计阶段就删掉讲不出宿舍冲突故事的特征。聚类完成后抽检3到5个簇,把簇内成员的特征值列出来人工核对。如果每个簇的作息、卫生、性格确实各自相似,再信轮廓系数。
6. 进阶:把宿舍分配聚类做成可复用的分配服务
前面五章解决了“跑通一次”的问题,最后一章说怎么让它每个学期都能用。
6.1 增量分配:老生不动,只聚类新生
每学年的分配不是从零开始。老生宿舍已经稳定,如果整体重新聚类,今年的结果和去年完全不一致,等于把所有人都拆散。做法是把老生宿舍视为固定簇,每个宿舍的特征质心已经存在。新生单独做KMeans,聚类后按距离匹配到未满的老宿舍质心,用4.1那套欧氏距离判断。老宿舍不重新聚类,新生的分组只和“宿舍质心”比较,不和老生个体比较。
6.2 生成可解释性分配报告
辅导员要的不是质心坐标,是“这间宿舍为什么这样分”的人话。做法是每间宿舍算特征均值,自动生成一句话描述。比如“该宿舍成员作息相近,睡眠时间均值23.2点,性格偏内向,卫生习惯良好”。这比给出一堆聚类编号强得多,也减少了辅导员对算法的质疑。
6.3 有效的验证方式:回测换寝率
最有说服力的验证是用上一届学生的问卷数据和真实换寝记录做回测。先跑一遍聚类分配,得到模拟宿舍名单,再去对比真实换寝名单里有多少人被分到了“特征差异很大”的宿舍。换个说法:按聚类的标准看,上一届换寝的人是不是普遍被分在不合适的宿舍里。如果是,说明特征和算法方向对了;如果换寝的人和室友特征差异并不大,说明换寝主因不在作息和卫生,要去补特征维度。
我自己第一次做这个项目时没固定随机种子,连续跑出三份不同的宿舍名单,被辅导员当面质疑“这算法是不是扔骰子”。从那以后,所有聚类项目先把seed写死、数据版本打上标签,演示视频里录到什么样,复现就必须是什么样。宿舍分配这事,学生满意度很难量化,但至少做到名单可复现、理由可解释,宿管这边就愿意信你。希望帮到你。
本文还有配套的精品资源,点击获取