6  双塔模型

在第 4 章中,我们基于 MovieLens 10M 数据集构建了 Item-Based 协同过滤(IBCF)引擎。经过异常用户剔除和严格时序切分后,模型的 HR@10 达到了 0.8720。但 IBCF 有一个根本性的局限:它只能利用用户-物品交互矩阵中的共现信息,无法融入用户的人口属性(年龄、职业)和物品的内容特征(类型、标签)。这意味着,面对新注册的用户或新上架的商品,IBCF 完全无能为力——这就是推荐系统中最棘手的冷启动(Cold Start)问题。

第 5 章我们学习了 luz 高级训练框架,并初步接触了 Embedding 的思想:将实体映射为低维稠密向量,让模型在训练中自己发现语义相似关系。这一思想为突破 IBCF 的局限指明了方向——如果我们能为用户和物品分别学习 Embedding 向量,并用深度网络融合它们的各类特征,就能让模型”理解”冷启动实体的属性信息,而不再完全依赖历史交互。

这就是本章的核心——双塔模型(Two-Tower Model)

graph TD
  style U fill:#bbdef5,stroke:#1565c0,stroke-width:2px
  style I fill:#ffe0b2,stroke:#e65100,stroke-width:2px
  style UT fill:#c8e6e9,stroke:#00695c,stroke-width:2px
  style IT fill:#c8e6e9,stroke:#00695c,stroke-width:2px
  style UE fill:#a5d6a7,stroke:#2e7d32,stroke-width:2px
  style IE fill:#a5d6a7,stroke:#2e7d32,stroke-width:2px
  style S fill:#ffe082,stroke:#ff8f00,stroke-width:2px
  style P fill:#ef9a9a,stroke:#c62828,stroke-width:2px

  U[用户特征] --> UT[用户塔]
  UT --> UE[✨ 用户 Embedding]
  I[物品特征] --> IT[物品塔]
  IT --> IE[物品 Embedding]
  UE --> S[相似度计算]
  IE --> S
  S --> P[偏好分数]

本章的旅程分为三步:首先从最简单的矩阵分解(MF)切入,用梯度下降替代 IBCF 的余弦相似度,将”物品相似度”重新理解为”Embedding 内积”;然后引入双塔架构,将用户和物品的网络彻底解耦;最后注入丰富的画像和内容特征,让模型真正具备处理冷启动的能力。

6.1 矩阵分解

传统推荐系统中的经典算法矩阵分解(Matrix Factorization),在 R torch 的框架下,我们可以抛弃复杂的代数推导(如 SVD 或 ALS),直接用最纯粹的张量运算来重构它。假设我们将每个用户和每个物品都嵌入到一个 \(d\) 维的潜在空间中。令 \(\mathbf{p}_u \in \mathbb{R}^d\) 表示第 \(u\) 个用户的 Embedding 向量,\(\mathbf{q}_i \in \mathbb{R}^d\) 表示第 \(i\) 个物品的 Embedding 向量。

用户对物品的原始偏好得分(即交互强度)可以定义为这两个向量的内积:

\[ \text{Interaction}_{ui} = \mathbf{p}_u \cdot \mathbf{q}_i = \sum_{k=1}^{d} p_{uk} q_{ik} \]

从矩阵的视角来看,矩阵分解的本质是将一个巨大的、稀疏的用户-物品交互矩阵 \(R\),近似分解为两个低秩稠密矩阵的乘积:

\[ \underbrace{\begin{bmatrix} r_{11} & r_{12} & \cdots & r_{1n} \\ r_{21} & r_{22} & \cdots & r_{2n} \\ \vdots & \vdots & \ddots & \vdots \\ r_{m1} & r_{m2} & \cdots & r_{mn} \end{bmatrix}}_{R\ \in\ \mathbb{R}^{m \times n}} \approx \underbrace{\begin{bmatrix} \mathbf{p}_1 \\ \mathbf{p}_2 \\ \vdots \\ \mathbf{p}_m \end{bmatrix}}_{P\ \in\ \mathbb{R}^{m \times d}} \times \underbrace{\begin{bmatrix} \mathbf{q}_1 & \mathbf{q}_2 & \cdots & \mathbf{q}_n \end{bmatrix}}_{Q^T\ \in\ \mathbb{R}^{d \times n}} \]

