ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pandas cut函数详解:从数值分段到等级标签的优雅实现

Pandas cut函数详解:从数值分段到等级标签的优雅实现 先抛一个场景。手头有一份用户积分表运营那边要按积分数值把用户分成“普通”“青铜”“白银”“黄金”四档比如500分以下是普通500到1000是青铜1000到2000是白银2000以上是黄金。刚接触Pandas的时候碰到这种需求我的第一反应是写一堆if-elif再套个for循环或者apply。数据量小的时候这么写没啥感觉等表里躺着几十万行数据那段代码跑起来真的是又臭又长又慢。后来换了思路把“把连续数值切成多段、再贴上等级标签”这个动作交给Pandas的cut函数一行业务代码就干完了。这就是我写这篇的原因cut是Pandas里把数值分段变成等级标签最趁手的工具但它的边界规则、参数搭配、返回类型好多人在用的时候没有真正吃透容易在不知不觉中踩坑。这篇文章适合学过一点Pandas、但还没系统玩过cut的读者。我会从最基础的使用场景讲起把边界值归属、NaN处理、自定义边界这些日常绕不开的问题全部梳理一遍最后再上几个分析场景里的组合玩法。代码都是可以直接复制运行的。1. 为什么说cut是给数据“分等级”最趁手的工具1.1 先看传统写法的痛点假设我有这样一份积分数据import pandas as pd import numpy as np np.random.seed(42) df pd.DataFrame({ user_id: [fU{i:04d} for i in range(1, 21)], score: np.random.randint(0, 3000, size20) }) print(df.head())如果不了解cut常规思路是写一个函数然后用apply逐行去判断def score_level(score): if score 500: return 普通 elif score 1000: return 青铜 elif score 2000: return 白银 else: return 黄金 df[level] df[score].apply(score_level)这段代码逻辑清楚数据量小也没问题。但仔细想想它有几个隐患阈值一变函数体就得改阈值多了之后条件分支越来越长。apply本质上是逐行遍历性能在百万行级别会明显变差。函数只写一次还行如果要在多个项目里复用这套逻辑还得把阈值抽出来做配置非常麻烦。我自己在真实项目里干过这种事当时表里是300多万行订单金额数据要用金额区间给订单打上“小额”“中额”“大额”的标签。apply跑一趟等了将近半分钟每次改完阈值重新跑都很痛苦。后来我意识到数值分级的本质就是“把连续区间切分成若干个离散区间然后把每个数对应到区间的标签上”这个动作完全可以用向量化操作代替而Pandas里最直接的实现就是pd.cut。1.2 cut解决的不只是“懒”pd.cut的核心作用是把连续型数值按照给定的区间边界进行切分返回一个Categorical类型的序列。这个序列里的每一个元素对应的是原始数值所在的区间或者你给它起的等级标签。看一个最直白的例子scores pd.Series([200, 450, 800, 1500, 2500, 999]) result pd.cut(scores, bins[0, 500, 1000, 2000, 3000]) print(result)输出结果里每个数值坐落的区间是(0, 500]这种形式。200和450落在(0, 500]800落在(500, 1000]1500落在(1000, 2000]2500落在(2000, 3000]。这里需要先建立第一个关键认知cut默认的分箱区间是左开右闭的也就是(左边界, 右边界]这一点后面会展开说因为它直接决定了某些边界值会掉进哪个箱子。相比if-elif方案cut的好处体现在三个层面性能优势。cut是底层向量化实现数据量越大和apply的差距越明显。代码即配置。分箱边界就是bins参数等级名称就是labels参数阈值调整只需要改一行配置完全不用动业务逻辑。返回类型是可利用的。Categorical类型天然支持排序、分组、频数统计甚至能直接传给模型做类别特征这一点比普通字符串标签强得多。1.3 cut和qcut别把两个工具搞混了这里必须提一下它的兄弟pd.qcut。很多初学者把两者混为一谈实际它们的切分逻辑完全不同cut按数值区间宽度等分或者按你指定的边界切分每个区间覆盖的数值范围可能一样但区间里的样本数量可能差很多。qcut按分位数切分每个区间里的样本数量大致相等但每个区间的数值范围可能完全不同。举个最直观的例子。10个学生的成绩分别是[10, 20, 30, 40, 50, 60, 70, 80, 90, 100]。用cut以2个箱子切分得到的是50分以下和50分以上用qcut以2个箱子切分会把数据分成前5名和后5名即使前5名里有10分这种极端值。所以选择逻辑很清晰如果业务关心的是某个固定数值区间比如成绩60分及格、年龄18岁成年用cut如果业务关心的是排名位置比如前20%的用户用qcut。日常做等级划分绝大多数是前者。2. cut的完整参数拆解从一份积分数据说起2.1 bins传整数自动等宽切分bins参数最常见的两种用法第一种是传一个整数表示把数据范围等分成几段。df[level_auto] pd.cut(df[score], bins4) print(df[[score, level_auto]])这里cut会自动把score的最小值到最大值这个范围等分成4个宽度相同的区间然后返回每个值所在的区间。这种方式优点是不用自己动脑定边界缺点是切分后区间边界长得很“丑”通常是带小数点的比如(0.291, 750.0]这种所以它更适合先快速探索数据分布不适合直接作为业务等级标签。2.2 bins传列表自定义边界才是日常业务上做等级划分绝大多数是自定义边界。我要把积分切成“普通 0-500”“青铜 500-1000”“白银 1000-2000”“黄金 2000-3000”就把边界写成列表传进去bins [0, 500, 1000, 2000, 3000] df[level] pd.cut(df[score], binsbins)这里要特别留意列表里有5个边界值切出来是4个区间。很多初学者在这儿迷糊总觉得左边界的数量和区间数量应该一一对应其实边界和区间的关系是“区间数 边界数 - 1”。如果业务只有4个等级边界列表就是5个元素。还有一个常见的进阶写法边界可以不是等距的。比如bins [0, 100, 500, 1500, 3000] df[level_uneven] pd.cut(df[score], binsbins)这样切出来的等级里第一个区间跨度只有100分第二个区间跨了400分后面跨度越来越大。这种不等宽分箱在实际业务里非常常见因为低分段用户占大多数高分段用户稀少用等宽边界会导致低分段一个箱子塞满人高分段每个箱子只有几个人等级就失去了区分意义。用生活化的方式理解等宽分箱就像把一条马路每隔100米立一个路标不等宽分箱则是市中心200米一个路标郊区2公里一个路标——因为市中心岔路多、人流密你需要更精细的参照郊区人烟稀少参照物就没必要那么密。业务分级的底层逻辑完全一样。2.3 labels参数把区间转成有业务含义的等级名默认情况下cut返回的标签是(0, 500]这样的区间字符串。往报表上一放运营看到的是区间不是等级价值就打了折扣。这时用labels参数给它换名字bins [0, 500, 1000, 2000, 3000] labels [普通, 青铜, 白银, 黄金] df[level] pd.cut(df[score], binsbins, labelslabels)这样一跑df[level]里就是“普通”“青铜”“白银”“黄金”这些性格化的等级名称而不是(0, 500]这种生硬的数学区间。特别注意一个点labels的元素个数必须和区间个数一致也就是等于len(bins) - 1。多了少了都会直接报错。这是我见过频率最高的一个报错先记住这个规则后面能省不少事。2.4 左开右闭与include_lowest边界值到底归谁这是cut最容易让人迷惑的一环。前面提过cut的默认区间是左开右闭也就是(a, b]一个数恰好等于左边界a时它不会进入这个区间而是进入前一个区间。举个例子s pd.Series([100, 200, 499, 500, 999, 1000]) bins [0, 500, 1000] labels [低等级, 高等级] out pd.cut(s, binsbins, labelslabels) print(out)结果里500不会进入(0, 500]这个区间而是进入(500, 1000]。同理1000在(500, 1000]的右边界上进得去但1000同时也是下一个区间的左边界所以它不会落在下个区间。这就是左开右闭的含义。由此引出一个非常反直觉的坑当左边界正好等于数据的最小值时这个最小值会被划到区间外变成NaN。比如我的积分数据里最低score是42上面生成的那组数据如果bins从0开始那没问题因为42大于0它落在(0, 500]里。但如果临界值恰好是0比如某个字段的最小值正好是0而你指定bins[0, 100, 500]那么0这个值不属于(0, 100]也不属于任何前面的区间结果就是NaN。解决方法是同时加上include_lowestTruedf[level] pd.cut(df[score], bins[0, 500, 1000, 2000, 3000], labels[普通, 青铜, 白银, 黄金], include_lowestTrue)include_lowestTrue的作用是把第一个区间从(0, 500]变成[0, 500]左闭右闭。这样0分也能正常进入第一个等级不会产生NaN。我自己的经验是只要bins列表的第一个值有可能等于数据的最小值或者你不确定数据最小值是多少就无脑加上include_lowestTrue。它不会破坏正常数据的切分只会让左边界“闭起来”是防边界NaN最省心的保险。2.5 right参数如果你想要“左闭右开”有人会问我能不能不要右闭改成左闭右开可以用rightFalse。df[level_left_closed] pd.cut(df[score], binsbins, labelslabels, rightFalse)rightFalse时区间变成[a, b)卡在右边界上的值会进到下一个区间。这个参数用在某些特殊业务条件下很有用比如“满500减100”这种满赠活动正好500分的用户应该算作达标那你就得保证500落在达标区间里。默认配置下500落在(500, 1000]这个区间如果等级边界的低位是500那500会落到上一档这时要么用rightFalse要么调整边界值。2.6 retbins参数让cut告诉你它到底怎么切的bins传整数时你只知道cut会自动切成N段但不知道每段的具体边界。如果想要边界值把retbinsTrue加上out, bin_edges pd.cut(df[score], bins4, retbinsTrue) print(bin_edges)返回的bin_edges是一个NumPy数组里面就是自动切出来的所有边界。这个参数最典型的应用场景是先用cut(binsn, retbinsTrue)摸一遍数据分布拿到边界值之后再手动微调成整齐的业务边界传给正式的cut做等级切分。两步走既快速又可控。3. 边界值归属与NaN最容易踩的两个坑3.1 卡在刀口上的数据边界归属不一致踩坑这种事光看文档感受不深我举个例子大家就明白了。scores pd.Series([500, 1000, 1500, 2000, 2500]) bins [0, 500, 1500, 2500] labels [L1, L2, L3] out pd.cut(scores, binsbins, labelslabels) print(out)运行结果如下500 → NaN落不进(0, 500]因为500是左边界1000 → L1因为1000在(500, 1500]里1500 → L3因为1500是(500, 1500]的右边界落进去了但1500也是(1500, 2500]的左边界所以不落L2这个结果跟很多人的直觉完全相反。卡在1500的数值竟然直接被归到L3而不是L2。原因就是一个数同时是两个相邻区间的边界时它永远属于右边区间默认右闭情形。这种边界归属错误一旦出现在业务报表里是会直接引发客诉的。比如积分商城换礼品规则是1000分档和1500分档结果正好1500分的用户被划进了2500分档后台配置的权益就对不上了。所以我在实际项目里定过一个简单粗暴的规矩边界值一律避开数据的真实取值。比如积分基本不会出现小数但为了让分级稳妥我可以把边界设为[0, 499.999, 999.999, 1999.999, 3000]或者更优雅的方式是配合rightFalse和include_lowestTrue组合把规则统一成左闭右开或者左开右闭。3.2 边界不覆盖全量数据时多出来的值全部变NaN另一个高频坑是数据里存在超出bins最大边界的值或者小于bins最小边界的值这些数据全部会变成NaN。s pd.Series([100, 500, 1500, 3200]) out pd.cut(s, bins[0, 500, 1000, 2000], labels[A, B, C]) print(out)3200大于最大边界2000直接变NaN。这个NaN和原始数据里的空值混在一起如果不处理后面做分组统计时会把这部分用户丢掉导致统计结果和实际情况对不上。对我个人来说这个坑比边界归属还隐蔽因为它不报错就是静默丢数据。数据量小的时候肉眼能发现几十万行数据里混着几百个超范围值根本看不出来。所以我现在的习惯是分箱之后立刻执行一次isna().sum()检查看看有没有非预期的NaN数量。df[level] pd.cut(df[score], bins[0, 500, 1000, 2000, 3000], labels[普通, 青铜, 白银, 黄金]) nan_count df[level].isna().sum() print(fNaN数量: {nan_count})如果NaN数量不为0就得先看这些NaN对应的分数是多少然后决定怎么处理如果是边界最小值导致的NaN加include_lowestTrue。如果是有大于最大边界的超高分可以把bins最大边界扩到np.inf。如果有缺失值本身那就按照业务规则单独填充或删除不要留给cut。3.3 检查阈值用value_counts能看出多少端倪分完档后我几乎必做的一个检查就是value_counts看各档人数分布print(df[level].value_counts())这一步会暴露出好几类问题如果某档人数异常少可能是边界定得不合理如果出现NaN说明存在边界外数据。而且value_counts默认按频数降序排列看人数分布非常直观。value_counts还有一个参数叫dropna默认是True也就是NaN档不会出现在结果里。如果你想把NaN也统计进去写成value_counts(dropnaFalse)这样能直观看到有多少数据没被分进任何档。3.4 Categorical类型的特性等级顺序天然可排序cut返回的是Categorical类型不是普通的字符串。这个特性很多人没意识到但它非常有用。Categorical会记住类别的顺序——也就是bins列表里区间的顺序所以即使你打印出来看到的是“普通”“青铜”“白银”这些字符串它们内部的排序逻辑还是按原始区间来的。df[level] pd.cut(df[score], binsbins, labelslabels) print(df[level].dtype) # category print(df.sort_values(level))上面这段排序结果不是按拼音或首字母排而是按“普通 青铜 白银 黄金”的业务顺序排这对后续做分组对比非常友好。如果换成普通字符串类型排序就乱了还得手动指定CategoricalDtype的categories顺序非常麻烦。4. 分箱不只是打标签cut在数据分析与建模中的常见组合玩法4.1 先知道每个分数段有多少人cut配合value_counts最简单的分析需求是看每个等级的分布。value_counts默认按箱子的逆序排列但更常见的需求是按业务顺序展示这时用sort_index()dist df[level].value_counts().sort_index() print(dist)它返回的是“普通多少人、青铜多少人、白银多少人、黄金多少人”的结果一目了然。4.2 给分布加上比例配合normalizeTrue只看绝对人数还不够想一眼看出占比用normalizeTruedist_ratio df[level].value_counts(normalizeTrue).sort_index() print(dist_ratio)这样每档占比就出来了比如普通用户占了百分之多少黄金用户是不是过于稀少。这一步在业务报表里几乎是必用它能在几分钟内帮你判断当前边界定的合不合理。若某档占比过高比如80%的人都挤在普通档这个等级的区分度就不够需要调整边界。4.3 进阶玩法groupby统计不同等级的核心指标等级打上去之后典型需求是看不同等级的用户在另一个指标上的表现。比如除了积分还有消费金额或活跃天数想比较不同等级的平均消费。df[spend] np.random.randint(0, 5000, sizelen(df)) grouped df.groupby(level, observedTrue)[spend].agg([count, mean, sum]) print(grouped)这样就把每个等级的人数、平均消费、总消费全部拉出来了。这里我用observedTrue是因为pandas 2.0之后groupby分类列时默认observedFalse会保留所有可能的类别组合有时候会产生一些“幽灵”分组用observedTrue可以避免。这种组合分析在运营场景里分量很重。比如我之前处理过一个商城积分项目运营想知道不同等级用户的客单价是否真的存在梯度差。用cut分完等级后直接groupby计算结果发现白银用户的平均消费反而比黄金用户高说明积分规则和消费行为的耦合度有问题需要调整积分策略。整个分析过程十分钟搞定如果没有cut光是给几十万用户打等级标签就得写一长串滚动条件。4.4 可视化前把Categorical转成有序数值如果要做柱状图或者热力图经常需要把等级标签转成数值顺序可以用cat.codesdf[level_code] df[level].cat.codes print(df[[level, level_code]])cat.codes按照类别内部顺序从0开始编号普通对应0青铜1银2黄金3。这个序列在后续做模型特征的时候很好用因为模型通常不能直接吃字符串但可以直接吃有序整数。这里有一个坑必须提醒cat.codes在遇到NaN时会返回-1。如果数据里有没分进任何等级的值它的code是-1后续建模时要注意处理别把-1当正常值喂给模型。4.5 分类特征进入模型前的哑变量转换如果业务认为等级之间是并列关系而不是递增关系建模前需要做one-hot编码dummies pd.get_dummies(df[level], prefixlevel) print(dummies.head())这样每一列代表一个等级值为0或1适合逻辑回归、线性模型这类不能直接吃有序类别的算法。这里需要根据业务判断到底用有序编码还是哑变量如果等级之间的差别是“越高越好”一般保留有序编码如果等级只是类别归属彼此没有大小关系就用哑变量。4.6 cut与可视化的经典搭配画直方图时拦腰截断连续值分布也是cut的强项。与其直接用df[score].plot.hist()看一堆毛刺不如先手动分段再画柱状图import matplotlib.pyplot as plt score_bins [0, 500, 1000, 2000, 3000, np.inf] df[score_bucket] pd.cut(df[score], binsscore_bins, labels[0-500, 500-1000, 1000-2000, 2000-3000, 3000]) counts df[score_bucket].value_counts().sort_index() counts.plot(kindbar) plt.title(积分分段分布) plt.show()这样画出来的图每个柱子对应一个等级段分布情况一目了然。如果直接画连续值直方图非专业看图的人很难快速读出“高分段人群占比多少”这个信息。4.7 在机器学习项目里用cut做特征工程承接上面的话题实际建模流程里cut不只在分析阶段发挥作用特征工程阶段也是个高频工具。比如年龄、收入、金额这类连续变量经常因为离群值太多或者分布过于偏斜直接进模型效果不好。把它们用cut离散化成等级特征既能平滑异常值的影响又能保留一定的排序信息。具体操作上有一点经验值得分享正式建模前cut的边界最好基于训练集确定然后原样应用到测试集上。比如先对训练集做cut得到bins列表再用同一份bins去切测试集避免测试集的信息泄漏到训练过程中。代码上可以这样做train_bins [0, 500, 1000, 2000, 3000] train[level] pd.cut(train[score], binstrain_bins, labelslabels) test[level] pd.cut(test[score], binstrain_bins, labelslabels)这里有一个很容易被忽略的问题如果测试集中出现了大于3000的分数切完会变NaN建模前要么单独处理要么在bins里预留np.inf作为最大边界。我自己在做风控模型时遇到过类似情况。训练集里金额最大值是20000于是bins上界定在20000上线后发现测试集里出现了30000的样本全部变NaN特征缺失率一下多了好几个点。后来统一改成“最小边界用-np.inf最大边界用np.inf”从根上杜绝了这类问题。5. 按实际项目经验整理的cut用法速查表再分享一个我自己常用的速查表覆盖大多数日常场景放在手边可以随时翻使用场景推荐写法关键点快速等宽分箱pd.cut(x, bins4)自动边界适合初探分布自定义边界等级pd.cut(x, bins[0, 100, 500, 1000], labels[A,B,C])bins数量labels数量1最小值可能等于下边界pd.cut(x, bins[0, 100, 500], include_lowestTrue)防止下边界值变NaN边界统一归下档pd.cut(x, bins[0, 100, 500], rightFalse)得到[a, b)区间需要拿到自动边界edges pd.cut(x, bins3, retbinsTrue)[1]返回边界数组用于后续微调防止超范围值变NaNpd.cut(x, bins[0, 100, 500, np.inf])最大值上限设为无穷分类列作为特征编码df[level].cat.codesNaN会编码成-1注意处理统计各档人数df[level].value_counts().sort_index()按业务顺序显示分布分组聚合df.groupby(level, observedTrue)[value].mean()panda 2.x建议加observedTrue建模前onehotpd.get_dummies(df[level])适合类别之间无排序关系的场景这张表基本覆盖了我日常工作里会用到的全部cut场景。真要归纳一条经验cut的坑几乎都集中在边界值归属和NaN处理两个方向上写代码的时候对这两个点保持警觉就能避开90%的问题。6. 个人经验总结cut用得顺不顺手关键在边界思维做数据处理这几年我逐渐意识到cut看起来是个五分钟就能上手的函数真正决定它好不好用的其实是你的“边界思维”是否清晰。一份数据被切分时卡在刀口上的那些值怎么处理区间外部的值怎么收容这两个问题想透了cut的各种参数搭配自然就记住了想不透就会反复在边界NaN、错档、丢数据这些坑里打转。最后分享一个我一直在用的小习惯凡是涉及cut分档的代码我都会在分箱后紧跟着一行检查代码确认NaN数量是否符合预期assert df[level].notna().sum() len(df), f存在 {df[level].isna().sum()} 个未分箱样本这行断言在开发阶段能帮我快速发现边界设置不合理的地方省去了反复核对数据的麻烦。等你跑习惯了会发现这个小小的防御式写法能帮你省下大量排查问题的时间。另外如果你刚开始学建议拿一份真实数据动手跑一遍——比如把自己手机的APP使用时长、每月的消费记录或者随便一张Excel里的数值列用cut分个三档五档再配着groupby和value_counts走一遍全流程。踩过几个边界值的坑之后你对分级这件事的感觉就完全不一样了。
返回列表