每学期期末那几天,教务办公室里最热闹的就是各种手忙脚乱算成绩的场景。几十个班的成绩表散落在好几张Excel表格里,光是把平均分、及格率、优秀率一个个算出来就得加班好几天,更别提还得对比班级差距、找出挂科风险学生。我自己就是在那个时候开始写这套成绩分析系统的,前后迭代了两个学期,现在把完整的设计思路、核心代码和踩过的坑整理出来,希望对正在做类似项目的朋友有点帮助。
这个系统解决的核心问题很明确:把成绩分析从“手动Excel粘贴公式”变成“一条命令全出结果”。它基于Python的pandas做数据处理,matplotlib做可视化,支持一键读取成绩表、自动清洗数据、计算描述性统计指标、按班级/课程/年级多维度对比、成绩预警和学生风险识别,最后自动生成图表和汇总报表。无论你是计算机专业的学生要做课程设计,还是教务人员想用自动化减轻工作负担,又或者只是想学一下pandas和matplotlib怎么配合完成数据分析任务,这篇都能给你一套可以直接复用的完整方案。
1. 系统设计思路:先想清楚要解决什么问题
1.1 需求拆解:老师真正关心的是哪些指标
很多人一听到“成绩分析系统”就开始埋头写代码,结果写完才发现功能跟实际需求对不上。我在动手之前,专门跟几位任课老师和辅导员聊了一圈,把他们的真实诉求梳理了一遍。
老师们最惦记的事情有两类。第一类是常规统计口径,包括班级平均分、最高分、最低分、及格率、优秀率,这些指标用来快速判断“这个班学得怎么样”。第二类是更深层的教学诊断需求,比如某个分数段人数特别密集,说明可能大部分学生都卡在某个难点上;或者两个平行班的平均分差不多,但标准差差了一大截,说明班级内部两极分化程度完全不同。如果把这两类需求混在一起做,系统就会变成一个只出平均数的小工具,价值非常有限。
辅导员的关注点又不一样,他们更需要的是“哪些学生需要重点关注”。这里的重点不光是成绩不及格的学生,还包括成绩波动很大的学生、平时表现不错但某次考试突然崩掉的学生。所以我在设计的时候把成绩预警单独做成一个模块,和基础统计分开处理,这样不同角色拿到系统之后都能各取所需。
1.2 技术选型:为什么是pandas而不是Excel VBA
说实话,Excel自带的VBA也能做成绩分析,我第一版方案就是VBA宏,写到一半放弃了。原因有三:第一,VBA的表格操作代码非常啰嗦,同样的功能用pandas实现,代码量能少一半以上;第二,VBA跑数据处理逻辑时一旦报错,错误提示很晦涩,排查起来非常痛苦;第三,想想后面还要扩展Web界面、对接数据库,VBA这套体系基本走不通。
Python这边选型就顺理成章了。pandas是Python数据分析的事实标准,处理二维表格数据(DataFrame)能力非常强,读写Excel也方便。matplotlib则是Python可视化最成熟的库,虽然上手API有些老派,但胜在稳定、可定制性强。整个系统的依赖只有三个核心库:pandas、matplotlib、openpyxl(pandas读取新版Excel文件时需要),再配合Python自带的os、datetime做文件路径和时间处理,完全没有必要引入重型框架。
1.3 整体框架设计:数据流是怎么串起来的
这个系统的数据流可以概括成五步:读入、清洗、计算、可视化、输出。
第一步读入,pandas直接读取Excel或CSV文件。第二步清洗,把缺考标记、空值、异常分数处理掉,因为真实世界的数据永远不会规规矩矩。第三步计算,按班级、课程、年级等维度做分组聚合,算出所有统计指标,同时跑一遍预警规则,找出需要关注的学生名单。第四步可视化,把统计结果绘制成柱状图、饼图、箱线图。第五步输出,把分析结果整理成Excel汇总表,图表保存成PNG文件,还能生成一份简单的文本报告。
我把这五步拆成了独立的Python函数,每个函数只做一件事。这样做的好处是后期维护特别方便,比如老师提了新的统计需求,只需要改analyze_data函数的聚合逻辑,其他部分完全不用动。整套系统没有一个类,完全是函数式风格,属于典型的“脚本工具型”项目,简单直接,对新手非常友好。
2. 核心功能模块:从数据导入到图表的完整链路
2.1 数据导入:兼容常见的成绩表格式
成绩表这种东西,每个老师交上来的格式都不一样,有的表头占一行,有的表头占两行,有的列名叫“分数”,有的叫“最终成绩”,还有的干脆叫“期末总评”。因此数据导入模块的兼容性直接决定这个系统好不好用。
我用的是pandas的read_excel函数,它能自动识别最常见的.xlsx格式。关键参数是sheet_name,默认读第一个工作表,如果有多个工作表还可以通过列表把全部数据合并进来。实际操作中经常遇到的情况是,一个老师把不同班次的成绩放在同一个Excel的不同sheet里,这时候用pd.concat把多个sheet拼接成一个大DataFrame就行。
还有一个细节值得注意:读入之后最好统一列名。我在read_data函数里会做一次列名映射,把“姓名”和“名字”统一改成“姓名”,把“分数”“成绩”“最终成绩”统一改成“成绩”。这个映射逻辑看似简单,但能省掉后面分析步骤里大量的if else判断。
2.2 数据清洗:把脏数据改造成可分析的数据
数据清洗是整个流程里最枯燥但最关键的环节,也是很多初学者最容易忽略的环节。我的清洗逻辑主要有四步。
第一步处理重复值,用drop_duplicates把重复记录去掉。这里提醒一下,保险的做法是基于学号加课程名加考试时间去重,而不是只看整行是否重复。
第二步处理非数值内容。成绩列里经常混入“缺考”“缓考”“/”“-”“None”这类字符串,直接用pd.to_numeric转成数值类型时会报错或者变成缺失值。我的做法是先用errors='coerce'把所有无法转成数字的内容统一变成NaN,再单独统计缺失值数量和占比,看情况决定是剔除还是保留。
第三步处理异常分数。正常成绩范围是0到100,如果出现负数或者超过100,大概率是录入错误,需要人工核对。我一般会把异常值标记出来,生成一个exception_list返回,而不是直接静默剔除。
第四步处理缺考标记。这里要特别小心:缺考和考了0分是两个完全不同的概念,不能混在一起处理。如果成绩表里明确写了“缺考”,我的策略是把该成绩置为NaN,但单独保存一个缺考名单,后面统计及格率时这部分不计入分母。
关于第二点我要强调一下编码问题的处理,有些CSV文件是GBK编码,直接用pandas读会乱码,read_csv函数加上encoding='gbk'参数就能解决,当然如果文件是UTF-8,就不要额外指定编码。
2.3 统计指标:平均分及格率是最基础的,标准差更关键
成绩分析最常用的指标是平均分、最高分、最低分、及格率、优秀率、标准差,这些指标通过pandas的groupby加agg函数可以一次性算完。
这里的核心技巧是分组聚合。以班级为单位做统计,groupby('班级')就能把同一个班级的所有学生聚到一起,然后通过agg传入多个聚合函数。注意及格率和优秀率不能直接用现成的mean、max,需要传入自定义lambda函数:及格率是成绩大于等于60的人数占比,优秀率是成绩大于等于90的人数占比。
标准差这个指标经常被忽略,但它比平均分更能说明问题。举个例子,A班和B班的平均分都是75分,但A班标准差是5,说明学生成绩比较集中;B班标准差是15,说明成绩离散程度高,有大量低分也有大量高分。如果只看平均分,两个班似乎教学效果一样,但标准差告诉你B班可能有两极分化的苗头。
综合排名这个功能也很实用。我通常会在统计完各班级指标后,再按平均分做一个排名,排出一个班级综合水平的高低顺序。需要注意的是,这个排名容易引起争议,所以我会在报告中加一句温馨提示,说明这只是基于平均分的粗略对比,不代表班级整体教学质量评价。
2.4 可视化:哪些图最适合展示成绩
真正用过matplotlib的人都知道,画图本身不复杂,难的是选对图表类型。我根据成绩分析的实际需求,总结了几种最常用的组合。
柱状图最适合展示各班级或各课程的平均分对比,一目了然。饼图适合展示及格和不及格的比例关系,能直观反映挂科面的大小。直方图适合展示单门课程的成绩分布,能看出分数段是不是正态分布,教学效果好不好。箱线图是最适合班级间对比的图,一张图能同时展示中位数、四分位数、异常值,比单纯柱状图信息量多很多。
还有一个容易被忽略的细节就是图表的中文字体设置。matplotlib默认字体不支持中文,直接绘图会显示方框乱码。需要在绘图前加上两行配置:
plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False这行代码的意思是设置全局字体为黑体,同时修复负号显示异常的问题。如果不加这两行,柱状图上的班级名称和标题都会乱码,这是我第一版脚本踩过最典型的坑。
2.5 成绩预警:真正能帮到学生工作的功能
预警模块是整系统里最能提升使用体验的功能,也是普通统计工具里不会有的模块。
预警规则我设置了三条。第一条是及格线预警,成绩低于60分直接进入预警名单。第二条是退步预警,比较同一位学生本次成绩和上次成绩,如果下降超过10分,即使没挂科也要重点关注,因为成绩断崖式下滑往往意味着生活状态出问题了。第三条是多次不及格预警,同一个学生如果多门课都不及格,需要辅导员重点关注学业风险。
退步预警这个逻辑在实现上有个技巧,需要先把数据按学号和考试时间排序,然后用groupby加shift方法把上一次成绩取到同一行,这样就能直接用当前成绩减上次成绩算出波动幅度。这个技术点也值得展开讲讲,shift是pandas里特别实用但不太起眼的函数,它可以取某列的上一个值或下一个值,用在成绩对比场景中非常合适。
3. 实操全流程:一整套可直接运行的代码
3.1 环境准备:先花一分钟装好依赖
开始写代码之前,先把环境准备好。我建议用Python 3.9以上的版本,然后用pip安装以下几个库:
pip install pandas matplotlib openpyxl如果你的电脑已经装了Anaconda,那pandas和matplotlib大概率已经有了,缺的只是openpyxl,单独补装一下就行。安装完成后,可以用以下命令验证环境是否正常。
import pandas as pd import matplotlib.pyplot as plt print(pd.__version__) print(plt.__version__)如果都能正常打印出版本号,说明环境已经就绪。另外建议在项目目录下建两个文件夹,一个叫data放原始成绩表,一个叫output放分析结果,这样文件管理比较清晰。
3.2 成绩表格式规范:建议先统一再导入
为了减少清洗模块的工作量,我自己会先做一次源数据规范。一套标准的成绩表应该包含以下几列:学号、姓名、班级、课程名称、考试时间、成绩、学分。
其中学号用于去重和学生画像跟踪,班级用于分组统计,课程名称用于多课程分析,考试时间用于退步预警。如果只有单次考试成绩,考试时间列可以省略,但推荐保留,因为下学期继续使用时能直接做跨学期对比。
成绩列只填写数字,缺考情况在备注列里写“缺考”两个字。这样设计的好处是清洗逻辑可以非常稳定:读取后先看备注列是否包含“缺考”,有就把成绩置为NaN,同时把该学生加入缺考名单,其他的交给正则表达式统一处理。
3.3 代码逐步实现:读取、清洗、分析、绘图、报告
下面给出完整可运行的代码,每一步都有详细注释说明。
首先是数据读取模块:
import pandas as pd import numpy as np import matplotlib.pyplot as plt import os def read_data(file_path): """读取成绩表Excel文件,返回DataFrame""" df = pd.read_excel(file_path, sheet_name=0, engine='openpyxl') # 统一列名:把常见变体映射到标准列名 column_map = { '学号': '学号', '学生编号': '学号', '姓名': '姓名', '名字': '姓名', '班级': '班级', '专业班级': '班级', '分数': '成绩', '成绩': '成绩', '最终成绩': '成绩', '总评': '成绩', '课程': '课程名称', '课程名': '课程名称', '考试时间': '考试时间', '年月': '考试时间', } df = df.rename(columns=column_map) # 只保留标准列,其他列自动忽略 required_cols = ['学号', '姓名', '班级', '成绩'] for col in required_cols: if col not in df.columns: raise ValueError(f"缺少必要列:{col}") print(f"成功读取 {len(df)} 条数据") return df然后是清洗模块:
def clean_data(df): """清洗成绩数据,处理缺失值、异常值、重复记录""" df = df.copy() # 1. 去除完全重复的行 df = df.drop_duplicates() # 2. 把成绩列转为数值类型,非数值内容变成NaN df['成绩'] = pd.to_numeric(df['成绩'], errors='coerce') # 3. 检查缺失值 missing_count = df['成绩'].isna().sum() print(f"发现 {missing_count} 条缺失或无效成绩") # 4. 检查异常值(大于100或小于0) abnormal = df[(df['成绩'] > 100) | (df['成绩'] < 0)] if len(abnormal) > 0: print(f"警告:发现 {len(abnormal)} 条异常成绩") print(abnormal[['学号', '姓名', '班级', '成绩']].head()) return df然后是统计分析模块,这里我加入了自定义的及格率与优秀率计算逻辑:
def analyze_data(df): """按班级进行分组统计,计算核心描述性指标""" result = df.groupby('班级')['成绩'].agg( 人数='count', 平均分='mean', 最高分='max', 最低分='min', 中位数='median', 标准差='std', 及格率=lambda x: round((x >= 60).mean() * 100, 2), 优秀率=lambda x: round((x >= 90).mean() * 100, 2) ).reset_index() # 按平均分降序排列 result = result.sort_values('平均分', ascending=False).reset_index(drop=True) # 生成综合排名 result.insert(0, '排名', range(1, len(result) + 1)) return result然后是成绩预警模块,退步预警的逻辑稍微复杂一些:
def warning_analysis(df, fail_line=60, drop_threshold=10): """成绩预警:不及格预警 + 退步预警""" # 不及格预警 fail = df[df['成绩'] < fail_line].copy() fail = fail.sort_values(['班级', '成绩']) # 退步预警:需要上一次成绩数据 df_sorted = df.sort_values(['学号', '考试时间']) df_sorted['上次成绩'] = df_sorted.groupby('学号')['成绩'].shift(1) drop = df_sorted.dropna(subset=['上次成绩']).copy() drop['退步幅度'] = drop['上次成绩'] - drop['成绩'] drop = drop[drop['退步幅度'] >= drop_threshold] drop = drop[['学号', '姓名', '班级', '课程名称', '上次成绩', '成绩', '退步幅度']] return fail, drop然后是可视化模块:
def plot_charts(result, save_dir='output'): """生成核心可视化图表并保存""" os.makedirs(save_dir, exist_ok=True) plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False # 1. 各班级平均分柱状图 fig, ax = plt.subplots(figsize=(12, 6)) bars = ax.bar(result['班级'], result['平均分'], color='steelblue', width=0.6) ax.set_title('各班级平均分对比', fontsize=14) ax.set_xlabel('班级') ax.set_ylabel('平均分') ax.set_ylim(0, 100) # 在柱子上标注具体数值 for bar in bars: height = bar.get_height() ax.text(bar.get_x() + bar.get_width()/2., height + 0.5, f'{height:.2f}', ha='center', va='bottom', fontsize=9) plt.tight_layout() plt.savefig(f'{save_dir}/class_avg_compare.png', dpi=150) plt.show() # 2. 各班级成绩分布箱线图 # 注意这里需要原始明细数据才能画箱线图,result只有聚合数据, # 真正的实现需要把明细数据传进来,这里给出思路 return上面这段代码里我留了一个说明,箱线图需要原始明细数据,函数签名需要带完整数据。我在实际项目中把plot_charts拆成了两个函数,一个draw_compare_bar接收聚合结果,一个draw_box_by_class接收原始明细。
然后是报告生成模块,把所有分析结果写入一个Excel文件:
def generate_report(result, fail, drop, save_dir='output'): """把分析结果写入Excel汇总文件""" file_path = f'{save_dir}/成绩分析报告.xlsx' with pd.ExcelWriter(file_path, engine='openpyxl') as writer: result.to_excel(writer, sheet_name='班级统计', index=False) fail.to_excel(writer, sheet_name='不及格名单', index=False) drop.to_excel(writer, sheet_name='退步预警', index=False) print(f"报告已保存到:{file_path}") return file_path最后是主流程函数,把所有模块串起来:
def main(file_path): df = read_data(file_path) df_clean = clean_data(df) result = analyze_data(df_clean) fail, drop = warning_analysis(df_clean) chart_dir = 'output' plot_charts(result, df_clean, save_dir=chart_dir) report_path = generate_report(result, fail, drop, save_dir=chart_dir) print("分析完成,请查看output目录下的报告和图表。") return result, fail, drop3.4 运行效果与输出结果
我用一份模拟数据测试过这个系统。假设有一张成绩表包含3个班共90条记录,每条记录有学号、姓名、班级、课程名称、成绩、考试时间六列。
运行main函数后,控制台会打印出读取记录数、缺失值数量、异常值警告,然后在output目录下生成一个Excel报告和两张图表。Excel报告里能看到三个班的排名、平均分从高到低排列,并附带及格率、优秀率、标准差等指标,同时还有单独的sheet列出不及格学生和退步预警学生名单。
把图打开之后,柱状图能直观看出某个班的平均分明显领先或者落后,箱线图能看出某班成绩分布的离散程度。这个过程比人工翻Excel快太多了,原来要花一整个下午的工作,现在跑一次脚本只需要几秒钟。
4. 开发踩坑实录:环境、数据、分组计算的疑难杂症
4.1 依赖安装与文件读取报错
很多刚接触pandas读Excel的同学都会在第一步卡住:明明安装了pandas,read_excel依然报错。
ModuleNotFoundError: No module named 'openpyxl'这个错误非常典型。pandas读取Excel文件需要一个底层引擎,读.xls旧格式需要xlrd,读.xlsx新格式需要openpyxl。pandas本身不打包这些依赖,所以必须单独安装。解决办法很简单:
pip install openpyxl还有一个容易踩的坑是,pandas的read_excel函数默认引擎会根据文件扩展名自动选择,如果你的环境中xlrd和openpyxl都装了,但文件是.xlsx,pandas会优先使用openpyxl;如果是.xls文件,则会用xlrd。有些老版本xlrd只支持.xls,不支持.xlsx,会报“Excel xlsx file; not supported”的错误。遇到这种情况,升级xlrd到2.0以上版本,或者干脆统一把所有文件都转成.xlsx再处理。
4.2 中文显示与编码问题
matplotlib中文乱码问题我在前面提过,这里展开讲一下彻底解决方案。SimHei字体在Windows系统下基本都有,但在Linux服务器上不一定存在。如果你部署在Linux环境,需要先确认系统里有没有中文字体,可以通过以下代码查看当前matplotlib能识别的所有字体:
from matplotlib import font_manager fonts = [f.name for f in font_manager.fontManager.ttflist if 'Hei' in f.name or 'Song' in f.name] print(fonts)如果没有中文字体,一个简单的办法是下载一个开源中文字体文件放到代码目录里,然后动态注册到matplotlib:
import matplotlib matplotlib.font_manager.fontManager.addfont('fonts/NotoSansSC-Regular.ttf') plt.rcParams['font.sans-serif'] = ['Noto Sans SC']编码问题不只在绘图时出现。CSV文件的读取也经常遇到乱码,尤其是从国内一些教务系统导出的CSV,默认编码可能是GBK或者GB2312。pandas读取时用encoding='gbk'就能解决。如果文件编码不确定,可以用一个非常小的探测函数先猜编码,不过我试下来比较费劲,所以还是建议都转成UTF-8格式的文件统一处理。
4.3 groupby的索引与列名陷阱
pandas的groupby用起来方便,隐藏的坑也不少。
第一个坑是groupby之后的结果是一个GroupBy对象,不是DataFrame,必须配合agg、apply、transform方法使用。新手经常直接打印groupby对象,然后发现只有内存地址,误以为代码坏了。
第二个坑是groupby加agg之后,结果的索引是分组依据的列。比如按班级分组后,索引是班级名,如果后面要和其他DataFrame做merge操作,必须先用reset_index把班级变成普通列,否则合并时会因为索引不匹配报错。
第三个坑是agg时如果传入多个聚合函数,列名会变成元组,比如('成绩', 'mean')这样。这种列名在后续代码里直接访问会有问题,我通常会在agg之后用rename把列名改回去,或者直接用字典传参的方式指定输出列名。上面代码里用了agg(人数='count', 平均分='mean')这种写法,输出列名就是“人数”“平均分”,不会出现元组,推荐大家用这种方式。
4.4 数据量大时的性能表现
高校单次考试的成绩数据一般不会太大,一个年级几百到几千条记录,pandas处理起来毫无压力。但如果把几年的历史数据全部导入,数据量达到几万甚至几十万条,就要注意两个问题了。
第一个问题是重复绘图导致的内存占用。在循环里绘制大量图表时,如果每张图都plt.show()但不用plt.close(),matplotlib会把所有Figure对象保存在内存中,程序跑半个小时内存可能就爆了。我习惯用plt.savefig保存后立刻plt.close()释放内存。
第二个问题是groupby在大量数据下依然很快,但lambda函数会拖慢速度。尤其是及格率这种需要逐行判断的聚合逻辑,数据量大了以后效率会下降。优化办法是提前生成一个布尔列,比如df['是否及格'] = df['成绩'] >= 60,然后agg时直接对该列求平均,就不需要跑lambda了。这个优化在几十万条数据上效果立竿见影。
5. 后续还能怎么玩:扩展方向与升级路径
5.1 从命令行脚本升级为Web服务
这个系统目前是命令行脚本形式,已经能满足日常分析需求。但如果想给更多同事用,做成Web应用就是必经之路。我建议用Flask或FastAPI包装一层HTTP接口,前端用最简单的HTML表单实现文件上传,后端接收文件后调用已有的分析函数,把生成的图表和Excel报告返回给浏览器下载。
FastAPI在这几年非常流行,它天然支持文件上传下载、异步处理,而且自动生成接口文档,开发效率很高。如果想做更复杂的交互,比如选班级、选课程、动态切换统计维度,可以引入一个简单的表格组件或者图表库,前端和后端之间通过JSON传递数据。
做成Web服务的好处是,非技术用户不需要安装Python环境,直接用浏览器就能上传成绩表、查看结果。考虑给系里的老师试用的话,这是最理想的形态。
5.2 对接数据库与历史数据积累
第二个值得扩展的方向是数据库。当前系统每个学期的成绩表都是独立的Excel文件,跨学期对比需要手动把数据合并,效率不高。如果引入SQLite或者MySQL,把成绩明细存入数据库,再按学期、课程、班级做查询分析,系统的能力上限会大很多。
SQLite是最轻量级的选择,整个数据库就是一个单一文件,不需要额外部署数据库服务,非常适合单机个人工具。想升级的话,代码改动也不大,pandas的read_sql_query函数可以直接读取SQL查询结果为DataFrame,后面分析模块完全不用改。
有了数据库之后,就能做更有价值的分析了。比如跟踪同一个班级在多个学期的成绩走势,判断整体是在进步还是退步;再比如分析同一门课程在不同教师授课下的成绩差异,做教学参考。
5.3 更多维度的教学分析场景
除了基础的成绩统计,还有几个教学分析场景可以考虑。
第一个是学分绩点分析。如果成绩表里有学分列,可以按学分加权计算绩点,这个指标比平均分更能反映学生的学习质量。高学分课程的成绩差,对总绩点的影响远大于低学分课程,所以要按学分加权处理。
第二个是难度分析。通过比较同一门课程不同年份的成绩分布,可以评估试卷难度是否稳定。如果今年的成绩分布比去年左偏,平均分明显下降,可能是试卷偏难,也可能是生源质量变化,这些都能从数据上看出线索。
第三个是关联分析。如果有多门课程的成绩数据,可以计算课程之间的相关性,看看哪些课程学得好的学生,在其他课程上也普遍学得好。这种分析对教学安排的优化有参考价值。
我个人在实际开发中最大的体会是,这个系统能真正解决效率问题。以前我帮系里做期末成绩分析,光是把不同格式的成绩表整理成统一模板、人工核对数据、用Excel透视表做统计,前后要折腾好几天。现在从拿到成绩表到输出完整分析报告,整个过程不到十分钟,而且不会再出现复制粘贴公式时不小心选错区域的那种低级错误。如果未来能把Web界面和数据库补齐,这个工具就能在更大范围内帮助更多老师。