其中 \(m\) 是用户数,\(n\) 是物品数,\(d\) 是潜在空间的维度(\(d \ll m, n\))。矩阵 \(P\) 的每一行 \(\mathbf{p}_u\) 就是用户 \(u\) 的 Embedding 向量,矩阵 \(Q\) 的每一行 \(\mathbf{q}_i\) 则是物品 \(i\) 的 Embedding 向量。两者的内积 \(\mathbf{p}_u^\top \mathbf{q}_i\) 直接重构出原始矩阵中第 \(u\) 行第 \(i\) 列的交互强度。

在现实世界中,有些用户看什么都给高分,而有些电影天生就是大众爆款。为了消除这些个体或物品的全局固有偏差,我们需要在点积的基础上加上偏置项(Bias):

\[ \hat{y}_{ui} = \mu + b_u + b_i + \mathbf{p}_u^\top \mathbf{q}_i \]

其中各参数的含义如下:

  • \(\mu\) (Global Bias):全局平均分,代表数据集的底色。
  • \(b_u\) (User Bias):用户偏置,反映用户的打分习惯(如“严师”或“老好人”)。
  • \(b_i\) (Item Bias):物品偏置,反映物品的固有质量或大众流行度。
  • \(\mathbf{p}_u^\top \mathbf{q}_i\):核心交互项,衡量用户兴趣与物品特征的匹配程度。

这就是著名的带偏置项的矩阵分解模型。在深度学习的视角下,这根本不需要什么特殊的分解算法,它就是一个只有几组 Embedding 查找和一个加法操作的极简神经网络。

我们使用 nn_module 将上述数学公式翻译为 R torch 代码。请注意观察我们是如何在 forward 方法中精确控制张量维度的。

nn_matrix_factorization <- nn_module(
  "MatrixFactorizationModel",
  
  initialize = function(num_users, num_items, embed_dim = 32) {
    # 核心特征向量层
    self$user_embed  <- nn_embedding(num_users, embed_dim)
    self$item_embed  <- nn_embedding(num_items, embed_dim)
    
    # 偏置项层 (Bias Terms)
    self$user_bias   <- nn_embedding(num_users, 1)
    self$item_bias   <- nn_embedding(num_items, 1)
    self$global_bias <- nn_parameter(torch_zeros(1))
  },
  
  forward = function(x) {
    # 1. 提取隐向量 (p_u 和 q_i)
    u <- self$user_embed(x$user)
    i <- self$item_embed(x$item)
    
    # 2. 计算点积项: p_u^T * q_i
    dot_product <- (u * i)$sum(dim = -1)
    
    # 3. 提取偏置项 (b_u 和 b_i)
    u_b <- self$user_bias(x$user)$squeeze(-1)
    i_b <- self$item_bias(x$item)$squeeze(-1)
    
    # 4. 最终融合: dot_product + b_u + b_i + mu
    dot_product + u_b + i_b + self$global_bias
  }
)
注记nn_embedding 是什么?

nn_embedding 是 R torch 中最常用的层之一,它本质上是一个可学习的查找表(Lookup Table)。构造函数接收两个参数:

  • num_embeddings:字典大小,即一共有多少个不同的实体(如用户数、物品数)。
  • embedding_dim:每个实体对应的向量维度。

当我们传入一个整数索引时,nn_embedding 会返回该索引对应的那一行向量。一个直观的例子:

# 创建包含 1000 个用户的 Embedding 表,每个用户用 32 维向量表示
emb <- nn_embedding(1000, 32)

# 查询第 5 个用户的向量
idx <- torch_tensor(5L, dtype = torch_long())
emb(idx)  # shape: [1, 32]

在上面的矩阵分解模型中,nn_embedding(num_users, embed_dim) 本质上就是矩阵 \(P\)(用户隐因子矩阵),而 nn_embedding(num_items, embed_dim) 就是矩阵 \(Q\)(物品隐因子矩阵)。通过索引查找,我们无需手动维护巨大的参数矩阵——nn_embedding 会自动管理参数的存储和梯度更新。

