第11讲 Categorical 分类数据

📎 配套代码第11讲_Categorical.py
📊 配套数据data/quote/ data/industry/ —— 交易数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)

🎬 开场:分档标签排序,排出来是乱的

第 10 讲用 qcut 把市值切成五档,标签是”最小/小/中/大/最大”。现在按档位分组算平均 PE:

db["q"] = pd.qcut(db["total_mv"], 5, labels=["最小","小","中","大","最大"])
db.groupby("q", observed=True)["pe"].mean()
['最小', '小', '中', '大', '最大']        ← 顺序正确

同样的数据,如果那一列是普通字符串:

db["q_str"] = db["q"].astype(str)
db.groupby("q_str")["pe"].mean()
['中', '大', '小', '最大', '最小']        ← 顺序乱了

分组结果的内容一样,但档位的顺序全乱了。画出来的图、写进报告的表,五个档位不再是从小到大排列。

更麻烦的是比大小:

"最小" >= "大"        # True

字符串比较按 Unicode 码点逐字符比,”最”的码点大于”大”,于是”最小档”被判定为大于等于”大档”。这个结果荒谬,但它不报错。

qcut 原样返回的那一列可以正常比:

(db["q"] >= "大").sum()        # 2132

区别在于 qcut 返回的不是字符串,而是一个有序的 Categorical。它知道这五个档位的先后关系。

db["q"].dtype        # category

这一讲讲清这个类型:它怎么存、为什么能表达顺序、为什么能把 10MB 的列压到 0.4MB,以及什么时候不该用它。


🎯 这一讲结束时,你能

  • 说出 Categorical 的两件套:codes(整数码)+ categories(唯一值表)
  • 用有序类别表达档位、评级这类天然有顺序的数据,让排序和比较符合业务含义
  • 判断一列该不该转 category——并知道用错了会更费内存
  • .cat 访问器改类别而不动数据
  • 避开三个坑:拼接时类别不一致、observed 参数造出大量空组、不能赋新类别的值

一、🧰 codes + categories:重复的东西只存一次

一列 A 股代码,17 万行,但只有 300 个不同的值。object 类型下,”600519.SH” 这个字符串被独立存了几千遍。

Categorical 的做法是建一张唯一值表,每行只存一个整数下标:

object:
  [ptr][ptr][ptr][ptr]...          17 万个 8 字节指针
    │    │    │    │
    ▼    ▼    ▼    ▼
  "600519.SH" "000001.SZ" "600519.SH" ...   每个字符串对象独立存在,重复的存了几千遍

category:
  categories(唯一值表,只存一次)
  ['000001.SZ', '000002.SZ', ..., '600519.SH', ...]     300 个字符串,就这一份
        ▲                              ▲
        │  codes 里的整数 = 表的下标     │
  codes(每行一个小整数)
  [3821][0][3821][2][0]...              17 万个 int16,每个 2 字节

读第 i 行的值,就是 categories[codes[i]]——一次查表。

两条创建路径

s.astype("category")                                   # 最常用
pd.Categorical(["b", "a", "c"])                        # 直接造
s.astype(pd.CategoricalDtype(["低","中","高"], ordered=True))   # 指定顺序和有序性

看内部结构

c = s.astype("category")
c.cat.categories      # 唯一值表
c.cat.codes           # 整数码
c.cat.ordered         # 是否有序

codes 的类型按类别数自动选

   100 个类别 → codes.dtype = int8      (1 字节)
   200 个类别 → codes.dtype = int16     (2 字节)
 40000 个类别 → codes.dtype = int32     (4 字节)

类别数超过 127 就装不进 int8,只能升到 int16类别越多,每行的码越占字节,省内存的效果越弱——这直接决定了下面第三节的判据。


二、🧰 有序类别:让顺序成为数据的一部分

开场那个问题的解法,就是显式声明顺序。

dtype = pd.CategoricalDtype(["最小", "小", "中", "大", "最大"], ordered=True)
s = s.astype(dtype)

s >= "大"           # 能比大小
s.max()             # '最大'
s.sort_values()     # 按你定义的顺序排,不是字母序

顺序由 categories 列表的书写顺序决定,不是字母序:最小=0、小=1、中=2、大=3、最大=4。于是 s >= "大" 底层就是 codes >= 3,一次整数比较。

量化里哪些列天然有序

顺序
因子分档 最小 < 小 < 中 < 大 < 最大
信用评级 C < B < BB < BBB < A < AA < AAA
风险等级 低 < 中 < 高
财报期 Q1 < Q2 < Q3 < Q4

这些列如果存成普通字符串,排序和比较全都会按字母序,得到无意义的结果——而且不报错。

