用Pandas做数据分析,不管你是刚上手的新手还是写了好几年代码的老手,有两样东西几乎每次都会碰到:describe()和groupby()。我周围不少做数据分析的朋友,拿到一份陌生数据之后的第一反应都是先跑一下df.describe()看看整体情况,然后再结合业务问题用groupby()去拆不同维度。别看这两个方法基础,真要讲清楚它们的输出含义、踩过的坑、组合用法,还是有不少值得聊的内容。这篇文章就按我平时实际操作的思路,把这两个方法从头到尾拆开讲一遍。
1. 准备一份能讲清问题的模拟数据
1.1 为什么模拟一份数据而不是直接上真实数据
真实动手分析时,很多人一上来就拿着Excel导出的原始表跑describe(),结果发现一堆乱码列和缺失值,然后就开始慌了。我的习惯是先在本地构造一份结构合理、字段齐全的模拟数据,把每个方法的输出结果跟预期对一遍,再拿真实数据去套。这样做的好处是:模拟数据里的数值范围是可控的,分析结果好验证,出了问题也能快速定位是数据的问题还是写法的问题。
构造模拟数据的另一个价值在于,你可以手动制造一些"不太干净"的情况,比如重复值、缺失字段、超大数值,然后安全地在模拟环境里试验各种处理方式。真实业务数据一旦出错,影响面往往超出代码本身,但要是在自己造的数据里把问题都踩过一遍,处理真实数据时心里就有底了。
1.2 模拟一份小型销售流水
举个例子,假设我们在分析一家小型饮品连锁的日常销售数据。这个场景很适合演示describe()和groupby(),因为它既有数值列(销量、单价、评分),又有分类列(门店、品类),天然就有分组分析的需求。用下面的代码生成600条销售记录:
python复制import pandas as pd
import numpy as np
np.random.seed(42) # 固定随机种子,保证每次运行结果一致
df = pd.DataFrame({
'订单号': ['D' + str(i).zfill(4) for i in range(1, 601)],
'门店': np.random.choice(['A店', 'B店', 'C店'], 600),
'品类': np.random.choice(['咖啡', '奶茶', '果茶', '甜点'], 600),
'销量': np.random.randint(1, 20, 600),
'单价': np.round(np.random.uniform(10, 35, 600), 1),
'评分': np.random.randint(3, 6, 600)
})
df['销售额'] = df['销量'] * df['单价']
代码说明:np.random.seed(42)的意思是让随机数生成器按固定顺序输出,这样你在我这里看到的结果,在你自己的电脑上跑也一样。订单号用字符串格式化生成,是为了模拟真实业务的单号;销售额是销量乘单价算出来的衍生字段,这种字段在真实分析里非常常见,因为原始流水往往只记录数量和价格,统计口径需要自己算。
数据准备好之后,先不用急着分析。第一件事是快速确认这份数据的形状、列名和数据类型。这是很多初学者会忽略的一步,直接上来就跑统计函数,万一某个字段读成了文本类型,后面整个分析就全偏了。
python复制print(df.shape) # (600, 7)
print(df.dtypes) # 查看每一列的类型
print(df.head()) # 快速浏览前几行
看到输出之后,心里要有数:订单号是文本,门店和品类是分类字段,销量、单价、销售额是数值类型,评分实际上也应该当数值处理。类型对了,后面做统计才有意义。我见过太多次因为CSV里某列混入了一个汉字,导致整列被读成object类型,然后mean()直接报错的情况。提前花十秒钟确认类型,比事后排查半小时划算得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. describe():给数据做一次快速体检
2.1 默认输出里到底藏了哪些统计量
describe()最常用的用法就是对整个DataFrame调用,它会自动把数值列挑出来,计算一堆基础统计量。代码一行,信息量却很大:
python复制df.describe()
输出包含count、mean、std、min、25%、50%、75%、max这八行。逐个拆解:
- count:每列的非空值数量。和
len(df)对比一下,就能看出这一列有没有缺失。 - mean:算术平均值,代表数据的"中心"大概在哪。
- std:标准差,衡量数据围绕均值的离散程度。两项业务的销售额均值一样,不代表它们日常表现一样——标准差小的更稳定。
- min / max:最小值和最大值。如果某个数字明显超出合理范围,通常能从这里一眼看出来。
- 25%、50%、75%:百分位数。50%也就是中位数。
这里有一个初学者最容易误会的地方:std大的数据是不是就不好?不绝对。比如"销量"列的std可能很高,也许是因为有些订单是几杯,有些是几十杯的大单。标准差是描述波动性的工具,不是评价好坏的标准。要结合业务看。
百分位数的计算逻辑也值得多说一句。把某一列所有值从小到大排序,25%百分位就是排在四分之一位置的那个数值。25%和75%之间的区间叫四分位距(IQR),它在判断离群值时非常有用:如果某个值落在Q1 - 1.5 * IQR之下或Q3 + 1.5 * IQR之上,就可以视为离群值。这是统计上非常经典的做法,Pandas虽然没有直接提供函数,但你用describe()拿到四分位数之后自己就能算。
2.2 自定义百分位数,控制describe()的输出
默认的百分位数只有25%、50%、75%,有时我们需要更细的分位点。比如要看销量的90%分位,判断有没有极端大单,可以这样:
python复制df['销量'].describe(percentiles=[0.1, 0.25, 0.5, 0.75, 0.9])
需要注意,50%分位即使不传也会出现在结果里,如果你手动又传了0.5,输出里会重复显示。这个不算错误,但看起来有点冗余,自己心里清楚就行。
还有一个实用参数是include。默认情况下describe()只处理数值列,但有时候我们也想知道文本列里到底有哪些取值、哪个取值出现最多。这时候用:
python复制df.describe(include='object')
对文本列(门店、品类、订单号)会输出count、unique、top、freq这四行,分别代表非空数量、去重后取值个数、出现次数最多的值、以及最高频次。这在对分类字段做初步探查时非常有用。你立刻能看到哪个门店的单量最多、哪个品类卖得最频繁。
如果DataFrame里既有数值列又有文本列,想一次性都看,可以传include='all'。不过这种情况下输出会混着两类统计量,行数变多,读起来有点累。我平时更习惯分开看:先df.describe()看数值,再对文本列用value_counts()代替describe(include='object'),因为value_counts()能给出每个取值的完整频次分布,信息量更大。
提示:
describe()默认只统计非空值。如果一列有缺失,count会比实际行数小,mean和std也都是基于非空值算的。所以拿到describe()之后,先扫一眼count有没有明显偏小,如果有,就得先处理缺失值,再做后续分析。
2.3 多列一起看时,转置一下更舒服
当DataFrame里有多组数值列时,比如我们这份数据里的销量、单价、评分、销售额,直接df.describe()的输出是"统计量作为行、列名作为列",列一多宽得不行。我习惯直接转置:
python复制df.describe().T
转置之后,每一行是一列原始数据,每一列是一个统计量,在Jupyter里看起来舒服得多,复制到Excel里也更符合阅读习惯。
顺带说一句,如果你只想看某一列的分布,df['销售额'].describe()和df[['销售额', '销量']].describe()的差别要先搞清楚。前者返回一个Series,后者返回一个DataFrame。很多人在这两种结构之间切换时容易犯迷糊:Series的describe()结果没有列名维度,转置没有意义;DataFrame的describe()转置才有"每一行是一个指标"的效果。分清Series和DataFrame,是理解describe()输出的前提。
3. groupby():按维度拆开看数据
3.1 拆分-应用-合并,理解分组分析的底层逻辑
groupby()是Pandas中最重要的分组分析工具。它的哲学可以用一句话概括:"先按某个维度把数据拆开,对每个小组分别做计算,再把结果合并起来"。这句话背熟,groupby的一大半用法就掌握了。
打一个比方:假设你有全班几十个学生的成绩单,想看看每个小组的平均分。手工做法是先把名单按小组分开,一个组一个组地算平均分,最后把结果抄到一张新表里。groupby()干的就是这件事,只不过它把"分开-计算-合并"三步合成了一行代码。
最简单的用法是在DataFrame上指定一个分组键,然后调用聚合函数:
python复制df.groupby('门店')['销售额'].sum()
运行后会得到每家门店的销售额总计。这里有一个细节:结果里的"门店"并不在列里,而是变成了索引。很多人第一次用的时候找不到门店列,就在这里卡住了。后面专门说怎么处理。
3.2 多列分组:维度叠加,信息更丰富
单列分组只能回答"每家门店表现如何"这类问题。如果我想知道"每家门店里,不同品类的表现如何",就要用到多列分组:
python复制df.groupby(['门店', '品类'])['销售额'].mean()
这个结果是一个两级索引的结构:外层是门店,内层是品类。每一行代表一个"门店×品类"组合的平均销售额。多列分组的顺序会影响结果的排序方式,第一个分组键是外层,第二个是内层,读数据的时候要注意层级关系。
多列分组之后,结果出现元组索引,遍历的时候很多人会踩坑。比如想用for循环遍历每个小组,直接:
python复制for key, group in df.groupby(['门店', '品类']):
# key 是一个元组 ('A店', '咖啡')
print(key, len(group))
这里key就是('A店', '咖啡')这样的元组。如果心里以为它是字符串,直接拿key[0]去取,取出来的却是一个完整元组,后续逻辑就全乱了。我的习惯是每次分组后就地解包,for (store, category), group in ...,或者干脆用as_index=False把它当普通列处理。
3.3 聚合函数大合集:mean、sum之外还有什么
groupby之后可以直接调用的统计函数远不止mean()和sum()。如果你用Pandas有一段时间,大概对这张表不陌生:
| 函数 | 作用 | 典型场景 |
|---|---|---|
| mean() | 均值 | 各类目的平均销量 |
| sum() | 总和 | 各门店总销售额 |
| count() | 非空计数 | 各门店订单量 |
| size() | 行数计数(含NaN) | 各门店总记录数 |
| min() / max() | 最值 | 每家店单笔最高销售额 |
| std() | 标准差 | 各品类销量波动 |
| median() | 中位数 | 各门店单价的中位水平 |
其中size()和count()的差异是很多人的困惑点。size()统计的是每个分组里的行数,不管这一行有没有缺失值;count()统计的是每一列的非空值个数。对没有缺失值的列来说两者结果一样,但一旦数据不干净,size和count就会对不上。我的建议是:统计记录条数时用size(),统计某个字段有效填写数量时用count()。
直接调用函数的方式虽然简单,但一次只能算一种统计量。如果需要同时算多个统计量,就要用agg():
python复制df.groupby('门店')['销售额'].agg(['sum', 'mean', 'count', 'max'])
输出是一个"门店×统计量"的二维表。想对不同的列用不同的聚合函数,可以用更清晰的写法:
python复制df.groupby('门店').agg(
订单数=('订单号', 'count'),
总销售额=('销售额', 'sum'),
平均评分=('评分', 'mean')
)
这种"新列名=(旧列名, 函数名)"的写法是比较新的命名聚合语法,好处是一目了然,结果列名也完全由自己控制。强烈建议不要再用那种一长串agg({'列名': '函数名'})的字典写法,虽然也能用,但可读性差很多,尤其在列多的时候很容易写错。
3.4 组合拳:分组之后直接调用describe()
groupby()和describe()组合起来,是很多人忽略的一个高效用法。groupby()解决"按维度拆开",describe()解决"每个组内部怎么分布",两者组合起来就是"每个组各自的体检报告":
python复制df.groupby('门店')['销售额'].describe()
你会得到一张门店×统计量的矩阵:每一行是一家门店,列是count、mean、std、min、25%、50%、75%、max。这比单独算均值再单独算方差要方便得多。比如说,你想知道A店和B店虽然总销售额相近,但日常波动差异大不大,直接看std那一列就清楚了。
这个组合在写周报或月度分析时特别好用。先groupby().describe()拉出各维度的分布全貌,哪家店表现异常一眼就能看出来,然后再针对异常门店单独下钻。另外,如果分组后想看多个数值列的分布,df.groupby('门店')[['销售额', '销量']].describe()也能正常返回多层列索引的结果,配合.T可以横向比较不同门店、不同指标之间的差异。
4. 实操案例:用 describe() + groupby() 完成一份门店销售分析
4.1 把分析问题翻译成Pandas代码
场景设定:某饮品连锁的数据分析人员收到一份近期的销售流水数据,想回答三个问题:
- 整体销售额分布如何,有没有异常值或极端情况?
- 三个门店中,哪个门店总销售额最高?哪个门店的单量最多?
- 不同门店在不同品类上的销售表现有什么差异?尤其是咖啡这个主打品类。
这三个问题刚好覆盖了describe()、单列groupby、多列groupby三种用法。我的习惯是先想清楚要回答什么问题,再写代码,而不是拿到数据就开始跑。这两者的区别在真实项目里非常明显:前者每一步都有明确目的,后者常常跑了一堆代码,最后发现没有一个能回答问题。
4.2 完整代码与输出解读
第一步,先做整体体检:
python复制sales_desc = df['销售额'].describe()
print(sales_desc)
输出结果里重点关注mean和50%的差距。如果两者差距很大,说明分布偏斜,少数大单把均值拉高了。再看max,如果明显超出一般水平,说明存在异常大单。拿这份模拟数据举例,max和75%之间的差距如果很大,你就要思考:这个最大值是一个真实的大订单,还是数据录入时的错误?describe()本身不会告诉你答案,但它能提示你去查。
第二步,按门店聚合:
python复制store_summary = df.groupby('门店')['销售额'].agg(['sum', 'mean', 'count'])
print(store_summary)
在这个输出里,sum是总销售额,count是订单行数,mean是平均每单金额。总销售额高但单均低的门店,说明走的是薄利多销路线;总销售额一般但单均很高的门店,可能靠的是大单或者高价产品。这里要注意,count是订单行数而不是卖了多少杯,如果业务上关心杯量,应该看销量列。
第三步,多维度交叉:
python复制category_summary = df.groupby(['门店', '品类'])['销售额'].mean().unstack()
print(category_summary)
unstack()的作用是把内层索引(品类)转换到列上,变成"门店×品类"的矩阵,方便横向比较。这一眼就能看出:A店是不是咖啡卖得特别突出,B店的果茶是否拖了后腿。矩阵格式比一长串分组结果更贴近业务人员看报表的习惯。
4.3 结果表达:如何把分析结果讲给业务同事听
代码跑完之后,真正的考验是把结果翻译成人话。比如你会发现A店的总销售额最高,但原因是它的单价偏高而不是销量大;C店的订单量最多,但单均金额低。这就是分组分析的价值所在:总销售额排名只是表象,拆开门店和品类之后,才能看到驱动差异的真正因素。
在写分析结论时我一般会配合如下格式:先说整体结论,再用数字佐证,最后给出一个业务上可执行的建议。比如"咖啡品类整体销售额占比最高,B店在咖啡品类的平均客单显著低于其他门店,建议核对产品定价和促销策略"。这是完整数据分析完该有的样子,而不是把代码输出直接贴给业务方。
5. 常见问题与排查技巧
5.1 为什么 groupby 之后门店列不见了
这是使用groupby()时最高频的困惑。原因在于:分组键默认被当作结果索引,而不是普通列。如果希望分组列出现在结果里,或者想减少后续reset_index()的操作,在groupby时加as_index=False即可:
python复制df.groupby('门店', as_index=False)['销售额'].sum()
或者分步处理:
python复制result = df.groupby('门店')['销售额'].sum()
result = result.reset_index()
两种写法的差别是:as_index=False一步到位,reset_index()自由度更高,比如可以顺手rename。新手建议先学会reset_index(),因为它的逻辑更直白;用多了之后自然会习惯as_index=False的简洁。
5.2 数据里有缺失值时,mean 和 sum 会不会算错
先说结论:groupby()的聚合函数默认会跳过NaN,但"跳过"的逻辑有时会让人意外。比如sum()时,如果一组里全是NaN,结果会是0而不是NaN,这在某些业务场景里会误导。如果你想保留NaN,可以用min_count参数,不过这个参数比较冷门。更稳妥的做法是:在分组前先决定好缺失值策略,是删除、填充还是保持原样。真实项目里我有一次分析订单数据,有一个门店某天全部订单缺失了销售额字段,而聚合后它显示为0,差点被当成"当天没有营业"写进报告里。后来养成了习惯:分组前先df.isnull().sum()检查一遍。
5.3 结果索引乱套,想恢复成普通DataFrame
多列分组或者链式操作之后,结果可能带着一层又一层索引,阅读和导出都不方便。最直接的办法是reset_index():
python复制df.groupby(['门店', '品类'])['销售额'].mean().reset_index()
这样就把"门店×品类"从索引降级成普通列。注意多次reset_index()并不会把索引分层全部拍平,需要指定level参数。多数情况下一次reset_index()就够用,所以我建议只要不是特别复杂的操作,就分步写,每一步都确认一下输出结构,别到最后一锅端。
5.4 性能小技巧:聚合操作尽量一次完成
如果要对不同的列做不同的统计,不要写多个groupby再合并,比如:
python复制# 不推荐:多次分组多次遍历
a = df.groupby('门店')['销量'].sum()
b = df.groupby('门店')['销售额'].mean()
数据量小的时候没问题,但一旦数据量上到百万行,这种写法性能很差,因为每次groupby都要完整扫描一遍数据。正确做法是用一次agg()完成全部计算:
python复制df.groupby('门店').agg(
总销量=('销量', 'sum'),
平均销售额=('销售额', 'mean'),
订单数=('订单号', 'count')
)
一次遍历就把所有分组计算做完,速度和可读性都更好。
5.5 一个自查清单
最后分享一个我自己调试Pandas代码时用的检查顺序:
- 先确认dtypes有没有读错类型,字符串当数值用是一切错误的源头。
- 确认数据行数与
describe()里的count是否一致。 - 确认groupby键的取值是否是自己以为的那些类别。
- 确认结果索引是索引还是列,如果是索引,决定是否
reset_index()。 - 最后用
head()人工抽查几行,验证数字是否在合理区间。
按这个顺序排查,绝大多数问题都能在十几分钟内定位。
我自己刚开始用Pandas的时候,总觉得describe()和groupby()太简单,宁可去背一些复杂的透视表函数。后来写了几百次分析代码才意识到,日常80%的数据分析需求,这两个方法就能覆盖。它们一个回答"数据整体怎么样",一个回答"按某个维度拆开后怎么样",组合起来就是一个完整的数据分析闭环。希望这篇里的经验能帮你少踩几个我踩过的坑,真正把这个基础打扎实。