处理二分类问题,最好的工具是 Sigmoid 函数配合二元交叉熵损失(BCE)。我们将矩阵分解未归一化的 Logits 直接喂给 nnf_binary_cross_entropy_with_logits,从而有效避免梯度下溢。

本章后续的双塔模型中,所有类别特征(用户 ID、物品 ID、性别、年龄段、职业等)都会通过 nn_embedding 转换为稠密向量,这是整个 Embedding 范式的基石。

注记关于本节

出于篇幅考虑,本节仅展示 MF 的网络结构设计——如何用 nn_embedding 和点积运算替代传统的矩阵分解算法。完整的训练管线(数据集构建、DataLoaderluz 训练循环、评估函数)与下一节纯 ID 双塔完全一致,下文将一次性完整呈现。

6.2 纯 ID 的双塔架构

上一节的 MF 用可学习 Embedding 替代了 IBCF 的固定相似度,取得了初步提升。但无论是 IBCF 还是纯 ID 的 MF,都面临同一个根本性困境:冷启动(Cold Start)——这正是本章开头从第 4 章延续过来的核心挑战。

当一个新用户注册,或一件新商品上架时,它们没有任何历史交互记录。纯 ID 模型只能给它们分配随机初始化的 Embedding 向量,推荐结果将完全是随机的。要突破这一局限,模型必须能”看到”实体背后的属性:用户的年龄、地域、活跃度;商品的类目、价格带、文本标签。

如何将这些异构的上下文特征融入模型,同时又满足工业界毫秒级的线上延迟要求?这就是双塔架构要解决的问题。

6.2.1 结构解析

顾名思义,双塔架构在物理结构上被严格分为互不干扰的两半:用户塔和物品塔。

在这个网络结构中:

  • 用户特征 \(\mathbf{x}_u\) 只能进入用户塔,经过编码器网络,压缩成一个固定维度(如 64 维)的稠密向量 \(\mathbf{e}_u\)
  • 物品特征 \(\mathbf{x}_i\) 同理,在物品塔中被映射为同样维度的稠密向量 \(\mathbf{e}_i\)
  • 在这整个过程中,两边的信息没有任何交集。直到网络的最后一刻,两个塔的顶端才进行一次极简的代数运算,通常是计算这两个向量的余弦相似度或内积。

我们先做第一个实验,暂时不考虑用户和商品的上下文信息,只使用用户 ID 和物品 ID 的 Embedding 信息,观察双塔的复杂网络结构是否可以改善模型表现。

library(torch)
library(luz)
### 定义双塔模型
dual_tower_model <- nn_module(
  "DualTower",
  initialize = function(num_users,
                        num_items,
                        embedding_dim,
                        latent_dim) {
    self$user_embedding <- nn_embedding(num_users, embedding_dim)
    self$item_embedding <- nn_embedding(num_items, embedding_dim)
    
    # 用户塔:独立的 MLP
    self$user_tower <- nn_sequential(nn_linear(embedding_dim, 64),
                                     nn_relu(),
                                     nn_linear(64, latent_dim))
    
    # 物品塔:独立的 MLP
    self$item_tower <- nn_sequential(nn_linear(embedding_dim, 64),
                                     nn_relu(),
                                     nn_linear(64, latent_dim))
    
    nn_init_normal_(self$user_embedding$weight, std = 0.01)
    nn_init_normal_(self$item_embedding$weight, std = 0.01)
  },
  
  forward = function(x) {
    u_rep <- self$user_tower(self$user_embedding(x$user))
    i_rep <- self$item_tower(self$item_embedding(x$item))
    
    # 最终打分退化为点积,彻底解耦
    return(torch_sum(u_rep * i_rep, dim = -1))
  }
)

沿用第 4 章的数据处理管线(时序切分、负采样评估),我们继续在 MovieLens 数据集上进行实验。

library(vroom)
library(data.table)

DATA_DIR <- "/Users/liusizhe/data/ml-10m100K"
FILE_PATH <- file.path(DATA_DIR, "ratings.dat")

dt_ratings <- vroom(
  FILE_PATH,
  delim = "::",
  col_names = c("user_id", "movie_id", "rating", "timestamp"),
  col_select = c(user_id, movie_id, rating, timestamp),
  show_col_types = FALSE
)
setDT(dt_ratings)

