1. 先把症状钉死:那条报错到底在说什么
Pandas里做完groupby之后调用.mean(),控制台甩出一句No numeric types to aggregate,你揉揉眼换成.sum(),代码立刻跑通还返回了结果,于是第一反应多半是"这库是不是有 bug"。我最早碰到这情况是在一个渠道对账脚本上,当时折腾了小半个下午,最后发现跟 pandas 的 bug 一点关系都没有,纯粹是自己把数字读成了字符串。groupby、mean、sum这套组合在日常取数里太常见,No numeric types to aggregate也成了高频报错,但它的含义其实非常直白:分组之后,你交给聚合函数的那些列里,一个numericdtype 都没有。
这句话拆开看有两层。第一层是"聚合函数的可见范围",.mean()只认数值类型,int8、int64、float32、float64、Int64、Float64这些都在名单里,object、string、category、bool统统不在;第二层是"分组之后",groupby会把作为分组键的列从待聚合的列集合里剔除,所以有时候你明明看见原表里有数字列,但它在分组键里,等于被"吃掉"了。这两层叠加,就会出现"我明明有数字啊"的错觉。
先说清楚适合谁看:如果你写过df.groupby(...).mean()并见过这条报错,或者你正在做数据清洗、报表汇总、指标计算,被"看起来是数字实际是字符串"坑过,那这篇内容对你有用。完全没有 pandas 基础也能跟上,我会把 dtype 这一层讲透,因为这是所有问题的根。至于.sum()为什么没报错,那不是它聪明,而是它的行为跟mean根本不是一回事,后面单独拆。
1.1 一个能稳定复现的最小例子
不要凭记忆改代码,先造一份能百分百复现的数据,改起来才有参照。我用一段虚构的销售流水做演示,三个字段:区域、金额、数量,金额带千分位逗号和货币符号,数量前面有个看不见的空格,这两处是现实里最常见的污染源。
import pandas as pd raw = pd.DataFrame({ "region": ["华东", "华东", "华南", "华南", "华北"], "amount": ["1,280.50", " 980.00", "2,400.00", "1,150.75", "3,010.00"], "qty": ["12", "8", "20", "9", "25"], }) print(raw.dtypes)输出是:
region object amount object qty object dtype: object三列全是object。在 pandas 的语义里,object是一个"什么都能装"的容器,它可以是 Python 的str、int、Decimal、list、dict,甚至是这些类型的混合。正因为什么都能装,pandas 无法预判"对一列object求平均"是什么意思,于是选择拒绝。接着执行:
raw.groupby("region").mean()在 1.x 系列上会直接抛DataError: No numeric types to aggregate;在 2.x 系列上文案会变成TypeError,大意是某个值无法转换成数值。两种表现指向同一个病灶:没有可参与算术的列。而换成:
raw.groupby("region").sum()居然能出结果,而且amount列变成了"1,280.50980.00"这种被拼在一起的字符串。看到这个输出,问题成因基本就浮出水面了。
1.2.sum()不报错的真实原因
sum和mean在 pandas 内部走的是两条完全不同的路径。mean的数学定义要求先做加法再除以计数,前提是每个元素都能参与算术运算,所以它对 dtype 有硬性要求;sum的定义宽松得多,任何定义了"加法"语义的对象都能求和,字符串的加法就是拼接,列表的加法就是合并。Python 里"a" + "b"是合法的,pandas 只是把这个语义借了过来。
所以object列上的groupby().sum(),实际发生的是字符串连接,不是数值累加。这也是为什么结果看起来"有值",但拿去做下游计算立刻出问题——你得到的是"1,280.50980.00",而不是2260.50。更隐蔽的情况是混合列:某个分组里全是数字字符串,另一个分组里混进了一个None或者空串,sum可能在某组正常工作、另一组悄悄丢弃,最后你拿到一份半对半错的汇总表,比直接报错危险十倍。
提示:聚合前先用
df.dtypes扫一眼。只要参与计算的列不是int/float系,就别急着往下跑,sum能跑通不代表结果对。
还有一种容易被忽略的情形:列里既有数字又有字符串,比如["12", 12, "8"]。这时sum会在拼接过程中抛TypeError,因为int + str不合法;而mean依然报"没有数字类型"。同一份数据,两个函数报不同的错,很多人就此怀疑人生。
1.3 空表也会报同一句话
把场景换一下。你写了个带筛选的脚本,先过滤再加groupby:
result = raw[raw["region"] == "西北"].groupby("region").mean()数据里根本没有"西北",过滤完是空表。空 DataFrame 在经过某些groupby路径后,参与聚合的列集合为空,同样会抛出"没有数字类型聚合"这类错误。这种情况跟 dtype 无关,属于"逻辑上没数据",排查方向完全不同——先打印len(filtered),如果结果是 0,问题出在筛选条件而不是数据类型上。我在排查别人脚本时遇到过好几次,对方坚称"数据类型没问题啊我检查过",一看样本量是零。
2. 根因拆解:dtype 才是决定聚合行为的那只手
把上面三种情况收拢,会发现它们都由同一个变量支配:列的 dtype。pandas 不是"看值猜类型"的库(虽然在read_csv阶段它会猜,但猜完就固定下来了),一旦某列被定为object,后续所有聚合行为都会按object的规则走。理解这一点,No numeric types to aggregate就不再是玄学,而是一个可以提前预防的确定性结果。
2.1object列上mean与sum的分岔路径
展开讲讲内部逻辑。当你调用df.groupby("k").mean()时,pandas 会做这几件事:按k把行分到不同组,对每组取一个子 DataFrame,然后在这个子集上尝试对每一列求均值。求均值时它会检查列的 dtype 是否属于数值族,属于就交给底层的 Cython 聚合例程批量计算,不属于就跳过;如果所有非分组列都被跳过了,最终没有任何一列产出结果,它就认为这次聚合没有意义,抛错终止。
sum的检查宽松得多。它对object列有一个 fallback 分支,走到 Python 层面用functools.reduce之类的机制做逐元素累加。字符串能累加,所以它不报错;但如果列里混进了不能相加的类型,它会在累加中途炸掉,错误信息还未必指向真正的那一行。
用生活场景打个比方:mean像个严格的会计,看到一列写着"壹仟贰佰"的汉字金额,直接拒收,因为它只认阿拉伯数字;sum像个不挑食的登记员,你给它什么它就往本子上抄,汉字也抄、数字也抄,抄完你还以为账对上了。麻烦就在这儿——拒收的会计至少提醒了你表格有问题,不挑食的登记员反而掩盖了问题。
这也是我坚持"报错是好事"的原因。遇到No numeric types to aggregate,说明 pandas 在帮你守门;遇到sum顺利返回,反而要多问一句:它到底加的是什么?
2.2 分组键被"吃掉"造成的假象
第二种高频误判:原表里明明有float64列,为什么还说没有数值列?因为那列在groupby的参数里。
df.groupby("amount").agg({"region": "count"})这里的amount是分组键,它不会出现在聚合结果里,也不参与"待聚合列"的判定。真正被聚合的只有region,而它是object,所以.mean()会报错。如果你写的是df.groupby("amount").mean(),pandas 会尝试对其余所有列求均值,结果只剩object列,同样炸。
更隐蔽的是groupby传了列表:
df.groupby(["region", "amount"]).mean()两列都成了键,剩下的列如果全是字符串,报错就来了。这类问题在多层分组、透视表、pivot_table里特别常见,因为分组维度往往是维度表里的编码字段,本身是字符串。
还有一种"列被吃掉"的情况来自索引。如果某个数值列已经被set_index变成索引,groupby之后的.mean()默认不会把它算进去(除非你用reset_index()把它放回列)。有人纳闷"我明明有 price 列",一查df.columns里根本没有,它在df.index.names里躺着。
提示:
groupby之后先执行list(g)里的列名,或者打印g.obj.columns和g._selection(内部属性,仅调试用),确认聚合范围里到底有哪些列。
2.3 版本差异:numeric_only默认值变过
这套行为在 pandas 2.0 前后发生过一次明确的调整,很多人升级环境后代码突然报错,就是撞在这上面。在 1.x 时代,DataFrameGroupBy.mean的numeric_only默认值倾向于"自动丢弃非数值列",所以只要还有一列是数字就能跑,报错只发生在"一个数字列都没有"的时候。2.0 起,这个默认值改成了False,意思是"我不管你是什么类型,全都尝试算一遍",于是object列会被真的丢进均值计算里,在转换环节抛TypeError,文案也变了。
两种表现本质相同,但错误信息不同,排查时的第一反应也该不同:
| pandas 版本 | 报错文案特征 | 直接含义 | 处理策略 |
|---|---|---|---|
| 1.x | DataError: No numeric types to aggregate | 待聚合列里没有任何数值列 | 转 dtype 或补数值列 |
| 2.x | TypeError: Could not convert ... to numeric | 尝试对非数值列做算术 | 显式numeric_only=True或先清洗 |
| 任意版本 | 空结果集 | 筛选后无数据 | 检查过滤条件 |
知道版本差异有个实际好处:你在网上搜到的解决方案可能来自另一个大版本,直接照抄会失效。我一般会在脚本开头写一句print(pd.__version__),排查环境问题时省事。
3. 诊断流程:五步定位到具体那一列
报错信息只告诉你"没有数值类型",不告诉你是哪一列、为什么不是。所以诊断要自己动手。下面这套流程是我在多个项目里反复用过的,从快到慢排列,正常情况下前三步就能锁定问题。
3.1 第一步:打印 dtype 全景
最直接的动作永远是df.dtypes。它能一眼看出哪些列是object,哪些是数值。但要注意,只看dtypes有时会漏判——比如Int64(大写 I,可空整型)和int64在聚合行为上一致,都是数值;而category即使里面装的都是数字字符串,也仍然是分类类型,mean会拒收。
更细的检查用df.info(),它会顺带告诉你非空值的数量,能提前发现"整列都是 NaN"这种极端情况。还有一个辅助函数值得记住:
num_cols = df.select_dtypes(include="number").columns.tolist() print("可用于聚合的数值列:", num_cols)如果输出是空列表,报错就解释得通了。select_dtypes支持include=["number", "bool"]这类组合,也可以exclude="object"反过来筛,比起手工比对列名可靠得多。
3.2 第二步:抽查原始值,别信 dtype 的表面
object只是"非数值"的统称,具体是什么还得看值。我习惯抽几行打印:
df["amount"].head(10).tolist()常见发现包括:数字前后带空格、带千分位逗号、带货币符号、用括号表示负数、带百分号、十六进制、全角数字、空字符串、"NULL"这样的占位符。这些值肉眼看着"是数字",pd.to_numeric一跑全是NaN,原因就在这里。
更狠的做法是看值的类型分布:
df["amount"].map(type).value_counts()如果输出里同时出现str和float,说明这一列是混合类型,处理时必须先统一成字符串再清洗,否则to_numeric遇到混合类型可能在不同版本上表现不一致。
3.3 第三步:验证转换可行性
看到脏值别急着改,先做一次"试探性转换",统计失败率:
s = pd.to_numeric(df["amount"], errors="coerce") print("转换失败数:", s.isna().sum() - df["amount"].isna().sum()) print("失败样本:", df.loc[s.isna() & df["amount"].notna(), "amount"].head())errors="coerce"会把转不了的值变成NaN而不是抛错,配合isna统计就能知道有多少行会"消失"。这一步很关键,因为很多人直接astype(float),一旦遇到脏值就整行报错,连样本行号都没有。用coerce先摸底,再决定是清洗还是丢弃。
3.4 第四步:确认聚合范围没被掏空
前面说过分组键会被剔除。验证方法是显式指定要聚合的列:
df.groupby("region")[["amount", "qty"]].mean()用双方括号选中具体列,如果这时能跑通,说明原表没问题,只是之前的聚合范围包括了不该包括的列;如果仍然报错,问题就在这两列本身。显式指定列还有一个附加好处:报错时错误堆栈里的列名更明确,排查速度明显快一截。
另外一个检查点是筛选后的行数:
subset = df[df["region"] == "西北"] print(len(subset), subset.dtypes.to_dict())行数为 0 时,.mean()无论什么 dtype 都出不来结果,别浪费时间改类型。
3.5 第五步:确认索引和多重列名
如果数据来自pivot_table、read_excel的多表头,或者做过unstack,列名可能是MultiIndex,表面看列名叫"amount",实际是("2024", "amount")。这时df["amount"]会返回子 DataFrame 而不是 Series,后续聚合容易出幺蛾子。检查方法是:
print(type(df.columns), df.columns[:5])遇到MultiIndex就df.columns = df.columns.get_level_values(-1)压平,或者用df.droplevel(0, axis=1),压平之后一切照常。
4. 修复方案:从临时止血到长期治理
诊断清楚就该动手。修复分三个层次,按投入从低到高排:临时让代码跑通的补丁、针对性清洗脏值的处理、以及从数据进入系统时就锁死 dtype 的治理。三个层次各有适用场景,我不建议永远停在第一层,因为那只是把问题往后推。
4.1 最省事的一招:numeric_only=True
如果确认非数值列不参与统计、只是顺带被带进了聚合范围,加一个参数就能解决:
df.groupby("region").mean(numeric_only=True)它的语义是"只对能算的列算,其余忽略"。适用于两类场景:一是原表本来就混着描述性字段(备注、名称、地址),你只关心数值指标;二是你已经确认那些object列里没有需要转成数字的业务数据。
但这一招有明显的适用边界,用之前必须回答一个问题:那些被忽略的object列,里面装的到底是文本还是伪装成文本的数字?如果是后者,加参数只是把错误藏起来,你的均值结果会缺列,报表口径直接错掉。我见过有人为了"让脚本别报错"随手加了这个参数,跑了一个月才发现金额列从来没被统计进去。
在 2.x 环境下,配合版本行为变化,这个参数建议显式写出来,别依赖默认值:
df.groupby("region").mean(numeric_only=True)4.2 读入时定型:read_csv的关键参数
能修在最上游就别修在下游。read_csv有几个参数专门处理这类脏数据,用好之后后面几乎不用改:
| 参数 | 作用 | 适用场景 |
|---|---|---|
dtype | 指定列类型,如{"amount": "float64"} | 结构稳定的固定报表 |
thousands | 指定千分位符号,如"," | 金额字段带逗号 |
decimal | 指定小数点符号,如"," | 欧式数字格式 |
na_values | 自定义缺失值标记 | "-"、"NULL"、"N/A" |
converters | 按列自定义转换函数 | 复杂清洗逻辑 |
engine | "c"或"python" | 特殊格式解析失败时切换 |
thousands=","特别值得说,它会自动把"1,280.50"解析成1280.5,一步到位,比事后str.replace稳妥。但要注意它只处理标准格式,遇到"1,280.50 元"这种尾缀仍然会失败,得靠converters:
def parse_amount(x): if pd.isna(x): return x return float(str(x).replace(",", "").replace("元", "").strip()) df = pd.read_csv("data.csv", converters={"amount": parse_amount})converters的代价是慢,它是逐元素调用 Python 函数的,几十万行以内还好,上百万行就明显拖速。大批量数据我一般先用thousands加dtype兜底,剩下的漏网之鱼单独处理。
提示:
dtype指定为float64时,只要有一个值转换失败整列就会报错,所以它更适合"格式已经验证过"的稳定数据源,不适合第一次接触的陌生文件。
4.3 事后清洗:to_numeric的标准姿势
数据已经进了内存,或者来源是接口、数据库,就在DataFrame层面清洗。核心函数是pd.to_numeric,三个参数要记牢:errors、downcast、以及配合字符串操作的预处理。
s = df["amount"].astype("string").str.strip() s = s.str.replace(r"[,\s\u00a0\u200b]", "", regex=True) s = s.str.replace(r"^\((.*)\)$", r"-\1", regex=True) df["amount"] = pd.to_numeric(s, errors="coerce")这段做了四件事:统一转成可空字符串类型避免NaN捣乱、去掉首尾空白、去掉千分位逗号和不可见字符、把括号负数改成负号形式。\u00a0是不间断空格,\u200b是零宽空格,这两个从网页复制或者 Excel 导出时特别容易出现,肉眼完全看不出来,是排查时最耗时间的坑之一。
errors="coerce"的取舍要想清楚:它把脏值变成NaN,让代码能跑下去,但意味着这部分数据会被静默丢弃。我的习惯是转换后立刻统计:
bad = df["amount"].isna() & raw_amount.notna() print(f"转换失败 {bad.sum()} 行") print(df.loc[bad].head())如果失败行数不为零,就去看一眼具体是什么格式,补进清洗规则,而不是直接.fillna(0)糊弄过去。
4.4 写一个能复用的批量清洗函数
真实项目里需要清洗的列往往有十几二十个,逐列手写不现实。我常用的写法是把"列名到清洗策略"映射成配置,函数按配置批量处理:
import pandas as pd def coerce_numeric_cols(df, cols, percent_cols=(), currency=True): df = df.copy() for col in cols: s = df[col] if pd.api.types.is_numeric_dtype(s): continue s = s.astype("string").str.strip() pattern = r"[\s\u00a0\u200b,]" if currency: pattern = r"[\s\u00a0\u200b,¥$€£]" s = s.str.replace(pattern, "", regex=True) s = s.str.replace(r"^\((.*)\)$", r"-\1", regex=True) if col in percent_cols: has_pct = s.str.endswith("%", na=False) s = s.str.rstrip("%") num = pd.to_numeric(s, errors="coerce") num = num.where(~has_pct, num / 100) else: num = pd.to_numeric(s, errors="coerce") df[col] = num return df几个设计考虑值得说明。第一,先用is_numeric_dtype跳过已经是数字的列,避免重复转换损失精度,尤其是int64转成float64会带上浮点误差。第二,百分比列单独处理,因为"12.5%"去掉百分号得到12.5,业务含义是0.125,必须除以 100,这一步漏了会导致整张报表数值放大一百倍。第三,函数返回copy(),不修改入参,避免调用方数据被悄悄改掉——这条在多人协作的项目里能省掉大量扯皮。
4.5 保护不该被转换的列
清洗最怕误伤。订单号、邮编、商品编码、身份证号这些字段长得像数字,一旦被转成int或float,前导零会消失("007"变7),超长数字会丢精度(19 位以上的编码转float64直接失真)。所以清洗必须是白名单式的:明确列出要转的列,其余一律不动。
判断方法很简单,看这列满不满足两个条件:是否参与算术运算、是否允许丢失原始字符串形态。两个都为"是"才转。订单号只做分组和关联,永远不参与求和求平均,那就老老实实留着string类型。
如果既要保留原值又要参与计算,就建一份影子列:
df["amount_raw"] = df["amount"] df["amount_num"] = pd.to_numeric(...)后续groupby用amount_num,展示和核对用amount_raw,出问题时能对着原始值回溯,比只留一份转换后的数据踏实得多。
5. 特殊数据形态的清洗清单
真实数据的脏是五花八门的,光靠一条通用正则覆盖不全。下面这几类是我踩过坑之后专门记下来的,按出现频率排。
5.1 千分位、货币符号、百分号
千分位逗号是最常见的。处理时要注意str.replace在 pandas 2.x 里regex默认值改成了False,虽然对逗号这种字面量没影响,但如果你写的模式里有.、(、)这类正则元字符,行为会跟 1.x 不一样。稳妥做法是显式传regex=False处理字面量,需要正则时传regex=True。
货币符号除了常见的几种,还有一种"尾缀"形态:"1280.50 CNY"、"1280.50元"。这种不能靠strip去头尾解决,得用正则把非数字字符整体干掉:
s = s.str.replace(r"[^\d.\-]", "", regex=True)但要小心负号和小数点。这个模式保留-和.,能处理"-1,280.50元";代价是如果原始值里有多余的小数点(比如"1.280.50"),会被拼成"1.28050"然后转换失败。所以还是那句话:转换后必须统计失败数。
百分号的处理前面提过,核心是"去符号"和"除 100"两件事必须绑在一起做。
5.2 括号负数与全角字符
财务系统导出的数据常用括号表示负数:"(1,280.50)"意思是-1280.50。如果只去掉括号不补负号,符号就反了,而且不会报错,属于典型的"静默错误"。清洗时用捕获组把括号里的内容整体替换成带负号的形式:
s = s.str.replace(r"^\((.*)\)$", r"-\1", regex=True)注意要先处理括号再处理其他符号,顺序反了可能导致括号匹配不上。
全角数字"123"也是常见坑,从某些系统复制粘贴时会有。pd.to_numeric对它无能为力,得先做全角转半角:
def to_halfwidth(text): if not isinstance(text, str): return text result = [] for ch in text: code = ord(ch) if code == 0x3000: code = 32 elif 0xFF01 <= code <= 0xFF5E: code -= 0xFEE0 result.append(chr(code)) return "".join(result) s = s.map(to_halfwidth)这段转换对中文没有影响(中文字符不在0xFF01到0xFF5E区间),所以可以放心地整列过一遍。
5.3 不可见字符与空白
空格问题看起来小儿科,实际排查起来极其耗时。除了普通空格,还有前面提到的\u00a0(不间断空格)和\u200b(零宽空格),它们在编辑器里显示为空白或者干脆不显示,isna()判定为False,to_numeric转换失败,你盯着屏幕看不出任何异常。
我的做法是在清洗正则里统一带上:
s = s.str.replace(r"[\s\u00a0\u200b\u200e\u200f]", "", regex=True)\u200e和\u200f是方向标记,在中东语言混排的文本里偶尔出现,一并清掉无害。清理完再用s.str.len()看看有没有长度为 0 的残留,空串也要转成NaN,否则to_numeric会把它当无效值处理。
还有一种是数字中间夹空格,比如"1 280.50"。上面这条正则会把它拼成"1280.50",刚好是对的。但如果空格是分隔符(比如"1 280"表示两个编号),拼起来就错了。所以清洗前要理解业务语义,别想当然。
5.4Decimal、混合类型与科学计数法
有些系统(金融、财务类居多)为了精度会用 Python 的Decimal类型存金额,读进 pandas 后列 dtype 是object。这时mean同样报错,但值本身是能算的。解决办法是转成float64(会有微小精度损失)或者用object上的 Python 级聚合:
df.groupby("region")["amount"].apply(lambda x: sum(x) / len(x))后一种慢,但精度保留完整。数据量不大、对金额精度要求高时值得用。
科学计数法字符串"1.28e3"通常to_numeric能直接处理,不用特殊清洗。倒是"1,28e3"这种混了千分位的会失败,属于千分位规则和科学计数法冲突,只能先剥逗号。
还有一种是混合类型列:一部分是float,一部分是str。这种列dtype显示为object,to_numeric在某些版本上对混合类型的行为不太稳定。稳妥做法是先astype("string")统一成字符串再转:
s = df["amount"].astype("string").str.strip() df["amount"] = pd.to_numeric(s, errors="coerce")这一步会稍微慢一点,但能规避掉大量难以复现的边界问题。我在几个跨版本项目里都用这个写法,稳定性明显好于一上来就to_numeric。
6. 常见问题速查与避坑心得
前面讲的是"怎么想"和"怎么做",这节讲"怎么快速查"。我把遇到的问题整理成表格,遇到类似症状直接对号入座,能省掉不少来回折腾。
6.1 报错与现象速查表
| 现象 | 最可能的原因 | 快速验证 | 处理方式 |
|---|---|---|---|
No numeric types to aggregate | 待聚合列全是object | df.dtypes看列类型 | 清洗转数值或numeric_only=True |
sum能跑但结果像字符串拼接 | 对object列做了字符串连接 | 看返回值是不是长串文本 | 说明列没被转成数值,必须清洗 |
明明有float64列还报错 | 该列在分组键或被设为索引 | 检查groupby参数与df.index | 显式指定聚合列,或reset_index |
| 筛选后报错 | 结果集为空 | len(subset) | 检查过滤条件是否写错 |
2.x 环境报TypeError而非DataError | 版本行为差异 | pd.__version__ | 显式传numeric_only或先清洗 |
转换后大量NaN | 脏值太多,coerce吞掉了 | 统计转换失败行数 | 抽样看原始值,补清洗规则 |
| 订单号前导零消失 | 误把 ID 列当数值转换 | 看转换后的长度是否变短 | ID 列保留string类型,不参与计算 |
| 数值放大一百倍或缩小 | 百分号处理只去了符号没除 100 | 抽查几个原始值 | 去掉%后乘以 0.01 |
| 符号反了 | 括号负数只去了括号没补负号 | 看是否有"(123)"形式 | 用捕获组替换成-123 |
| 转换代码跑得很慢 | 用了converters或apply逐行 | 计时对比 | 优先向量化的str方法 |
这张表覆盖了大约九成的现场情况。剩下那一成通常是数据源本身的特殊格式,只能具体情况具体分析。
6.2 几条踩出来的经验
第一条,别在groupby之后才做类型转换。我早期习惯先分组再看哪列报错,然后回头处理,结果是每次报错只能发现一列问题,改完再跑又冒出下一列,来回拉扯。正确顺序是进DataFrame就做一次 dtype 体检,把所有该转的列批量转完,再进聚合。这一步多花十秒钟,能省掉半小时的来回。
第二条,给关键列加断言。数据管道跑在后台的时候,没人盯着输出看,coerce悄悄吞掉几行你根本不知道。我现在习惯在清洗之后加一段:
assert df["amount"].dtype.kind == "f", "amount 未转成浮点" assert df["amount"].isna().mean() < 0.01, "amount 缺失率超过 1%"第一行守住类型,第二行守住数据质量。阈值按业务定,缺失率突然从 0.2% 跳到 5%,通常意味着上游格式变了,这时候报错比默默跑完有价值得多。
第三条,对同一份数据做"均值交叉验证"。清洗完做一次sum和len的手工核对:拿几行原始数据,用计算器算一遍均值,跟 pandas 的结果对一下。这听起来笨,但能抓住符号错误、百分比倍数错误、千分位丢失这类"代码不报错但结果错"的问题。我在一个季度报表里就是这么发现金额被放大了 100 倍的——代码一路绿灯,数字完全不对。
第四条,保存一份清洗前后的对照样本。每次批量转换后随机抽 20 行,把原始值和转换值并排存下来,出问题时直接对着看。比事后翻日志快得多,也比凭记忆猜可靠。
第五条,pandas 版本写进依赖文件并锁死。requirements.txt里写pandas==2.1.4而不是pandas>=2.0,因为numeric_only这类默认值的变化属于静默行为变更,不锁版本的话,某天部署到新环境,昨天跑通的脚本今天就报错,而且报的错还是同一句"没有数字类型聚合",让人误以为是数据问题。这个坑我在两个项目里都踩过,后来所有数据脚本都强制锁版本。
第六条,把numeric_only=True当止痛药而不是解药。它能让你快速恢复运行,但它不会告诉你那列数据到底该不该参与统计。加完参数之后,回头一定要确认被忽略的列里没有业务需要的数值。判断方法很简单:把被忽略的列名打印出来,逐个人工过一遍,问一句"这列里有数字吗"。有,就说明清洗漏了。
第七条,遇到混合类型先统一再转换。astype("string")这一步看着多余,实际能规避大量版本差异带来的边界问题。代价是性能略降,但数据量在百万行以内基本感知不到。
最后分享一个我常用的调试小工具,三行代码,出问题时粘上去就能定位:
def peek(df, col): print(df[col].dtype, "|", df[col].map(type).value_counts().to_dict()) print(df[col].head(5).tolist())它会同时告诉你 dtype 和值的实际 Python 类型,混合类型的列一眼就能看出来。比单独看dtypes多一层信息,比info()少一堆噪音。我在排查"明明是数字为什么不是数字"这类问题时,第一件事就是跑它。