news 2026/9/13 4:29:58

从样本处理到训练落地:C++手写BP神经网络在推荐算法竞赛中的实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从样本处理到训练落地:C++手写BP神经网络在推荐算法竞赛中的实践

简介:阿里移动推荐算法竞赛资源包是一份围绕移动端推荐场景的完整参考实现,面向推荐算法、数据挖掘方向的开发者和高校学生,尤其适合作为毕业设计、课程设计或实训项目的学习模板。包内整合了从数据预处理到模型训练的主干流程,涵盖样本扩展、训练与测试批处理脚本,以及BP神经网络核心模块实现;代码结构清晰,样本加载、网络层定义、训练主程序等均独立成模块,便于二次开发与对照学习。资源共118个文件,以CSV数据、Python脚本、C++源码和C#工程文件为主,另含SQL脚本、配置文件与说明文档,压缩包仅636KB,体量轻巧,下载便捷,也方便按模块快速查阅。已有225人学习浏览,适合希望借鉴推荐算法竞赛完整思路,或需要直接可运行参考代码的读者。

1. 阿里移动推荐算法竞赛的BP神经网络,从样本处理到训练落地

打开这份阿里移动推荐算法竞赛源码包,最先看到的不是训练代码,而是三个bat脚本和一组C++源文件。竞赛任务本身很直接:根据用户前几天的移动端行为序列,预测最后一天会对哪些商品产生购买。数据量不小,但真跑一遍会发现问题不在模型选型,而在样本构造。购买行为在所有交互里占比极低,直接把原始行为日志送进任何分类器都会得到近乎恒为0的预测。这套工程选择用C++手工实现BP神经网络,把行为日志到特征文件、样本扩展、网络训练、概率输出整条链路完整走通,不依赖任何第三方AI框架。对推荐方向刚起步的同学,这份代码能把特征、样本、训练三者的关系讲清楚;对有工程经验的读者,也能从中看到手写BP在内存布局和批量更新上的取舍。

2. 训练样本与测试样本的bat处理链:特征抽取与正负样本构造

三个bat脚本里,最先看的是6.处理训练样本.bat7.处理测试样本.bat。它们做的事看起来一样,实际上分别对应两条数据路径:训练集要输出“特征 + 标签”,测试集只输出“特征”。如果训练样本和测试样本的处理逻辑不一致,比如归一化参数不同、特征顺序不同,后面训练和预测会对不上。

2.1 先把原始行为拆成用户侧、商品侧、交互侧特征

原始行为日志的常见字段是 user_id、item_id、behavior_type、timestamp,behavior_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:特征复用,标签位用占位符

测试样本的生成命令和训练样本几乎一样,只改一个 mode:

set 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::vector<int>& userItems, const std::vector<int>& itemPool, int negRatio, std::vector<int>* outNegItems) { std::set<int> positive(userItems.begin(), userItems.end()); std::mt19937 rng(20240714); std::uniform_int_distribution<int> 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与BPNetwork:C++前向与反向传播的代码细节

样本和特征就绪后,核心训练部分落在 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::vector<std::vector<double>> W; // W[i][j] 表示第i个输入到第j个输出的权重 std::vector<std::vector<double>> dW; // 权重梯度累积 std::vector<double> b, db; // 偏置及偏置梯度 std::vector<double> input, output; // 前向过程缓存 std::vector<double> delta; // 当前层输出端的误差信号 BPLayer(int in, int out, double learningRate) : inN(in), outN(out), lr(learningRate), W(in, std::vector<double>(out)), dW(in, std::vector<double>(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::vector<double> forward(const std::vector<double>& 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::vector<double> backward(const std::vector<double>& 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::vector<double> 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 组装成一个可训练的 BPNetwork

class BPNetwork { public: std::vector<BPLayer*> layers; BPNetwork(const std::vector<int>& 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::vector<double>& x) { std::vector<double> cur = x; for (auto* layer : layers) cur = layer->forward(cur); return cur[0]; // 二分类任务只取最后一个输出 } void backward(double error) { std::vector<double> 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先窄一点,数据量大再放宽到 128
learning 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::vector<double> x; int label; }; bool load(const std::string& path, std::vector<Sample>* 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::vector<double> 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 出来;测试文件没有标签,统一填0;
  • atof不会检查数字合法性,样本文件里出现非数字时会静默变成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::vector<SampleLoader::Sample>& data); int main(int argc, char** argv) { SampleLoader loader; std::vector<SampleLoader::Sample> all; if (!loader.load(argv[1], &all, true)) return 1; std::vector<SampleLoader::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.4095

F1 在第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::vector<double>& preds, const std::vector<int>& 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是防止 tp+fp 或 tp+fn 为 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.005,loss会降得更快。我的经验是每次只动一个变量:先固定宽度扫学习率,找到F1最高的一段,再固定学习率调宽度,最后回来微调阈值。这样一轮改动下来,F1通常能比默认参数提高1到2个百分点,而且每次改动都可以复现。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 4:29:21

Kiro架构实战:多智能体协作与AWS原生组件运维

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 4:27:46

dub CLI 如何完成本地构建、npm link 与 dub login 首次登录

dub CLI 如何完成本地构建、npm link 与 dub login 首次登录 【免费下载链接】dub The modern link attribution platform. Loved by world-class marketing teams like Framer, Perplexity, Superhuman, Twilio, Buffer and more. 项目地址: https://gitcode.com/GitHub_Tre…

作者头像 李华
网站建设 2026/9/13 4:27:17

CloddsBot:名称解析与技术实体确认指南

我无法基于当前输入生成符合要求的博文内容。原因如下&#xff1a;输入中仅提供了项目标题"CloddsBot"&#xff0c;以及相关热搜词和空的网络搜索内容&#xff08;三组反引号内无任何实际信息&#xff09;&#xff1b;缺少【项目正文】、【关键词】、【摘要描述】等核…

作者头像 李华
网站建设 2026/9/13 4:25:53

LunaTranslator:Galgame 实时翻译,从安装到出字的操作笔记

LunaTranslator&#xff1a;Galgame 实时翻译&#xff0c;从安装到出字的操作笔记 【免费下载链接】LunaTranslator 视觉小说翻译器 / Visual Novel Translator 项目地址: https://gitcode.com/GitHub_Trending/lu/LunaTranslator 你双击启动了一款全日 Galgame&#xf…

作者头像 李华