
WideDeep核心算法实现在这里.核心代码改动区别在于参数调优的时候为了寻找更好的参数在Deep组件里加入了暂退层所以WideDeep也要跟着传一下参数。当然事实证明在我这个数据集上因为采用了早停策略(在第5-10个epoch就停止训练)所以Dropout加不加也区别不大核心代码不做改动也是完全没问题的。改动如下class Deep(nn.Module): def __init__(self, sparse_vocab_sizes, embed_dim, output_dim, hidden_units, activationrelu, dropout_rate 0.3): sparse_vocab_sizes: 每个稀疏特征的词汇表大小 embed_dim: 每个特征的嵌入维度 output_dim: 输出维度 hidden_units: 隐藏层神经元列表如 [256, 128] activation: 激活函数 super(Deep, self).__init__() # 为每个稀疏特征构建嵌入层 self.embeddings nn.ModuleList([ nn.Embedding(vocab_size, embed_dim) for vocab_size in sparse_vocab_sizes ]) # 拼接后的总维度 deep_input_dim len(sparse_vocab_sizes) * embed_dim # 构建隐藏层 self.hidden_layers nn.ModuleList() in_dim deep_input_dim for unit in hidden_units: self.hidden_layers.append(nn.Linear(in_dim, unit)) in_dim unit self.output_layer nn.Linear(in_dim, output_dim, biasTrue) self.dropout nn.Dropout(pdropout_rate) # 激活函数 if activation relu: self.activation nn.ReLU() elif activation tanh: self.activation nn.Tanh() else: raise ValueError(Unsupported activation) # 初始化 for layer in self.hidden_layers: nn.init.xavier_normal_(layer.weight) nn.init.zeros_(layer.bias) nn.init.xavier_normal_(self.output_layer.weight) nn.init.zeros_(self.output_layer.bias) for emb in self.embeddings: nn.init.normal_(emb.weight, mean0, std0.01) def forward(self, sparse_indices): sparse_indices: (batch, sparse_num) # 查表并拼接 embed_list [emb(sparse_indices[:, i]) for i, emb in enumerate(self.embeddings)] concat_embed torch.cat(embed_list, dim1) # (batch, sparse_num * embed_dim) x concat_embed for layer in self.hidden_layers: x self.activation(layer(x)) x self.dropout(x) return self.output_layer(x) # (batch, output_dim)WideDeep改动class WideDeep(nn.Module): def __init__(self, dense_num, sparse_vocab_sizes, embed_dim, output_dim, hidden_units, activationrelu, dropout_rate 0.3): super(WideDeep, self).__init__() self.wide Wide(dense_num, sparse_vocab_sizes, output_dim) self.deep Deep(sparse_vocab_sizes, embed_dim, output_dim, hidden_units, activation, dropout_rate) def forward(self, dense_inputs, sparse_indices): wide_out self.wide(dense_inputs, sparse_indices) deep_out self.deep(sparse_indices) return torch.sigmoid(0.5 * (wide_out deep_out))本文除最后一个结果外其余数据都是在不加暂退层的情况下得到的。数据加载——基础版因为我们只是换了一个数据集所以数据加载时核心改动的地方先仅处理user和item两个特征def load_movielens_100k(data_path., test_size0.2, random_state42, threshold3): # 1. 读取 u.data data pd.read_csv( f{data_path}/u.data, sep\t, headerNone, names[user_id, item_id, rating, timestamp] ) # 2. 将评分转为二分类标签 data[label] (data[rating] threshold).astype(int) # 3. 获取用户数和电影数ID从1开始因此最大值就是数量 n_users data[user_id].max() # 943 n_items data[item_id].max() # 1682 n n_users n_items # 2625 # 4. 构建独热编码矩阵稠密矩阵2625列内存可接受 # user_ids data[user_id].values - 1 # 转为0-based索引 # item_ids data[item_id].values - 1 # X np.zeros((len(data), n), dtypenp.float32) # X[np.arange(len(data)), user_ids] 1.0 # X[np.arange(len(data)), n_users item_ids] 1.0 # 新增一个虚拟数值特征常数1 data[dummy] 1.0 dense_features [dummy] sparse_features [user_id, item_id] # 对稀疏特征进行整数编码Label Encoding sparse_vocab_sizes [] for col in sparse_features: le LabelEncoder() data[col] le.fit_transform(data[col].astype(str)) sparse_vocab_sizes.append(len(le.classes_)) # 记录词汇表大小 # 分离特征和标签 X_dense data[dense_features].values.astype(np.float32) X_sparse data[sparse_features].values.astype(np.int64) # 整数索引 y data[label].values.astype(np.float32) # 划分训练集和测试集 X_dense_train, X_dense_test, X_sparse_train, X_sparse_test, y_train, y_test train_test_split( X_dense, X_sparse, y, test_sizetest_size, random_staterandom_state ) # 转为张量 X_dense_train torch.tensor(X_dense_train, dtypetorch.float32) X_sparse_train torch.tensor(X_sparse_train, dtypetorch.long) y_train torch.tensor(y_train, dtypetorch.float32).view(-1, 1) X_dense_test torch.tensor(X_dense_test, dtypetorch.float32) X_sparse_test torch.tensor(X_sparse_test, dtypetorch.long) y_test torch.tensor(y_test, dtypetorch.float32).view(-1, 1) print(f数据加载完成) print(f 用户数: {n_users}, 电影数: {n_items}) print(f 特征总数: {n}) print(f 总样本数: {len(data)}) print(f 正样本比例: {data[label].mean():.2%}) print(f 训练集: {X_sparse_train.shape[0]}, 测试集: {X_sparse_test.shape[0]}) print(fsparse_vocab_sizes: {sparse_vocab_sizes}) return (X_dense_train, X_sparse_train, y_train), (X_dense_test, X_sparse_test, y_test), sparse_vocab_sizes运行结果——基础版在不同参数下的运行结果数据加载——结合上下文将年龄、性别、职业、电影类型也加进来def load_movielens_with_context(data_path., test_size0.2, random_state42, threshold3): # 1. 读取评分数据u.data ratings pd.read_csv( f{data_path}/u.data, sep\t, headerNone, names[user_id, item_id, rating, timestamp] ) # 2. 读取用户数据u.user users pd.read_csv( f{data_path}/u.user, sep|, headerNone, names[user_id, age, gender, occupation, zip_code] ) # 3. 读取电影数据u.item # 注意u.item 有 24 列前5列是电影ID、标题、上映日期、视频发布日期、IMDb链接后面19列是类型标签 item_columns [item_id, title, release_date, video_release_date, IMDb_url] [fgenre_{i} for i in range(19)] items pd.read_csv( f{data_path}/u.item, sep|, headerNone, encodinglatin-1, # 有些电影名包含特殊字符 namesitem_columns ) # 4. 合并数据评分 用户特征 电影特征 data ratings.merge(users, onuser_id).merge(items, onitem_id) # 5. 处理标签二分类 data[label] (data[rating] threshold).astype(int) # 6. 对用户ID和电影ID进行 LabelEncoder用于one-hot索引 user_encoder LabelEncoder() item_encoder LabelEncoder() data[user_encoded] user_encoder.fit_transform(data[user_id]) data[item_encoded] item_encoder.fit_transform(data[item_id]) # 7. 处理类别型上下文特征用 LabelEncoder gender_encoder LabelEncoder() occ_encoder LabelEncoder() data[gender_encoded] gender_encoder.fit_transform(data[gender]) data[occupation_encoded] occ_encoder.fit_transform(data[occupation]) # 9. 处理电影类型19个类型标签每个取值为0或1本身就是one-hot genre_cols [fgenre_{i} for i in range(19)] # 这19列已经是0/1直接使用 # 10. 构建特征矩阵 X # 特征组成用户ID one-hot 电影ID one-hot 年龄(数值) 性别编码 职业编码 19个类型标签 n_users len(user_encoder.classes_) # 943 n_items len(item_encoder.classes_) # 1682 n_genders len(gender_encoder.classes_) # 2 n_occupations len(occ_encoder.classes_) # 21 dense_features [age] sparse_features [user_encoded, item_encoded, gender_encoded, occupation_encoded] # 词汇表大小直接使用之前统计的 sparse_vocab_sizes [n_users, n_items, n_genders, n_occupations] # 分离特征和标签 X_dense data[dense_features].values.astype(np.float32) X_sparse data[sparse_features].values.astype(np.int64) # 整数索引 y data[label].values.astype(np.float32) # 划分训练集和测试集 X_dense_train, X_dense_test, X_sparse_train, X_sparse_test, y_train, y_test train_test_split( X_dense, X_sparse, y, test_sizetest_size, random_staterandom_state ) # 8. 处理数值型上下文特征归一化到0~1, 避免数据泄露 scaler StandardScaler() X_dense_train scaler.fit_transform(X_dense_train) X_dense_test scaler.transform(X_dense_test) # 转为张量 X_dense_train torch.tensor(X_dense_train, dtypetorch.float32) X_sparse_train torch.tensor(X_sparse_train, dtypetorch.long) y_train torch.tensor(y_train, dtypetorch.float32).view(-1, 1) X_dense_test torch.tensor(X_dense_test, dtypetorch.float32) X_sparse_test torch.tensor(X_sparse_test, dtypetorch.long) y_test torch.tensor(y_test, dtypetorch.float32).view(-1, 1) print(f数据加载完成) print(f 用户数: {n_users}, 电影数: {n_items}) print(f 特征总数: {sum(sparse_vocab_sizes) 1}) print(f 总样本数: {len(data)}) print(f 正样本比例: {data[label].mean():.2%}) print(f 训练集: {X_sparse_train.shape[0]}, 测试集: {X_sparse_test.shape[0]}) print(fsparse_vocab_sizes: {sparse_vocab_sizes}) return (X_dense_train, X_sparse_train, y_train), (X_dense_test, X_sparse_test, y_test), sparse_vocab_sizes运行结果——结合上下文在不同参数下的运行结果观察前两次Loss值变化容易看出基本上模型在5-10轮训练左右AUC就达到了峰值并且模型容量增大后显然过拟合了所以这里增加了验证集正准确地观察什么时候出现过拟合。修改train()函数如下得到第3次实验结果def train(file_path): set_seed(42) (X_dense_train, X_sparse_train, y_train), (X_dense_test, X_sparse_test, y_test), sparse_vocab_sizes load_movielens_with_context(file_path) # ---- 新增从训练集中划分验证集 (10%) ---- X_dense_train, X_dense_val, X_sparse_train, X_sparse_val, y_train, y_val train_test_split( X_dense_train, X_sparse_train, y_train, test_size0.1, random_state42 ) # DataLoader (训练集) train_dataset TensorDataset(X_dense_train, X_sparse_train, y_train) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue) dense_num X_dense_train.shape[1] model WideDeep( dense_numdense_num, sparse_vocab_sizessparse_vocab_sizes, embed_dim4, output_dim1, hidden_units[8, 4, 2], activationrelu ) criterion nn.BCELoss() optimizer torch.optim.Adam(model.parameters(), lr0.01) epochs 5 for epoch in range(epochs): # 训练 model.train() total_loss 0 for dense_batch, sparse_batch, y_batch in train_loader: pred model(dense_batch, sparse_batch) loss criterion(pred, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() avg_train_loss total_loss / len(train_loader) # ---- 验证集评估 ---- model.eval() with torch.no_grad(): val_pred model(X_dense_val, X_sparse_val) val_loss criterion(val_pred, y_val).item() val_auc roc_auc_score(y_val.numpy(), val_pred.numpy()) print(fEpoch {epoch1}: Train Loss {avg_train_loss:.6f}, Val Loss {val_loss:.6f}, Val AUC {val_auc:.4f}) # 最终测试集评估保持不变 model.eval() with torch.no_grad(): y_pred model(X_dense_test, X_sparse_test) test_auc roc_auc_score(y_test.numpy(), y_pred.numpy()) print(fTest AUC: {test_auc:.4f})运行结果——结合上下文 加入暂退层 早停加入暂退层后的运行结果