# 提取唯一 ID 并计算维度
unique_users <- unique(dt_ratings$user_id)
unique_items <- unique(dt_ratings$movie_id)
num_users <- length(unique_users)
num_items <- length(unique_items)
all_items_vec <- 1:num_items

# 映射为从 1 开始的连续整数索引
dt_ratings[, user_idx := match(user_id, unique_users)]
dt_ratings[, item_idx := match(movie_id, unique_items)]

# 过滤异常用户(交互次数小于等于 800)
valid_users <- dt_ratings[, .(action_cnt = .N), by = user_idx][
  action_cnt <= 800, user_idx
]
dt_ratings <- dt_ratings[user_idx %in% valid_users]

# 严谨的时序划分 (80% Train, 20% Test)
setorder(dt_ratings, user_idx, timestamp)
dt_ratings[, `:=`(seq_num = 1:.N, total_num = .N), by = user_idx]

# 按照用户观影的时间顺序,预留 20% 做 test 数据集。
dt_train_raw <- dt_ratings[seq_num <= floor(0.8 * total_num)]
dt_test_raw  <- dt_ratings[seq_num > floor(0.8 * total_num)]

# 过滤测试集中的冷启动物品
valid_train_items <- unique(dt_train_raw$item_idx)
dt_test_raw <- dt_test_raw[item_idx %in% valid_train_items]

# 预先计算每个用户的全局交互历史,用于负采样时避开已交互物品
dt_user_history <- dt_ratings[, .(interacted = list(item_idx)), by = user_idx]

构造评估集:

# 构造评估集 (严格留一法: 1 正 + 99 负) 
set.seed(42)
# 取测试集第一条作为目标正样本
dt_eval_pos <- dt_test_raw[, .SD[1], by = user_idx][, .(user_idx, item_idx, label = 1)]

# 仅为有效的评估用户生成 99 个负样本
valid_eval_users <- dt_eval_pos$user_idx
dt_eval_neg <- dt_user_history[user_idx %in% valid_eval_users,
                               .(item_idx = sample(
                                 setdiff(
                                   all_items_vec, unlist(interacted)
                                   ), 99, replace = FALSE),
                                      label = 0), by = user_idx]

dt_eval <- rbindlist(list(dt_eval_pos, dt_eval_neg), use.names = TRUE)
setorder(dt_eval, user_idx, -label) # 保证每个用户 100 行连续排列


# --- 构造训练集 (1:1 正负样本均衡) ---
dt_train_pos <- dt_train_raw[, .(user_idx, item_idx, label = 1)]
train_counts <- dt_train_pos[, .(pos_count = .N), by = user_idx]

# 按每个用户的正样本数量,抽取等量的训练负样本
dt_train_neg <-
  train_counts[, .(item_idx =
                     sample(setdiff(all_items_vec,
                                    dt_user_history[user_idx == .BY$user_idx,
                                     unlist(interacted)]),
                            pos_count, replace = TRUE),
                   label = 0), by = user_idx]

dt_train <- rbindlist(list(dt_train_pos, dt_train_neg), use.names = TRUE)
dt_train <- dt_train[sample(.N)] # 随机打乱训练集

6.2.2 构建数据集

这里有一个非常重要的细节,如果你按照之前章节的常规方法编写 dataset(即使用 .getitem 逐行读取数据),在推荐系统这种动辄数百万行级别的任务中,训练会被拖的非常慢。正确的做法是在于抛弃 .getitem 方法,改用 .getbatch 进行纯张量切片,二者速度对比非常夸张。

ds_matrix_factorization <- dataset(
  name = "MatrixFactorizationDataset",
  initialize = function(dt) {
    self$users <- torch_tensor(dt$user_idx, dtype = torch_long())
    self$items <- torch_tensor(dt$item_idx, dtype = torch_long())
    self$labels <- torch_tensor(dt$label, dtype = torch_float())
  },
  
  # 抛弃 .getitem,改用 .getbatch 进行纯张量切片
  .getbatch = function(indices) {
    # indices 是一个包含 batch_size 个索引的向量
    # 这里直接利用 C++ 底层一次性切出整个 Batch
    list(
        list(
            user = self$users[indices],
            item = self$items[indices]), self$labels[indices])
  },
  
  .length = function() {
    self$labels$size(1)
  }
)

