背景与动机
问题场景
在 FM 中,每个特征只有一个隐向量:
用户 123 → v₁ = [0.2, 0.3, -0.1, ...]
广告 45 → v₂ = [0.1, -0.2, 0.4, ...]
交互: v₁ · v₂ = 0.2×0.1 + 0.3×(-0.2) + ...
问题: 同一个特征在不同域(Field)中应该有不同的表示!
例子说明
| 域 (Field) | 特征 | 语义 |
|---|---|---|
| 用户域 | 用户 123 | 用户偏好 |
| 广告域 | 广告 45 | 广告属性 |
FM 的问题:
- 用户 123 在"用户域"和"广告域"用同一个向量
- 但语义完全不同!
FFM 的解决:
- 用户 123 在"用户域"有向量 v₁
- 用户 123 在"广告域"有另一个向量 v₂
- 同一特征在不同域有不同表示
FFM vs FM 对比
核心区别
| 维度 | FM | FFM |
|---|---|---|
| 参数量 | n × k | n × f × k |
| 隐向量数 | 每个特征 1 个 | 每个特征 f 个 (f=域数) |
| 交互项 | vᵢ · vⱼ | vᵢ,ⱼ · vⱼ,ᵢ |
公式对比
FM:
y = w₀ + Σᵢ wᵢxᵢ + Σᵢⱼ vᵢ · vⱼ xᵢxⱼ (i < j)
FFM:
y = w₀ + Σᵢ wᵢxᵢ + Σᵢⱼ vᵢ,ⱼ · vⱼ,ᵢ xᵢxⱼ (i < j)
关键区别: FFM 的隐向量依赖于目标域 (Field)
模型原理
Field (域) 的概念
Field 是特征的分组:
Field 0: 用户相关特征
- 用户 ID
- 用户年龄
- 用户性别
Field 1: 广告相关特征
- 广告 ID
- 广告类型
- 广告价格
FFM 的交互项
假设有 f 个域:
特征 i 在域 j 的隐向量: vᵢ,ⱼ
特征 j 在域 i 的隐向量: vⱼ,ᵢ
交互项: vᵢ,ⱼ · vⱼ,ᵢ
直观理解:
| 交互 | 使用的向量 | 含义 |
|---|---|---|
| 用户 × 广告 | v_用户,广告 · v_广告,用户 | 用户从"广告域角度看" · 广告从"用户域角度看" |
参数量计算
FM 参数量:
n × k # n 个特征,每个 k 维
FFM 参数量:
n × f × k # n 个特征,f 个域,每个域内 k 维
示例:
- 特征数 n = 100
- 域数 f = 5
- 隐向量维度 k = 8
| 模型 | 参数量 | 增加倍数 |
|---|---|---|
| FM | 800 | 1x |
| FFM | 4000 | 5x |
代码实现
PyTorch 实现
python
import torch
import torch.nn as nn
class FFM(nn.Module):
"""
Field-aware Factorization Machine
核心思想:
每个特征在每个域都有独立的隐向量
特征 i 与域 j 交互时,使用 vᵢ,ⱼ
"""
def __init__(self, feature_dims, field_dims, k=8):
"""
Args:
feature_dims: 每个特征的可能取值数
field_dims: 每个特征所属的域索引
k: 隐向量维度
"""
super().__init__()
self.feature_dims = feature_dims
self.field_dims = field_dims
self.num_features = len(feature_dims)
self.num_fields = max(field_dims) + 1
self.k = k
# 一阶权重
self.w0 = nn.Parameter(torch.zeros(1))
self.w = nn.Parameter(torch.zeros(self.num_features))
# ==================== FFM 的核心 ====================
# 形状: (num_features, num_fields, k)
# 每个特征在每个域都有一个 k 维向量
self.V = nn.Parameter(torch.randn(self.num_features, self.num_fields, k) * 0.01)
def forward(self, x):
"""
Args:
x: (batch_size, num_features) 特征索引
Returns:
logits: (batch_size, 1) 预测分数
"""
batch_size = x.shape[0]
# 一阶线性项
linear = self.w0 + torch.matmul(x, self.w)
# ==================== 二阶交互项 ====================
second_order = torch.zeros(batch_size)
# 遍历所有特征对
for i in range(self.num_features):
for j in range(i + 1, self.num_features):
# 获取特征 i 和 j 所属的域
field_i = self.field_dims[i]
field_j = self.field_dims[j]
# ==================== FFM 关键 ====================
# 特征 i 在域 j 的隐向量
vi_fieldj = self.V[i, field_j] # (k,)
# 特征 j 在域 i 的隐向量
vj_fieldi = self.V[j, field_i] # (k,)
# 交互项
interaction = torch.sum(vi_fieldj * vj_fieldi, dim=-1) # (batch_size,)
second_order += x[:, i] * x[:, j] * interaction
output = linear + second_order.view(-1, 1)
return output
# ==================== 使用示例 ====================
if __name__ == '__main__':
# 特征定义
# Field 0: 用户相关
# Field 1: 广告相关
feature_dims = [1000, 5, 3, 500, 4, 2] # 用户ID, 年龄, 性别, 广告ID, 类型, 价格
field_dims = [0, 0, 0, 1, 1, 1] # 前三个是用户域,后三个是广告域
model = FFM(
feature_dims=feature_dims,
field_dims=field_dims,
k=8
)
print('=== FFM 模型结构 ===')
print(model)
# 计算参数量
total_params = sum(p.numel() for p in model.parameters())
print(f'参数量: {total_params:,}')
# 生成模拟数据
batch_size = 32
x = torch.tensor([
[torch.randint(0, dim, size=(1,)).item() for dim in feature_dims]
for _ in range(batch_size)
])
y = torch.randint(0, 2, (batch_size, 1), dtype=torch.float32)
# 训练
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
print('\n=== 开始训练 ===')
for epoch in range(100):
pred = model(x)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (epoch + 1) % 20 == 0:
print(f'Epoch {epoch + 1}, Loss: {loss.item():.4f}')
代码详解:交互项计算
FFM 最核心的两行代码:
python
# 第一行:计算两个隐向量的内积
interaction = torch.sum(vi_fieldj * vj_fieldi, dim=-1) # (batch_size,)
# 第二行:累加所有交互
second_order += x[:, i] * x[:, j] * interaction
第一行:计算向量内积
这是什么?
计算两个隐向量的内积(点积):
vi_fieldj = [v₁, v₂, v₃, v₄, v₅, v₆, v₇, v₈] # 特征 i 在域 j 的向量
vj_fieldi = [u₁, u₂, u₃, u₄, u₅, u₆, u₇, u₈] # 特征 j 在域 i 的向量
interaction = v₁·u₁ + v₂·u₂ + v₃·u₃ + ... + v₈·u₈
内积的含义: 衡量两个向量的相似度/关联强度
为什么这样设计?
- FFM 的核心:特征 i 在不同域有不同表示
- 特征 i 与域 j 交互时,用
vi_fieldj - 特征 j 与域 i 交互时,用
vj_fieldi
第二行:累加所有交互
这是什么?
将当前特征对 (i, j) 的交互项加到总和中:
second_order = Σᵢⱼ (xᵢ · xⱼ · vᵢ,ⱼ · vⱼ,ᵢ)
分解说明:
| 部分 | 含义 | 作用 |
|---|---|---|
x[:, i] |
特征 i 的值 | 0 或 1 (one-hot) |
x[:, j] |
特征 j 的值 | 0 或 1 (one-hot) |
vᵢ,ⱼ · vⱼ,ᵢ |
两个隐向量的内积 | 关联强度 |
累加过程:
python
second_order = 0
# 第一对: (用户ID, 广告ID)
second_order += x[:, 0] * *x[:, 3] * v_用户,广告 · v_广告,用户
# 第二对: (用户ID, 广告类型)
second_order += x[:, 0] * x[:, 4] * v_用户,类型 · v_类型,用户
# ... 继续,直到所有特征对计算完毕
为什么需要 x[:, i] * x[:, j]?
这是稀疏特征处理的技巧:
python
# one-hot 特征
x = [1, 0, 0, 1, 0] # 特征 0 和 3 存在
# 交互项计算
x[:, 0] * x[:, 3] = 1 × 1 = 1 # 两个特征都存在,交互有效
x[:, 0] * x[:, 1] = 1 × 0 = 0 # 特征1不存在,忽略
作用:
- 只计算同时存在的特征对
- 稀疏特征优化(大多数项是 0)
直观理解:用户点击广告
场景: 用户 123 点击广告 45
特征:
特征 0: 用户ID = 123 (在用户域)
特征 3: 广告ID = 45 (在广告域)
计算过程:
1. 获取隐向量
v_用户,广告 = 用户123从"广告域"的角度看 = [0.23, 0.15, ...]
v_广告,用户 = 广告45从"用户域"的角度看 = [0.67, 0.32, ...]
2. 计算内积 (第一行代码)
interaction = 0.23×0.67 + 0.15×0.32 + ... = 0.34
3. 累加交互项 (第二行代码)
second_order += 1 × 1 × 0.34 = 0.34
(x[:, 0] = 1, x[:, 3] = 1,因为特征存在)
实战应用
域的划分策略
| 策略 | 说明 | 示例 |
|---|---|---|
| 语义域 | 按语义分类 | 用户域、广告域、语境域 |
| 时间域 | 按时间划分 | 实时域、历史域 |
| 交叉域 | 特殊组合 | 用户×广告域 |
超参数选择
| 参数 | 推荐值 | 影响 |
|---|---|---|
| k (隐向量维度) | 4-16 | 过大过拟合 |
| 学习率 | 0.001 | 标准值 |
| 正则化 | L2, λ=0.01 | 防止过拟合 |
优化技巧
1. 预训练 Embedding
先用 FM 训练,初始化 FFM 的隐向量:
python
FFM.V[:, :, :] = FM.V.unsqueeze(1).repeat(1, num_fields, 1)
``''
**2. 稀疏矩阵优化**
对于稀疏特征,只计算非零特征的交互:
```python
# 只遍历非零特征
non_zero_indices = (x > 0).nonzero()
面试常见问题
Q1: FFM 和 FM 的核心区别是什么?
A:
- FM: 每个特征只有 1 个隐向量
- FFM: 每个特征在每个域都有独立的隐向量
- 本质: FFM 考虑了特征在不同上下文中的不同语义
Q2: 为什么 FFM 需要更多参数?
A:
因为每个特征在 f 个域中都有独立表示:
FM 参数: n × k
FFM 参数: n × f × k # 多了 f 倍
f 是域的数量,通常 3-10 个。
Q3: FFM 什么时候效果比 FM 好?
A:
当满足以下条件时:
- 特征有明确的域划分
- 同一特征在不同域的语义差异大
- 数据量足够(能训练更多参数)
例子: 用户 ID 在"用户域"和"广告域"语义明显不同。
Q4: FFM 可以和 Embedding 结合吗?
A:
可以!有两种方式:
方式 1: 直接 Embedding
python
# 为每个特征在每个域创建独立的 embedding
self.embeddings = nn.ModuleList([
nn.ModuleList([
nn.Embedding(feature_dims[i], k)
for _ in range(num_fields)
])
for i in range(num_features)
])
方式 2: FFM 特有的设计
使用传统的 one-hot 特征,FFM 的隐向量矩阵 V 本身就是可学习的。
Q5: FFM 的优化公式?
A:
FFM 无法使用 FM 的优化公式,因为:
- FM 的二阶项:
vᵢ · vⱼ - FFM 的二阶项:
vᵢ,ⱼ · vⱼ,ᵢ(向量依赖于域)
所以 FFM 必须用双重循环,复杂度 O(n²k)。
Q6: FFM 的推理速度如何优化?
A:
FFM 的推理速度是主要瓶颈,优化方法:
- 稀疏优化: 只计算非零特征
- 量化: 将 float32 降为 float16
- 蒸馏: 用小模型学习 FFM 的知识
- 批量推理: 增加 batch size
Q7: 实际应用中如何选择 FM vs FFM?
A:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 数据量小 | FM | FFM 容易过拟合 |
| 无明显域划分 | FM | FFM 优势不明显 |
| 语义域明确 | FFM | 充分利用域信息 |
| 追求极致效果 | FFM | 通常优于 FM |
| 延迟敏感 | FM | FFM 推理慢 |
Q8: FFM 的扩展模型?
A:
1. DeepFFM
- 类似 DeepFM
- FFM + DNN
2. Field-weighted FM (FwFM)
- 引入域权重
- 区分不同域的重要性
3. Attentional FM (AFM)
- 引入注意力机制
- 学习特征交互的重要性
模型演进路线
FM (2010)
↓ 同一特征只有一个向量
FFM (2016)
↓ 每个特征在每个域都有向量
DeepFM (2017)
↓ 加入 DNN 捕捉高阶交互
DeepFFM (2018)
↓ FFM + DNN
AFM (2017), FwFM (2019)
↓ 注意力机制、域权重
快速检查清单
理解 FFM,你应该能回答:
- 解释 FFM 和 FM 的区别
- 说明 Field (域) 的概念
- 计算 FFM 的参数量
- 理解 vᵢ,ⱼ 和 vⱼ,ᵢ 的含义
- 知道 FFM 的适用场景
- 了解 FFM 的局限性(参数多、推理慢)
- 能实现简单的 FFM 模型
实现案例(CTR预测)
python
import torch
import torch.nn as nn
class FFM(nn.Module):
"""
Field-aware Factorization Machine (FFM)
核心模型:
FM 的扩展,每个特征在每个域(Field)都有独立的隐向量
与 FM 的区别:
FM: 每个特征只有 1 个隐向量
FFM: 每个特征在每个域都有独立的隐向量
交互项:
FM: vᵢ · vⱼ
FFM: vᵢ,ⱼ · vⱼ,ᵢ (特征 i 在域 j 的向量 · 特征 j 在域 i 的向量)
"""
def __init__(self, feature_dims, field_dims, k=8):
"""
Args:
feature_dims: 每个特征的可能取值数
例如: [1000, 5, 3, 500, 4, 2]
field_dims: 每个特征所属的域索引
例如: [0, 0, 0, 1, 1, 1]
前3个是用户域,后3个是广告域
k: 隐向量维度
"""
super().__init__()
self.feature_dims = feature_dims
self.field_dims = field_dims
self.num_features = len(feature_dims)
self.num_fields = max(field_dims) + 1
self.k = k
# ==================== 一阶部分 ====================
# 偏置项
self.w0 = nn.Parameter(torch.zeros(1))
# 一阶权重: 每个特征一个权重
self.w = nn.Parameter(torch.zeros(self.num_features))
# ==================== FFM 核心:隐向量 ====================
# 形状: (num_features, num_fields, k)
# 每个特征在每个域都有一个 k 维向量
self.V = nn.Parameter(torch.randn(self.num_features, self.num_fields, k) * 0.01)
def forward(self, x):
"""
前向传播
Args:
x: (batch_size, num_features) 特征索引
Returns:
logits: (batch_size, 1) 预测分数
"""
batch_size = x.shape[0]
# ==================== 一阶线性项 ====================
# y₁ = w₀ + Σᵢ wᵢxᵢ
# x 需要转为 float,因为输入是 Long 类型 (索引)
# matmul 结果形状: (batch_size,),需要 unsqueeze 变成 (batch_size, 1)
linear = self.w0 + torch.matmul(x.float(), self.w).unsqueeze(1)
# ==================== 二阶交互项 ====================
# FFM 的交互项: Σᵢⱼ vᵢ,ⱼ · vⱼ,ᵢ xᵢxⱼ
second_order = torch.zeros(batch_size)
# 将 x 转为 float,避免后续类型错误
x_float = x.float()
# 遍历所有特征对 (i < j)
for i in range(self.num_features):
for j in range(i + 1, self.num_features):
# 获取特征 i 和 j 所属的域
field_i = self.field_dims[i]
field_j = self.field_dims[j]
# ==================== FFM 关键设计 ====================
# 特征 i 在域 j 中的隐向量
# 形状: (k,)
vi_fieldj = self.V[i, field_j]
# 特征 j 在域 i 中的隐向量
# 形状: (k,)
vj_fieldi = self.V[j, field_i]
# 两个向量的内积
# 形状: (batch_size,)
interaction = torch.sum(vi_fieldj * vj_fieldi, dim=-1)
# 乘以特征值 xᵢxⱼ
# 这里 x 是 one-hot 形式,所以 x[:, i] 就是 0 或 1
second_order += x_float[:, i] * x_float[:, j] * interaction
# 合并输出
output = linear + second_order.view(-1, 1)
return output
# ==================== 使用示例 ====================
if __name__ == '__main__':
# ==================== 特征定义 ====================
# Field 0: 用户相关特征
# Field 1: 广告相关特征
# 特征: [用户ID, 用户年龄, 用户性别, 广告ID, 广告类型, 广告价格等级]
feature_dims = [1000, 5, 3, 500, 4, 2]
# 每个特征所属的域
# [用户域, 用户域, 用户域, 广告域, 广告域, 广告域]
field_dims = [0, 0, 0, 1, 1, 1]
# 创建 FFM 模型
model = FFM(
feature_dims=feature_dims,
field_dims=field_dims,
k=8 # 隐向量维度
)
print('=== FFM 模型结构 ===')
print(model)
# ==================== 参数量分析 ====================
total_params = sum(p.numel() for p in model.parameters())
# 计算各部分参数量
linear_params = 1 + model.num_features # w0 + w
ffm_params = model.num_features * model.num_fields * model.k
print(f'\n参数量分析:')
print(f' 总参数量: {total_params:,}')
print(f' 一阶部分: {linear_params:,} (w0 + w)')
print(f' FFM 隐向量: {ffm_params:,} (特征×域×k)')
print(f' = {model.num_features} × {model.num_fields} × {model.k}')
# 与 FM 对比
fm_params = model.num_features * model.k
print(f'\n与 FM 对比:')
print(f' FM 参数量: {fm_params + linear_params:,}')
print(f' FFM 参数量: {total_params:,}')
print(f' 增加倍数: {total_params / (fm_params + linear_params):.2f}x')
# ==================== 生成训练数据 ====================
batch_size = 32
# 生成随机训练样本
# 每个样本: [用户ID, 年龄, 性别, 广告ID, 类型, 价格]
x = torch.tensor([
[torch.randint(0, dim, size=(1,)).item() for dim in feature_dims]
for _ in range(batch_size)
])
# 生成随机标签
y = torch.randint(0, 2, (batch_size, 1), dtype=torch.float32)
# ==================== 训练配置 ====================
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
print(f'\n=== 开始训练 ===')
print(f'batch_size: {batch_size}')
print(f'特征数: {model.num_features}')
print(f'域数: {model.num_fields}')
# ==================== 训练循环 ====================
for epoch in range(1000):
# 前向传播
pred = model(x)
# 计算损失
loss = criterion(pred, y)
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (epoch + 1) % 20 == 0:
print(f'Epoch {epoch + 1:3d}, Loss: {loss.item():.6f}')
# ==================== 预测示例 ====================
model.eval()
with torch.no_grad():
# 生成测试样本
test_x = torch.tensor([[
torch.randint(0, dim, size=(1,)).item() for dim in feature_dims
]])
# 预测
logits = model(test_x)
click_prob = torch.sigmoid(logits)
print(f'\n=== 预测结果 ===')
print(f'模型输出 (logits): {logits.item():.4f}')
print(f'点击概率 (sigmoid): {click_prob.item():.4f}')
# ==================== 可视化隐向量 ====================
print(f'\n=== 隐向量示例 ===')
print('特征 0 (用户ID) 在不同域的隐向量:')
print(f' 在用户域 (Field 0): {model.V[0, 0][:3].tolist()}... (前3维)')
print(f' 在广告域 (Field 1): {model.V[0, 1][:3].tolist()}... (前3维)')
print('\n特征 3 (广告ID) 在不同域的隐向量:')
print(f' 在用户域 (Field 0): {model.V[3, 0][:3].tolist()}... (前3维)')
print(f' 在广告域 (Field 1): {model.V[3, 1][:3].tolist()}... (前3维)')
print('\n说明: 同一特征在不同域有不同的向量表示')
参考资料
- FFM 原始论文: https://www.csie.ntu.edu.tw/\~r9509/project/ffm.pdf
- FFM 代码: https://github.com/guanhaw/FFM
- 推荐系统综述: https://arxiv.org/abs/1906.02966