学生画像可视化与课程聚类推荐系统:Python特征工程到ECharts大屏实践
简介这是一套基于Python、HTML、JavaScript与CSS的学生画像可视化与课程聚类推荐系统完整源码面向正在学习Web开发、数据挖掘或准备毕业设计的高校学生与开发者可用于校园教学数据分析和个性化选课推荐。项目采用Django框架按业务分层组织覆盖用户、学生、课程三大模块利用Kmeans聚类算法对课程数据建模并通过图表形式呈现学生画像。压缩包共56个文件30个Python脚本承载数据生成、算法实现、接口与业务逻辑9个HTML页面、4个JavaScript脚本和2个CSS样式表构成前端界面另含SQL数据库脚本、4个TTF字体、图片与动图素材整个包仅3.13MB结构清晰易于定位。目前已有409人学习下载。这套资源能够帮助读者一次性看清从原始数据产生、特征聚类、业务封装到网页展示的完整工程项目readme与代码注释也便于二次开发尤其适合作为课程设计、毕业设计或推荐系统入门实践的参考模板。1. 学生画像可视化与课程聚类推荐系统从课程列表到推荐结果的完整链路在一所高校的信息中心做课程推荐最容易收到的反馈是“推荐列表里全是我已经修过且拿高分的课”。这不是排序权重没调好而是推荐系统把学生和课程都当成了孤立对象。想解决这个矛盾技术路径就是把成绩、专业、学习行为这些维度揉成一个多维向量先聚类再匹配最后用可视化把“为什么推这门课”讲清楚。这正是这类系统标题背后的设计意图Python负责特征计算、聚类与接口输出HTML、JavaScript、CSS负责把画像雷达图、聚类散点和推荐课表渲染成可视化大屏。适合有Python和前端基础、想把教学数据变成可交互分析工具的开发者参照改造。2. 特征工程与数据准备把学生成绩、行为、专业变成可聚类的向量2.1 原始数据怎么来教务导表、SQL建表与清洗学生画像可视化系统能不能跑通先决条件不是聚类算法而是整理出一张干净的学生宽表。常见做法是把学生基础信息、选课记录、成绩单、学习平台行为日志分别导出再用学号作为关联键合并。字段不要贪多先保证每个学生都有“成绩、专业、学习投入”这三维里的可量化来源缺少点击量这类行为数据时可以用重修次数、选课门数来替代。CREATE TABLE student_course ( stu_id VARCHAR(32) NOT NULL COMMENT 学生学号, course_id VARCHAR(16) NOT NULL COMMENT 课程编号, term VARCHAR(16) NOT NULL COMMENT 开课学期, score DECIMAL(5,2) COMMENT 课程成绩如89.50, credit DECIMAL(3,1) COMMENT 课程学分, course_type VARCHAR(16) COMMENT 公共课/专业课/选修课, click_count INT DEFAULT 0 COMMENT 学习平台点击量, PRIMARY KEY (stu_id, course_id, term) );建表时用复合主键而不是自增ID是为了保证同一个人同一学期同一门课只保留一条记录避免后续 groupby 聚合时把成绩算重。click_count 是行为特征允许为空导入后用填充而不是删除。Python清洗脚本如下import pandas as pd df pd.read_csv(student_course.csv, encodingutf-8) stu_info pd.read_csv(student_info.csv, encodingutf-8) df df.dropna(subset[stu_id, course_id, score]) df[click_count] df[click_count].fillna(0).astype(int) # 小于60分记为不及格供后续统计重修次数 df[is_retake] (df[score] 60).astype(int)dropna 只删除主键和成绩为空的记录行为字段缺失用 0 填充这是考虑到学习平台可能只覆盖部分课程。is_retake 用 0/1 标记比直接保留原始分数更稳因为 K-Means 是距离敏感算法原始 55 分和 59 分在算法眼里非常接近但业务上一个没过、一个过了含义完全不同。2.2 学生画像与课程画像的构造2.2.1 学生维度聚合加权成绩比平均分更可信把课程级记录按学号聚合得到的是学生画像的核心向量。特征维度遵循一个原则每个字段都要能被一句话解释。无法解释的隐向量适合深度模型不适合这类需要把画像可视化到前端大屏的系统。agg df.groupby(stu_id).agg( weighted_score( score, lambda s: round( (s * df.loc[s.index, credit]).sum() / df.loc[s.index, credit].sum(), 2, ), ), score_std(score, std), retake_count(is_retake, sum), total_clicks(click_count, sum), course_num(course_id, nunique), ).reset_index()加权平均比直接平均值更能反映课程负担差异。3 学分的高难专业课考 85 分与 1 学分的选修课考 85 分对画像的贡献应当不同。score_std 反映偏科程度标准差低说明各科均衡高说明学生在某些方向存在明显短板。total_clicks 是学习投入的近似指标适合做聚类依据但不适合单独排名。接着合并专业信息做 one-hot 编码stu_encoded pd.get_dummies(stu_info.set_index(stu_id)[major], prefixmajor) profile_df agg.set_index(stu_id).join(stu_encoded) profile_df profile_df.fillna(0)get_dummies 会把专业扩成多个 0/1 列聚类时一个学生只在一个专业列上取 1。这里不能用 LabelEncoder 直接编码成 0、1、2因为整数大小会给距离计算造成根本不存在的远近关系这是特征工程里非常典型的误用。2.2.2 标准化一套给算法一套给可视化from sklearn.preprocessing import StandardScaler, MinMaxScaler numeric_cols [weighted_score, score_std, retake_count, total_clicks, course_num] scaler StandardScaler() scaled_profile scaler.fit_transform(profile_df[numeric_cols]) minmax MinMaxScaler(feature_range(0, 100)) viz_profile minmax.fit_transform(profile_df[numeric_cols])同一份数据做两份缩放是因为用途完全不同。StandardScaler 把均值变为 0、方差变为 1适合直接交给 K-Means 计算欧氏距离MinMaxScaler 把数值映射到 0 到 100适合前端雷达图直接展示。后端接口可以同时输出这两份结果让可视化不干扰聚类输入。特征维度参考表如下特征类型计算方式说明weighted_score数值学分加权平均整体学业水平score_std数值成绩标准差偏科与稳定性retake_count数值不及格次数学业风险total_clicks数值点击量求和学习投入代理course_num数值课程去重数选课跨度major_xx0/1one-hot专业大类归属到这一步数据已经可以被聚类算法直接消费。实际落地时Python 环境只需要 pandas、scikit-learn 和一个后端框架不需要引入 Spark 这类重组件。如果某个专业样本太少先合并成“其他”避免聚类簇里出现只有两个人的专业影子簇。3. 课程聚类与推荐映射Python端K-Means的训练参数与收敛逻辑3.1 课程作为样本、学生画像作为特征聚类对象的选择决定了推荐逻辑怎么写。标题指向“课程聚类”所以把每门课当作一个样本用选课学生画像的均值向量表示这门课。这样推荐链路就变成给定一个学生的画像向量计算它与各课程簇中心的距离找到最近簇后从簇内课程里做排序推荐。course_matrix profile_df.T.dot(course_assign_onehot).T course_matrix course_matrix.div(course_assign_onehot.sum(axis0), axis0) course_matrix course_matrix.fillna(0)这段代码先把学生画像转置后与选课矩阵相乘得到每门课在每个画像维度上的原始累加值再除以选课人数得到均值。除法之后得到的 course_matrix 就是“课程画像矩阵”行是课程列是特征维度。这里有三个容易出错的地方第一选课矩阵必须是 0/1不能带权重第二除数是选课人数而不是记录数重复选课会被计两次第三无人选的新课程会得到 NaN必须 fillna(0)否则后面聚类直接报错。K-Means 聚类代码如下from sklearn.cluster import KMeans k 6 km KMeans( n_clustersk, initk-means, n_init10, max_iter300, random_state42, ) course_kmeans_labels km.fit_predict(course_matrix)k-means 初始化会刻意拉开初始簇中心的距离比完全随机初始化更不容易陷入局部最优。n_init10 表示用 10 组不同的初始中心各跑一遍最终保留 SSE 最小的结果max_iter300 是单次运行的最大迭代轮数一般数据量下 100 轮内就能收敛300 是为了防止个别簇不收敛导致训练时间失控。random_state 固定后评估结果可以稳定复现这在调参时非常关键。K-Means 核心参数说明如下参数推荐值作用调参方向n_clusters4~8簇数量用肘部法则或轮廓系数确认initk-means初始中心策略数据量大时效果好n_init10随机重启次数越大越稳定耗时越高max_iter300单次最大迭代数据规范时 100 够用random_state42随机种子固化可复现3.2 推荐映射最近簇与簇内排序得到课程簇标签之后推荐逻辑就落到一个距离计算上import numpy as np centers km.cluster_centers_ # shape: (6, n_features) def recommend_courses(stu_vec, top_n5): # 计算学生画像与各簇中心的欧氏距离 dist np.linalg.norm(centers - stu_vec, axis1) best_cluster np.argmin(dist) pool course_df[course_df[cluster] best_cluster] return ( pool.sort_values(popularity, ascendingFalse) .head(top_n)[course_id] .tolist() )这里的欧氏距离是在标准化后的空间里计算的所以 stu_vec 也必须是经过同一个 StandardScaler 转换过的向量不能直接传入加权平均分。popularity 常见做法是取课程的历史选课人数或课程评分如果都没有就用该课程所在簇内学生平均点击量作为排序依据。若两个课程在排序值上打平可以再加一个随机种子做稳定排序避免每次刷新推荐结果都不一样。3.3 层次聚类作为可视化补充什么时候才值得换算法除了 K-Means层次聚类在这个标题下也值得提因为很多课程聚类项目最终要输出一张“课程关系树”。层次聚类在 Python 里的实现同样直接from scipy.cluster.hierarchy import linkage, fcluster Z linkage(course_matrix, methodward) course_hier_labels fcluster(Z, t6, criterionmaxclust)ward 方法让每次合并时簇内方差增幅最小和 K-Means 的目标函数一致所以两者结果通常接近。层次聚类的优势是能够输出树状图在可视化大屏上展示“课程簇之间如何演化、哪些课程簇在特征空间上相邻”。缺点是 linkage 需要计算两两距离矩阵课程量在几百门时没问题换成几万学生样本就不现实复杂度过高。实际项目中可以把层次聚类作为离线分析工具把 K-Means 作为线上推荐引擎。层次聚类负责回答“课程体系长什么样”K-Means 负责回答“这个人现在该学什么”两者在数据流上是串行的。4. 学生画像可视化前端HTML、JavaScript、CSS如何联动渲染图表4.1 前后端接口约定Python返回JSON前端fetch拉取可视化大屏的数据来源不是直接读数据库而是由 Python 后端聚合后输出 JSON。很多刚接触这类项目的人把聚合逻辑写在 JavaScript 里页面一刷新就卡死正确做法是前端只负责渲染所有聚类计算和画像统计都在 Python 侧完成。后端接口用 Flask 就能满足from flask import Flask, jsonify app Flask(__name__) app.route(/api/student/sid) def student_profile(sid): row profile_df.loc[sid].to_dict() return jsonify({ sid: sid, dimensions: numeric_cols, values: viz_profile[sid].tolist(), cluster: student_cluster_map[sid], }) app.route(/api/course_clusters) def course_clusters(): payload { courses: course_names, x: pca_x.tolist(), y: pca_y.tolist(), cluster: course_kmeans_labels.tolist(), } return jsonify(payload)接口返回值里的 dimensions 和 values 是两个平行数组dimensions 是特征名称列表values 是 MinMaxScaler 之后的 0 到 100 分数。前端雷达图的 indicator 就从 dimensions 直接生成不需要在 JavaScript 里再写一份字段名配置。这样如果某个特征名字改了后端只需要改一处。前端项目文件职责如下文件职责关键内容index.html页面结构三个图表容器卡片css/dashboard.css大屏布局grid 自适应、卡片尺寸js/app.js数据拉取与渲染fetch、ECharts、事件绑定js/echarts.min.js图表库本地化引入避免 CDN 不稳定4.2 雷达图渲染ECharts与JavaScript对象合并HTML 骨架比较简单突出大屏的网格布局!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title学生画像可视化与课程聚类推荐系统/title link relstylesheet hrefcss/dashboard.css script srcjs/echarts.min.js/script /head body main classdashboard section classcard idradar-card/section section classcard idscatter-card/section section classcard idrecommend-card/section /main script srcjs/app.js/script /body /html雷达图的 JavaScript 渲染逻辑const radarChart echarts.init(document.getElementById(radar-card)); fetch(/api/student/S001) .then((res) res.json()) .then((profile) { const radarOptions { radar: { indicator: profile.dimensions.map((d) ({ name: d, max: 100 })), }, series: [ { type: radar, data: [ { value: profile.values, name: profile.sid, areaStyle: { opacity: 0.25 }, }, ], }, ], }; radarChart.setOption(radarOptions); }) .catch((err) console.error(画像接口加载失败, err));fetch 拿到的是后端已经完成缩放的数据前端不需要再做归一化。indicator 里的 max 全部设成 100是因为传给前端的 viz_profile 已经过 MinMaxScaler 处理。这里有一个 JavaScript 的细节如果用 Object.assign 合并多个配置对象需要注意嵌套对象的覆盖规则Object.assign 是浅拷贝直接合并两层以上的配置会互相污染。更稳妥的做法是像上面这样把 nested 结构整段写在同一次 setOption 里避免“JavaScript合并两个对象”时把 radar 配置覆盖掉。4.3 聚类散点图与大屏适配CSS Grid布局和resize监听聚类结果用散点图呈现最直观。为了在二维平面上展示高维聚类结果Python 端可以先用 PCA 把课程画像降到两个主成分再把坐标传给前端fetch(/api/course_clusters) .then((res) res.json()) .then((data) { scatterChart.setOption({ xAxis: { name: PC1 }, yAxis: { name: PC2 }, series: [ { type: scatter, data: data.courses.map((name, idx) ({ name: name, value: [data.x[idx], data.y[idx], data.cluster[idx]], })), symbolSize: 14, }, ], }); });散点图的每个点代表一门课颜色用 cluster 值映射点击时可以通过事件监听把学生画像雷达图切换成当前课程的画像形成交互闭环。大屏适配用 CSS Grid 做响应式网格比手写百分比宽度直观得多.dashboard { display: grid; grid-template-columns: repeat(12, 1fr); gap: 1rem; padding: 1rem; } .card { grid-column: span 4; min-height: 360px; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } media (max-width: 900px) { .card { grid-column: span 12; } }12 列栅格是可视化大屏适配里比较通用的做法。大屏上三个卡片各占 4 列并排展示平板宽度下改为单列堆叠。ECharts 实例需要在窗口尺寸变化时手动触发 resize 方法否则图表会保持初始化时的像素宽度这也是大屏适配最常见的样式问题需要在 app.js 里加 window.addEventListener(resize, () { radarChart.resize(); scatterChart.resize(); })。5. 聚类结果验证与前端渲染的几个实操细节5.1 轮廓系数与簇数选择K-Means 的簇数不能用业务直觉拍脑袋跑一轮轮廓系数就能定位合理的 k 值区间from sklearn.metrics import silhouette_score scores {} for k in range(2, 9): km KMeans(n_clustersk, n_init10, random_state42) labels km.fit_predict(course_matrix) scores[k] silhouette_score(course_matrix, labels)轮廓系数接近 1 表示样本离近邻簇很远接近 0 表示两个簇在边界处重叠负值说明样本可能被分错簇。实际操作中不要只看最大值要结合业务经验判断如果 k3 时系数最高但簇内课程主题仍然混杂说明特征维度还需要调整而不是继续增加簇数。这里有个验证技巧把聚类标签和聚类热图放在一起看热图的行和列都按簇标签排序簇内颜色越一致说明聚类边界越干净。5.2 前端性能ECharts实例复用与数据裁剪可视化大屏在切换学生时最常见的错误是每次 fetch 后重新 echarts.init 一次导致 DOM 节点越来越卡。正确做法是页面初始化时只 init 一次后续只用 setOption 更新数据。若图表类型动态切换先调用 dispose 释放旧实例再重建。接口向下推数据时不要一次性把全部学生画像塞给前端推荐列表和散点图数据做 top 50 或采样后再下发否则前端 JSON.parse 会阻塞渲染。5.3 把 k 值和缩放方式做成可调参数验证期把 n_clusters、StandardScaler 是否启用做成 URL query 或后端配置文件参数比如访问 /api/course_clusters?k6后端动态加载不同 k 值的聚类结果。这么做的好处是调参不用改前端代码还能在同一个页面上快速对比 k4、6、8 时的散点图形态。对于这类学生画像可视化与课程聚类推荐系统可解释性比精确度更重要任何调参动作最终都要能落到“这个学生为什么被推荐这门课”这句话上否则可视化就只剩美观一个作用。本文还有配套的精品资源点击获取