# 使用纯张量切片后,单线程即可喂饱 GPU,关掉 num_workers 避免进程通讯开销
dl_train <- dataloader(
  ds_matrix_factorization(dt_train),
  batch_size = 1024 * 2,
  shuffle = TRUE,
  num_workers = 0
)

dl_eval  <- dataloader(
  ds_matrix_factorization(dt_eval),
  batch_size = 3000,
  # 保持 100 的倍数
  shuffle = FALSE,
  num_workers = 0
)

6.2.3 训练模型和评估

这里选用了 optim_adamw 优化器。AdamW 将权重衰减(Weight Decay)与梯度更新解耦,相比标准 Adam 具有更好的泛化能力,尤其适合 Embedding 参数较多的推荐模型。weight_decay = 1e-5 提供轻微的正则化,防止 Embedding 过拟合。

fitted_model_dual <- dual_tower_model %>%
  setup(loss = nnf_binary_cross_entropy_with_logits, optimizer = optim_adamw) %>%
  set_hparams(num_users = num_users,
              num_items = num_items,
              embedding_dim = 128,
              latent_dim = 128) %>%
  set_opt_hparams(lr = 1e-3, weight_decay = 1e-5) %>%
  fit(
    data = dl_train,
    valid_data = dl_eval,
    epochs = 5,
    accelerator = accelerator()
  )

观察模型训练的过程:

Epoch 1/5
Train metrics: Loss: 0.2848                                                   
Valid metrics: Loss: 0.2713
Epoch 2/5
Train metrics: Loss: 0.2238                                                   
Valid metrics: Loss: 0.216
Epoch 3/5
Train metrics: Loss: 0.2023                                                   
Valid metrics: Loss: 0.2013
Epoch 4/5
Train metrics: Loss: 0.188                                                    
Valid metrics: Loss: 0.1987
Epoch 5/5
Train metrics: Loss: 0.1787                                                   
Valid metrics: Loss: 0.2014

模型评估函数:

evaluate_recall <- function(fitted_model, dl_eval, k = 10) {
  # 1. 获取模型预测结果 (luz 的 predict 默认在无梯度模式下运行)
  preds <- predict(fitted_model, dl_eval)
  
  # 确保转为 torch_tensor
  if (!inherits(preds, "torch_tensor")) {
    preds <- torch_tensor(as.numeric(preds), dtype = torch_float())
  }
  
  # 2. Reshape 为 [N_users, 100] 的矩阵
  # 依赖前置条件: batch_size 是 100 的倍数,且每个用户 1正+99负 连续排列
  num_users_eval <- preds$size(1) / 100L
  preds_matrix <- preds$view(c(num_users_eval, 100L))
  
  # 3. 提取 Top-K 的索引
  topk_indices <- torch_topk(
    preds_matrix,
    k,
    dim = -1L,
    largest = TRUE,
    sorted = TRUE
  )[[2]]
  
  # 4. 计算 Recall
  # 因为正样本永远在每个用户的第一列 (Index = 1L)
  # 直接统计每行 (每个用户) 的 Top-K 索引中是否包含 1L
  hits_tensor <- (topk_indices == 1L)$sum(dim = -1L)$to(dtype = torch_float())
  recall <- hits_tensor$mean()$item()
  
  return(recall)
}

# 执行评估
recall_score <- evaluate_recall(fitted_model_dual, dl_eval, k = 10)
cat(sprintf("=> Recall@10: %.4f\n", recall_score))
> cat(sprintf("=> Recall@10: %.4f\n", recall_score))                          
=> Recall@10: 0.9107

从纯 ID 双塔的运行结果来看,HR@10 指标提升到了 0.9107(第 4 章基准算法 Item-CF 的 HR@10 为 0.8720)。 这是因为 MLP 层对原始的 Embedding 进行了内部重组和交叉,赋予了模型极大的特征组合自由度。

观察 Loss 曲线可以发现:第 5 轮时 Valid Loss 从 0.1987 微升至 0.2014,说明模型开始出现过拟合迹象。在实际调优中,可以借助 Early Stopping(第 5 章已介绍)在 Valid Loss 不再下降时自动终止训练,避免无效迭代。

6.3 引入特征的双塔

注记关于本节

出于篇幅考虑,本节聚焦于网络结构设计——如何将异构特征通过 nn_embeddingtorch_cat 注入双塔架构。特征工程(类别变量对齐、Multi-hot 编码)和模型定义是本节的讲解重点,训练管线和评估流程与前文纯 ID 双塔完全一致,不再重复展示。

双塔架构真正的魅力在于它是一个”容器”,可以通过物理拼接(torch_cat),无缝装入各种异构特征。为了引入用户画像数据,本节改用 MovieLens 1M 数据集(约 6000 用户、4000 部电影、100 万条评分),它额外提供了用户的性别、年龄段、职业,以及电影的多值类型标签(如 Action|Adventure)1

1 我们前面课程内容使用的 MovieLens 100M 数据集没有提供用户和物品的详细特征,如性别、年龄段、职业、历史行为等。

你肯定想将更多的用户信息加进来,比如用户最后一次距今消费时长、高价值用户标签、活跃度标签、活跃时间标签、价格敏感度标签、用户来源渠道等等。 这些能够帮助我们理解用户的特征,都可以通过拼接的方式引入到用户塔。

graph TD
  %% 样式定义 - 商务简洁风
  classDef inputStyle fill:#eceff1,stroke:#455a64,stroke-width:1.5px,rx:4
  classDef fuseStyle fill:#e1f5fe,stroke:#0277bd,stroke-width:1.5px,rx:4
  classDef mlpStyle fill:#e8eaf6,stroke:#3949ab,stroke-width:1.5px,rx:4
  classDef embStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,rx:4
  classDef dotStyle fill:#fff8e1,stroke:#f9a825,stroke-width:2px,rx:4
  classDef scoreStyle fill:#fbe9e7,stroke:#d84315,stroke-width:2px,rx:4

  subgraph User_Tower [用户塔]
    direction TB
    U1[年龄]:::inputStyle --> U_comb[特征融合层]:::fuseStyle
    U2[性别]:::inputStyle --> U_comb
    U3[历史行为]:::inputStyle --> U_comb
    U_comb --> U_mlp[MLP 层]:::mlpStyle
    U_mlp --> U_emb[用户 Embedding]:::embStyle
  end

  subgraph Item_Tower [物品塔]
    direction TB
    I1[物品ID]:::inputStyle --> I_comb[特征融合层]:::fuseStyle
    I2[品类]:::inputStyle --> I_comb
    I3[价格]:::inputStyle --> I_comb
    I_comb --> I_mlp[MLP 层]:::mlpStyle
    I_mlp --> I_emb[物品 Embedding]:::embStyle
  end

  U_emb --> Dot([相似度]):::dotStyle
  I_emb --> Dot
  Dot --> Score[偏好分数]:::scoreStyle

  style User_Tower fill:#fafafa,stroke:#37474f,stroke-width:1.5px,rx:8
  style Item_Tower fill:#fafafa,stroke:#37474f,stroke-width:1.5px,rx:8

1. 用户特征:类别变量的连续整数化对齐

在利用 vroom 包读入用户数据后,接着对用户的性别、年龄段、职业进行 nn_embedding 处理。这个函数的入参必须是从 1 开始的连续整数。最优雅的处理方式是利用 R 的 as.factor() 配合 as.integer() 进行一键转换。

# 类别特征连续整数化
dt_users[, gender_idx := as.integer(as.factor(gender))]
dt_users[, age_idx    := as.integer(as.factor(age))]
dt_users[, occ_idx    := as.integer(as.factor(occupation))]

在这一阶段,映射后的类别总数(如 max(dt_users$gender_idx))用于后续初始化网络时 Embedding 层的字典维度。同时,用户 ID 的映射必须与交互日志(ratings)中的全局字典严格复用 match() 保持对齐,避免 ID 错位。

2. 物品特征:基于 data.table 透视的 Multi-hot 极速编码

电影的类型(Genres)通常是一个多值特征(例如 “Action|Adventure”),需要被编码为 Multi-hot 稠密向量。这里利用 data.table 的长宽表转换能力瞬间完成 Multi-hot 构建:

