ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

pandas cut分箱避坑指南:解决min值丢失与区间边界问题

2026/10/8 16:16:06 拓冰建站 浏览量
pandas cut分箱避坑指南:解决min值丢失与区间边界问题 平时做数据分析最常碰到的一个操作就是分箱。销售数据里把用户按年龄分段、把订单按金额分层、把活跃时长按区间归类这些活儿本质上都是拿连续数值换成一个档次。pandas 里的cut就是专门干这个的但它有一个特别容易踩的坑数据的min值会平白无故变成NaN而且区间边界的开闭规则如果没搞懂分箱结果大概率和你心里想的不一样。这篇文章主要针对cut分箱里的区间边界、右闭合与左闭合差异、min值丢失问题以及我实际踩过的各种高频报错做一个完整复盘适合刚接触 pandas 的初学者也适合用了很久但还没系统梳理过cut参数的人。1. 分箱的本质先搞懂 cut 是在帮你做什么1.1 一个真实到不能再真实的场景先说个场景。你拿到一张用户表里面有一列age年龄从 15 到 70 不等。老板让你把用户分成青年、中年、老年三档给运营做差异化营销。这时候如果用最笨的办法就是写一堆np.where嵌套条件比如df[age_group] np.where(df[age] 20, 青年, np.where(df[age] 35, 中年, 老年))短期看没问题但一旦分组规则变了或者分组档位从 3 档变成 8 档这堆np.where嵌套能把人活活绕晕。而且np.where处理的是边界值语义上很容易产生歧义比如 20 岁到底算青年还是中年没人能一眼看出来。pd.cut解决的就是这个痛点。它接收一个连续数值列再传入一组区间边界输出一个Categorical类型每个值对应一个区间标签。分几档、每一档边界是什么全部集中在一个参数里可读性和可维护性都强很多。另外cut返回的是一整个分类变量后续接groupby、value_counts、agg都非常顺。1.2 cut 的核心参数一张表看明白pd.cut的完整签名是pd.cut(x, bins, rightTrue, labelsNone, retbinsFalse, precision3, include_lowestFalse, duplicatesraise, orderedTrue)我把关键参数列成一张表方便对照参数名默认值作用最容易踩的坑bins必填整数表示等分成几段数组表示自定义边界边界序列必须是单调递增的否则报错rightTrue区间是否右闭合即边界是否算作本区间默认是左开右闭min值容易被漏掉labelsNone分箱后显示的名称传False时返回整数编码数量必须和分箱数一致否则直接报错retbinsFalse是否返回具体的边界数组只有在传整数bins时差异明显include_lowestFalse第一个区间是否包含左边界很多人不知道它只在rightTrue时才有意义precision3区间显示精度浮点边界容易显示成很长的数duplicatesraise出现重复边界时的处理方式用duplicatesdrop可以自动去重orderedTrue返回的分类是否有序pandas 2.0 后有细微行为变化这些参数里最需要先理解的是bins和right的交互。bins传一个数组时比如[20, 35, 50, 65]实际会生成 3 个区间。如果你以为这能分成 4 段那就错了它会分成 3 段。这个区间数量 边界数量 - 1的规则是很多初学者第一次跑代码就懵掉的地方。2. 区间边界决定数据生杀大权的那条线2.1 左开右闭与左闭右开谁算进来谁算出去cut默认的区间语义是左开右闭rightTrue也就是说每个区间长这样(bins[i-1], bins[i]]圆括号表示不包含方括号表示包含。举个例子bins[20, 35, 50]时默认分成区间 0(20, 35]大于 20 且小于等于 35区间 1(35, 50]大于 35 且小于等于 50注意看35 这个数字被归进了区间 0而不是区间 1。这是很多人刚开始容易搞混的地方边界值到底算哪一边默认情况下右边界算进来的左边界不算。我习惯用一个快递计费的类比理解假设 1kg 内一档1kg 到 3kg 一档。如果包裹正好 1kg你会希望它算进便宜的那档还是贵的那档快递公司如果把计费区间写成超过 1kg 到 3kg收取 8 元那正好 1kg 的包裹应该走更便宜的一档这就是左开右闭的直觉右边的临界点算进本区间左边的临界点推给上一个区间。反过来的rightFalse则是左闭右开[bins[i-1], bins[i])这时候 35 会被归进区间 1而不是区间 0。这两套规则没有绝对的对错但绝对会改变结果。排错的第一步永远是先问自己我的边界值希望进哪个区间2.2 边界重合时的归属规则边界重合的情况更隐蔽。比如数据里刚好有一个值是 20而bins[20, 35, 50]默认情况下这个 20 会变成NaN。原因很简单默认第一个区间是(20, 35]左开所以 20 不在第一个区间内同时它也不在任何其他区间里只能被丢进NaN。看看这个实际运行效果import pandas as pd import numpy as np df pd.DataFrame({age: [15, 20, 25, 30, 35, 40, 45, 50, 55, 60]}) bins [20, 35, 50, 65] df[group_default] pd.cut(df[age], binsbins) print(df)输出age group_default 0 15 NaN 1 20 NaN 2 25 (20, 35] 3 30 (20, 35] 4 35 (20, 35] 5 40 (35, 50] 6 45 (35, 50] 7 50 (35, 50] 8 55 (50, 65] 9 60 (50, 65]看第 0 行和第 1 行15 比最小边界 20 还小变成NaN不奇怪但 20 恰好等于最小边界bins[0]也被丢了这就是左开的锅。很多人在这一步就开始怀疑人生我明明把 20 写进边界了怎么数据跑掉了这其实是cut按设计工作的结果默认仓库的墙在左边边界上的值等于在墙外。你要做的就是加开一扇门。2.3 一个隐藏的组合拳right include_lowestinclude_lowestTrue存在的意义就是给第一扇墙开个门。它把第一段区间从(bins[0], bins[1]]变成[bins[0], bins[1]]使得bins[0]这个最小值能顺利归入。但注意它只改第一个区间。什么意思如果你数据里有 15就算加了include_lowestTrue15 依然小于 20照样是NaN。很多人加了参数发现没效果就是这个原因——你处理的对象根本不在那个区间里。right和include_lowest的四组组合我用一张表总结rightinclude_lowest第一段区间典型结果TrueFalse(bins[0], bins[1]]bins[0]丢失TrueTrue[bins[0], bins[1]]bins[0]保留FalseFalse[bins[0], bins[1])bins[0]保留FalseTrue[bins[0], bins[1])无额外变化从这个表可以直接看出两个结论第一include_lowest只有在rightTrue时才发挥实质作用第二如果数据的最小值就等于bins[0]你有两种办法让它保留要么include_lowestTrue要么rightFalse。这两种办法会对其他边界值产生不同的重分配影响所以要结合业务需求选。3. min 值丢失最容易让新手翻车的坑以及三种解法3.1 复现问题为什么最小值会变 NaNmin值丢失的本质可以总结成一句话数据最小值不在任何分箱区间内。要么是小于bins[0]要么是恰好等于bins[0]但区间左开把它拒之门外。我实际测试过一份销售数据金额最小值是 0bins[100, 500, 1000]。理论上 0 元应该算进低价档但cut直接给我赋了一堆NaN。第一次碰到的时候我以为是数据问题排查了半天后来发现是区间语义问题。这类问题最大的迷惑性在于不是所有数据都丢失只是最小的一批丢失统计结果看起来好像也能用但分组后的样本数和原始对不上后面的准确率全受影响。复现代码很简单import pandas as pd import numpy as np data pd.Series([0, 150, 300, 600, 900, 1200, 1500]) bins [100, 500, 1000, 2000] labels [low, mid, high] result pd.cut(data, binsbins, labelslabels) print(result)输出0 NaN 1 low 2 low 3 mid 4 mid 5 high 6 high dtype: category0 被丢成了NaN。很多人一看dtype: category就以为处理没问题直到result.isna().sum()不为 0 才发现异常。3.2 解法一include_lowest 扩展边界既然问题根因是最小值不在范围内最直接的做法就是让边界覆盖住数据的全部取值。如果数据的最小值恰好就是边界值用include_lowestTrue就够了result pd.cut(data, bins[100, 500, 1000, 2000], labels[low, mid, high], include_lowestTrue)这样 100 就能被包含进来了。但如果数据里还有比bins[0]更小的值比如上面的 0那include_lowest也救不了。这时候需要把边界手动往下扩展让第一个边界小于或等于数据的最小值bins_extended [-1, 100, 500, 1000, 2000] # 开一扇给0的区间如果数据里可能存在负值可以写成bins_extended [np.NINF, 100, 500, 1000, np.inf]np.NINF和np.inf是正负无穷用它们做边界可以保证任何合理的数据都会被兜住不会出现NaN。这个方法简单暴力效果最稳定缺点是第一个区间的语义会变成小于 100 的都在 low如果业务上要求 low 有下界就需要额外处理。3.3 解法二rightFalse但要留意最大值第二种解法是直接切换开闭规则把right设成Falseresult pd.cut(data, bins[100, 500, 1000, 2000], labels[low, mid, high], rightFalse)这时候区间变成左闭右开[100, 500)[500, 1000)[1000, 2000)100 可以被归入 low但注意最后一个区间的右边界 2000 是开放的如果数据里有 2000它也会变成NaN。也就是说rightFalse把最小值丢失的问题转移到了最大值可能丢失。实际操作中我更推荐数据最小值两边都留出余量别让关键边界刚好卡在数据极值上。你可以先看看数据的真实范围再决定边界print(data.min(), data.max())如果max等于bins[-1]建议要么改rightTrue要么给最后一个边界加一个冗余值。3.4 解法三np.inf 兜底适用于不确定的数据范围如果你不确定未来数据的取值范围比如以后可能突然出现一笔超大金额我建议直接设定bins [-np.inf, 100, 500, 1000, np.inf] labels [low, low-mid, mid-high, high]这样写的好处很明显不论来多少数据哪怕负数、零、超大数都被包在区间里不会因为新数据超出边界而产生一堆NaN。但副作用是边界不够对齐分类标签的语义会偏宽泛比如low这一档实际覆盖的是小于 100和100 到 500两个段如果业务上要区分 0 元和 100 元这个方案就不合适。所以我个人经验是np.inf做兜底只适合探索性分析阶段正式报表里最好还是根据业务知识定死边界并加一段防御性检查确认isna()的数量为零。3.5 分箱后的数据怎么用groupby / agg 实操解决掉NaN问题之后分箱真正发挥价值的地方是和分组统计配合。df pd.DataFrame({ 年龄: [18, 22, 25, 30, 35, 40, 45, 50, 65, 70], 消费金额: [300, 800, 1200, 500, 1000, 2000, 1500, 3000, 2500, 1800] }) df[年龄段] pd.cut(df[年龄], bins[0, 20, 35, 50, np.inf], labels[青年, 中青年, 中年, 老年]) print(df.groupby(年龄段, observedTrue).agg( 人数(年龄, count), 平均消费(消费金额, mean), 最高消费(消费金额, max) ))输出人数 平均消费 最高消费 年龄段 青年 1 300 300 中青年 2 650 800 中年 4 1125 2000 老年 3 2433 3000这里有一个很容易被忽略的参数observed。pandas 1.x 之后groupby一个Categorical列时如果某个分类没有任何样本默认可能会保留这个空类别导致结果里出现 0 计数的空行。用observedTrue可以不展示空分组用observedFalse则保留所有类别这在需要补齐所有档位的场景反而有用比如按档位做汇总图缺档会让图出现空洞。我建议看情况选择而不是无脑固定一个。4. 高频报错与疑难杂症排查实录4.1 常见问题与解决方案速查表实际问题永远比文档丰富。我把这几年用cut踩过的坑集中整理成一个表每个都可以直接当排查手册用症状根本原因解决方案分箱结果里出现NaN数据超出边界或min等于最左边界且左开include_lowestTrue、扩展边界或用np.inf兜底Length of bins must be greater than number of labelslabels数量不等于区间数保证len(labels) len(bins) - 1bins must increase monotonically边界数组没有严格递增检查是否有重复或倒序的边界值全部数据集中在同一个区间等宽分箱遇到长尾分布改用pd.qcut按分位数分箱分箱标签变成(0.0, 2.0]这种看不懂的东西没传labels区间对象直接显示按业务需求传labels或labelsFalse浮点边界显示成0.30000000000000004浮点数精度问题显式设置precision2groupby后出现空白分组Categorical中保留空类别groupby(..., observedTrue)或dropnaTrue转成str后排序错乱Categorical的有序性丢失用astype(category)并重设categories顺序分箱数不对明明有 5 个边界结果只有 4 段bins数组被当成自定义边界记住区间数 边界数 - 1相同边界反复出现但抛错bins里有重复值duplicatesdrop自动去重4.2 排查思路三步走遇到分箱异常我先建议按这三步走而不是直接去查资料第一步打印出cut返回值里的NaN数量和具体值cat pd.cut(df[age], binsbins) print(cat.isna().sum()) print(df[age][cat.isna()].describe())看看丢失的都是偏小还是偏大。如果全是偏小十有八九是左开边界问题如果偏大看是不是rightFalse导致的右边界开放。第二步把bins和实际数据的范围对齐print(df[age].min(), df[age].max(), bins)这个对比一目了然边界没有覆盖数据范围就一定会有NaN。第三步看bins的数量和labels的数量是否匹配。这个错误在日志里会直接报出来但有时候是labels写成了和边界数量一样的长度导致区间数和标签数差 1报错信息会像这样ValueError: Bin labels must be one fewer than the number of bin edges这时候去数一数就行不用慌。4.3 一个很少人提但很致命的坑分箱后的数据类型cut返回的是Categorical不是普通的字符串或整数。这个类型有两面性好处是它可以有序有categories和codes两个内部表示坏处是很多人不知道它是个分类变量后续操作会产生连锁反应。比如你把分箱结果直接存回 DataFrame想用df[年龄段] 青年做筛选没问题但如果把它转成strdf[年龄段_str] df[年龄段].astype(str)排序就变成了字典序中青年和老年会按照拼音或者 Unicode 码排序完全失去原有顺序。如果你还想保留顺序就得显式指定df[年龄段_cat] pd.Categorical(df[年龄段_str], categories[青年, 中青年, 中年, 老年], orderedTrue)另外cut返回的Categorical默认是有序的这在 pandas 2.0 之后表现更稳定。如果你用labelsFalse得到的是一个整数编码数组这样虽然丢了可读性但排序天然正确而且节省内存。数据量特别大的场景我会倾向用labelsFalse加一个独立的标签映射表既保证效率又方便业务人员理解。还有一个细节retbinsTrue返回的边界数组在你用整数bins时可以看到实际切分点。求等宽分箱的实际边界有奇效cat, edges pd.cut(df[age], bins4, retbinsTrue) print(edges)对比一下手动指定的bins和retbins返回的边界你就知道 pandas 内部是怎么等分数据范围的。这个办法有时候也能刚好多出一个预想不到的NaN因为最小那个边界就是data.min()它可能是左开放的。我个人在处理分箱任务时最后总是顺手跑一条断言assert pd.cut(df[age], binsbins, include_lowestTrue).isna().sum() 0有这道保险在后面统计结果无论怎么折腾都不会被那个莫名消失的最小值拖后腿。cut本身不复杂核心就是边界、开闭、标签这三件事。把这三件事想明白分箱这个操作基本就通了。如果是想按数据分布等频切分就去看看qcut两种工具的适用场景不一样但思路是一脉相承的。