📑 本页目录(点开跳转)
07 · 特征工程
⏱ 20 分钟 | ⭐ 核心
🎯 一句话
「数据和特征决定了机器学习的上限,模型只是逼近这个上限。」 在推荐系统里,好特征带来的收益经常大于换一个更炫的模型。
🧩 特征的四个来源(照着这个清单填,不会漏)
- 静态:user_id, 年龄, 性别, 城市, 注册天数, 设备价位
- 统计:历史点击率, 活跃度, 平均停留时长, 消费能力
- 偏好:类目偏好分布, 价格带偏好, 作者偏好
- 序列:最近 500 次点击/购买的物品 ID 序列 ⭐ 最重要
- 静态:item_id, 类目, 标签, 作者, 时长, 价格, 发布时间
- 统计:曝光数, 点击率, 完播率, 点赞率, 近1h/1d/7d 热度
- 内容:标题/正文 Embedding, 封面图 Embedding, 视频 Emb
- 时间:小时, 星期几, 是否节假日, 距上次访问多久
- 环境:设备, 操作系统, 网络(WiFi/4G), 地理位置
- 会话:本次会话第几刷, 本次已看内容, 已停留时长
- 用户类目偏好 × 物品类目 ← 最经典最有用
- 用户价格带 × 物品价格
- 用户历史序列 × 当前物品(→ 注意力机制,第 8 节 DIN)
- 时间 × 类目(周五晚上看电影,早高峰刷新闻)
🔑 如果只能加一类特征,加「用户行为序列」。 它是现代推荐模型效果的主要来源。
🔢 特征怎么变成模型能吃的东西
1. 类别特征 → Embedding(推荐系统的标准做法)
为什么不用 One-Hot? - 城市有 3000 个 → One-Hot 是 3000 维稀疏向量,浪费且学不到关系 - Embedding 是 16 维稠密向量,且「北京」和「上海」会自动学得接近
import torch.nn as nn
city_emb = nn.Embedding(num_embeddings=3000, embedding_dim=16)
⚠️ 推荐系统 Embedding 的真实规模:
item_id 可能有 1 亿个 × 32 维 × 4 字节 = 12.8 GB,单机放不下。
→ 工业界用 参数服务器(Parameter Server) 分片存储,或哈希分桶压缩。
2. 数值特征 → 必须处理,不能直接扔进去
| 问题 | 处理方法 |
|---|---|
| 量级差异大(价格 0-10000,评分 0-5) | 归一化:(x - min) / (max - min) 或标准化 |
| 长尾分布(播放量 0 到 1 亿) | 对数变换:log(1 + x) ⭐ 最常用 |
| 想让模型学非线性关系 | 分桶离散化 → 再 Embedding ⭐ 推荐系统最常用 |
为什么推荐系统偏爱分桶?
分桶技巧:等频分桶(每桶样本数相同)通常比等宽分桶好。
3. 序列特征 → Pooling 或 Attention
💥 特征交叉:推荐模型的核心命题
为什么需要交叉? 看这个经典例子:
| 特征 | 单独看 |
|---|---|
| 性别 = 女 | 没什么信息 |
| 类目 = 化妆品 | 没什么信息 |
| 性别=女 AND 类目=化妆品 | 点击率飙升 ⭐ |
线性模型(LR)永远学不到这个,除非你手动造出这个组合特征。
交叉方式的演进史(这就是推荐模型的进化史)
① 人工交叉 (LR 时代)
算法工程师手写几千条组合规则
❌ 累死人、组合爆炸、无法穷举
② 自动二阶交叉 (FM, 2010) ⭐ 关键突破
给每个特征一个隐向量,任意两个特征的交叉权重 = 两个隐向量的点积
w_ij = <v_i, v_j>
✅ 参数从 O(n²) 降到 O(nk)
✅ 即使 (女, 化妆品) 从没共现过,也能通过其他数据学出来
③ 域感知交叉 (FFM, 2016)
每个特征对不同「域」有不同的隐向量
✅ 更精细 ❌ 参数量爆炸
④ 深度自动交叉 (2016~)
Wide&Deep / DeepFM / DCN / xDeepFM
让神经网络自己学高阶交叉
✅ 无需人工 ⚠️ 但显式交叉结构仍然有用(DCN 的 Cross Network)
⑤ 注意力式交叉 (2018~)
DIN / AutoInt:用注意力机制动态决定哪些特征该交叉
📐 FM 的核心公式(值得看懂)
$$\hat{y} = w_0 + \sum_{i=1}^{n} w_i x_i + \sum_{i=1}^{n}\sum_{j=i+1}^{n} \langle \mathbf{v}_i, \mathbf{v}_j \rangle x_i x_j$$
💡 人话翻译:
第 1 项:基准分。 第 2 项:每个特征单独的贡献(这就是逻辑回归)。 第 3 项:任意两个特征组合起来的贡献,权重用两个隐向量的点积表示。
为什么用点积而不是直接给每对特征一个权重? 因为直接给权重的话,(女, 化妆品) 这一对如果训练数据里从没出现过,权重就学不到。 用隐向量的话,「女」的向量可以从 (女, 连衣裙) 学,「化妆品」的向量可以从 (25岁, 化妆品) 学,两者一点积就有了预测能力。
🔑 这就是 Embedding 泛化能力的又一次体现——和第 5 节矩阵分解是同一个思想。
实际上 FM 是矩阵分解的推广:MF 只有 user_id 和 item_id 两个特征,FM 允许任意多个特征。
🕳️ 特征工程的六个大坑(血泪教训)
坑 1:特征穿越(Feature Leakage)⭐ 最致命
用了预测时刻拿不到的信息。
❌ 用「这个视频的总播放量」当特征 → 但训练时用的是今天的总量,
而预测时刻这个量还没产生 → 离线 AUC 0.95,上线暴死
✅ 用「预测时刻之前的累计播放量」
自查方法:对每个特征问 「线上服务的那一毫秒,我真的能拿到这个值吗?」
坑 2:离线/在线特征不一致(Training-Serving Skew)
离线用 Spark 算特征,在线用 Java 算特征 → 两边逻辑有细微差别 → 效果莫名其妙地差。
✅ 解法:特征平台 / Feature Store,一套代码同时产出离线和在线特征。这是工业界的标准基建。
坑 3:统计特征的时间窗口
item_ctr 用全历史算 vs 用近 1 小时算,效果差别巨大。
✅ 通常做多个窗口都喂进去:ctr_1h, ctr_1d, ctr_7d, ctr_30d。
坑 4:新物品的统计特征全是 0
新发布的视频没有 CTR,填 0 会被模型认为「很差」。 ✅ 用贝叶斯平滑: $$\text{smoothed\_ctr} = \frac{\text{clicks} + \alpha}{\text{impressions} + \alpha + \beta}$$ 其中 α/(α+β) 是全局平均 CTR,曝光少时向全局均值靠拢。
坑 5:ID 特征的低频问题
只出现过 2 次的 item_id,Embedding 学不好还占内存。
✅ 频次过滤:出现次数 < 阈值(如 10)的统一映射到一个 <UNK> ID。
坑 6:类目特征的高基数爆炸
author_id 有 5000 万个 → Embedding 表巨大。
✅ 哈希分桶:hash(author_id) % 1000000。会有冲突,但实践中影响可接受,且模型能靠其他特征区分。
🔨 动手:一个特征处理的完整例子
import numpy as np
import pandas as pd
def build_features(df, global_ctr=0.05):
"""一个典型的推荐特征处理流水线"""
out = pd.DataFrame()
# ---- 数值特征:对数变换(长尾) ----
out["log_play_cnt"] = np.log1p(df["play_count"])
out["log_price"] = np.log1p(df["price"])
# ---- 数值特征:等频分桶 → 类别 ----
out["age_bucket"] = pd.qcut(df["age"], q=10, labels=False, duplicates="drop")
out["duration_bucket"] = pd.cut(df["duration"],
bins=[0, 15, 30, 60, 180, 600, 99999],
labels=False)
# ---- 统计特征:贝叶斯平滑,解决冷启动全 0 问题 ----
alpha, beta = global_ctr * 100, (1 - global_ctr) * 100
out["item_ctr_smooth"] = (df["clicks"] + alpha) / (df["impressions"] + alpha + beta)
# ---- 时间特征:周期性用 sin/cos 编码 ----
# 直接用 0-23 的话,23点和0点在模型看来差了 23,其实只差 1 小时
out["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24)
out["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24)
out["is_weekend"] = df["weekday"].isin([5, 6]).astype(int)
# ---- 交叉特征:最有效的那一类 ----
out["user_cat_pref"] = df.apply(
lambda r: r["user_cat_prefs"].get(r["item_category"], 0.0), axis=1)
# ---- 新鲜度:物品发布多久了 ----
out["item_age_hours"] = (df["request_time"] - df["publish_time"]) / 3600
out["log_item_age"] = np.log1p(out["item_age_hours"].clip(lower=0))
# ---- 高基数 ID:哈希分桶 ----
out["author_hash"] = df["author_id"].apply(lambda x: hash(str(x)) % 1_000_000)
return out
📏 怎么知道一个特征有没有用
| 方法 | 说明 | 成本 |
|---|---|---|
| 特征重要性 | 用 GBDT 跑一遍看 gain / split 次数 | 低,先做这个 |
| Ablation(消融) | 去掉这个特征,看 AUC 掉多少 | 中,最可靠的离线方法 |
| 覆盖率 | 这个特征有多少样本非空?<10% 的基本没用 | 极低,先查这个 |
| AUC 增益 | 加入前后离线 AUC 对比 | 中 |
| A/B 实验 | 最终裁判 ⭐ | 高,但只有它算数 |
⚠️ 重要提醒:离线 AUC 涨了,线上不一定涨(见第 6、12 节)。 特征上线前的标准流程:覆盖率检查 → 穿越检查 → 离线 AUC → 小流量 A/B。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| ML基础 16 | 分桶、归一化、log 变换、类别编码的通用版,本章只是把它搬到推荐场景 |
| 上线之后 04 | 坑 2「离线/在线特征不一致」的系统解法:Feature Store 与一套代码双产出 |
| Kaggle 01 | 特征穿越在竞赛里怎么被发现,以及目标编码为什么必须用折外统计 |
✅ 检查点
- 特征的四个来源是什么?哪一类最重要?
- 为什么类别特征用 Embedding 而不用 One-Hot?
- 为什么推荐系统喜欢把数值特征分桶?
- FM 解决了什么问题?为什么用隐向量点积而不是直接给交叉权重?
- 什么是特征穿越?怎么自查?
- 新物品的 CTR 特征该怎么填?
👀 答案
- 用户侧、物品侧、上下文、交叉特征。最重要的是用户行为序列。
- One-Hot 高维稀疏、学不到特征间关系;Embedding 低维稠密,语义相近的值向量也相近,且能泛化。
- 让模型能学到非线性关系,不必假设「数值大小和目标是线性关系」。
- 解决了人工交叉不可穷举 + 交叉特征稀疏学不动的问题。用隐向量点积可以把没共现过的特征对也算出权重(泛化)。
- 用了预测时刻拿不到的信息(通常是未来信息)。自查:问「线上服务那一刻我真的能拿到这个值吗?」
- 贝叶斯平滑,曝光量少时向全局平均 CTR 靠拢,而不是填 0。
🛑 可以停在这里
⚡ 走神救援
特征四来源:用户/物品/上下文/交叉,其中「用户行为序列」最重要。类别特征→Embedding,数值特征→log变换或分桶。特征交叉是核心命题,演进:人工→FM(隐向量点积)→DeepFM→DIN(注意力)。六大坑:特征穿越(最致命)、离线在线不一致、时间窗口、新物品全0(用贝叶斯平滑)、低频ID、高基数(哈希分桶)。⭐ FM 用隐向量点积代替「每对特征一个权重」,才是特征交叉的关键突破:直接给权重的话,(女, 化妆品) 只要训练数据里没共现过就永远学不到;换成 <v_i, v_j> 之后,「女」可以从 (女, 连衣裙) 学、「化妆品」可以从 (25岁, 化妆品) 学,两者一点积就有了预测能力。所以 FM 是矩阵分解的推广——MF 只有 user_id 和 item_id 两个特征,FM 允许任意多个。⚠️ 自查特征穿越只需要一句话:「线上服务的那一毫秒,我真的能拿到这个值吗?」用「视频总播放量」当特征离线 AUC 0.95、上线暴死,就是因为那个量在预测时刻还没产生。⭐ 两个容易漏掉的手法:统计特征要同时喂
ctr_1h / 1d / 7d / 30d多个时间窗口,长短窗口的含义完全不同;小时、星期这种周期特征要用 sin/cos 编码,否则模型会认为 23 点和 0 点相差 23。
下一节 👉 08-深度学习推荐模型.md