从样本处理到训练落地:C++手写BP神经网络在推荐算法竞赛中的实践
简介阿里移动推荐算法竞赛资源包是一份围绕移动端推荐场景的完整参考实现面向推荐算法、数据挖掘方向的开发者和高校学生尤其适合作为毕业设计、课程设计或实训项目的学习模板。包内整合了从数据预处理到模型训练的主干流程涵盖样本扩展、训练与测试批处理脚本以及BP神经网络核心模块实现代码结构清晰样本加载、网络层定义、训练主程序等均独立成模块便于二次开发与对照学习。资源共118个文件以CSV数据、Python脚本、C源码和C#工程文件为主另含SQL脚本、配置文件与说明文档压缩包仅636KB体量轻巧下载便捷也方便按模块快速查阅。已有225人学习浏览适合希望借鉴推荐算法竞赛完整思路或需要直接可运行参考代码的读者。1. 阿里移动推荐算法竞赛的BP神经网络从样本处理到训练落地打开这份阿里移动推荐算法竞赛源码包最先看到的不是训练代码而是三个bat脚本和一组C源文件。竞赛任务本身很直接根据用户前几天的移动端行为序列预测最后一天会对哪些商品产生购买。数据量不小但真跑一遍会发现问题不在模型选型而在样本构造。购买行为在所有交互里占比极低直接把原始行为日志送进任何分类器都会得到近乎恒为0的预测。这套工程选择用C手工实现BP神经网络把行为日志到特征文件、样本扩展、网络训练、概率输出整条链路完整走通不依赖任何第三方AI框架。对推荐方向刚起步的同学这份代码能把特征、样本、训练三者的关系讲清楚对有工程经验的读者也能从中看到手写BP在内存布局和批量更新上的取舍。2. 训练样本与测试样本的bat处理链特征抽取与正负样本构造三个bat脚本里最先看的是6.处理训练样本.bat和7.处理测试样本.bat。它们做的事看起来一样实际上分别对应两条数据路径训练集要输出“特征 标签”测试集只输出“特征”。如果训练样本和测试样本的处理逻辑不一致比如归一化参数不同、特征顺序不同后面训练和预测会对不上。2.1 先把原始行为拆成用户侧、商品侧、交互侧特征原始行为日志的常见字段是 user_id、item_id、behavior_type、timestampbehavior_type 的编码规则通常是 1 表示点击、2 表示收藏、3 表示加购物车、4 表示购买。这四个数值本身没有大小含义不能直接作为特征输入。常见做法是分别统计用户侧、商品侧和交互侧的特征用户侧记录这个人总共发生了多少行为、各类行为分别多少次商品侧记录这个商品被多少用户点击、购买交互侧只统计当前用户对当前商品的行为次数这是推荐模型里最核心的信号。如果只看原始表一个用户可能只有两三条购买记录但点击记录有几百条。特征拆分后神经网络才有机会把“行为强度”和“购买意图”区分开。类目和品牌这类ID特征也不要直接塞给网络简单做法是做Hash分桶把ID映射到固定维度桶位上置1后文SampleLoader解析特征时按稠密向量处理即可。2.2 6.处理训练样本.bat从原始表生成带标签的特征文件这个脚本的核心调用很短我先按自己的工程习惯整理一份等价写法echo off setlocal enabledelayedexpansion set TOOL.\release\sample_tool.exe set RAW.\data\train_log.csv set OUT.\feature\train_feature.txt %TOOL% -input %RAW% -output %OUT% -mode train -header if errorlevel 1 goto :failed echo train feature generated. pause exit /b 0 :failed echo train feature error. pause exit /b 1逻辑说明TOOL指向由项目源码编译出来的样本处理程序处理训练样本和测试样本用的是同一个可执行文件-mode train表示解析原始表时保留最后一列作为标签该用户是否购买该商品1或0并在输出文件最后追加 label 列-header表示输入文件首行是字段名需要跳过否则第一行数据会被当成特征读入。bat脚本本体并不复杂复杂的是这个 sample_tool 内部的统计逻辑它要扫描每个用户的全部记录先算用户侧、商品侧统计量再逐条计算交互侧统计量并输出。我一般在写这类工具时会先对原始表按 user_id 排序再一次性扫描避免O(n²)的遍历。2.3 7.处理测试样本.bat特征复用标签位用占位符测试样本的生成命令和训练样本几乎一样只改一个 modeset RAW_TEST.\data\test_log.csv set OUT_TEST.\feature\test_feature.txt %TOOL% -input %RAW_TEST% -output %OUT_TEST% -mode test -header区别在于-mode test不做标签拼接但特征列必须和训练样本保持完全一致的顺序。如果某个用户只出现在测试集他的用户侧统计量可能为0这是正常现象不要用全量数据替他补均值。一个容易踩的细节是特征归一化。训练脚本里算出的每个特征的均值、最大值应该保存到一个 meta 文件测试脚本读取同一个 meta 文件做归一化而不是在测试集上重新计算。重新计算会让测试集特征分布知道验证阶段没有购买行为相当于轻微的数据泄漏。2.4 特征模板参考表下面是一份可供对位的特征模板实际维度取决于原始表的字段多少特征组特征字段含义取值示例用户侧u_behavior_cnt该用户总行为数187用户侧u_click_cnt / u_cart_cnt / u_fav_cnt / u_buy_cnt按行为类型拆分132 / 12 / 18 / 25商品侧i_click_cnt / i_buy_cnt商品曝光与购买热度2401 / 86交互侧u_i_click / u_i_cart / u_i_fav / u_i_buy该用户对当前商品的各类行为次数3 / 1 / 0 / 0交互侧u_i_total交互总次数4时间侧last_gap距最近一次行为的小时数7.5身份侧cate_enc / brand_enc类目与品牌 Hash 分桶索引203 / 1883提示如果 behavior_type 在预处理时就已经合并成多位枚举不要贪省事直接对它做数值比较先把每一位拆开统计再考虑是否保留整体频数。3. 扩展样本.bat里的不平衡策略负采样与滑窗扩展训练样本生成之后正样本比例一般会很低。以移动端行为日志为例点击占绝大多数购买可能只占0.5%甚至更低。这时候直接进入训练BP网络的输出会被压到接近0的那一侧验证集的F1恒为0因为模型把所有样本都判成“不购买”就已经能拿到97%以上的准确率。扩展样本.bat就是为破解这种不平衡而存在的。3.1 先用负采样拉平正负比例负采样的思路是从“该用户没有交互过”的商品里随机抽一部分作为负样本。扩展的入口同样是 bat 调用set TOOL.\release\sample_tool.exe %TOOL% -input .\feature\train_feature.txt -output .\feature\train_ext.txt -expand neg -ratio 3 -seed 20240714参数说明-expand neg指定要做负采样-ratio 3表示每个正样本配3个负样本。我一般先在1到5之间做一组实验再按验证集F1决定具体比例-seed 20240714固定随机种子保证两次扩展结果完全一致方便做改参前后的对比。3.2 负采样怎么实现才不引入重复样本实际工程里负采样通常要在C里自己实现核心逻辑如下#include set #include random #include vector void negativeSampling(const std::vectorint userItems, const std::vectorint itemPool, int negRatio, std::vectorint* outNegItems) { std::setint positive(userItems.begin(), userItems.end()); std::mt19937 rng(20240714); std::uniform_int_distributionint dist(0, (int)itemPool.size() - 1); while (outNegItems-size() userItems.size() * negRatio) { int itemId itemPool[dist(rng)]; if (positive.count(itemId)) continue; // 跳过已交互商品 outNegItems-push_back(itemId); } }代码逻辑说明positive集合用来判断当前商品是否已经被该用户交互过如果交互过就跳过避免把正样本重复放入负样本itemPool是所有商品ID的集合抽样时不再对每个用户重新建池减少内存占用循环终止条件直接与当前用户的正样本数量挂钩保证每个用户配到相同比例的负样本不会因热门商品太多而让采样偏向某一部分用户。推荐赛中这个做法比“全局按比例抽取”更稳因为它按用户维度配平避免了热门商品被过度抽为负样本、冷门商品没负样本可学的倾向。3.3 用时间滑窗把一条用户序列拆成多条样本负采样之外扩展样本.bat还可以承担时间窗口扩展把用户前7天的行为序列按不同长度切分生成多组“特征 标签”。比如第1到6天做特征、第7天做标签是一组第1到5天做特征、第6天做标签是另一组。这样原本只有一条购买行为的用户在有足够历史记录时能产生多条训练样本。窗口扩展在样本量不足时很有效但它和后文的验证集划分需要同步调整。如果原来按“用户最后一天”切验证集窗口扩展后应该改成“按用户ID切分训练集和验证集”否则同一个用户的多条样本会同时进入训练集和验证集F1会被严重高估。3.4 扩展时最容易埋下的时间泄漏扩展样本时最怕的是把“未来信息”写进特征。比如用第7天的购买行为当标签但特征里却统计了第7天之后的行为模型等于直接看到了答案。排查方法是打印特征生成时的截止时间戳检查每个特征的统计窗口是否严格早于标签时间。这一条可以放到部署脚本里做自动化校验比人工盯代码可靠。4. BPLayer与BPNetworkC前向与反向传播的代码细节样本和特征就绪后核心训练部分落在 BPNetwork.cpp 和 BPLayer.cpp 上。工程里另外几个文件如 ClassDiagram1.cd、App.config 对编译运行没有直接影响真正干活的也就是 BPLayer.cpp、BPNetwork.cpp、SampleLoader.cpp 和 main.cpp。这套实现最大的价值在于把神经网络最原始的前向、反向、更新拆成了非常清晰的单层封装每一层只关心自己的权重和梯度层与层之间只传递一个 vector 。不依赖任何深度学习库反而更容易看出梯度是怎么流动的。4.1 单层结构权重、偏置、梯度缓存#include vector #include cmath #include algorithm class BPLayer { public: int inN, outN; // 输入维度和输出维度 double lr; std::vectorstd::vectordouble W; // W[i][j] 表示第i个输入到第j个输出的权重 std::vectorstd::vectordouble dW; // 权重梯度累积 std::vectordouble b, db; // 偏置及偏置梯度 std::vectordouble input, output; // 前向过程缓存 std::vectordouble delta; // 当前层输出端的误差信号 BPLayer(int in, int out, double learningRate) : inN(in), outN(out), lr(learningRate), W(in, std::vectordouble(out)), dW(in, std::vectordouble(out, 0.0)), b(out, 0.0), db(out, 0.0) { // 权重随机初始化范围收窄一些避免sigmoid过早饱和 double scale 0.5 / std::sqrt(inN); for (int i 0; i inN; i) for (int j 0; j outN; j) W[i][j] (rand() / (double)RAND_MAX - 0.5) * 2.0 * scale; } std::vectordouble forward(const std::vectordouble x) { input x; output.assign(outN, 0.0); for (int j 0; j outN; j) { double sum b[j]; for (int i 0; i inN; i) sum W[i][j] * input[i]; output[j] 1.0 / (1.0 std::exp(-sum)); // sigmoid } return output; } };几个要重点看的地方权重矩阵按 W[i][j] 存储前向计算时内层循环读 W 的同一行对CPU缓存更友好sigmoid 的输出范围是(0,1)所以最后一层可以直接当作二分类概率用初始化范围取0.5 / sqrt(inN)而不是固定[-0.5, 0.5]是为了让不同宽度的层保持大致相同的初始输出方差。4.2 backward误差信号如何逐层传递std::vectordouble backward(const std::vectordouble upstream) { delta.resize(outN); for (int j 0; j outN; j) { delta[j] upstream[j] * output[j] * (1.0 - output[j]); db[j] delta[j]; for (int i 0; i inN; i) dW[i][j] delta[j] * input[i]; } std::vectordouble prevGrad(inN, 0.0); for (int i 0; i inN; i) for (int j 0; j outN; j) prevGrad[i] W[i][j] * delta[j]; return prevGrad; }逻辑说明upstream是反向传播到当前层输出端的梯度如果是最后一层且使用均方误差它就是预测值 - 标签值delta[j] upstream[j] * output[j] * (1.0 - output[j])是 sigmoid 求导结果。因为d(sigmoid(z))/dz sigmoid(z) * (1 - sigmoid(z))dW[i][j] delta[j] * input[i]是权重梯度的累加Batch 内所有样本的梯度先累在 dW 和 db 里最后prevGrad[i] W[i][j] * delta[j]把误差信号往上一层传链式法则靠这段把每一层串起来。用均方误差作为损失时下面这个表对照会清晰很多层位置上游梯度来源本层 delta 计算输出层p - y网络输出减真实标签delta upstream * p * (1-p)隐藏层上一层传入的 prevGrad同样乘本层 sigmoid 导数如果用交叉熵损失输出层的上游梯度仍然是 p - y但不再乘 sigmoid 的导数隐藏层保持原样。两者在工程上只差一行特判但收敛速度会有差异。4.3 update梯度累加完成后统一更新void update() { for (int j 0; j outN; j) { b[j] - lr * db[j]; for (int i 0; i inN; i) { W[i][j] - lr * dW[i][j]; dW[i][j] 0.0; // 更新后立即清零 } } std::fill(db.begin(), db.end(), 0.0); }注意这里不是每个样本更新一次而是累积一个 Batch 的梯度后才调用一次 update。所以训练循环里 forward 和 backward 要重复执行 batchSize 次update 只执行一次。梯度清零放在 update 里而不是 forward 前是为了避免遗忘上次残留。4.4 组装成一个可训练的 BPNetworkclass BPNetwork { public: std::vectorBPLayer* layers; BPNetwork(const std::vectorint dims, double lr 0.01) { for (size_t i 1; i dims.size(); i) layers.push_back(new BPLayer(dims[i-1], dims[i], lr)); } double forward(const std::vectordouble x) { std::vectordouble cur x; for (auto* layer : layers) cur layer-forward(cur); return cur[0]; // 二分类任务只取最后一个输出 } void backward(double error) { std::vectordouble grad(1, error); for (int i (int)layers.size() - 1; i 0; --i) grad layers[i]-backward(grad); } void update() { for (auto* layer : layers) layer-update(); } };常见问题汇总参数推荐范围说明输入维度特征文件实际列数与 SampleLoader 解析出的 feature 长度严格一致隐藏层64 - 32 - 1先窄一点数据量大再放宽到 128learning rate0.001 ~ 0.01学习率过大sigmoid 容易饱和梯度消失batch size128 ~ 512手写BP流程里batch太大收敛慢太小抖动明显5. SampleLoader与main.cpp训练循环与可复现的结果BPLayer 和 BPNetwork 只是网络骨架数据从哪里来、训练循环怎么组织还得看 SampleLoader.cpp 和 main.cpp。这两个文件决定了整套代码能不能在一个晚上内从编译跑到出结果。5.1 SampleLoader把特征文件读进内存#include fstream #include sstream #include string #include vector class SampleLoader { public: struct Sample { std::vectordouble x; int label; }; bool load(const std::string path, std::vectorSample* samples, bool hasLabel true) { std::ifstream in(path); if (!in.is_open()) { fprintf(stderr, cannot open %s\n, path.c_str()); return false; } std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] #) continue; std::stringstream ss(line); std::string item; std::vectordouble x; while (std::getline(ss, item, ,)) { x.push_back(atof(item.c_str())); } if (hasLabel) { int label (int)x.back(); x.pop_back(); // 最后一列是标签从特征里去掉 samples-push_back({x, label}); } else { samples-push_back({x, 0}); } } return true; } };逻辑说明hasLabel区分训练和测试文件。训练文件最后一列是标签必须 pop 出来测试文件没有标签统一填0atof不会检查数字合法性样本文件里出现非数字时会静默变成0.0所以特征文件在 sample_tool 阶段就要保证格式干净在大文件场景下建议先samples-reserve(行数)避免频繁扩容。这个类在500MB的数据集上也能在两三秒内读完。5.2 main.cpp训练循环的基本骨架#include random #include cstdio #include algorithm #include vector double evaluate(const BPNetwork net, const std::vectorSampleLoader::Sample data); int main(int argc, char** argv) { SampleLoader loader; std::vectorSampleLoader::Sample all; if (!loader.load(argv[1], all, true)) return 1; std::vectorSampleLoader::Sample train, valid; for (size_t i 0; i all.size(); i) { if (i % 10 0) valid.push_back(all[i]); // 每隔10条抽1条作验证 else train.push_back(all[i]); } int featureDim (int)train[0].x.size(); BPNetwork net({featureDim, 64, 32, 1}, 0.003); int batchSize 256; for (int epoch 1; epoch 60; epoch) { std::shuffle(train.begin(), train.end(), std::mt19937(epoch)); double totalMse 0.0; for (int i 0; i (int)train.size(); i batchSize) { int end std::min(i batchSize, (int)train.size()); for (int k i; k end; k) { double pred net.forward(train[k].x); double error pred - train[k].label; totalMse error * error; net.backward(error); } net.update(); // 一个batch结束后统一更新 } double f1 evaluate(net, valid); printf(epoch %3d, mse %.6f, valid f1 %.4f\n, epoch, totalMse / train.size(), f1); } return 0; }这里的流程要点验证集用“每隔10条抽1条”的方式分割简单而且不会把同一用户的所有样本都留在训练集实际调参时可以换成按用户ID切分shuffle 每轮都做避免训练顺序带来的偏差随机种子直接用 epoch保证每个 epoch 的洗牌结果确定net.backward(error)接收的是pred - label这是均方误差对输出层的梯度不再额外做导数运算。5.3 训练日志怎么读正常训练时输出会类似这样epoch 1, mse 0.248931, valid f1 0.3471 epoch 5, mse 0.228114, valid f1 0.3912 epoch 10, mse 0.218437, valid f1 0.4058 epoch 20, mse 0.207512, valid f1 0.4133 epoch 40, mse 0.195048, valid f1 0.4120 epoch 60, mse 0.189732, valid f1 0.4095F1 在第20个epoch附近到顶之后再训练只会缓慢下降这种情况就是过拟合的信号。做法是保存第20轮的权重作为最终模型而不是一味跑满60轮。MSE 一直下降只能说明训练集在拟合不能代表验证集表现所以一定要结合验证集 F1 判断模型是否还在学习有效特征。5.4 第一次运行时最常见的失败点现象可能原因处理方式程序启动后直接越界崩溃特征文件列数与网络输入维度不一致在加载后打印featureDim并检查输出文件的行数验证集 F1 恒为 0特征文件里的 label 列没有被 pop检查 hasLabel 分支或用小数据打印前 5 条 Sample 的 label预测概率全集中在 0.5 附近学习率过大或初始化过大把 lr 降到 0.001权重初始化范围缩小到 0.05训练 loss 不下降特征未归一化检查特征均值是否过大必要时在 sample_tool 里加 min-max 归一化6. 用F1验证代码改动的三个见效技巧6.1 自检F1不要只看准确率二分类在正负样本极不均衡时准确率没有意义。每次改完特征或调完参数需要用同一份验证集算 F1。直接用已有的 BPNetwork 输出即可double calcF1(const std::vectordouble preds, const std::vectorint labels, double threshold) { int tp 0, fp 0, fn 0; for (size_t i 0; i preds.size(); i) { int pred preds[i] threshold ? 1 : 0; if (pred 1 labels[i] 1) tp; else if (pred 1 labels[i] 0) fp; else if (pred 0 labels[i] 1) fn; } double precision tp * 1.0 / (tp fp 1e-8); double recall tp * 1.0 / (tp fn 1e-8); return 2.0 * precision * recall / (precision recall 1e-8); }传参时把 threshold 单独拿出来方便后面做阈值扫描。加1e-8是防止 tpfp 或 tpfn 为 0 时除零。6.2 阈值不要焊死在0.5网络输出的0.5并不是天然的最优分割点。数据扩充后正负比接近1:3输出概率整体会被压得偏小这时候取0.3可能比0.5效果好。最快的做法是把阈值从0.2到0.7按0.05步长扫一遍对每个阈值计算 F1取最大者。这个扫描用上面 calcF1 改个循环即可double bestThreshold 0.5, bestF1 0.0; for (double th 0.2; th 0.7; th 0.05) { double f1 calcF1(preds, labels, th); if (f1 bestF1) { bestF1 f1; bestThreshold th; } } printf(best threshold: %.2f, f1: %.4f\n, bestThreshold, bestF1);6.3 隐层宽度与学习率联动这组参数在训练里是耦合的。网络宽度从64加到128时学习率要从0.003降到0.001附近否则权重的方差会变大sigmoid更容易饱和反过来网络缩到32时学习率可以适当提到0.005loss会降得更快。我的经验是每次只动一个变量先固定宽度扫学习率找到F1最高的一段再固定学习率调宽度最后回来微调阈值。这样一轮改动下来F1通常能比默认参数提高1到2个百分点而且每次改动都可以复现。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →