缘由

艺多不压身

碎碎念

我们继续飞行在云层和雾气之间,飞行在一个无光无影的空洞世界中。 ——《风沙星辰》

Ref

17 Encoding Categorical Data | Tidy Modeling with R

解读

在房价预测中,如何把分类变量(比如 Neighborhood 小区名)转成模型能用的数值特征,是建模的关键。这里展示三种思路:

  1. 目标编码(step_lencode_glm)
  2. 部分池化的混合效应编码(step_lencode_mixed)
  3. 哈希编码(step_dummy_hash)

1. 数据准备

library(embed)
library(tidymodels)
library(modeldata)
tidymodels_prefer()
 
data(ames)
ames <- mutate(ames, Sale_Price = log10(Sale_Price))
 
set.seed(502)
ames_split <- initial_split(ames, prop = 0.80, strata = Sale_Price)
ames_train <- training(ames_split)
ames_test  <- testing(ames_split)

2. GLM 目标编码

ames_glm <-
	recipe(Sale_Price ~ Neighborhood + Gr_Liv_Area + Year_Built +
		   	Bldg_Type + Latitude + Longitude, data = ames_train) %>%
	step_log(Gr_Liv_Area, base = 10) %>%
	step_lencode_glm(Neighborhood, outcome = vars(Sale_Price)) %>%
	step_dummy(all_nominal_predictors()) %>%
	step_interact( ~ Gr_Liv_Area:starts_with("Bldg_Type_") ) %>%
	step_ns(Latitude, Longitude, deg_free = 20)
  • 核心:step_lencode_glm 用小模型来估计 Neighborhood 对 Sale_Price 的贡献,把类别变成连续数值。
  • 好处:信息量大,直接和目标变量相关。
  • 风险:过拟合(尤其是样本量少的小区)。

3. 部分池化编码(Mixed Encoding)

ames_mixed <-
	recipe(Sale_Price ~ Neighborhood + Gr_Liv_Area + Year_Built +
		   	Bldg_Type + Latitude + Longitude, data = ames_train) %>%
	step_log(Gr_Liv_Area, base = 10) %>%
	step_lencode_mixed(Neighborhood, outcome = vars(Sale_Price)) %>%
	step_dummy(all_nominal_predictors()) %>%
	step_interact( ~ Gr_Liv_Area:starts_with("Bldg_Type_") ) %>%
	step_ns(Latitude, Longitude, deg_free = 20)
  • 核心:step_lencode_mixed 用混合效应模型,把 Neighborhood 当作随机效应。
  • 效果:不同小区之间会部分共享信息,样本量少的小区不容易过拟合。

我们可以比较两种编码方式的差异:

glm_estimates <- ames_glm %>% prep() %>% tidy(number=2)
mixed_estimates <- ames_mixed %>% prep() %>% tidy(number=2)
 
glm_estimates %>%
	rename(`no pooling` = value) %>%
	left_join(
		mixed_estimates %>%
			rename(`partial pooling` = value), by = "level"
	) %>%
	left_join(
		ames_train %>% 
			count(Neighborhood) %>% 
			mutate(level = as.character(Neighborhood))
	) %>%
	ggplot(aes(`no pooling`, `partial pooling`, size = sqrt(n))) +
	geom_abline(color = "gray50", lty = 2) +
	geom_point(alpha = 0.7) +
	coord_fixed()

4. 哈希编码

当类别数量非常多时(比如上千个小区名),虚拟变量会让特征空间爆炸。哈希编码就是解决方案。

4.1 R 内置哈希函数示例

library(rlang)
 
ames_hashed <-
	ames_train %>%
	mutate(Hash = map_chr(Neighborhood, hash))
 
ames_hashed %>%
	select(Neighborhood, Hash)

输出是一个长字符串哈希值,我们可以手动缩短为数值:

ames_hashed %>%
  ## 转为 R 能处理的整数
  mutate(Hash = strtoi(substr(Hash, 26, 32), base = 16L),
         ## 取模,压缩到 16 个桶
         Hash = Hash %% 16) %>%
  select(Neighborhood, Hash)

这样每个小区就被随机分配到 0~15 的一个桶里。


4.2 在 Recipe 里用哈希

library(textrecipes)
 
ames_hash <-
	recipe(Sale_Price ~ Neighborhood + Gr_Liv_Area + Year_Built +
		   	Bldg_Type + Latitude + Longitude, data = ames_train) %>%
	step_log(Gr_Liv_Area, base = 10) %>%
	step_dummy_hash(Neighborhood, signed = FALSE, num_terms = 16L) %>%
	step_dummy(all_nominal_predictors()) %>%
	step_interact( ~ Gr_Liv_Area:starts_with("Bldg_Type_") ) %>%
	step_ns(Latitude, Longitude, deg_free = 20)
  • num_terms = 16L 表示压缩到 16 个特征。
  • signed = FALSE 表示不会用正负号抵消冲突,简单地把类别“落桶”。
  1. 为什么要用哈希?

    • 类别太多时,dummy 变量会导致特征空间过大(比如一万维)。
    • 哈希能强制压缩到一个固定维度(比如 16 维),节省内存和计算量。
  2. 哈希冲突是什么?

    • 不同类别可能被分到同一个桶 → 信息混淆。
    • 这就像宿舍分配时两个人被分到同一间 → 他们的“特征”就重叠了。
  3. 冲突影响大怎么办?

    • 增大桶数(num_terms,比如从 16 提高到 128)。
    • 或者引入 signed hashing(正负号抵消一部分冲突)。
  4. 适用场景

    • 类别非常多(上千或更多)。
    • 模型本身能容忍一定噪声(比如树模型、神经网络)。
    • 如果类别少(几十个),还是直接用目标编码或 dummy 更合适。

常见问题解答

1. 为什么 step_lencode_glm 可以把类别变成数值?只有有序因子才能这样吗?

  • 因为它不是简单的“类别编号”,而是用目标变量(Sale_Price)训练得到的一个预测值。
  • 所以即使类别无序(比如小区名),也能合理地映射到数值。
  • 注意:这种方法用了目标变量信息,所以要小心过拟合,最好配合交叉验证或部分池化。

2. 为什么需要哈希处理?什么时候该用?

  • 当类别数量非常多(上千甚至上万),虚拟变量维度会太大,训练速度慢、内存占用高。
  • 哈希能强制压缩到固定长度,比如 16、32 或 128 个特征。
  • 缺点是可能“撞车”,尤其当类别差异很大但落在同一桶时,模型可能难以区分。
  • 如果类别数不多(几十个),没必要用哈希。

3. step_lencode_glm 和哈希是并列关系还是从属关系?

  • 它们是并列的编码策略:

    • lencode 系列:用目标变量信息 → 更精细,但有过拟合风险。
    • 哈希:不看目标变量 → 更粗糙,但能应对超大类别数。
  • 具体选哪一个,看数据特点:

    • 类别少 → lencode 更合适。
    • 类别多 → 哈希更实用。