这是学习日记的第六十七天。前面六十多天,我把Python基础语法、函数、文件操作、列表推导式这些东西过了一遍,也写了不少练习题,但总有一种"学了但没真用"的飘忽感。今天终于下决心干一件实在的事:把一份乱糟糟的Excel表格清洗成能直接做分析的数据表。这份表来自我手头一个模拟的订单数据项目,里面什么奇怪的东西都有——重复行、日期格式三四种写法、金额列里混着千分位逗号和"未知"两个字。整个过程从上午折腾到傍晚,最大的收获不是记住了几个函数,而是明白了"数据清洗"四个字到底有多重要。这篇文章就当今天的复盘笔记,把每一步做了什么、为什么这么做、踩了什么坑,完整记录下来。如果你也在自学Python,正处于"基础都会但不知道做什么"的阶段,今天的经历应该能给你一个不错的参考方向。
1. 第67天,我决定先解决"数据太脏"这个现实问题
1.1 一小时的Excel手操彻底劝退了我
事情的起因很简单:模拟项目X的接口导出了一份订单明细,一共三千多行,我原本想用Excel透视表做个销售汇总。结果打开文件第一眼就觉得不对劲——订单编号这一列有那么几行没有表头,往下翻又看到同一个订单号出现了三次,备注栏还不一样;成交金额里既有"1,200"这种带逗号的,又有"- 50"这种带负号的,甚至还有两格直接写着"未知"。日期就更别提了,2024/1/5、2024-01-05、2024.01.05三种格式混在一起,Excel的筛选功能面对这种数据完全失灵。
我试着在Excel里手动修,先把重复行高亮出来,再一行一行改日期格式。弄了一个小时,眼睛都快花了,才处理了两百行。那一刻我突然意识到,如果将来面对的是几万行数据,手工清洗这条路根本走不通。于是我把Excel关掉,打开编辑器,决定用Python把整个清洗过程写成脚本。
1.2 今天的目标范围和"不做清单"
为了防止自己陷入"什么都想学、什么都没做完"的怪圈,我给自己定了三条清晰的目标:第一,把原始数据读进pandas,保证中文不乱码;第二,清洗后生成一份新表,行数准确、字段类型正确、日期统一、金额可以参与聚合计算;第三,把清洗逻辑写成函数,方便下次换一个文件直接复用。
同时我也列了一份"今天不做"清单:不搞可视化、不做数据分析报告、不优化性能。理由很简单——先把基础链路打通,其他都是后续的事。事实证明这个边界很管用,因为数据清洗本身已经够折腾了,如果再掺入画图之类的事,今天肯定写不完。
2. 工具选型:为什么是pandas而不是openpyxl或手工在Excel里改
2.1 三个候选工具的对比
在动手之前,我特意把pandas、openpyxl和Python内置的csv模块放在一起比了比。我之前学过openpyxl,它擅长处理Excel的单元格样式、合并单元格、写公式,但在"按列批量转换类型、过滤重复行、填充缺失值"这些分析场景里,操作起来非常别扭——你得一个个单元格读,再自己写循环判断。内置csv模块更基础,既能读也能写,但同样没有现成的去重、类型转换方法。
pandas则完全是另一个思路:它把整张表当成一个DataFrame对象,对列做操作时一次作用于整列,根本不需要显式写循环。比如去除重复行就是一行动手,统一日期格式也是一行调用。我把三者简单整理成了对比表,方便自己决策。
| 能力维度 | pandas | openpyxl | csv 模块 |
|---|---|---|---|
| 批量类型转换 | 强,支持整列操作 | 弱,需逐单元格处理 | 无 |
| 重复行检测与删除 | 一行搞定,支持按子集判断 | 需手动遍历 | 需手动遍历 |
| 缺失值处理 | 内置fillna / dropna | 需自定义逻辑 | 需自定义逻辑 |
| 日期解析 | 能自动解析多种格式 | 需自己逐格解析 | 无 |
| 写入Excel样式 | 一般,能写表头、列宽有限 | 非常强 | 不支持Excel |
| 学习曲线 | 中等偏陡 | 平缓 | 平缓 |
我看完这张表之后,思路基本清楚了:如果任务是"给表格做体检、做整形",pandas是效率最高的那个。如果任务是"生成一份版式精美、带公式和样式的报表",openpyxl更合适。今天的场景明显属于前者,所以主工具就直接定为pandas。
2.2 环境准备:装库和确认版本
我当前的Python版本是3.11,pandas已经是2.x版本了。如果你还没有装pandas,直接执行安装命令就行:
pip install pandas openpyxl这里有一点需要特别说明:pandas读取xlsx文件时需要依赖openpyxl引擎,所以即使你只需要读取Excel,也建议把openpyxl一起装上。另外,如果之前装过旧版本,最好顺手升级一下,因为pandas 1.x和2.x在部分方法的行为上有细微差异,网上不少教程是旧版本的写法,实际运行时可能会弹警告。
python -c "import pandas as pd; print(pd.__version__)"我在装完库之后先输出版本号确认环境正常,这一步虽然琐碎,但能避免后面排查问题时搞不清楚是代码问题还是版本问题。
3. 清洗过程拆解:从乱码到干净数据的五个关键步骤
3.1 第一步:用正确的编码把CSV读进来
我手头这个模拟项目的导出文件其实是CSV格式,但它是用Excel"另存为CSV"生成的文件。这里有一个隐藏很深的坑:Excel在Windows上另存的CSV,默认使用ANSI编码,也叫GBK;而pandas默认用UTF-8读取。如果我不做任何处理,直接执行pd.read_csv('订单明细.csv'),读出来的中文大概率是乱码。
我第一次踩这个坑的时候,第一反应是"pandas出bug了",后来才明白是编码问题。解决的写法是显式指定编码:
import pandas as pd df = pd.read_csv('订单明细.csv', encoding='gbk')如果你不确定文件的编码,可以用一种实用的探测方法:用二进制模式先读一小段,然后依次尝试utf-8、gbk、utf-8-sig三种编码解码,哪种不报错就采用哪种。具体代码我放在后面"踩坑实录"小节里。这里只给结论:今天这个文件用gbk读取是对的,读进来后中文列名、中文备注都正常显示。
3.2 第二步:统一列名和数据类型
读进来之后,我先用df.head()和df.info()两张经典命令看一下结构。df.head()能看到前五行数据,df.info()能显示每一列的非空数量、数据类型等信息。这一步很有必要,因为原始文件里首列没有表头,pandas会自动把第一列命名为"Unnamed: 0",不处理的话后面筛选会非常困惑。
我决定给列做一次统一重命名,顺手把列名改成年初中文的清晰版本:
df = df.rename(columns={'Unnamed: 0': '行号', '订单号': '订单编号', '订单编号': '订单编号_旧'})等等,这么做其实不太对——我仔细看了原始文件的表头,发现"订单号"和"订单编号"两列都存在,而且内容有交叉重复。这种情况下,只改列名是不够的,还要想清楚到底以哪一列为主。最终我选择以"订单编号"为唯一订单标识,把"订单号"这一列直接保留并重命名为"订单号_冗余",后续做校验用,不参与主键判断。
关于数据类型,df.info()显示成交金额这一列是object类型,而不是float,原因就是里面有逗号和"未知"字样。日期列同样是object。这两列都需要在后续步骤里做特殊处理,我先标记出来,等到第3.5步统一解决。
3.3 第三步:按订单维度去重
去重是这次清洗里第一个需要业务判断的地方。如果简单地用df.drop_duplicates(),pandas会把所有列都相同的行视为重复,但今天这份数据里,同一个订单编号出现了多次,备注内容不同,说明它在业务上可能被多次修改过。我真正需要的"重复"定义是:同一个订单编号 + 同一个商品行号,如果这两项都相同,才算真正重复。
实现方式是用subset参数指定参与判断的列,keep参数决定保留哪一行。我选择保留最后一条记录,因为模拟项目的导出逻辑里,越靠后的记录越接近最终状态:
df_clean = df.drop_duplicates(subset=['订单编号', '商品行号'], keep='last')去掉重复之前的行数是3271,去重之后变成3002。这个数字对得上我的预期,说明原始数据里确实有269行冗余。去重后我把旧索引重置了一下,方便后面操作。
df_clean = df_clean.reset_index(drop=True)这一步看似不起眼,但没有reset_index的话,经过筛选后的DataFrame索引会保留原来的杂乱数字,后续用iloc或者按索引取数时很容易出错。
3.4 第四步:处理缺失值和无效数据
接着看缺失值。df.isnull().sum()按列统计了缺失数量,问题最大的是"收货城市",缺了102行;"支付方式"缺了31行;"成交金额"里有一行是"未知"。
缺失值怎么处理,取决于业务含义。pay方式缺失,我的选择是填一个"未知"字符串,因为即使支付方式不明确,这条订单记录本身还是有效的,不能删。收货城市缺失的情况比较复杂,我去掉了这些行。原因是我后续可能会做区域维度的分析,城市为空会导致整条记录没法参与区域统计,而且102行相对于3002行来说是3.4%的比例,删除后不影响整体分析。
这里我提醒自己不要养成就地改数据的坏毛病——原始文件始终备份着,清洗后生成的是新文件,万一判断错误还能重新清洗。代码是:
df_clean = df_clean.dropna(subset=['收货城市']) df_clean['支付方式'] = df_clean['支付方式'].fillna('未知')清洗到这里,"未知"金额那一行还没有处理。我把它放到金额统一处理的环节一起解决,因为"未知"本质是类型转换问题,单独填充没有意义。
3.5 第五步:日期、金额的格式统一与业务标记
原始数据里的日期有三种格式,而且都是字符串。pandas的to_datetime函数能自动解析大部分常见写法,但为了让解析更稳定、速度更快,我建议显式传入format参数。今天的日期统一用format='%Y-%m-%d'处理:
df_clean['下单日期'] = pd.to_datetime(df_clean['下单日期'], format='%Y-%m-%d', errors='coerce')errors='coerce'的意思是:如果某个字符串转换失败,就把该位置设为NaT(表示时间缺失)。我特意没有用默认的errors='raise',因为测试数据里总有几行格式是乱的,有了coerce,之后可以用df_clean[df_clean['下单日期'].isna()]快速定位这些问题行。
金额处理分两步。第一步是清理,把千分位逗号、人民币符号"¥"去掉,并把"未知"替换为NaN;第二步是类型转换,使用pd.to_numeric,转换失败的也会变成NaN。清清楚的写法如下:
def clean_money(value): if isinstance(value, str): value = value.replace(',', '').replace('¥', '').replace('元', '') if value == '未知': return float('nan') return value df_clean['成交金额'] = df_clean['成交金额'].apply(clean_money) df_clean['成交金额'] = pd.to_numeric(df_clean['成交金额'], errors='coerce')至于那些金额是负数的行,我没有直接删除。因为负数在业务上可能是退款单或冲销单,真正的处理方式应该是先加一个"交易类型"标记列,把金额为负的行标记为"退款",金额为正的标记为"成交"。这样在做销售额汇总时可以用条件筛选排除退款单,而不是把原始数据弄丢。
df_clean['交易类型'] = df_clean['成交金额'].apply(lambda x: '退款' if x < 0 else '成交')这一步做完之后,df.info()显示df_clean的成交金额是float64,下单日期是datetime64,整张表的数据类型终于符合"可以直接做分析"的标准了。我把清洗后的表写成了新文件:
df_clean.to_excel('订单明细_clean.xlsx', index=False, engine='openpyxl')4. 踩坑实录:编码、类型、日期三大问题的完整排查链路
4.1 乱码:是UTF-8还是GBK,我用一个方法探测出来
前面说过,直接读CSV时中文乱码是大概率事件。但"乱码"也分情况,有时候是utf-8文件被当成了gbk,有时候反过来。光靠肉眼猜不是办法,我写了一个探测函数,用三组常见编码依次尝试,哪个成功就用哪个:
def detect_encoding(file_path): with open(file_path, 'rb') as f: raw = f.read(2000) for enc in ['utf-8', 'gbk', 'utf-8-sig']: try: raw.decode(enc) return enc except UnicodeDecodeError: continue return 'utf-8'原理很简单:每种编码都有自己的字节规律,比如utf-8编码的中文通常以多字节形式存在,用gbk去解码时大概率触发UnicodeDecodeError。通过逐一尝试,就能锁定正确编码。今天这个文件返回的是gbk,和我预判一致。
有一点要提示:CSV文件的编码和Excel文件的编码规则不完全相同。xlsx文件内部是XML,天然使用UTF-8,pandas读xlsx时一般不会出现中文乱码;容易出问题的是CSV,尤其是从老版本Excel"另存为CSV"出来的文件。所以遇到乱码时先问一句:这个文件是CSV还是Excel?
4.2 "1,200"和"未知":to_numeric的暴力转换为什么不可行
我刚处理金额时,脑子一热直接写了pd.to_numeric(df_clean['成交金额'], errors='coerce'),结果转换后的金额列出现了很多NaN。细查发现,像"1,200"这样的值会被整体视为无法转换的字符串,从而变成NaN。原因很直接:pandas默认不识别千分位逗号"1,200"中的逗号,它认为这不是合法的数字写法。
如果不想自己写清洗函数,也可以用pandas内置的字符串替换方法,比如:
df_clean['成交金额'] = df_clean['成交金额'].astype(str).str.replace(',', '', regex=False) df_clean['成交金额'] = df_clean['成交金额'].str.replace('¥', '', regex=False) df_clean['成交金额'] = pd.to_numeric(df_clean['成交金额'], errors='coerce')作为踩坑总结,我想强调一个原则:类型转换失败时不要急着用errors='coerce'掩盖错误,而要先看看转换失败的到底是一些什么样的值。我就是在转换之后用df_clean[df_clean['成交金额'].isna()]把问题值全部列出来,才发现漏了逗号和"未知"两个字。如果你先看数据再动手,很多坑都能提前避开。
4.3 日期解析的Warning:为什么同一列有三种写法
日期列统一格式时,pandas弹出了一条警告,大意是"某种格式无法自动解析,已使用替代方式"。我一开始没在意,后来发现部分日期的数值被解析成了当前时间附近的近似值,甚至年份都变了。这条警告提醒我:自动解析看起来方便,但面对混合格式时并不可靠。
解决方案是先用pd.to_datetime(..., errors='coerce')做一次试探,找出所有转换后为NaT的行,把这些行的原始写法打印出来:
mask_nat = df_clean['下单日期'].isna() if mask_nat.any(): print(df_clean.loc[mask_nat, '下单日期'].head(20))然后根据打印结果决定是否按格式拆分处理。今天的文件里除了三种常规日期,还有两行写着"2024年1月6日",这就是自动解析失败的原因。我把它们单独提取出来,用第二种format做转换后再填回去。思路是:先按主流格式解析,对解析失败的行再逐个格式补测。
4.4 去重后行数反而变多?我对"重复"的定义做了修正
第一次做去重时,我用subset=['订单编号'],结果去重后行数从3271变成了2987,表面看起来没问题。但实际一核对,发现同一个订单编号下面可能有两种不同的商品行,按订单编号去重会把同一订单的多行明细误杀。
这让我意识到,"重复"的定义完全取决于业务语义。如果一份订单对应多条商品明细,那么"订单编号"只能是组标识,不能作为唯一键;真正唯一的是"订单编号+商品行号"这个组合。我改成这个组合去重后,行数从2987变成了3002,反而比原来多了15行。这15行就是之前误删的商品明细。
这是一个很典型的业务理解问题,不是代码问题。所以我特别建议:在进行任何去重操作之前,先想清楚"什么字段组合在一起才能唯一标识一行"。想不清楚就把两个可能性都跑一遍,对比差异,往往能发现数据背后的业务规律。
5. 从一次性脚本到可复用工具:函数化、参数化和容错
5.1 函数拆分:read_raw / clean_data / write_output
清洗流程跑通之后,我没有就此收工,而是把整个逻辑拆成三个函数。这样做的好处很实际:下次换一个数据文件,我不需要从头改代码,只要改文件路径、列名映射、去重规则三个参数就行。
def read_raw(file_path): encoding = detect_encoding(file_path) df = pd.read_csv(file_path, encoding=encoding) return df def clean_data(df, key_cols=None, date_col=None, date_format='%Y-%m-%d'): if key_cols is None: key_cols = ['订单编号', '商品行号'] df = df.drop_duplicates(subset=key_cols, keep='last') df = df.dropna(subset=['收货城市']) if date_col: df[date_col] = pd.to_datetime(df[date_col], format=date_format, errors='coerce') # 其他清洗逻辑根据实际数据扩展 return df.reset_index(drop=True) def write_output(df, out_path): df.to_excel(out_path, index=False, engine='openpyxl')函数化的同时,我把路径都改成了Path对象管理,这样在Windows和macOS之间切换时不容易出现斜杠方向的问题:
from pathlib import Path raw_path = Path('data') / '订单明细.csv' clean_path = Path('output') / '订单明细_clean.xlsx'5.2 参数化路径与执行日志
为了让脚本不至于"跑完就忘",我还加了几行简单的日志输出,把关键的过程信息打印出来。别看只是几行print,它能在你第二次运行脚本、数据出现变化时,快速判断清洗结果是否合理。
df = read_raw(raw_path) print(f'原始行数: {len(df)}') df_clean = clean_data(df) print(f'清洗后行数: {len(df_clean)}') print(f'成交金额缺失数: {df_clean["成交金额"].isna().sum()}') write_output(df_clean, clean_path) print(f'已输出: {clean_path}')现在运行这个脚本,屏幕上的输出是这样的:
原始行数: 3271 清洗后行数: 2858 成交金额缺失数: 41 已输出: output/订单明细_clean.xlsx等等,清洗后行数怎么是2858?我回头检查了一下,原来是因为我在函数里加了dropna(subset=['收货城市']),把102行没有城市的记录删掉了,又把"未知"金额的41行删掉了。这个数字和之前人工核对一致,说明整体逻辑是靠谱的。看到这个输出,我才有信心说:今天的清洗过程是基本可靠的。
5.3 异常兜底与大数据量下的两个性能建议
脚本只对"正常路径"有效还不够。我补了一个简单的try-except结构,把找不到文件、读取编码异常、写入失败这类常见错误统一兜住:
def main(): try: df = read_raw(raw_path) except FileNotFoundError: print(f'文件不存在: {raw_path}') return except Exception as e: print(f'读取失败: {e}') return # 清洗与输出逻辑这一点在真实工作中尤其重要——数据清洗脚本往往是定时任务或批处理任务的一部分,一个文件出问题不应当让整个任务崩溃,而应该清晰地报告错误并继续处理下一个文件。
另外,针对以后可能遇到的大数据量场景,我记录了两个来自今天的性能心得:第一,读取CSV时能用dtype参数指定列类型的就尽量指定,比如把某些列指定为str,能显著减少内存占用;第二,尽量避免用apply逐行处理百万级数据,优先考虑正则替换、str操作、向量化计算。今天的"1,200"这种问题,用df['列'].str.replace(',', '')会比apply函数快不少。
最后再分享一个今天最大的体会。学习日记写到第六十七天,我慢慢发现编程学习的真正分水岭,不是你背了多少个函数,而是你能不能对着一份真实且混乱的数据,把"要什么、怎么处理、为什么会出错"想清楚。比如今天"去重"这一个动作,代码只有一行,但决定用哪个字段组合去重,背后是对业务的理解。如果只学API不学思路,下次换个数据照样抓瞎。所以我建议每个自学者都找一份真实场景里的脏数据,亲手把它洗到"敢拿去分析"的程度,这个过程踩过的每一个坑,都比看十遍教程管用。