IMPORTANT: 🔑 astype("category") 默认是无序的
直接 astype("category") 得到的类别按字母/数值排序、且 ordered=False。它不是你数据里出现的顺序,也不是你心里的大小顺序。
要表达顺序必须用 CategoricalDtype 显式指定,或者事后 s.cat.reorder_categories([...], ordered=True)
好消息是无序类别比大小会直接报错TypeError: Unordered Categoricals can only compare equality or not),而不是像字符串那样静默给你一个错误答案。报错比静默出错强。

qcutcut 直接返回有序类别

pd.qcut(db["total_mv"], 5, labels=["最小","小","中","大","最大"]).dtype   # category

分箱天然就是”少数几个档位 + 每个值属于哪一档”,正好是这个结构,而且档位之间有大小关系。所以第 10 讲的分箱结果开箱即用,只要你别顺手 astype(str) 把它降级成字符串。

🎮 随堂快练

QUESTION: 一列信用评级 ["BBB", "AA", "A", "BBB"],你要选出所有”A 级以上(含 A)”的。直接写 s >= "A" 会怎样?
TIP: 👉 答案
如果 s 是普通字符串,"BBB" >= "A"True(B 的码点大于 A),于是 BBB 也被选进来了——评级更低的反而被当成更高的
正确做法是先声明有序类别:

order = ["C", "B", "BB", "BBB", "A", "AA", "AAA"]
s = s.astype(pd.CategoricalDtype(order, ordered=True))
s[s >= "A"]        # 只剩 AA 和 A

评级这类”字面顺序和实际顺序相反”的数据,是这个坑的重灾区。


三、🧰 什么列该转,什么列不该

低基数列:效果显著

拿这份 17 万行行情表实测:

行数 不同值 object category codes
ts_code 171,347 300 10 MB 0.4 MB 27.0× int16
trade_date 171,347 574 10 MB 0.4 MB 24.9× int16

这就是第 08 讲那个 10MB → 0.4MB 的来源。17 万行里只有 300 个不同的代码,重复度极高。

高基数列:反而更费

hi = pd.Series([f"id{i}" for i in range(300000)])     # 30 万行,30 万个不同值
object    17.0 MB
category  26.6 MB      ← 更费了

每个值都只出现一次,categories 表和原数据一样大,还额外多存了一列 codes

IMPORTANT: 🔑 两条判据,缺一不可
第一条:重复度够高吗。

s.nunique() / len(s)

真实数据上:ts_code 是 0.0018(省 27 倍),唯一 ID 是 1.0(更费)。经验上比值小于 0.5 才值得转,小于 0.05 效果显著。

第二条:这列要不要做算术。
光看比值会误判。close 收盘价 17 万行里有 21,324 个不同值,比值 0.1244,重复度看着还行。实测转过去 0.7 MB → 0.8 MB,反而更费。但就算它省内存也不该转,因为:

cat * 2            # TypeError
cat.mean()         # TypeError
cat.pct_change()   # TypeError
cat > 10           # TypeError

分类列不支持任何算术运算。 收盘价要算收益率、要求均值、要比阈值——转成 category 等于把这列废掉。
省不省内存是次要的,能不能算才是决定性的。
所以判据是:重复度高,且这列只用来分组、筛选、贴标签。数值型的度量列(价格、成交量、财务数字)一律不转,哪怕重复度看起来很合适。

这也回答了第 10 讲末尾那个问题:低基数的标签列用 category,高基数的文本列用 string[pyarrow]。两者省的是不同的东西——前者省重复,后者省 Python 对象开销。


四、🧰 .cat 访问器:改类别不动数据

分类专属操作都挂在 .cat 下,和字符串的 .str 是同一种设计。

方法 作用 动 codes 吗
.cat.rename_categories([...]) 改类别名字 不动,只换表头
.cat.add_categories([...]) 加新类别 不动
.cat.remove_categories([...]) 删类别,对应值变缺失 相关码变 -1
.cat.remove_unused_categories() 删掉没被用到的类别 重排码
.cat.reorder_categories([...]) 重定义顺序 重排码
.cat.as_ordered() / .as_unordered() 切换有序性 不动

rename_categories 特别便宜:一个 17 万行的列改类别名,改的只是那张几百项的表,codes 一个字节都不用碰。

这是”改元数据、不动大数据”的通用思路——第 12 讲 MultiIndex、第 15 讲 groupby 都会再用到。

NOTE: 💡 codes 是只读的
c.cat.codes[0] = 5 改不了。因为码必须始终指向 categories 里合法的下标,随手改会让整个结构失效。pandas 把它设成只读,强制你走 .cat 的方法。
同样的原因,给分类列赋一个不在类别表里的值会报错