# 1. 炸开多值列:将 Action|Adventure 拆分成多行长表,并赋值为 1
dt_genres_long <- dt_movies[, 
        .(genre = unlist(strsplit(genres, "\\|"))), by = item_idx]
dt_genres_long[, value := 1L] 

# 2. 宽表透视:极速生成 Multi-hot 矩阵,缺失类型自动填 0
dt_genres_wide <- dcast(dt_genres_long, 
                        item_idx ~ genre, value.var = "value", fill = 0L)

透视完成后,紧接着就是构建全局索引矩阵。我们需要创建一个包含所有 1:num_items 的完整骨架表与透视表进行 merge,对冷启动电影(完全没有类别信息的 Item)补 0。随后剥离 ID 列,将其转化为严格按照 item_idx 排序的全局 Multi-hot Matrix。

3. 数据集融合与 dataload

首先在 data.table 层面通过按键关联(Join)直接将用户特征拼接入训练主表:

dt_train[dt_users, on = "user_idx",
 `:=`(gender_idx=i.gender_idx, age_idx=i.age_idx, occ_idx=i.occ_idx)]

这是一步原地 Left Join(左连接),:=data.table 原地操作的语法,意思是:去取 i 表(也就是 dt_users 表)里的 gender_idx 列拿来赋值。

接着,重写 dataset 方法,依然是利用了 .getbatch 数据切片:

initialize = function(dt, genre_mat) {
  # 初始化时,将全量数据一次性送入底层转为 torch_tensor
  self$genders <- torch_tensor(dt$gender_idx, dtype = torch_long())
  self$genres_multi_hot <- torch_tensor(genre_mat, dtype = torch_float())
  # ... 其他列同理
},

.getbatch = function(indices) {
  x <- list(
    user            = self$users[indices],
    gender          = self$genders[indices],
    genre_multi_hot = self$genres_multi_hot[indices]
    # ...
  )
  list(x, self$labels[indices])
}

6.3.1 模型结构框架

数据处理完毕后,我们为所有的类别特征分配独立的 Embedding 层,并在输入各个塔之前,将它们 torch_cat 起来:

DualTower_WithFeatures <- nn_module(
  "DualTower_WithFeatures",
  
  initialize = function(num_users, num_items, 
                        num_genders, num_ages, num_occs, num_genres,
                        id_dim = 32, feat_dim = 8, latent_dim = 32) {
    
    # 1. Embeddings 初始化 (保持不变)
    # user/item Embedding 的定义同前面,这里省略…… 
    self$gender_emb <- nn_embedding(num_genders, feat_dim)
    self$age_emb    <- nn_embedding(num_ages, feat_dim)
    self$occ_emb    <- nn_embedding(num_occs, feat_dim)
    self$genre_linear <- nn_linear(num_genres, feat_dim, bias = FALSE)
    
    user_input_dim <- id_dim + 3 * feat_dim
    item_input_dim <- id_dim + 1 * feat_dim
    
    # 2. user/item 的结构保持不变
    # 通过 nn_sequential() 构建,定义同前面,这里省略……
  },
  
  # 工程部署接口:在线实时计算用户表征
  get_user_rep = function(user_tensor, gender_tensor,
                          age_tensor, occ_tensor) {
    u_id  <- self$user_embedding(user_tensor)
    u_gen <- self$gender_emb(gender_tensor)
    u_age <- self$age_emb(age_tensor)
    u_occ <- self$occ_emb(occ_tensor)
    
    u_concat <- torch_cat(list(u_id, u_gen, u_age, u_occ), dim = -1L)
    return(self$user_tower(u_concat))
  },
  
  # 工程部署接口:离线批量计算物品表征
  get_item_rep = function(item_tensor, genre_multi_hot_tensor) {
    i_id  <- self$item_embedding(item_tensor)
    
    # 内置 Mean Pooling 逻辑
    genre_counts <- torch_clamp(
        genre_multi_hot_tensor$sum(dim = -1L, keepdim = TRUE), min = 1)
    normalized_genres <- genre_multi_hot_tensor / genre_counts
    i_gen <- self$genre_linear(normalized_genres) 
    
    i_concat <- torch_cat(list(i_id, i_gen), dim = -1L)
    return(self$item_tower(i_concat))
  },
  
  forward = function(x) {
    u_rep <- self$get_user_rep(x$user, x$gender, x$age, x$occ)
    i_rep <- self$get_item_rep(x$item, x$genre_multi_hot)
    
    return(torch_sum(u_rep * i_rep, dim = -1L))
  }
)

