如果你就是那个每月月底对着签到表挨个登记培训时长的人,下面这个场景你一定不陌生:几十个人、十几场培训,得把每个人的参训日期、培训主题、签到签退时间一条条从纸质签到单或者Excel截图里录进汇总表,录到一半还有人跑来问“我的时长怎么少了一小时”。我上个月就是用Python写了个自动统计的小脚本,把整套手工流程换成了三步:导入签到表、跑一遍代码、导出汇总表。这篇文章完整记录我是怎么拆解需求、整理数据、写代码,以及落地时踩过的几个坑,适合所有想把自己从重复登记里解放出来的HR、培训专员和兼职管培训的同学。
1. 培训时长统计为什么会让HR头大
1.1 手动登记的三大痛点
先算一笔账。假设公司一个月有15场培训,每场30人参加,一个人登记的耗时大约10秒,不算后期核对,光录入就是4500秒,差不多75分钟。如果用的是纸质签到表,还得先把纸上的名字输进电脑,这个时间直接翻倍。更要命的是,纯手工录入的眼睛疲劳问题——把13:30看成14:30、把张三签到时间填到李四那一行,这类错误几乎每个月都会发生。被发现的时候,你已经在错误的汇总表上又加了好几行数据,返工成本非常高。
第二个痛点是核对困难。老板问“这个季度研发部人均培训时长多少”,如果你只有一张手工登记的总表,最快的方式是先找部门名单,再逐个匹配,最后打开计算器慢慢加。这已经不是在“统计”,而是在“考古”。
第三个痛点是数据没法二次利用。手工汇总完的表格,基本就为了填一个总数字。哪类培训参与率最高、哪个部门平均时长偏低、哪位讲师的课程被频繁跳过,这些信息全部淹没了。而Python做自动化统计,本质上不是让你“不用打字”,而是把签到表变成一份随时可查、可透视、可分析的数据资产。
1.2 从“登记表”到“数据表”的思维转换
我接手培训统计的时候,第一反应也是想着怎么写代码替换人工,但真正卡住我的不是代码,而是数据本身乱七八糟。签到表里既有纸质扫描件,也有线上会议导出的Excel,还有培训助理发来的Word名单。后来我把核心精力放在一件事上:把所有来源的签到记录,统一整理成一张标准结构的数据表。
这张标准表的每行是一次“某人参加了某场培训”的事实,每列是描述这次事实的字段,比如姓名、部门、培训日期、签到时间、签退时间。人脑统计时是“看到名字,找到对应行,手工累加”;而Python的做法是“按姓名分组,对时长列求和”。前者是逐条对账,后者是批量计算,效率差异完全不在一个量级。
所以,在写任何代码之前,先把“这张表长什么样”想清楚,比急着装环境、调库更重要。
2. 先把手头的签到表整理成机器能读的样子
2.1 推荐的表头结构与字段规范
我最终用的是下面这个结构,你可以直接照搬:
| 字段 | 示例 | 说明 |
|---|---|---|
| 姓名 | 张三 | 必须与花名册一致,不能有空格和别名 |
| 部门 | 研发部 | 统一部门名称,别一会儿“研发”一会儿“研发部” |
| 培训主题 | 新员工入职安全培训 | 同一个主题尽量用同一个名称 |
| 培训日期 | 2024-11-08 | 统一成YYYY-MM-DD格式 |
| 签到时间 | 2024-11-08 09:12:30 | 精确到秒 |
| 签退时间 | 2024-11-08 11:30:00 | 精确到秒 |
| 备注 | 下午场 | 可留空,用于标识上午场/下午场、线上/线下等 |
这个结构的核心原则是“一行一个事实”。千万别把一个人的多场培训压缩进一行单元格里,否则后面任何统计都很难做。我在第一次整理数据时,发现有的同事把“参加过的三场培训”用顿号并列写在一个格子里,这种表Python读进来以后根本没法直接分组,最后还得回去拆行重录。
2.2 常见脏数据有哪些
Excel里的签到表,脏数据的重灾区基本集中在五类:
- 时间格式混乱:同一列里,有人填“9:12”,有人填“09:12:30”,还有人填“上午9点12分”。Python处理这类文本虽然能解析,但解析失败时整列会变成空值,必须提前统一。
- 合并单元格:很多人习惯把同一个人的多场培训合并成一个单元格,或者把日期列合并,看起来整洁,但读进pandas后只有左上角第一个有值,其余全是NaN。
- 姓名前后的空格:Excel里看着是“张三”,实际可能是“ 张三 ”或“张三 ”,如果不清理,groupby会把同一个人拆成三个组。
- 全角与半角混用:比如“(下午)”和“(下午)”,中文输入法下很容易混入全角括号,这在文本匹配时会变成完全不同的内容。
- 列名不稳定:这个月叫“签到时间”,下个月叫“入场时间”,Python脚本是按列名取数的,列名一变就得改代码。
2.3 快速用Excel预处理一遍数据
即使你打算用Python自动化,前期的数据清洗也建议先人工快速过一遍,效率反而更高。我的做法是先把所有签到单录进一张Excel模板表,然后做三件事:
- 选中“签到时间”和“签退时间”列,在单元格格式里统一设置为“YYYY-MM-DD HH:MM:SS”,避免显示异常。
- 用Excel的查找替换功能,把姓名列里肉眼可见的多余空格和全角符号清掉。
- 取消所有合并单元格,填充空值——同一个人同一场培训的信息,就把上方的姓名、部门下拉填充下来。
这个过程看起来朴素,但它决定了后续代码的成败。我见过太多人一上来就写复杂脚本,结果数据乱成一锅粥,最后反而怀疑Python不好用。其实Python处理规范化表格的能力非常强,前提是你喂给它的表格足够规范。
提示:不要把“数据清洗”理解为特别高深的事。在这里,它就是“保证同一列格式一致、同一人名称一致、同一行信息完整”这三件事。
3. 环境准备:装Python和两个库,全程十分钟
3.1 Python安装时的两个关键勾选
很多HR同事听到“装Python”就觉得头疼,但实际操作比装微信复杂不了多少。去python.org下载最新稳定版,安装时务必勾选“Add Python to PATH”这一项,它的作用是让电脑上的命令行能直接识别python命令。如果不勾选,你后面在终端里输入python会提示“不是内部或外部命令”,那时候再回去补环境变量会绕不少弯路。
第二个建议是“Install launcher for all users”也勾上,这样以后多用户使用电脑时不会出现权限问题。安装完之后,Win+R打开运行窗口,输入cmd回车,进入黑底命令行界面,输入:
python --version如果输出了类似Python 3.12.x的版本号,说明安装成功。这个黑窗口就是你以后运行脚本的地方,不用怕,它其实只是一个对话界面。
3.2 用pip安装pandas和openpyxl
Python本身自带了一些基础能力,但要处理Excel还得装第三方库。我在这个场景里只用两个库:
pandas:负责数据读取、清洗、分组计算,是自动化统计的核心引擎。openpyxl:负责Excel文件的读写,尤其是给汇总表调样式、改列宽时很顺手。
在命令行里执行:
pip install pandas openpyxl如果下载速度慢,可以加一个国内镜像源:
pip install pandas openpyxl -i https://pypi.tuna.tsinghua.edu.cn/simple等待进度条跑完,环境就准备好了。这里多提一句,有些教程会让你先建虚拟环境再用pip,但对非专业开发者来说完全没必要,全局装这两个库足够日常办公使用。
3.3 验证环境是否就绪
装完以后,在命令行里输入:
python -c "import pandas; print(pandas.__version__)"如果能打印出版本号,比如“2.2.1”,说明环境一切正常。这个验证步骤虽然简单,但能帮你把“代码写完了才发现库没装”这种尴尬彻底消灭在开始之前。我第一次写脚本时跳过了验证,结果写了半天代码才发现pandas没装上去,浪费了将近二十分钟。
4. 自动统计培训时长的核心代码
4.1 读取Excel:pandas怎么把登记表读进来
环境就绪后,先把代码文件和数据文件放在同一个文件夹里。假设签到表文件名是“培训签到记录.xlsx”,工作表名为“签到记录”,用三行代码就能读进Python:
import pandas as pd df = pd.read_excel("培训签到记录.xlsx", sheet_name="签到记录") print(df.head())print(df.head())打印前几行,是为了确认数据读进来了、列名是否跟预期一致。这里我见过最典型的报错是“Excel file format cannot be determined”,大概率是文件其实是CSV文件但后缀改成了xlsx,或者文件被其他程序占用。解决方案是另存为真正的“Excel工作簿”格式再读。
4.2 解析时间字段:文本变计算单位的两个坑
原始签到表里的时间是文本,Python无法直接做减法。关键一步是用pd.to_datetime把文本转成标准时间对象:
df["签到时间"] = pd.to_datetime(df["签到时间"]) df["签退时间"] = pd.to_datetime(df["签退时间"])这一步看起来简单,但有两个坑必须处理。第一,如果某一列里混入了无法解析的文本(比如有人填了“9点半”),pd.to_datetime会直接报错。稳妥的做法是加参数errors="coerce",这样解析失败的会变成空值而不是中断程序:
df["签到时间"] = pd.to_datetime(df["签到时间"], errors="coerce") df["签退时间"] = pd.to_datetime(df["签退时间"], errors="coerce")第二,Excel里有些“看起来是时间”的单元格,实际上存储的是浮点数序列,比如45238.38这种。这种情况下pd.to_datetime有时能自动识别,有时不能。如果遇到解析异常,可以在读取时先把该列指定为字符串,然后再手动转换,这里不展开,后面第六章会详细讲。
4.3 计算每个人的累计时长:groupby聚合
时间解析成功后,计算时长就很简单:
df["时长_分钟"] = (df["签退时间"] - df["签到时间"]).dt.total_seconds() / 60两个时间相减会得到一个“时间差”对象,.dt.total_seconds()换算成秒,再除以60就得到分钟数。这里不用小时是因为分钟精度更高,汇总后再转小时更灵活。
按人汇总的代码是:
summary = df.groupby("姓名", as_index=False)["时长_分钟"].sum() summary["累计小时"] = (summary["时长_分钟"] / 60).round(2)groupby可以理解成Excel里的“分类汇总”:按“姓名”分组,对“时长_分钟”列求和。as_index=False是为了让姓名变成普通列而不是索引,后面导出时表头更干净。round(2)保留两位小数,避免出现“47.1666666667小时”这种难看的结果。
如果你还想要部门维度,可以在groupby里加列名:
summary_by_dept = df.groupby(["部门"], as_index=False)["时长_分钟"].sum() summary_by_dept["累计小时"] = (summary_by_dept["时长_分钟"] / 60).round(2)4.4 导出汇总表
计算完成后,用一行代码导出Excel:
summary.sort_values("累计小时", ascending=False).to_excel("培训时长汇总表.xlsx", index=False)index=False表示不把行索引写进Excel,否则文件第一列会出现一堆无意义的数字。sort_values则让时长最长的人排在最前面,方便领导一眼看到排名。
完整跑通的脚本只有十几行,效果却非常直接:原来一小时手工登记的工作,现在从读入到导出Excel,全程不超过三秒。第一次跑通的时候我自己都有点不真实感,特意对着Excel里的数字手工验算了三个人,确认分毫不差后,才敢正式发给业务部门。
5. 围绕真实场景的进阶处理
5.1 培训有休息时间怎么扣减
很多培训不是连续进行的,比如上午9点到12点,午休一小时,下午14点到17点。如果签到表里只记录了一个总的签到时间和一个总的签退时间,直接用减法会把午餐时间也算进培训时长,数据虚高。我处理这种情况有两种思路,取决于原始数据长什么样。
思路一:干脆拆行。上午一场记录成“2024-11-08 09:00:00”到“2024-11-08 12:00:00”,下午一场记录成“2024-11-08 14:00:00”到“2024-11-08 17:00:00”。这样根本不用做任何扣减逻辑,两条记录各自算时长后汇总即可。这也是我推荐的方式,因为它保留了“场次”粒度,后续想看每个时间段谁参加了,随时能拆出来。
思路二:不拆行,用固定时长扣减。如果签到表里本来就是一条大记录,那只能按规则扣。比如规定中午休息固定1小时,可以在算完总时长后统一减去60分钟。写成代码就是:
df["应扣减分钟"] = df["培训主题"].apply(lambda x: 60 if "全天" in str(x) else 0) df["实际时_分钟"] = df["时长_分钟"] - df["应扣减分钟"]但“固定扣减”的前提是每个人进出午休的时间都一致,实际情况往往对不上,所以能用思路一就尽量用思路一。
5.2 有人漏签退,怎么标记而不是乱算
现实里总有同事提前走或者忘了签退,签退时间那一列就会出现空值。如果不处理,相减后得到的是空值,汇总时这个人会被直接跳过,累计时长少一大截。我的处理方式是:保留数据,但单独标记出来,让人工确认。
df.loc[df["签退时间"].isna(), "异常标记"] = "缺少签退时间,需人工确认"这样导出的明细表里,凡是异常的人都会有一个显眼的标记。你拿着这张表去问本人“你这场培训几点走的”,补上后再重新跑一遍脚本,就比手工去翻签到单快得多。
5.3 多场次多月份的合并统计
如果公司每个月都有十几场培训,你手里会有12张Excel表。千万别把每张表单独跑一遍再把结果抄进总表,正确做法是循环读取、合并计算。用glob模块可以一行找出所有符合条件的文件:
import glob files = glob.glob("培训签到_*.xlsx") df_all = pd.concat([pd.read_excel(f) for f in files], ignore_index=True)只要你的文件名有规律,比如“培训签到_2024_01.xlsx”,这段代码就能把所有文件一次性合并成一个大表,然后直接走groupby汇总。第一次跑通批量合并的时候,我发现有些同事在不同月份里的部门名称又变了,所以建议在正式汇总前先看一眼df_all["部门"].unique(),把模糊的地方提前统一掉。
5.4 按部门透视,给领导一张漂亮报表
领导想看的不只是“谁最长”,更常见的需求是“哪个部门整体参与度高”。这时候用透视函数pivot_table最方便:
pivot = pd.pivot_table( df_all, values="时长_分钟", index="部门", aggfunc={"时长_分钟": ["sum", "mean", "count"]} ) pivot.columns = ["总时长_分钟", "人均时长_分钟", "参与人次"] pivot["总时长_小时"] = (pivot["总时长_分钟"] / 60).round(2) pivot["人均时长_小时"] = (pivot["人均时长_分钟"] / 60).round(2) pivot = pivot.sort_values("总时长_分钟", ascending=False) pivot.to_excel("部门培训时长透视.xlsx")sum给出部门总时长,mean给出人均时长,count给出参与人次。一张透视表,基本覆盖了领导问得最多的三个问题:哪个部门学得多、平均每个人学了多少、哪个部门参与率最低。
6. 落地时最容易踩的坑,我替你踩过了
6.1 时间格式被Excel偷偷改掉
这是我在真实数据里遇到的最隐蔽的坑。Excel表格里看起来清清楚楚的“2024/11/8 9:12”,实际存储的可能是浮点数,比如45334.383333。这种单元格读进pandas后,pd.to_datetime可能解析出完全错误的时间,甚至直接报错。
一个稳妥的解决方法是读取时把时间列先按字符串读进来,然后再手动指定格式转换:
df = pd.read_excel( "培训签到记录.xlsx", dtype={"签到时间": str, "签退时间": str} ) df["签到时间"] = pd.to_datetime(df["签到时间"], format="%Y-%m-%d %H:%M:%S", errors="coerce")但format参数必须跟你的实际格式严格匹配,否则会全部转成空值。如果你不确定格式,最保险的办法是先在Excel里把该列统一设置成“文本”格式,再手动输一遍,或者在Excel里用“分列”功能把格式彻底固化。
注意:运行代码前备份原始签到表。数据清洗出错时,至少还有一份原始文件可以回退,不用从头开始录。
6.2 文件名、路径、编码问题
Windows系统里跑Python,中文文件名和中文路径是最容易出问题的环节。比如“培训签到记录.xlsx”在导入时,老版本pandas可能会有编码识别失败的情况,但新版基本已经解决。如果你遇到UnicodeDecodeError,大概率不是Excel读取问题,而是打开或保存CSV文件时编码没选对。
我的建议是:把脚本、Excel文件都放在同一个英文路径的文件夹里,比如D:\hr_training_stat,文件名尽量用英文或拼音,如training_records.xlsx。虽然这不是必须的,但能少掉一大批让人抓狂的编码问题。代码文件本身,用VS Code或者记事本保存时,选择UTF-8编码即可。
6.3 合并单元格读取后的空值
原始表里最常见的“美观陷阱”就是合并单元格。比如一个同事参加了两场培训,Excel里把他的姓名合并成一个格子,看起来很整洁。但pandas读进来以后,第二行“姓名”列是空值NaN,直接groupby的话这个人会被单独拆成一个“无名氏”组,时长全算到NaN头上,导致最终汇总表里多出一行空行。
识别方式很简单,读完后执行:
print(df["姓名"].isna().sum())如果数量大于0,说明存在合并单元格或漏填。处理方法是前向填充——把上方同一个人的值填充到空白行:
df["姓名"] = df["姓名"].ffill() df["部门"] = df["部门"].ffill()这种“前向填充”逻辑说起来有点学术,但你可以理解为:每一行如果姓名和部门是空的,就自动抄上面的一行。它完美适配了合并单元格被拆开后的场景。
6.4 别盲目相信汇总数据,留好核对样本
自动化脚本不是“跑完就完事”的魔法。我第一次跑通完整流程后,做了一件事:从结果里随机抽了5个人,手工去原签到表里数这5个人这个月到底参加了几场、每场多久,加总后跟脚本结果对比。确认完全一致后,才正式投入使用。
从那以后,每次调整脚本逻辑,我都会保留一份“黄金样本”做回归验证。所谓的黄金样本,就是某个月三到五个人的人工核对结果。脚本只要改了数据清洗规则或时间解析逻辑,就用这份样本再测一遍,确保新逻辑没把原来跑对的数据搞坏。这个习惯,后来帮我避免了好几次因为调整“休息时长扣减”规则导致的整体数据偏差。
另外,所有生成的汇总Excel,我都会复制一份原签到表备份文件按月份归档,文件名叫“原始数据_2024年11月_备份.xlsx”。一旦有人质疑数据,随时可以从原始数据重新生成一遍,而不是只能对着结果表解释“这数怎么来的”。这一点在跟业务部门和财务对账时,特别有底气。
最后再说说这套流程的边界
如果你所在的公司已经上线了企业培训系统,系统本身就能导出每个人的学时明细,那你可能不需要从零造轮子,直接在系统里做导出和分析就行。但如果你和我当初一样,手里只有一堆格式各异的Excel签到表,那么这个“Excel数据规范 + pandas自动聚合”的思路,绝对值得动手试一次。
代码就摆在上面的章节里,复制下来,把自己公司的表头改一改名,基本就能用。第一次跑通之后,你可能会发现,培训时长统计这件事终于不是“月末恐怖的代名词”了,甚至还有余力多做一张“各部门培训投入对比图”,那画面比在表格里一个个数名字舒服多了。