把世界压成一个矩形
上一章说 sklearn 只认 (n_samples, n_features) 的数字矩形。可现实里的数据是城市名、商品类别、一段用户评论、一个时间戳——没有一样是数字。「特征工程」这个听起来很唬人的词,说的就是这一步:把世界压成一个矩形。这一章过一遍这道关卡上的每一条报错,以及一个所有教程都会跳过、但上线第一天就会遇到的问题。
你用五个城市的历史数据训了一个模型,上线了。第二天,一条来自「杭州」的记录进来了——训练时没有这个城市。
问:OneHotEncoder 默认会怎么做?
先看这道关卡长什么样
把数据递给 sklearn 之前,它有三条硬要求。三条都会当场报错:
| 要求 | 违反时的报错 |
|---|---|
| X 必须是二维 | ValueError: Expected 2D array, got 1D array instead |
| X 必须全是数字 | ValueError: could not convert string to float |
| X 不能有 NaN | ValueError: 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) # 数值列标准化,类别列独热,横着拼起来
它本身也是一个估计器(有 fit/transform),所以可以直接塞进 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。
handle_unknown="ignore" 的行为,需要你显式要求。它是生产环境里的常见选择,但必须配一个「未知类别占比」的监控,否则你换来的是静默降级。
D 「挑个相近的顶上」——sklearn 不会做任何形式的猜测。「相近」需要领域知识(杭州和上海相近吗?按什么标准?),而这套工具不掌握领域知识。要做这件事,得你自己在编码之前把杭州映射成别的什么。
(n_samples, n_features) 的纯数字矩形——稠密或稀疏,但一定二维、一定没有 NaN。
sklearn,而且三条要求各有一条明确的报错。但有一件事它不检查:这个矩形是怎么压出来的。用全量数据 fit 编码器再交叉验证,它一句话都不会说——那正是下一章的内容。
这一章的一句话
「特征工程」就是把世界压成一个数字矩形:类别用独热(因为编号会造出不存在的顺序),文本用稀疏矩阵(因为 98.4% 是零)——而压的过程中最要紧的一个决定,是训练时没见过的东西来了怎么办。
下一章是全书的招牌。我会拿一份完全没有信号的数据——50 个样本、5000 列纯随机数、标签靠抛硬币——让交叉验证报出 98.9% 的准确率。同一份数据、同一个模型、同一套交叉验证,只是把挑特征这一步放进 Pipeline,准确率就掉回 51.1%。唯一的差别是:挑特征这件事,发生在交叉验证的外面还是里面。