s = pd.Series(["银行", "地产"], dtype="category")
s.iloc[0] = "科技"        # ValueError: Cannot setitem on a Categorical with a new category
s = s.cat.add_categories(["科技"])    # 先报备
s.iloc[0] = "科技"                     # 现在可以了

五、🧰 groupby 会更快

按股票代码分组算均值,17 万行:

object      4.3 ms
category    1.7 ms       ← 快 2.5 倍

原因是 groupby 内部本来就要把分组键转成整数码才能分组(第 15 讲会展开)。category 列的 codes 本身就是现成的分组号,这一步可以直接跳过。

比较运算同理:s >= "大" 执行的是 codes >= 3,一块连续的 int16 和一个整数做向量化比较,不碰字符串。


六、🐛 三个坑

坑一:拼接时类别不一致,会退化成 object

a = pd.Series(["银行", "地产"], dtype="category")     # categories: ['地产', '银行']
b = pd.Series(["银行", "科技"], dtype="category")     # categories: ['科技', '银行']
pd.concat([a, b]).dtype
object        ← 类别表不同,pandas 放弃分类,退回 object

两边的 codes 用的是不同的”字典”——同一个码 1 在 a 里是”银行”,在 b 里也是”银行”,但表长和顺序都不同,直接拼会错位。pandas 选择退化而不是猜。

按月读取数据再 concat 时特别容易中招:每个月的数据里出现的行业不完全一样,各自 astype("category") 就得到不同的类别表,拼完发现内存优化白做了。

两种解法:

pd.api.types.union_categoricals([a.array, b.array])       # 合并类别表
# → categories: ['地产', '银行', '科技']

dtype = pd.CategoricalDtype(all_industries)               # 或者一开始就统一
a = a.astype(dtype); b = b.astype(dtype)

推荐第二种:先把完整的类别集确定下来,所有分片都用同一个 CategoricalDtype

同样的原因,两个类别表不同的 Categorical 不能比较,会直接 TypeError

坑二:observed 参数会造出大量空组

多个分类列一起 groupby 时,pandas 默认(observed=False)会生成所有类别的笛卡尔积,包括数据里根本不存在的组合。

真实数据:5243 只股票,按 industry(110 类)× market(4 类)× mv_q(5 档)分组:

observed=True   →     885 组
observed=False  →   2,200 组

2200 = 110 × 4 × 5,是全组合。其中 1315 组是空的,聚合结果全是 NaN

d.groupby(["industry", "market", "mv_q"], observed=False)["total_mv"].mean().isna().sum()
# 1315

类别列一多,这个笛卡尔积会爆炸——三个分类列各 100 个类别就是 100 万组。

IMPORTANT: 🔑 分类列做 groupby 时显式写 observed=True
除非你确实需要那些空组(比如要求输出必须包含所有档位,缺的补 0)。
pandas 2.x 已经开始把默认值往 observed=True 迁移,但过渡期里显式写出来最稳妥,也让读代码的人知道你想要哪种。

坑三:remove_categories 会制造缺失

s.cat.remove_categories(["银行"])      # 原来是"银行"的行变成 NaN,不是被删掉

想删的是”没被用到的类别”,用 remove_unused_categories()


🏋️ 训练营

QUESTION: 🟢 训练 1:把 s = pd.Series(["中","高","低","高","中"]) 转成有序类别(低 < 中 < 高),选出所有 >= "中" 的行,并打印它的 codes
TIP: 👉 参考

s = s.astype(pd.CategoricalDtype(["低", "中", "高"], ordered=True))
s[s >= "中"]           # 中、高、高、中
s.cat.codes            # [1, 2, 0, 2, 1]

关键是 CategoricalDtype 一次把顺序和有序性都指定了。直接 astype("category") 得到的是无序类别、且按字母序排,>= 会直接报错。

QUESTION: 🟡 训练 2:判断下面三列该不该转 category,说出判据和预期效果。
① 17 万行的 ts_code(300 个不同值)
② 17 万行的 close 收盘价(2 万多个不同值)
③ 5000 行的 industry(110 个不同值)
TIP: 👉 参考
① 转nunique/len ≈ 0.0018,重复度极高,实测 10MB → 0.4MB。
② 不转close 有 21,324 个不同值,比值 0.1244,重复度看着还可以,但实测转过去 0.7 MB → 0.8 MB 反而更费
而且就算省内存也不该转——分类列不支持算术运算cat * 2cat.mean()cat.pct_change() 全部 TypeError。收盘价是要参与计算的度量值,不是标签。转了它就废了。
这道题的用意就在这里:只看 nunique/len 会误判。要同时问”这列是拿来算的还是拿来分组的”。
③ 可以转,但意义不大。比值 110/5000 = 0.022 确实低,但总共才 5000 行,省下的绝对内存微不足道。
真正值得转的判断是两个条件同时成立:比值足够小,且数据量足够大到内存是个问题

