卷 IV · 唯一的队形CH 17深度 17/23

把世界压成一个矩形

上一章说 sklearn 只认 (n_samples, n_features) 的数字矩形。可现实里的数据是城市名、商品类别、一段用户评论、一个时间戳——没有一样是数字。「特征工程」这个听起来很唬人的词,说的就是这一步:把世界压成一个矩形。这一章过一遍这道关卡上的每一条报错,以及一个所有教程都会跳过、但上线第一天就会遇到的问题。

1 列变 4000 列152.59 MB 变 3.7 MB没见过的类别

▷ 先猜一下

你用五个城市的历史数据训了一个模型,上线了。第二天,一条来自「杭州」的记录进来了——训练时没有这个城市。

问:OneHotEncoder 默认会怎么做?

A 自动加一列给杭州。编码器会随数据扩展 B 抛异常。它拒绝处理没见过的类别 C 给一行全零,当成「哪个城市都不是」 D 随便挑一个最相近的城市顶上

先看这道关卡长什么样

把数据递给 sklearn 之前,它有三条硬要求。三条都会当场报错:

要求违反时的报错
X 必须是二维ValueError: Expected 2D array, got 1D array instead
X 必须全是数字ValueError: could not convert string to float
X 不能有 NaNValueError: Input contains NaN

第一条最常撞。「只有一个特征」和「一维」是两回事

model.fit(x, y)                 # x 形状 (100,)      ✗ ValueError
model.fit(x.reshape(-1, 1), y)  # x 形状 (100, 1)    ✓
model.fit(df[["price"]], y)     # 双层方括号        ✓
model.fit(df["price"], y)       # 单层,是 Series   ✗

这就是第 4 章那个 (n, 1) 的来源之一——sklearn 逼你造出 (n, 1),而 (n, 1) 一旦和 (n,) 相遇就会广播成方阵。两层之间的要求互相拉扯,这类事故就是这么来的。

类别怎么变成数字:一个陷阱和一个正解

一列城市名怎么办?最省事的做法是编号:

{"北京": 0, "广州": 1, "上海": 2, "深圳": 3}

这是错的,而且错得很安静。因为编号一旦进了模型,模型看到的是数值:北京 < 广州 < 上海 < 深圳,而且「上海 = (北京 + 深圳) / 2 − 0.5」。你凭空造出了一个不存在的顺序和距离。线性模型、K 近邻、SVM 全部会当真。

正解是独热编码:一列变 k 列,每列一个 0/1。

5 行 1 列的城市名        →        5 行 4 列的 0 和 1
categories_  ['上海', '北京', '广州', '深圳']      ← 按字典序排的
第一行「北京」            [0, 1, 0, 0]

这样四个城市互相之间的距离全部相等,没有编造任何顺序。代价是列数变多——四个城市多 3 列还好,四万个商品 ID 就是四万列。

什么时候可以用编号?当那个顺序是真的时候。「小/中/大」「一星/二星/三星」「小学/中学/大学」——这些叫有序类别,用 OrdinalEncoder 编号是对的,而且比独热更省。判据只有一句:问「第二类减第一类,等于第三类减第二类吗?」说得通就编号,说不通就独热。

那个上线第一天的问题

回到开头。OneHotEncoder 默认是 handle_unknown="error"

ValueError: Found unknown categories ['杭州'] in column 0 during transform

它抛异常。这个默认值是有道理的——遇到没见过的东西,与其猜,不如告诉你。但在生产里,一个请求把服务打挂通常不是你要的。所以有第二个选项:

enc = OneHotEncoder(handle_unknown="ignore")
enc.transform([["杭州"]])        # [0, 0, 0, 0]   ← 一行全零

全零的含义是「这四个城市都不是」。模型会用截距项对付它,相当于回退到「不知道城市时的平均预测」。这两个选项代表两种生产策略,而且没有哪个总是对的:

"error""ignore"
遇到新类别抛异常,请求失败全零,正常返回一个预测
好处你会立刻知道数据变了服务不会挂
坏处线上事故静默降级——可能几个月后才发现半数请求都在走全零
适合批量任务、训练管线在线服务 + 监控未知类别的比例

最后那句是重点:用 "ignore"必须配一个监控,统计有多少请求命中了未知类别。否则你是把一个会响的报警器换成了一个不会响的。

文本:一列变四千列,而且 98.4% 是零

更极端的例子是文本。最朴素的做法叫词袋:每个词一列,格子里是这个词在这条文本里出现了几次。本机拿 5000 段文本、4000 个词的词表跑了一遍:

5000 段文本                  →     (5000, 4000)
非零格子                            322862
稠密度                              1.6%          ← 98.4% 是零

稠密存(float64)                   152.59 MB
稀疏存(CSR,只存非零)             3.7 MB        ★ 省 41 倍

所以 sklearn 的文本转换器默认返回稀疏矩阵scipy.sparse),而不是 numpy 数组。它只存三样东西:非零的值、它们的列号、每行从哪儿开始。

这带来一个实际的注意事项:稀疏矩阵不是 ndarray,很多操作不支持,随手一个 .toarray() 就可能把 152 MB 铺开(在真实的文本数据上是几十 GB)。sklearn 里大部分估计器原生支持稀疏输入,能不铺开就别铺开

顺带一提:OneHotEncoder 默认也返回稀疏矩阵,本章前面写 sparse_output=False 只是为了打印出来看。

把这些拼起来:ColumnTransformer

真实的表里,数值列和类别列混在一起,各要各的处理。ColumnTransformer 就是干这个的:

from sklearn.compose import ColumnTransformer

pre = ColumnTransformer([
    ("num", StandardScaler(),                        ["金额", "件数", "会员天数"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"),  ["城市", "渠道"]),
])
pre.fit_transform(df)          # 数值列标准化,类别列独热,横着拼起来

它本身也是一个估计器(有 fittransform),所以可以直接塞进 Pipeline而「塞进 Pipeline」这件事,是下一章那个招牌实验的全部要害。

还有一个好用的方法:pre.get_feature_names_out(),告诉你压出来的每一列叫什么(num__金额cat__城市_上海……)。压完之后列名就没了,这是唯一能找回来的途径。

✎ 术语正名

特征(feature)在 sklearn 里就是「X 的一列」,没有更玄的含义。特征工程(feature engineering)就是「造出这些列」的全部工作——编码、缩放、组合、离散化、从时间戳里抠出星期几。

三个容易混的编码器:

  • OneHotEncoder:一列 → k 列 0/1。用在 X 上,处理无序类别。
  • OrdinalEncoder:一列 → 一列整数。用在 X 上,处理有序类别,或者喂给树模型(树不在乎数值大小,只在乎能不能切开)。
  • LabelEncoder:名字最像,但它是给 y 用的,只接受一维输入。拿它编码 X 是最常见的误用之一,因为它会把无序类别变成有序整数。

稀疏矩阵的几种格式也值得知道一个:CSR(按行压缩)适合按行取和矩阵乘法,是 sklearn 的默认;COO 适合构造;CSC 适合按列取。日常不用管,报错提到格式时知道是怎么回事就行。

⌨ 自己跑一遍

那三条报错值得亲手撞一次:

import numpy as np, pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import OneHotEncoder

LogisticRegression().fit(np.arange(10), np.arange(10))
# ValueError: Expected 2D array, got 1D array instead

独热编码和那个「没见过的类别」:

cities = pd.DataFrame({"city": ["北京", "上海", "广州", "北京", "深圳"]})

enc = OneHotEncoder(sparse_output=False)          # 默认 handle_unknown="error"
oh = enc.fit_transform(cities)
print(oh.shape, oh[0].astype(int), enc.categories_[0])
# (5, 4)  [0 1 0 0]  ['上海' '北京' '广州' '深圳']

enc.transform(pd.DataFrame({"city": ["杭州"]}))    # ValueError: Found unknown categories

enc2 = OneHotEncoder(sparse_output=False, handle_unknown="ignore").fit(cities)
print(enc2.transform(pd.DataFrame({"city": ["杭州"]}))[0].astype(int))   # [0 0 0 0]

稀疏矩阵省了多少:

from sklearn.feature_extraction.text import CountVectorizer
rng = np.random.default_rng(17)
VOCAB = [f"w{i:04d}" for i in range(4000)]
w = 1 / (np.arange(1, 4001) ** 0.9); w /= w.sum()
docs = [" ".join(rng.choice(VOCAB, size=int(rng.integers(40, 120)), p=w))
        for _ in range(5000)]
bow = CountVectorizer().fit_transform(docs)
print(bow.shape, bow.nnz, "%.1f%%" % (bow.nnz / np.prod(bow.shape) * 100))
print("稠密 %.2f MB   稀疏 %.1f KB"
      % (np.prod(bow.shape) * 8 / 1024**2,
         (bow.data.nbytes + bow.indices.nbytes + bow.indptr.nbytes) / 1024))
# (5000, 4000) 322862 1.6%     稠密 152.59 MB   稀疏 3803.1 KB

python3 -c "from sklearn.preprocessing import OneHotEncoder;import pandas as pd;e=OneHotEncoder(sparse_output=False,handle_unknown='ignore').fit(pd.DataFrame({'c':['a','b','c']}));print(e.transform(pd.DataFrame({'c':['z']})))"

handle_unknown 改回 "error" 再跑一次,感受两种策略的差别。

▸ 在现实里
  • 推荐系统的物品 ID 就是这个问题的极端版。一千万个商品独热编码就是一千万列,稀疏矩阵也扛不住。所以有了 embedding:把一千万个类别映射到 128 维的稠密向量。它解决的正是这一章的问题——只是把「每个类别一列」换成了「每个类别一个可学习的向量」。Word2Vec、BERT、推荐系统的用户塔,源头都在这儿。
  • 「没见过的类别」在生产里叫冷启动。新用户、新商品、新城市——所有推荐和风控系统都要专门设计这条路径。handle_unknown="ignore" 是最朴素的一种回退策略,工业界的做法通常是「用一个默认 embedding」或者「回退到内容特征」。
  • 数据漂移监控,监控的就是这条边界。训练时的类别分布 vs 线上的类别分布,差多少?未知类别占比涨了多少?这是模型监控里最容易实现、也最早预警的一个指标——比监控准确率早得多,因为准确率要等标签回来。
  • Kotlin 的 sealed class 和这里的取舍很像。穷举所有已知类型(编译期安全)vs 留一个 else 分支(运行期灵活)。"error""ignore" 就是这两种态度。
✗ 这个直觉是错的

「类别变量转成数字就行了,用 LabelEncoder 或者手动映射一下最方便。」

编号会凭空造出顺序和距离。「上海 = 2」意味着上海比北京(0)大 2,而且正好在北京和深圳(3)之间偏一点。线性模型、K 近邻、SVM、神经网络全都会当真,因为它们的数学里就只有数值。

而且 LabelEncoder 本来就不是给 X 用的——它是给 y 用的(标签转成 0..k−1),只接受一维输入。拿它处理特征列是 sklearn 生态里最常见的误用之一。

判据一句话:问「第二类减第一类,等于第三类减第二类吗?」「一星/二星/三星」说得通,用 OrdinalEncoder;「北京/上海/广州」说不通,用 OneHotEncoder唯一的例外是树模型——它们只用数值来切分,不做算术,所以对无序类别用编号影响小得多(不过第 20 章会讲,这里还藏着另一个坑)。

◇ 揭晓

正确答案是 B抛异常。原文是 ValueError: Found unknown categories ['杭州'] in column 0 during transform

A 「自动加一列」——绝对不行,而且理由很重要:加一列会让 X 的列数变成 5,而模型是在 4 列上训练的。形状对不上,下游立刻崩。编码器学到的类别集合,是模型契约的一部分,上线之后就冻结了。 C 「给一行全零」——这是 handle_unknown="ignore" 的行为,需要你显式要求。它是生产环境里的常见选择,但必须配一个「未知类别占比」的监控,否则你换来的是静默降级 D 「挑个相近的顶上」——sklearn 不会做任何形式的猜测。「相近」需要领域知识(杭州和上海相近吗?按什么标准?),而这套工具不掌握领域知识。要做这件事,得你自己在编码之前把杭州映射成别的什么。
⌗ 交接单
一张混着数字、文本、类别、缺失值的真实的表。 (n_samples, n_features)纯数字矩形——稠密或稀疏,但一定二维、一定没有 NaN sklearn,而且三条要求各有一条明确的报错。但有一件事它不检查:这个矩形是怎么压出来的。用全量数据 fit 编码器再交叉验证,它一句话都不会说——那正是下一章的内容。

这一章的一句话

「特征工程」就是把世界压成一个数字矩形:类别用独热(因为编号会造出不存在的顺序),文本用稀疏矩阵(因为 98.4% 是零)——而压的过程中最要紧的一个决定,是训练时没见过的东西来了怎么办。

下一章是全书的招牌。我会拿一份完全没有信号的数据——50 个样本、5000 列纯随机数、标签靠抛硬币——让交叉验证报出 98.9% 的准确率。同一份数据、同一个模型、同一套交叉验证,只是把挑特征这一步放进 Pipeline,准确率就掉回 51.1%。唯一的差别是:挑特征这件事,发生在交叉验证的外面还是里面。