如果本课程涉及的推荐模型均在统一的评估设置(MovieLens 1M 数据集,相同的训练/测试划分,未进行第 4 章的异常用户剔除)下运行,各个模型的 HR@10 如下:

IBCF (0.6445) < MF (0.6719) < 纯 ID 双塔 (0.7003) < 带特征双塔 (0.7171)。

在真实的工业推荐场景中,追求极致的 SOTA(State-of-the-Art)往往还需要借助网格搜索进行海量的超参数调优(如损失函数定义、正则化系数等),强烈建议读者亲自体会指标突破的成就感。

6.3.2 部署和表征导出

在真实的工业流水线中,双塔模型通常只用于“召回(Recall)”阶段。在完成双塔模型的训练与离线评估后,我们面临的核心挑战是如何将其部署到高并发的线上生产环境。

双塔结构的精妙之处在于,用户塔和物品塔在网络最后一步进行点积(Dot Product)之前是完全独立的。这种“解耦”特性,使得我们可以采用非对称部署架构:

离线预计算物品表征,在线实时计算用户表征

从而将线上推理的计算复杂度从 \(O(N \times M)\) 骤降为 \(O(1)\) 的单次模型前向传播与一次向量检索。

物品(如电影、商品)的属性在短时间内通常是静态的。因此,我们可以设定一个定时任务(例如每天凌晨),使用训练好的物品塔,将库中所有物品的特征转化为稠密向量(Dense Embeddings),并导出至向量数据库(专门存储和高效检索高维向量数据的系统,能快速找到与查询向量最相似的向量),如 FAISS、Milvus 或 Redis。

我们直接调用模型中预留的 get_item_rep 方法,并利用全局特征矩阵一次性完成计算:

# 1. 准备全局物品 ID 与 Multi-hot 特征矩阵
item_ids <- torch_tensor(all_items_vec, dtype = torch_long())
item_genres <- torch_tensor(genre_matrix_global, dtype = torch_float())

# 2. 提取底层模型并设置为评估模式
model_core <- fitted_model_feat$model
model_core$eval()

with_no_grad({
  item_embeddings_tensor <- model_core$get_item_rep(item_ids, item_genres)
})

# 3. 转为普通 R 矩阵,写入向量数据库
# 省略……

实时用户向量表征的预测同理,线上接口调用 get_user_rep 返回用户向量表达。向量引擎会在毫秒级内,拿这 1 个用户向量去和库里几百万个物品向量进行内积计算,返回得分最高的 Top-K 物品,完成一次极速的召回。

通过数学上的彻底解耦,双塔模型完美平衡了深度学习的表达能力和苛刻的工程耗时要求。

6.4 本章小结

本章从 IBCF 的冷启动困境出发,沿三条主线逐步构建了工业级双塔召回模型:

  • nn_embedding 和点积运算替代传统的矩阵分解 SVD/ALS,将矩阵分解重新诠释为极简神经网络。这是理解所有 Embedding 范式模型的起点。
  • 在 MF 基础上引入独立的用户塔和物品塔(MLP),通过非线性变换增强特征组合能力。在 MovieLens 10M 数据集上,HR@10 从 IBCF 的 0.8720 提升至 0.9107。
  • 通过 torch_cat 将画像特征(性别、年龄、职业)和内容特征(Multi-hot 类型标签)注入对应塔中,使模型具备冷启动能力。利用解耦特性实现非对称部署——离线预计算物品向量,在线实时计算用户向量,将推理复杂度从 \(O(N \times M)\) 降至 \(O(1)\) 向量检索。

回顾整条技术演进路线: IBCF -> MF -> 纯 ID 双塔 -> 带特征双塔,每一步都在前一步的基础上解决一个具体问题。这也是工业推荐系统迭代的核心方法论——不是一步到位设计完美模型,而是沿着清晰的技术脉络持续演进。