QUESTION: 🔴 训练 3:你按月读取十二个月的行情数据,每个月的表都做了 df["industry"] = df["industry"].astype("category") 来省内存,最后 pd.concat 拼成全年。结果发现拼完的表内存暴涨、industry 列变回了 object。解释原因,给出两种修法,并说明哪种更好。
TIP: 👉 参考
原因:每个月出现的行业不完全一样(有的月份某些行业没有股票交易,或数据源本身有差异),各自 astype("category") 得到的 categories 表内容和顺序都不同。concat 遇到类别表不一致,无法安全合并 codes,退化成 object。
修法一——拼完再转:

full = pd.concat(monthly_dfs)
full["industry"] = full["industry"].astype("category")

修法二——先统一类别表:

dtype = pd.CategoricalDtype(sorted(all_industries))
for df in monthly_dfs:
    df["industry"] = df["industry"].astype(dtype)
full = pd.concat(monthly_dfs)          # 类别一致,保持 category

修法二更好。修法一在 concat 那一刻整列都是 object,内存峰值就在那里,而峰值往往才是 OOM 的原因。修法二全程保持 category,而且拼完的类别表是完整的、跨月可比的——做跨期分组时不会出现”一月的银行和二月的银行是不同码”这种问题。


🐛 常见坑

  • ⚠️ 分档标签降级成字符串astype(str) 之后排序按字母序,五档变成”中/大/小/最大/最小”。qcut 的结果直接用,别转。
  • ⚠️ astype("category") 默认无序:类别按字母序排,不是你要的顺序。要表达顺序用 CategoricalDtype(..., ordered=True)
  • ⚠️ 字符串比大小不报错但结果荒谬"最小" >= "大"True。评级、档位这类列尤其危险。
  • ⚠️ 高基数列转 category 更费内存:唯一 ID 转完从 17MB 涨到 26.6MB。先看 nunique()/len()
  • ⚠️ 数值度量列不能转 category:省内存也没用,* / mean / pct_change / > 全部 TypeError。只有标签列才转。
  • ⚠️ 类别表不一致,concat 退化成 object:分片各自转 category 是常见错误。用统一的 CategoricalDtype
  • ⚠️ 类别表不一致不能比较:两个 Categorical 的 categories 不同,== 直接 TypeError
  • ⚠️ groupby 忘了 observed=True:多个分类列会生成全组合,110×4×5 得到 2200 组,其中 1315 组是空的。
  • ⚠️ 不能赋新类别的值:先 .cat.add_categories([...]) 再赋值。
  • ⚠️ remove_categories 制造缺失:被删类别对应的值变成 NaN 而不是消失。想删没用到的用 remove_unused_categories()

✍️ 作业

  1. 取本地行情数据的 ts_code 列,打印 nunique()len()、以及 object 和 category 两种类型下的 memory_usage(deep=True)。再对 close 列做同样的事,解释为什么结果相反。
  2. 造一个 5 档因子标签列,分别以字符串和有序类别两种形式存,各做一次 groupby(...).mean() 和一次 sort_values(),把两组结果的顺序并排打印,确认差异。
  3. 用不同的类别数(如 100、200、40000)各造一个 category 列,打印 codes.dtype,验证 pandas 按类别数选择最小整数类型的规则。
  4. 复现拼接的坑:造两个类别集不同的分类 Series,concat 后打印 dtype,确认退化成 object。再用 union_categoricals 和统一 CategoricalDtype 两种方式各修一次。
  5. 取本地数据,用两个以上分类列做 groupby,分别传 observed=Trueobserved=False,打印组数和结果里 NaN 的行数。计算全组合数并验证它等于各列类别数的乘积。
  6. 思考题:这一讲的 codes + categories 和第 15 讲 groupby 内部的 factorize 是同一件事。那么,把一列先转成 category 再 groupby,和直接 groupby,pandas 的工作量差在哪一步?(提示:想想 groupby 拿到一列字符串时,第一件事要做什么。)

🔮 下讲预告:第 12 讲——MultiIndex 层次索引。它和这一讲是同一个结构的另一处应用:MultiIndex 内部是 levels(每一级的唯一值表)+ codes(每行在各级的整数码)。分类数据把这套编码用在一列的值上,MultiIndex 把它用在索引的层级上。量化数据天然是二维的——(股票, 日期) 唯一确定一行——下一讲讲怎么用层次索引表达这种结构,以及它带来的取数写法。


← 上一讲  ·  返回课程  ·  下一讲 →