1. 项目背景:这份销售数据根本没有办法直接拿来分析
接手的是一份某零售企业的月度销售明细,Excel文件大概120MB,行数接近80万。第一次用pandas读取时,光是read_excel就等了将近四分钟,读进来之后更是一堆问题:日期字段变成了浮点数,销售额列混着文本和数字,同一个客户ID在数据表里居然有三种写法。当时的第一反应是数据太脏,但后来发现,真正麻烦的其实是“数据没脏到报错,却脏到结果不可信”的程度——跑出来的汇总数字,对不上财务那边Excel透视表的数。
这种项目场景在数据分析日常里几乎每周都会碰到。数据量不大不小,脏法千奇百怪,时间紧,要求快,且最终结果必须能跟业务口径对上。这篇文章就是我当时解决这批数据时的完整过程和问题清单,包括读入、清洗、转换、聚合、透视、可视化和导出全链路,希望能给正在跟Excel导出的脏数据死磕的人一点参考。
适合的读者:有一定pandas基础、能写简单脚本,但还没系统性踩过“真实业务数据”坑的人。文章里涉及的每个问题都尽量给出根因、现象、解决代码和复盘建议,而不是只丢一个函数名。
2. 读入环节最容易被忽视的编码、表头与列类型问题
2.1 GBK编码CSV直接read_csv导致乱码,换utf-8后仍报错
这份数据最初是从ERP系统导出的CSV,系统默认用了GBK编码。直接执行:
import pandas as pd df = pd.read_csv('sales_data.csv')读出来所有中文字段全部乱码,而且列名变成了“客户\xc3\xa6\xc2\xa0\xc2\xa1”这样的诡异字符串。换成encoding='utf-8'之后直接抛UnicodeDecodeError,原因是文件里有一小部分行是GBK编码,但有个别列被写进了UTF-8字符,导致整个文件编码不统一。
这种“双编码混杂”的情况在真实系统导出文件里非常常见,尤其是ERP老系统升级后,导出的字段来自不同时期的不同模块。解决办法是先探测再兜底:
# 先用chardet探测整体编码,再按errors='replace'容错读取 import chardet with open('sales_data.csv', 'rb') as f: result = chardet.detect(f.read(100000)) df = pd.read_csv('sales_data.csv', encoding=result['encoding'], errors='replace')注意,errors='replace'会把无法解码的字符替换成 �,而不是直接让程序崩溃。这样读进来之后,再集中排查几列关键字段的乱码行,手动修正。实测这种方案在对80万行数据的读取过程中,完整读入只需要一次。
2.2 Excel文件里隐藏的空行和合并单元格导致表头错位
读CSV只是第一个坑,真正费时间的是另一个Excel版本的数据交付件。业务方给的Excel里有隐藏空行、合并单元格,read_excel默认从第0行开始解析,结果表头变成了第0行的“序号 日期 客户名称…”,而真正的字段名在第二行:
df = pd.read_excel('sales_report.xlsx', header=1)这里header=1指定第二行为表头,解决了第一层错位。但还有一个隐藏问题:Excel的前几列存在合并单元格,导致客户ID字段读出来后,大量行显示为NaN,原因不是真的缺失,而是Excel里这些单元格被合并了,只有合并区域的第一行才有值。
这种情况在pandas里没有直接的参数能解决,通用做法是用ffill()向下填充:
df['客户ID'] = df['客户ID'].ffill()不过要先确认业务上同一合并区域确实属于同一个客户,不能机械填充。我当时的做法是先groupby('客户ID')看每组的日期范围是否连续,确认没问题后才批量填充。
2.3 read_csv逐块读取解决内存不足,以及dtype的隐式类型错误
还有一个读入阶段的高频问题——内存不足。120MB的Excel转成CSV后约400MB,直接用read_csv默认把所有列都读成 object,内存占用会飙升到2GB以上。对8GB内存的笔记本来说,跑别的程序时很容易直接卡死。
这里的核心问题是pandas读CSV时会尝试推断每列类型,而推断过程需要扫描全文件,所以即使最终只有几列是数值列,过程内存占用依然很大。当时我改成了分批读取:
chunk_iter = pd.read_csv('sales_data.csv', chunksize=100000, low_memory=False) df_list = [] for chunk in chunk_iter: chunk = chunk.convert_dtypes() df_list.append(chunk) df = pd.concat(df_list, ignore_index=True)后来发现更好的方案是第一次先用很小的nrows=1000读一个样本,确认列名和大致类型后,在正式读取时用dtype参数固定列类型:
dtype_spec = { '客户ID': 'string', '订单号': 'string', '销售金额': 'float64', '销售数量': 'int64', '日期': 'string' } df = pd.read_csv('sales_data.csv', dtype=dtype_spec)这里有个容易被忽略的点:如果把日期列声明成string而不是datetime,读入阶段会快很多,日期解析放到后面单独处理。实际测试,固定dtype比默认推断快大约40%左右。
3. 日期时间处理的三个陷阱:Excel序列号、字符串日期和时区干扰
3.1 读出来的日期是42939.0,它是怎么变成数字的
从之前的Excel读取结果里可以看到一个典型问题:日期列的值是42939.0、43101.0这样的浮点数,这些数是Excel内部的日期序列号。Excel把1900年1月1日记为1,之后每天加1,所以42939.0实际表示2017年8月1日。
直接拿这个数字当“日期”处理肯定不行,月份汇总时会全部错乱。解决办法用pandas的to_datetime加origin参数:
df['日期'] = pd.to_datetime(df['日期'], unit='D', origin='1899-12-30')为什么要用1899-12-30而不是1900-01-01?因为Excel有一个著名的1900闰年bug,它错误地把1900年2月29日当作有效日期,所以真正对应到Excel序列号1的日期是1899年12月30日。这个参数正确设置后,所有日期都能准确转换,和财务口径一致。
3.2 日期列是"2017/8/1"和"2017-08-01"混着来的字符串
同一个Excel文件里不可能,但在多个Excel文件合并时经常出现。业务人员录入习惯不一样,有的是2017/8/1,有的是2017-08-01,还有的是20170801。如果直接用pd.to_datetime大多数情况能解析,但解析速度慢,而且偶尔会有歧义。
我用的是先统一格式再解析:
df['日期_标准化'] = df['日期'].astype(str).str.replace('/', '-', regex=False) df['日期_标准化'] = pd.to_datetime(df['日期_标准化'], format='%Y-%m-%d', errors='coerce')errors='coerce'会把解析不了的置成NaT,之后排查这批数据究竟是格式本身错了还是录入内容错了。
这里有个经验:不要指望to_datetime自动推断格式,当数据量大、格式乱时,自动推断的时间开销会拖慢整个流程。显式指定format参数往往能快数倍。
3.3 时间字段带到8小时误差之后如何判断是否该处理
这次数据里还附带了一个下单时间字段,格式是2017-08-01 13:24:56,但数据源来自部署在不同地域的服务器,某些接口导出的时间带时区后缀,比如+08:00或+00:00。如果不处理,跨天汇总时凌晨单会跑偏到前一天。
解决办法统一转成无时区的东八区时间:
df['下单时间'] = pd.to_datetime(df['下单时间'], utc=True) df['下单时间'] = df['下单时间'].dt.tz_convert('Asia/Shanghai').dt.tz_localize(None)去掉tz后缀是为了后续groupby按日期分桶时不报“时区感知的Datetime不能与时区无知Datetime比较”错误。处理时要注意:如果业务方只关心日期不关心具体几点,这个调整影响不大;但如果要做分时段分析,就必须保证所有时间在同一时区基准上。
4. 重复值与缺失值的处理不能只靠drop_duplicates和fillna
4.1 同一个订单号出现多行,但行内容不完全相同——真重复和伪重复
拿到数据后第一步做了去重,但发现按全部列去除重复行后,还有大量“看起来重复但去不掉”的行。细看发现是同一个订单号对应了多行明细:一行金额为正表示销售,一行为负表示退货。这类行在业务上不是重复,不能删。
还有一种情况是同一个订单号出现了两次,金额、数量完全一致,但备注列不同(一个写“正常”,一个写“补录”)。这是系统重复导入造成的真重复。
去重逻辑需要分清楚业务含义:
# 只按业务唯一键去重 df = df.drop_duplicates(subset=['订单号', '商品编码', '下单时间'], keep='first')keep='first'保留第一次出现的行。如果你发现重复行里金额有差异,就一定不要用drop_duplicates硬去,先分组看差异分布,再有针对性地合并。
4.2 fillna填充之前必须先搞明白数据为什么缺失
缺失值处理上,这次的坑来自两个源头:一个是ERP系统里未填写的部分字段,比如“区域经理”列在很多行上是空的;另一个是从宽表转长表时,部分列本来就没有值,比如赠品行没有“销售收入”。
直接df.fillna(0)会把无意义的空也填成0,导致统计时“赠品销售0元”这种记录被算进销售额平均值,拉低整体数据。正确做法是先区分缺失类型:
# 查看每列缺失率 missing_ratio = df.isna().mean().sort_values(ascending=False) print(missing_ratio[missing_ratio > 0])看到缺失率后逐列决策:客户ID缺失直接删;区域经理缺失的值统一填“未知”;销售金额和销售数量缺失则进一步检查原始Excel对应单元格是否有值,确认系统导出确实没写入之后,再决定是删除还是按同商品均价填充。
4.3 用interpolate插值填充时间序列的坑
业务方要求补齐“每日本应销售但未记录”的缺失值,做环比计算用。我把宽表转成了按日期排列的序列,然后用了df.interpolate()。第一次跑出来数据非常平滑,但细看发现插值把“周末无销售”的天然缺口也填上了一条平滑曲线,业务上完全说不通。
后来改成只对单日偶发缺失插值,连续缺失超过3天的统一置为NaN,再标注原因:
missing_run = df['销售金额'].isna().astype(int).groupby((df['销售金额'].notna()).cumsum()).sum() valid_mask = missing_run[df['销售金额'].isna().index.to_series().map(lambda x: missing_run.get((df['销售金额'].notna()).cumsum().loc[x], 0) <= 3).reindex(df.index).fillna(False)]这段逻辑比较绕,核心思想是:连续缺失超过阈值的不插值,避免把真实业务波动抹平。处理时间序列数据时,interpolate永远要留个心眼,它是数学上的平滑,不代表业务上该有值。
5. 类型转换与内存优化的组合拳:从2.1GB降到430MB
5.1 astype把float64变float32之后精度会变吗
数据分析类项目里,内存溢出不总是数据量大,更多时候是列类型太“奢侈”。pandas默认读入的数值列基本是float64,如果数据本身最多只有两位小数,改成float32完全够用,内存直接减半。
我的做法是写了一个自动压缩函数,遍历所有数值列:
def optimize_numeric(df): for col in df.select_dtypes(include=['float64']).columns: c_min, c_max = df[col].min(), df[col].max() if c_min > 1.175494e-38 and c_max < 3.4028235e38: df[col] = df[col].astype('float32') for col in df.select_dtypes(include=['int64']).columns: c_min, c_max = df[col].min(), df[col].max() if c_min >= -32768 and c_max <= 32767: df[col] = df[col].astype('int16') elif c_min >= -2147483648 and c_max <= 2147483647: df[col] = df[col].astype('int32') return df这里有个精度问题需要说明:float32的有效数字约7位,如果金额字段需要精确到分且数值在亿级,7位有效数字已经不够。当时我特意检查了最大单值,确保百万级金额没问题之后才压缩。
5.2 category类型压缩卡片式字段,order状态这类列降幅明显
像“订单状态”这种值集合很小的列(只有“已完成、已取消、待发货”等少数几个值),用category类型压缩效果很明显,同时后续groupby分组的速度也会提升:
categorical_cols = ['订单状态', '区域', '支付方式'] for col in categorical_cols: df[col] = df[col].astype('category')转category之后,内存里存的不再是每个单元格的完整字符串,而是整数编码加映射表。实测“区域”列从将近200MB降到了几MB,整个DataFrame的内存占用从2.1GB降到了430MB左右。这里还要提醒一下:category的底层编码是int8/int16还是int32取决于类别数量,pandas会自动选择,一般不用手工干涉。
5.3 用info()和memory_usage()对比前后内存差异
压缩完一定要验证,不能感觉“可能小了”就继续往下做:
print(df.info(memory_usage='deep')) print('总内存:', round(df.memory_usage(deep=True).sum() / 1024**2, 1), 'MB')第一版优化后,日期列还是object类型,占了大头。后来把日期统一转成datetime64,内存又降了一截。整个过程对比下来:原始读入约2.1GB,做完类型压缩与日期转换后约430MB。这不仅让后续操作不再卡顿,也让最终导出Excel时不再需要分批次写。
6. 分组聚合的四个易错点:apply、map、transform、合并结果
6.1 groupby之后直接拿到的是索引,不是DataFrame
第一次做“按区域汇总销售金额”时,我写了:
result = df.groupby('区域')['销售金额'].sum() print(result['华东'])这样输出能正常出数,但如果你试图result.to_excel(),会发现区域列变成了索引而不是列。更麻烦的是后续要把这个结果和原表关联时,直接merge就会因为key类型不一致报错。
通用解法是在groupby后显式reset_index():
result = df.groupby('区域', as_index=False)['销售金额'].sum()这个as_index=False参数很容易被忽略,但能省掉后面一次reset_index(),代码也更好读。
6.2 聚合后还想保留原始行全部信息,用transform
有一次需要给每一行补上“该客户ID对应的总消费金额”,用于判断单笔订单占比。这个问题本质上是“按客户ID分组求sum,再映射回原表的每一行”。
如果用merge会引入重复列,更简洁的方式是transform:
df['客户总消费'] = df.groupby('客户ID')['销售金额'].transform('sum')transform的特点是保持原索引形状,各组聚合结果广播回每一行。这个操作的执行效率和先groupby再merge差不多,但代码简洁很多,也不会改变行顺序。
6.3 多个指标聚合时用agg,而不是分别sum再拼接
业务方要的汇总表包含销售金额合计、订单数、平均折扣、支付方式种类四五个指标,分别写几个groupby.sum()再reset_index然后拼接非常啰嗦,而且容易在join时产生重复。
推荐写法:
summary = df.groupby('区域', as_index=False).agg( 销售金额合计=('销售金额', 'sum'), 订单数量=('订单号', 'nunique'), 平均折扣=('折扣', 'mean'), 支付方式数=('支付方式', 'nunique') )这里nunique算订单数量比直接count更准,因为同一订单号可能有多行商品明细。聚合列的重命名直接在agg里完成,后续列名完全可控。
6.4 分组后排序一定要sort_values,groupby顺序不是固定的
有一次我以为groupby('区域').sum()的结果会自动按区域名拼音或首字母排序,实际数据显示的却是原始数据中“区域首次出现顺序”。这是因为pandas的groupby默认不排序也保持出现顺序的版本差异。
为了保证汇总表顺序稳定,必须在聚合后显式排序:
summary = summary.sort_values('销售金额合计', ascending=False)如果希望按区域名称排序就用sort_values('区域')。这个问题看起来很小,但在交付给业务方时,如果两次运行结果的顺序不同,会很影响信任度。
7. 数据透视表与索引的进阶技巧:unstack、reset_index和性能对比
7.1 pivot_table还是groupby,什么时候用哪个
做“各区域各月份的销售额矩阵”这类需求时,可以用pivot_table一步到位:
pivot = pd.pivot_table( df, values='销售金额', index='区域', columns='月份', aggfunc='sum', fill_value=0 )这个结果结构清晰,但有个隐患:它有MultiIndex(当index或columns传多个维度时),如果后续要把这个透视结果继续和其他表merge,会很不方便。相比之下,用groupby + unstack得到的是同样的效果但写法更灵活:
temp = df.groupby(['区域', '月份'])['销售金额'].sum().unstack(fill_value=0)如果你只需要一次性按业务方要求的格式出表,pivot_table足够;如果还要继续做加工、合并、纵向拼接,我会优先用groupby + unstack + reset_index的方式来得到普通二维表。
7.2 stack/unstack把长表变宽表,再用reset_index让索引进列
这次业务方最后要的报表并不是宽表,而是“区域、月份、销售额”三列长表,用来做图表导入。所以我把透视出来的表再stack回去:
long_df = pivot.stack().reset_index() long_df.columns = ['区域', '月份', '销售金额']这里有一个关键点:unstack后的NaN值需要处理,否则stack会把缺失月份也带出来。我上面设置了fill_value=0,但如果业务上缺失和0不是一个概念,这里就不要填0,后续用dropna()把缺失行去掉。
7.3 透视表性能对比:80万行groupby耗时与内存开销
如果只是几十万行数据,不同方法的性能差异很难感知。但在80万行规模上,pivot_table和groupby + unstack的实测差距比较明显。我跑了三组对比:
| 操作 | 耗时 | 峰值内存 |
|---|---|---|
| pivot_table(含fill_value=0) | 约5.3秒 | 约980MB |
| groupby + sum + unstack | 约3.1秒 | 约720MB |
| 先groupby.agg再pivot | 约4.7秒 | 约850MB |
虽然差距不算悬殊,但在内存受限的机器上,groupby + unstack方案更稳。原因在于pivot_table内部会做更多数据重构操作,而groupby已经优化得比较彻底。
8. 可视化输出与Excel导出的坑:中文乱码、坐标轴日期和行数限制
8.1 matplotlib中文显示成方块,必须设置字体
数据分析结果最终要生成图表,第一个碰到的是中文乱码。matplotlib默认字体不包含中文字符,不加设置直接plot,所有中文字符显示为黑色方块。
解决办法:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = Falseaxes.unicode_minus要单独设,否则负号显示成乱码。不同系统的字体名不同,Windows常用SimHei/microsoft yahei,macOS可以用PingFang SC,Linux则可能要用WenQuanYi。字体设置必须在绘图前完成,且每次重启内核后需要重新设置。
8.2 坐标轴日期刻度过密或格式不对,用DateFormatter与AutoDateLocator
按月份做趋势折线图时,直接plot日期列会出现两个问题:刻度标签全部挤在一起,且日期格式含时分秒,很冗余。
解决办法:
import matplotlib.dates as mdates fig, ax = plt.subplots(figsize=(12, 5)) ax.plot(monthly.index, monthly['销售金额'], marker='o') ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m')) plt.xticks(rotation=45)这里要注意:如果x轴数据不是datetime64,mdates的定位器会失效。所以绘图前先确认monthly.index类型是DatetimeIndex。
8.3 80万行如何导出到Excel,to_excel一次性写爆内存的替代方案
把80万行直接df.to_excel('output.xlsx')在部分环境下会非常慢甚至直接卡死,原因还不是行数本身,而是Excel格式的写入需要大量CPU和内存,尤其在有多个sheet、大量样式时。
替代方案有两种:一是只导出汇总后的报表(通常只有几千行);二是如果需要导出明细,用csv格式:
df.to_csv('output.csv', index=False, encoding='utf-8-sig')utf-8-sig加了BOM头,Excel直接打开不会乱码。如果业务方强行要求xlsx格式,则分块写入:
with pd.ExcelWriter('output.xlsx', engine='openpyxl') as writer: for start in range(0, len(df), 20000): df.iloc[start:start+20000].to_excel(writer, index=False, sheet_name=f'Sheet_{start//20000}')分块写sheet的方法虽然可行,但多个sheet后期合并也比较麻烦。所以我的建议还是:交付明细用CSV(大多业务方可以接受),交付报表用xlsx。
8.4 导出后数值被Excel自动转成科学计数法,怎么避免
订单号这类长数字列在写入CSV后,Excel会显示成4.29509E+11这种科学计数法,虽然数据没丢,但看起来非常不专业。解决办法是在导出前把长数字列转成字符串:
df['订单号'] = df['订单号'].astype(str)如果必须用ExcelWriter写xlsx,还可以手动设置列的number_format:
ws = writer.book.active for col_letter in ['C', 'D']: for row in ws.iter_rows(min_row=2): row[col_letter].number_format = '0'这个操作相对麻烦,实际项目中我一般优先用转字符串的方式解决。
9. 一次排查“销售额环比计算偏差”的完整debug链路复盘
前面介绍的都是独立问题,最后分享一个串联多个问题出现的典型案例。业务方反馈“8月销售额环比下降15%”和财务系统对不上,要求复查。
第一步,先确认当月汇总值:用df.groupby('月份')['销售金额'].sum()对比财务口径,结果差了约3%。当时先怀疑数据清洗有问题,重新检查去重逻辑后发现,之前按['订单号','商品编码','下单时间']去重,把同一订单线下分批补录的合法明细删掉了。换成只按['订单号','下单时间']去重后,差异缩小到0.3%。
第二步,剩下的0.3%差异来自退货订单。业务方的“销售额”口径是不含退货的,而我的汇总只用了销售金额字段,没有排除订单类型=='退货'的行。增加过滤条件后,两个口径完全对上。
第三步,环比计算时发现上月的汇总也有类似的漏排除问题,修正后环比反而从“下降15%”变成了“下降2%”,结论完全不同。这个案例说明一个问题:数据分析结果差异不一定是pandas操作错了,很可能错在业务口径的拆解上。
整个排查过程我养成了一个习惯:每一步清洗、过滤、聚合后都打印行数和总和,跟上一个环节做对比。数据变动的每一步都要有据可查,这样最终结果出了问题才能往回追踪。
10. 这次处理80万行数据的操作顺序与流水账
最后整理一下完整流程顺序,这个顺序不是随手定的,而是多次处理类似数据后总结出的最省事路径。
- 先读入一个样本(
nrows或者head),确认列名、类型、基本脏数据形态。 - 全量读取,固定dtype,缺失值先不处理,让数据原样进来。
- 处理编码、日期格式统一、时区,把
to_datetime做干净。 - 重复值处理:先按业务唯一键去重,分析“伪重复”是否该保留。
- 缺失值分析:逐列统计缺失比例,区分“该删”“该填”“不该动”。
- 类型压缩与内存优化:float64转float32,字符串分类字段转category。
- 画基本功的汇总表:业务需要的各类groupby聚合。
- 转透视表或长表,按交付格式要求做长宽转换。
- 可视化:先小规模测试字体、刻度,再全量绘图。
- 导出:明细用CSV(utf-8-sig),报表用xlsx,长数字列转字符串。
这套流程跑完后,这份80万行的数据全链路处理下来大概耗时2分半到3分钟,主要时间花在日期解析和几张透视表上,内存峰值控制在1GB以内。如果你在自己环境里跑类似数据明显更慢,建议回头检查一下有没有遗漏dtype指定,以及是否重复转了日期。
这次项目做完之后最大的体会是:pandas的问题大多不是函数不会用,而是对数据的“业务语义”理解不够。去重、缺失、填充、聚合,每一个动作背后都要先回答“这个数据在业务上到底应不应该存在”。把这些问题先想清楚,写代码只是顺势而为。