做数据分析的人大概都有这么一个阶段:read_csv会了,df.head()会了,选列选行也勉强能写,然后突然遇到一个需求——"把这份销售表按区域和月份做个透视,顺便算一下每个区域环比增长率,再按增长率排个序"——就卡在那儿了。这不是你不够聪明,而是从"会用DataFrame"到"用DataFrame干活"之间,隔着一层东西:进阶用法。这篇内容就是讲这层东西的。
我把这篇定位成机器学习前置技术栈里的DataFrame进阶环节,适合已经能跑通基础增删改查、但一遇到多表合并、分组聚合、透视重塑就心里没底的人。下面这些内容全部围绕真实的数据处理场景展开,不讲花架子,每个用法我都会说清楚它解决什么问题、为什么这么设计、以及我在实际项目里踩过的坑。看完你至少能做到:拿到一份脏数据,知道该用哪几个方法把它整理成能直接喂给模型的样子。
1. 为什么基础操作撑不到真实项目
1.1 从"能读能看"到"能算能整"的断层在哪
基础篇教的东西,本质上是在跟一张干净的表打交道:选一列、筛几行、改个值。但真实项目里的数据从来不是一张表,而是散落在多个文件、多个sheet、甚至多个系统里的碎片。你需要把它们拼起来,拼的过程中列名对不上、索引重复、缺失值乱入,这些都不是df.loc能解决的。
我见过太多人卡在这一步:拿到三张表,一张用户表、一张订单表、一张商品表,想算"每个用户买了哪些品类的商品",结果对着三张表发呆。这个需求的本质是关系型数据的连接 + 分组聚合,对应到DataFrame就是merge和groupby。基础操作里没有这两个东西的位置,因为它们处理的是"单表内部"的事,而进阶用法处理的是"表与表之间""组与组之间"的事。
所以我把进阶用法的核心归纳成四类能力:连接(把散的表拼起来)、聚合(把细的粒度汇总)、重塑(把宽窄形态转换)、变换(在组内做计算)。这四类能力覆盖了数据清洗到特征工程之间绝大部分工作。你不需要背下所有API,但必须对这四类各有一个"肌肉记忆级"的熟练方法。
1.2 一个贯穿全文的模拟场景
为了让后面的内容不飘在空中,我先设定一个贯穿的场景,后面所有例子都基于它。假设你在处理一份电商订单数据,手上有三张表:
orders:订单表,字段有order_id、user_id、product_id、qty、order_dateusers:用户表,字段有user_id、city、reg_dateproducts:商品表,字段有product_id、category、price
目标是算出"每个城市每个品类的销售额,以及各城市内部的品类销售占比"。这个目标听起来简单,但它逼着你把连接、聚合、重塑、组内变换全用一遍。后面每一节我都会回到这个场景,告诉你当前讲的方法在这个目标里扮演什么角色。这种"带着目标学工具"的方式,比孤立地记API有效得多,因为你知道它什么时候会被调用。
提示:自己动手造这三张表的时候,故意留一些缺失值、重复行、类型不一致的列,后面讲清洗和合并时你会感谢自己。
2. merge:多表连接的坑比你想的深
2.1 连接类型的选择逻辑,不是背出来的
pd.merge的how参数有四个值:inner、left、right、outer。很多人是靠背的,但真正该做的是理解每种连接在业务上对应什么。inner是"两边都有的才留下",对应"只分析既有订单又有完整用户信息的记录";left是"左边的全留,右边没有的补空",对应"所有订单都要,用户信息缺失的标记出来";outer是"两边都留",对应"盘点时既想看有订单的用户,也想看没订单的用户"。
关键在于:连接类型选错,不会报错,只会悄悄给你错误的结果。这是merge最阴险的地方。比如你用inner连接订单表和用户表,结果发现总销售额比预期少了一截——因为有些订单的user_id在用户表里不存在(可能是历史脏数据),inner把这些订单直接丢了。这种错误在数据量大的时候极难发现,因为程序不报错,数字看起来也"合理"。
我的习惯是:每次merge之后立刻做一次行数核对。连接前记下左表行数,连接后看结果行数是否符合预期。left连接的结果行数应该等于左表行数(除非右表有重复键导致行数膨胀),inner连接的结果行数应该小于等于两表中较小的那个。这个核对动作只需要一行代码,但能拦住80%的连接错误。
before = len(orders) merged = orders.merge(users, on='user_id', how='left') after = len(merged) print(f"左表 {before} 行,连接后 {after} 行") # 如果 after > before,说明 users 表里 user_id 有重复2.2 键重复导致的行数爆炸:最容易被忽视的灾难
上面那段代码的注释里藏着一个大坑:如果右表的连接键不唯一,left连接会让结果行数膨胀。假设users表里同一个user_id出现了两次(比如用户注册了两次,或者数据导出时重复了),那么一条订单记录会匹配到两条用户记录,结果就是这条订单被算了两遍。销售额直接翻倍,而你毫不知情。
排查方法很直接:连接前对右表的键做一次duplicated检查。
dup = users[users.duplicated(subset='user_id', keep=False)] print(f"重复键记录数:{len(dup)}")如果确实有重复,你得先决定怎么处理:是去重(drop_duplicates)、还是聚合(比如取最新一条)、还是这本身就是业务上合理的多对多关系。不要跳过这一步直接merge,我见过一个项目因为用户表有重复键,月度报表的GMV虚高了30%,查了两天才定位到。
还有一种情况是连接键的类型不一致。比如订单表的user_id是整数,用户表的user_id是字符串(从CSV读进来时带了引号),merge的时候pandas不会报错,但会认为它们完全不匹配,结果就是全部补空。这种问题在跨系统取数时特别常见。我的做法是merge前统一做一次类型转换:
orders['user_id'] = orders['user_id'].astype(str) users['user_id'] = users['user_id'].astype(str)2.3 多键连接与连接后的列名冲突
当一张表需要靠多个字段才能唯一确定一行时,就要用多键连接。比如订单明细表可能用order_id+product_id作为联合主键。写法是把on传成一个列表:
merged = orders.merge(details, on=['order_id', 'product_id'], how='left')多键连接时,两个键必须同时匹配才算匹配上,这跟SQL里的复合主键逻辑一致。这里容易出的问题是:你以为两个键组合是唯一的,实际上不是。比如order_id+product_id看起来唯一,但如果同一订单同一商品分两次加购,就会出现重复。所以多键连接前同样要做duplicated(subset=['order_id','product_id'])检查。
另一个高频问题是列名冲突。如果两张表都有price列(订单表里是成交价,商品表里是标价),merge后pandas会自动加后缀_x和_y。这个后缀很难看,而且容易搞混哪个是哪个。解决办法是用suffixes参数显式指定:
merged = orders.merge(products, on='product_id', how='left', suffixes=('_order', '_product'))这样列名就变成price_order和price_product,语义清晰。我强烈建议养成这个习惯,因为默认的_x/_y在列多的时候根本分不清,后面引用列名时极易出错。
3. groupby:聚合的灵魂在于"分组键"和"聚合函数"的配合
3.1 分组聚合的执行模型:split-apply-combine
groupby这个名字容易让人以为它只是"按某列分组然后求和",但它真正的威力在于一套执行模型:split(按key把数据切成若干组)→ apply(对每组施加一个函数)→ combine(把结果拼回一个表)。理解了这个模型,你就能理解为什么groupby后面可以接agg、可以接transform、可以接apply,因为它们都是"apply"这一步的不同形态。
回到我们的场景:要算"每个城市每个品类的销售额"。销售额=数量×单价,所以先得在订单表上算出每行的销售额,再按城市和品类分组求和。城市在用户表里,品类在商品表里,所以得先merge再groupby。
merged['amount'] = merged['qty'] * merged['price_order'] city_cat = merged.groupby(['city', 'category'])['amount'].sum().reset_index()这段代码里,groupby(['city','category'])就是split,sum()就是apply,reset_index()把结果从多级索引转回普通列,相当于combine之后整理形态。多级索引是groupby结果最容易让人懵的地方——如果你不reset_index,结果是一个带MultiIndex的Series,后续想按列筛选会很别扭。我的习惯是聚合完立刻reset_index(),除非我明确要用多级索引做透视。
3.2 agg的多种写法:从单函数到字典到命名聚合
agg是groupby最常用的搭档,但很多人只会agg('sum')这一种写法。实际上它支持三种层次:
第一种,单函数,返回单列结果。第二种,函数列表,对同一列施加多个函数:
merged.groupby('city')['amount'].agg(['sum', 'mean', 'count'])这会返回一个多列的表,列名就是函数名。第三种,字典映射,对不同列施加不同函数:
merged.groupby('city').agg({'amount': 'sum', 'qty': 'mean', 'order_id': 'nunique'})这种写法在需要"一列求和、另一列计数、第三列去重计数"时特别顺手。但字典写法有个缺点:结果列名是"原列名_函数名"的拼接,不好看也不好引用。所以pandas后来引入了命名聚合(named aggregation),用关键字参数直接指定输出列名:
result = merged.groupby('city').agg( 总销售额=('amount', 'sum'), 平均客单量=('qty', 'mean'), 订单数=('order_id', 'nunique') )这种写法输出的列名就是你指定的中文名,干净利落。我在实际项目里几乎只用命名聚合,因为它让代码自解释,别人(包括三个月后的自己)读起来不用猜amount_sum到底是什么意思。
3.3 分组键的几种形态与observed参数的陷阱
分组键不一定是列名。它可以是:列名、列名列表、Series、函数、甚至字典(把某列映射到分组)。比如你想按订单金额区间分组,可以传一个函数:
merged.groupby(lambda idx: 'high' if merged.loc[idx,'amount']>1000 else 'low')['amount'].sum()不过这种写法性能一般,更常见的做法是先cut出一个区间列再分组。
这里要重点说一个坑:当分组键是分类类型(Categorical)时,observed参数的默认行为会坑你。pandas在某些版本里默认observed=False,意味着即使某个分类在数据里一次都没出现,它也会出现在分组结果里,对应的聚合值是0或NaN。这在做品类分析时会导致结果里冒出一堆"空品类"行。解决办法是显式传observed=True:
merged.groupby('category', observed=True)['amount'].sum()这个参数在数据量大、分类多的时候影响很明显,我建议养成显式指定的习惯,别依赖默认值。
4. transform与apply:组内计算和自定义逻辑的分界线
4.1 transform:保持原表形状的组内变换
agg会把每组压缩成一行,但有时候你要的是"在原表上增加一列,这一列是该行所在组的统计值"。这就是transform的用武之地。回到场景:要算"每个城市内部的品类销售占比",分母是该城市的总销售额,分子是该城市该品类的销售额。如果用agg,你得先算出城市总额,再merge回去,两步。用transform一步到位:
merged['city_total'] = merged.groupby('city')['amount'].transform('sum') merged['share'] = merged['amount'] / merged['city_total']transform('sum')返回的是一个和原表等长的Series,每个位置填的是该行所属城市的总和。这样你就不用改变表的形状,直接在原表上加列。这个特性在做特征工程时极其有用——比如"每个用户的历史平均订单额"作为特征,用transform('mean')一行搞定,不用先聚合再连接。
transform和agg的区别可以用一句话概括:agg改变行数,transform不改变行数。记住这句话,你就知道什么时候该用哪个。需要汇总表用agg,需要给原表加派生列用transform。
4.2 apply:当内置函数不够用时的逃生舱
有些计算逻辑没法用内置的sum/mean表达,比如"每组内取销售额最高的那条记录的品类"。这时候就要用apply,它允许你对每个组施加一个任意函数,函数接收的是该组的子DataFrame。
top_category = merged.groupby('city').apply( lambda g: g.loc[g['amount'].idxmax(), 'category'] )这段代码对每个城市取销售额最大的那一行的品类。apply很灵活,但代价是慢。因为它本质上是Python层面的循环,每个组都要调用一次函数,组数多的时候性能下降明显。我的经验是:能用agg/transform解决的绝不用apply,只有当逻辑确实无法向量化时才用它。如果非用不可,尽量让函数内部的操作也向量化,别在函数里再写for循环。
还有一个apply的坑:返回结果的形状取决于函数返回什么。如果函数返回一个标量,结果是Series;返回Series,结果是DataFrame;返回DataFrame,结果可能是多级索引的DataFrame。这种不确定性会让后续处理很麻烦。所以用apply时我习惯在函数里明确返回类型,并在外面立刻检查结果的shape。
4.3 组内排序与累计计算
transform还能配合cumsum、rank等做组内累计和排名。比如"每个城市内按销售额给品类排名":
merged['rank_in_city'] = merged.groupby('city')['amount'].rank(ascending=False, method='dense')method='dense'表示并列名次不跳号(1,1,2而不是1,1,3)。这个参数在做排行榜类需求时很关键,默认的average会把并列的算成平均名次(1.5),通常不是你要的。
组内累计也很常用,比如"每个用户按时间顺序的累计消费额":
merged = merged.sort_values(['user_id', 'order_date']) merged['cum_amount'] = merged.groupby('user_id')['amount'].cumsum()注意这里必须先按user_id和order_date排序,否则cumsum的顺序是错的。groupby本身不保证组内顺序,它只保证同组的在一起,组内顺序取决于原始数据的顺序。这个细节很多人忽略,导致累计结果对不上。
5. pivot与melt:宽表窄表的来回转换
5.1 pivot_table:从长表到宽表的透视
透视表的本质是"把某列的值变成列名,把另一列的值填进单元格"。回到场景,city_cat是一个长表,三列:city、category、amount。如果想看"行是城市、列是品类、单元格是销售额"的宽表,就用pivot_table:
wide = city_cat.pivot_table( index='city', columns='category', values='amount', aggfunc='sum', fill_value=0 )这里几个参数各有讲究。aggfunc是必须想清楚的:如果index+columns的组合在数据里唯一,那用sum还是mean结果一样;但如果不唯一(比如同一城市同一品类有多条记录),aggfunc就决定了怎么合并。默认是mean,这往往不是你要的——销售额当然要sum。我见过有人用默认的mean做透视,结果数字全错,查了半天才发现是聚合函数的问题。
fill_value=0也很实用。透视后如果某些城市没有某些品类,单元格会是NaN。对于销售额这种"没有就是0"的指标,填0比留NaN更合理,后续计算也不会被NaN污染。
pivot_table还支持margins=True加总计行/列,相当于Excel透视表里的"总计"。这个功能在做汇报表时很方便,但要注意总计行会混进数据里,如果后续还要对结果做计算,记得用drop去掉或者单独处理。
5.2 melt:从宽表回到长表
melt是pivot的逆操作,把宽表"融化"成长表。什么时候需要它?最常见的是宽表不适合画图或喂给模型。比如你有一张"每行一个用户、每列一个月的消费额"的宽表,想画趋势图,就得先melt成"user_id、month、amount"三列的长表。
long = wide.reset_index().melt( id_vars='city', var_name='category', value_name='amount' )id_vars是保持不动的列(标识列),其余列会被融化成"变量名-值"两列。var_name和value_name指定这两列的名字。这个操作在特征工程里很常见——很多模型要求长表格式,或者你要做groupby之前必须先把宽表转长。
pivot_table和melt配合使用,基本能覆盖所有宽窄转换需求。判断用哪个很简单:要"展开成矩阵看"用pivot,要"拉平做计算"用melt。
5.3 stack与unstack:索引层面的重塑
stack/unstack是另一套重塑工具,作用在索引上而不是列上。unstack把最内层行索引变成列,stack反过来把列变成行索引。它们和pivot的区别在于:pivot操作的是列的值,stack/unstack操作的是索引层级。
什么时候用stack/unstack而不是pivot?当你已经在用多级索引做groupby,结果本身就是MultiIndex Series时,unstack是最自然的展开方式:
city_cat_series = merged.groupby(['city','category'])['amount'].sum() wide = city_cat_series.unstack(fill_value=0)这比先reset_index再pivot_table少一步,而且语义更连贯。缺点是stack/unstack对索引的依赖强,如果索引有重复或者层级不对,容易报错。我的经验是:数据本来就是多级索引形态时用stack/unstack,数据是普通列形态时用pivot/melt,别硬套。
6. 那些让我返工的细节:索引、类型与性能
6.1 索引不是装饰品,它会影响你的每一步操作
很多人把DataFrame的索引当成"行号",觉得可有可无。但在进阶用法里,索引直接影响merge、groupby、pivot的行为。比如merge时如果两边索引都有意义,可以用left_index=True或right_index=True按索引连接;groupby默认不把索引当分组键,除非你显式指定。
一个典型的坑是:经过筛选、排序、去重之后,索引会变得不连续。比如你df[df.amount>100]之后,索引可能是0,3,7,12这样跳着的。这时候如果你用df.loc[0]可能取不到想要的行,用iloc[0]才是取第一行。更隐蔽的是,某些操作(比如concat)会按索引对齐,索引不连续会导致意外的NaN。
我的习惯是:在数据清洗的每个关键节点后做一次reset_index(drop=True),让索引回归连续整数。这样后续所有基于位置的操作都可预期。代价是丢失原始索引信息,但如果你需要保留,可以先reset_index()把它变成普通列存着。
6.2 数据类型:看不见的性能杀手和错误源头
pandas对类型的处理比想象中宽松,这既是优点也是坑。一个从CSV读进来的数字列,如果里面有缺失值,pandas会自动把它变成float(因为int不能表示NaN)。这导致本来该是整数的user_id变成了1234.0,merge的时候跟另一张表的整数1234对不上。
解决办法是在读入时就指定类型:
users = pd.read_csv('users.csv', dtype={'user_id': 'int64', 'city': 'category'})category类型对低基数的字符串列(比如城市、品类)特别有用,它把字符串编码成整数存储,内存占用能降一个数量级,groupby也更快。但要注意category类型在merge和比较时的行为跟普通字符串略有不同,用之前确认一下你的pandas版本行为一致。
另一个类型坑是日期。从CSV读进来的日期默认是字符串,你得显式pd.to_datetime转换,否则按日期分组、算时间差全都不对。转换时加errors='coerce'能把无法解析的值变成NaT而不是报错,方便后续排查脏数据。
6.3 性能:什么时候该担心,什么时候不用
DataFrame进阶用法里,性能问题主要出在三个地方:apply的循环、链式索引的赋值、以及不必要的数据复制。对于百万行以内的数据,大部分操作都是秒级,不用过度优化。但如果你发现某个groupby跑了十几秒,或者apply跑了半分钟,就该检查了。
第一个检查点:是不是用了apply但能用agg/transform替代。这是最常见的性能瓶颈。第二个检查点:是不是在循环里反复筛选DataFrame。比如for city in cities: df[df.city==city]这种写法,每次筛选都是全表扫描,城市多了就慢。正确做法是一次groupby搞定。第三个检查点:字符串列是不是该转category。低基数字符串列转category后,groupby和merge能快好几倍。
还有一个容易被忽视的点:链式赋值。df[df.amount>100]['flag'] = 1这种写法在pandas里可能不生效(返回的是副本),而且会触发警告。正确写法是df.loc[df.amount>100, 'flag'] = 1。这个坑在新版pandas里会直接报错,早点改掉省得以后返工。
7. 把场景跑通:从三张原始表到一张分析结果
7.1 完整链路的代码骨架
现在把前面所有方法串起来,跑通那个贯穿全文的目标。整个过程分五步:连接、算销售额、分组聚合、透视、算占比。每一步我都标注了关键决策点。
import pandas as pd # 1. 连接:订单左连接用户和商品,保留所有订单 df = orders.merge(users, on='user_id', how='left', suffixes=('', '_user')) df = df.merge(products, on='product_id', how='left', suffixes=('', '_product')) # 2. 算销售额:用订单成交价,不是商品标价 df['amount'] = df['qty'] * df['price'] # 3. 分组聚合:城市+品类维度 city_cat = df.groupby(['city', 'category'], observed=True).agg( 销售额=('amount', 'sum'), 订单数=('order_id', 'nunique') ).reset_index() # 4. 透视:城市为行,品类为列 wide = city_cat.pivot_table( index='city', columns='category', values='销售额', aggfunc='sum', fill_value=0 ) # 5. 算占比:每行除以该行总和 share = wide.div(wide.sum(axis=1), axis=0)这段代码里有两个决策点值得说。第一,第2步用price而不是price_product,因为成交价在订单表里,商品表的标价只是参考。如果merge时没加suffixes,这里就会因为列名冲突而报错或取错列。第二,第5步的div(..., axis=0),axis=0表示按行对齐做除法,也就是每一列都除以对应行的总和。如果写成axis=1就变成按列除了,结果完全错。这个axis参数是除法、减法这类操作最容易搞反的地方,我的记法是:axis指的是"要对齐的轴",按行算就用axis=0。
7.2 结果校验:怎么确认你算对了
代码跑通不代表结果对。我每次做完这类聚合都会做三个校验:
第一,总量校验。所有城市所有品类的销售额加起来,应该等于原始订单表的总销售额(假设没有订单被连接丢掉)。如果对不上,说明merge环节丢了数据。
print(df['amount'].sum()) # 连接后的总额 print(orders['qty'].mul(orders['price']).sum()) # 原始总额第二,占比校验。每个城市的品类占比加起来应该等于1(或100%)。如果某行加起来不是1,说明透视或除法环节有问题。
print(share.sum(axis=1)) # 应该全是 1.0第三,抽样校验。随机挑一个城市,手工从原始数据里算一遍,跟结果对比。这一步最笨但最可靠,尤其是当聚合逻辑复杂(多表连接+多函数聚合)时,抽样能发现那些"总量对但分组错"的问题。
7.3 从分析结果到模型特征的衔接
这份结果离"喂给模型"还差一步。机器学习模型要的是数值特征矩阵,而share这张表里城市是索引、品类是列名,直接喂进去不行。需要做的是:把索引reset成列,把品类列名规范化,处理可能的缺失。
features = share.reset_index() features.columns = ['city'] + [f'cat_{c}_share' for c in features.columns[1:]]这样每个城市一行,每个品类一列,列名是cat_品类_share,语义清晰。如果后续要跟其他特征(比如用户画像)连接,城市这一列就是连接键。这一步看似简单,但它是"数据分析"和"特征工程"的接缝处,很多项目在这里出问题——要么列名带特殊字符导致模型报错,要么索引没reset导致连接失败。
我在实际项目里的体会是:分析阶段的最后一步,永远要为"下一步怎么用"做准备。如果你知道结果要进模型,那在透视的时候就想好列名规范;如果知道要进报表,那在聚合的时候就把中文列名定好。这种"向前看一步"的习惯,能省掉大量返工。
8. 几个我反复踩的坑和对应的解法
8.1 SettingWithCopyWarning:它不是噪音,是真问题
这个警告几乎每个pandas用户都见过,很多人的处理方式是加个.copy()或者直接忽略。但它背后是真问题:你对一个切片赋值,可能改的是副本而不是原表,导致你以为改了其实没改。根源在于pandas无法确定切片是视图还是副本。
正确的处理不是无脑.copy(),而是搞清楚你的意图。如果你就是要改原表,用.loc定位;如果你就是要一个独立副本,显式.copy()。比如:
# 错误:可能不生效 df[df.city=='A']['flag'] = 1 # 正确:明确改原表 df.loc[df.city=='A', 'flag'] = 1 # 正确:明确要副本 sub = df[df.city=='A'].copy() sub['flag'] = 1我早期的习惯是看到警告就.copy(),结果内存里堆了一堆副本,大数据量时直接爆内存。后来改成"先想清楚要不要改原表",代码反而更清晰了。
8.2 空值与空字符串:清洗时最容易被混淆的一对
NaN和''在pandas里是两回事。isna()能识别NaN但识别不了空字符串,==''能识别空字符串但识别不了NaN。真实数据里两者经常混着出现,比如用户填表时有的留空(变成NaN),有的输入了空格(变成' ')。
清洗时我通常两步走:先把空字符串和纯空格统一替换成NaN,再用fillna或dropna处理。
df['city'] = df['city'].replace(r'^\s*$', pd.NA, regex=True) df['city'] = df['city'].fillna('未知')这个顺序很重要。如果先fillna再处理空字符串,那些空字符串就漏网了,最后分组时会冒出一个空字符串的组,看起来像bug。我在一个项目里就因为这个问题,报表里多了一行"空城市",查了半天才发现是空格没清干净。
8.3 分组聚合后顺序变了:别假设groupby保持原序
groupby默认会按分组键排序(sort=True),这意味着结果的顺序跟你原始数据的顺序可能不一样。如果你后续依赖顺序(比如取每组第一条),就会出错。解决办法要么显式sort=False保持原序,要么在聚合前先排好序。
更隐蔽的是,即使sort=False,组内顺序也不保证。所以任何"取每组第一条/最后一条"的操作,都必须先显式排序。我见过有人用groupby().first()取"每个用户最新订单",结果取到的是原始数据里碰巧排在前面的那条,而不是时间最新的。正确做法是先按时间排序再groupby().last(),或者用idxmax定位。
8.4 内存不够用:分块与类型优化的组合拳
数据量上到千万行,一个merge就可能吃掉几个G内存。这时候几个手段可以救急:读入时用usecols只读需要的列、用dtype指定更省空间的类型(int32代替int64、category代替object)、用chunksize分块处理。
分块处理的思路是:把大文件切成若干块,每块做聚合,最后把各块的聚合结果再聚合一次。注意这里只能做"可结合"的聚合(sum、count、min、max),mean这种需要先算sum和count再除,不能直接对mean再mean。
result = [] for chunk in pd.read_csv('big.csv', chunksize=100000): agg = chunk.groupby('city')['amount'].agg(['sum', 'count']) result.append(agg) final = pd.concat(result).groupby(level=0).sum() final['mean'] = final['sum'] / final['count']这段代码展示了"分块聚合再合并"的标准套路。关键点是每块只算可结合的统计量,最后统一算比率。这个技巧在处理日志类数据时特别实用,因为日志往往按时间追加,天然适合分块。
9. 写在这套用法之后的一点个人习惯
用DataFrame做进阶处理,技术点其实就上面这些,真正拉开差距的是习惯。我自己总结了几条,都是踩坑踩出来的:
每次merge必核对行数,每次聚合必校验总量。这两个动作各一行代码,但能拦住大部分静默错误。数据处理的可怕之处不是报错,而是不报错但结果错。
列名从一开始就起好。amount、city_total、cat_share这种自解释的名字,比col1、tmp、x强一百倍。命名花的那几秒钟,会在调试时十倍还回来。
中间结果该存就存。清洗完的数据存一份parquet,聚合结果存一份csv,别每次都从头跑。pandas的parquet读写比csv快很多,而且保留类型。我现在的习惯是每个关键节点存一个带版本号的中间文件,出问题时能快速定位是哪一步坏的。
别怕写长代码,怕的是写重复代码。同一个聚合逻辑在三个地方出现,就该抽成函数。DataFrame的链式调用虽然优雅,但超过五六步就该拆开,中间变量命名清楚,比一长串.连起来好调试得多。
这套东西说到底是为机器学习服务的。模型效果的上限由特征决定,而特征的质量由数据处理的细致程度决定。DataFrame进阶用法就是那个"把原始数据变成可用特征"的转换器,它不炫技,但每一步都影响最终结果。把上面这些方法练到不用查文档就能写出来,你在特征工程这一步就不会再卡壳了。