简介《基于深度学习的软件缺陷预测模型》PDF文档聚焦软件工程中的缺陷预测方向面向从事软件可靠性研究、数据分析与深度学习应用的研究人员及学生。文章提出基于卷积神经网络的预测模型针对传统静态代码度量忽略语义特征的问题从源代码抽象语法树中提取表征向量借助GoogLeNet挖掘语法语义特征并结合随机过采样与丢弃法处理不平衡及过拟合问题最后在Promise数据集上以AUC和F1-measure验证效果。资源为单篇PDF全文文件大小约994KB包含完整中英文摘要、关键词与引用格式便于下载后直接阅读或作为参考文献引用。目前已有359位学习者浏览尤其适合用于了解软件缺陷预测、抽象语法树、卷积神经网络等关键技术的研究参考与专业指导。读者从中可获得论文完整论证过程、实验对比数据及引用信息对撰写相关论文或开展实验具有直接参考价值。1. 软件缺陷预测模型这篇资源能解决什么从静态度量到 AST 语义特征两份代码在行数、注释数、圈复杂度上几乎完全一致一份运行正常另一份却让程序卡死在循环里——这就是传统静态代码度量在缺陷预测上的真实盲区它只负责计数不负责理解代码在做什么。这篇论文提出的深度学习软件缺陷预测模型正是冲着这个盲区去的。它用抽象语法树表征源代码的语法结构再用基于 GoogLeNet 改造的卷积神经网络从树状结构中挖出静态度量看不到的语义特征。读完这个 PDF你能拿到一条完整可复现的技术链路AST 表征提取、字典映射成整数向量、随机过采样处理类别不均衡、Dropout 防过拟合以及 Promise 数据集上 6 组跨版本任务的完整评估协议。适合正在做缺陷预测方向课题的研究生也适合想从静态分析切到深度学习方案的软件工程从业者。2. 把代码变成整数向量AST 表征提取、字典映射与定长处理2.1 为什么静态代码度量压不住缺陷代码 a 与代码 b 的对比传统软件缺陷预测走的是静态代码度量 机器学习分类器的路线。静态度量主要分四类代码度量总行数、代码行、注释行、空白行、McCabe 度量圈复杂度、基本复杂度、设计复杂度、Halstead 度量程序长度、容量、难度、工作量、CK 度量面向对象程序的结构指标如类方法复杂度带权和 WMC、继承树最大深度 DIT。这些指标的共同问题是它们是计数结果不是语义结果。论文里给了一个非常直观的反例。代码 a 的 while 循环体内有i这条增量语句代码 b 的循环体是空的。两份代码的静态度量几乎一样分类器在特征空间里看到的距离非常小很容易归为一类。但代码 a 是健康的代码 b 是个死循环缺陷——i永远是 0循环条件永远为真。缺陷预测在这里翻车不是因为分类器不够好而是因为喂进去的特征根本没有表达循环体里有没有干实事这个语义。这就是为什么论文选择从抽象语法树上做文章。AST 把源代码解析成树形结构ForStatement、WhileStatement、MethodInvocation这些语法单位各自成为结点。代码 b 的 while 语句下没有循环体的内容结点而代码 a 的循环体内有表达式、方法引用等结点。两份代码的 AST 在结构上有实质差异提取出的表征向量不同模型才有机会区分它们。2.2 用 Javalang 提取三类 AST 结点解析与表征规则论文用的解析工具是开源包 Javalang只针对 Java 源代码。它按 Java 语言规范做词法分析和语法解析从源码构造出 AST。拿到 AST 之后按照深度优先顺序遍历所有结点但不是所有结点都进特征向量只保留三类方法调用结点MethodInvocation、SuperMethodInvocation声明结点PackageDeclaration、InterfaceDeclaration、ClassDeclaration、MethodDeclaration、ConstructorDeclaration、VariableDeclaration控制流结点IfStatement、WhileStatement、DoStatement、ForStatement、SwitchStatement、AssertStatement、BreakStatement、ContinueStatement、ReturnStatement、ThrowStatement、TryStatement、SynchronizedStatement、BlockStatement、TryResource、CatchClause、EnhancedForControl这三类结点的标识规则也不一样。方法调用结点用方法名做标识比如代码里调用了System.out.println就取方法名println声明结点用结点的名称做标识类声明就取类名控制流结点用结点类型名做标识条件分支记录IfStatement循环控制记录WhileStatement。把一棵树遍历完就得到一份代码的表征向量。# 论文中三类结点的选取与标识规则按原文思路写成可读伪代码 from javalang import parse # 方法调用结点记录方法名 # 声明结点记录结点名称 # 控制流结点记录结点类型名 node_identifiers { MethodInvocation: member, SuperMethodInvocation: member, PackageDeclaration: name, InterfaceDeclaration: name, ClassDeclaration: name, MethodDeclaration: name, ConstructorDeclaration: name, VariableDeclaration: name } # 不在上面映射里的结点统一用类型名作标识即控制流结点 control_flow_types { IfStatement, WhileStatement, DoStatement, ForStatement, SwitchStatement, AssertStatement, BreakStatement, ContinueStatement, ReturnStatement, ThrowStatement, TryStatement, SynchronizedStatement, BlockStatement, TryResource, CatchClause, EnhancedForControl } def extract_ast_vector(java_code): tree parse.parse(java_code) vector [] for path, node in tree: type_name type(node).__name__ if type_name in node_identifiers: # 方法调用和声明结点优先取方法名/名称 vector.append(getattr(node, node_identifiers[type_name], type_name)) elif type_name in control_flow_types: # 控制流结点只取类型名如 WhileStatement vector.append(type_name) return vector这段代码的逻辑是遍历整棵 AST先把方法调用和声明结点按语义内容取名字再把控制流结点按类型名记录。没有进入这两类的结点直接跳过不参与表征向量构建。注意这里的getattr(node, node_identifiers[type_name], type_name)做了一个兜底如果某个结点没有对应的属性就退回到类型名保证遍历过程不中断。需要说明的是Javalang 的 AST 结构在不同版本里略有差异实际复现时要以你安装的版本为准做属性兼容处理。2.3 字典映射与定长向量处理补 0 和删最大整数的逻辑表征向量还是字符串序列神经网络吃不了必须转成整数向量。论文的做法是统计所有特征在全部样本中出现的频数按频数从高到低排序频率最高的特征对应整数 1依次递增。假设一共有 m 个不同特征整数范围就是 1 到 m。这个字典一旦构建好后续所有表征向量都按字典映射成整数向量。但这里有两个工程细节很容易被忽略。第一神经网络的输入长度必须是固定的一份代码的长度可能 200另一份可能 800所以要定长。论文的处理方式是向量短了在末尾补 0向量长了从向量里找最大的正整数删掉循环往复直到长度符合要求。为什么要删最大整数而不是随便删因为最大整数对应频数最小的特征在作者看来它是最稀有的相对而言对整体语义贡献最小。第二为什么 0 可以用来填充因为 0 没有参与字典映射在神经网络的嵌入层计算中没有实际语义只承担占位作用。这个方案的优点是实现简单不需要像 word2vec 那样单独训练词嵌入。缺点也藏在细节里删最大整数这个操作等于主动丢弃了低频特征。低频特征不一定不重要比如某些特殊 API 调用恰好是缺陷高发点它因为出现次数少被排在字典末端一旦向量超长就被优先删掉。我一般会先把向量长度的分布画出来选一个能覆盖绝大多数样本的分位点而不是随手拍一个长度值。提示构建字典时论文把训练集和测试集的代码全部提取出来一起统计频数。如果你追求严格的数据隔离应该只用训练集构建字典测试集里没见过的特征映射到一个统一的未知位否则测试集的分布信息会通过字典泄漏给模型。后面避坑章节会展开讲这个问题。3. 基于 GoogLeNet 的 CNN 分类器Inception 块串联结构与参数设置3.1 随机过采样为什么这里不能套 SMOTE软件缺陷预测数据集里有缺陷的样本通常远少于无缺陷样本直接训练会让模型偏向多数类预测结果几乎全判成无缺陷。传统解法是过采样或欠采样。欠采样把多数类样本砍到和少数类一样多省事但信息损失大模型容易欠拟合。过采样是复制少数类样本论文用的是最简单的随机过采样。有一个关键问题为什么不直接上 SMOTESMOTE 的核心是找少数类样本的 k 近邻在样本与近邻的连线上插值生成新样本它依赖样本之间的欧氏距离。但这里喂给网络的整数向量不是连续的特征向量是从 AST 结点编号映射来的符号序列两个代码片段语义上像不像完全不能用整数距离来衡量。在这个场景里套 SMOTE 生成所谓的新样本实际上是拿无意义的距离做插值生成的东西既不真实也不连续。随机过采样的做法就朴素得多从有缺陷的样本集里随机挑一个样本复制一份丢进训练集重复直到两类样本数量一致。论文给的算法有一个细节已经复制过的样本会被记录避免同一轮里反复复制同一条样本直到所有少数类样本都被复制过一轮才重置这个记录。这个细节在复现时很容易被省略它的作用是让复制出来的样本分布更接近原始分布而不是集中在那几条被反复抽中的样本上。# 随机过采样按论文算法 1 的思路实现 def random_oversample(X, y): # X: 整数向量样本集y: 标签1 表示有缺陷0 表示无缺陷 defect_indices [i for i, label in enumerate(y) if label 1] clean_indices [i for i, label in enumerate(y) if label 0] # 以多数类数量为目标复制少数类到等量 target_count len(clean_indices) current_count len(defect_indices) duplicated set() while current_count target_count: candidate random.choice(defect_indices) if candidate not in duplicated: X.append(X[candidate]) y.append(1) duplicated.add(candidate) current_count 1 if len(duplicated) len(defect_indices): duplicated.clear() # 已复制完一轮清空记录重新开始 return X, y这段代码有个注意点random.choice要保证样本索引在两个类别里有足够区分度如果你的数据集里缺陷样本只有十几个复制出来的数据会高度重复这个场景下随机过采样的增益非常有限。论文的实验数据里缺陷率最低的 Lucene 只有 5.7%这类任务靠复制少数类其实只能让模型不偏向多数类没法凭空增加判别信息。3.2 Inception 块在做什么四条并行线路各有分工Inception 块是 GoogLeNet 的基础卷积块论文用它作为网络的核心组件。它的结构是输入同时走 4 条并行线路线路一1×1 卷积线路二1×1 卷积 → 3×3 卷积线路三1×1 卷积 → 5×5 卷积线路四3×3 最大池化 → 1×1 卷积四条线路的输出最后在通道维上连结作为整个块的输出。这个结构的意图是在同一个块内用不同尺寸的卷积核同时抽取信息。1×1 卷积核看到的是单个位置上的特征3×3 看到的是局部窗口5×5 看到的是更大的邻域。对应到代码向量上1×1 卷积做的是特征维度上的融合3×3 相当于窗口为 3 的滑动卷积能捕捉相邻几个 AST 结点之间的组合模式比如循环语句后面紧跟一个 return这类局部结构。这和图像里的边缘、纹理抽取是一个逻辑。线路二和线路三先接一个 1×1 卷积再进 3×3 和 5×5目的是降维。假设输入有 256 个通道直接做 3×3 卷积的参数量很大先用 64 个 1×1 卷积把通道压到 64再进 3×3参数量小了一个量级。线路四先池化再卷积池化负责在局部区域内提炼显著特征后面的 1×1 卷积负责把池化结果的通道数调整到和其他线路一致否则无法在通道维上做连结。四条线路的填充参数是精心设置的要让输出的高和宽保持和输入一致最后才能拼接。3.3 五模块网络结构从嵌入层到 Sigmoid 输出的参数链路先看整张网络的数据流。输入是一个定长的整数向量比如长度 n。第一层是嵌入层它把向量里的每个整数元素查表映射成一个向量这样每个 AST 结点标识从离散的整数 ID 变成了稠密的数值向量之后才能做卷积。嵌入层有俩关键参数num_embeddings等于字典里的特征总数 membedding_dim等于整数向量长度 n。把长度为 n 的整数向量过一遍嵌入层输出是 n×n 的矩阵。数据分批量训练论文设置的 Batch_Size 是 16。卷积部分分 5 个模块模块之间用步幅为 2 的 3×3 最大池化层把输出高度降一半。模块一3 层 3×3 卷积模块二1 个 64 通道的 1×1 卷积加 1 个将通道数扩大 3 倍的 3×3 卷积模块三2 个串联的 Inception 块模块四5 个串联的 Inception 块模块五2 个串联的 Inception 块。模块四放最多 Inception 块这是整个网络挖掘特征最深的地方负责捕捉代码结构中更长距离的语义组合。网络部分结构关键参数输入层嵌入层num_embeddings 字典特征总数 membedding_dim 整数向量长度 nBatch_Size 16模块一3 层 3×3 卷积激活函数 ReLU模块二64 通道 1×1 卷积 192 通道 3×3 卷积1×1 降通道3×3 扩 3 倍通道模块三2 个串联 Inception 块四路并行卷积模块四5 个串联 Inception 块网络最深特征挖掘段模块五2 个串联 Inception 块输出进入池化层输出层全局平均池化 → 全连接 → Sigmoid全连接输出通道数 标签类别数输出层先做全局平均池化把每个通道的高和宽都压成 1相当于把前面提取到的所有特征图汇总成向量然后接全连接层输出通道数等于标签类别数这里是缺陷/无缺陷两类最后接 Sigmoid 函数输出的是一个 0 到 1 之间的缺陷概率本质上就是一个逻辑回归分类器。激活函数上除了输出层用 Sigmoid其他层全部用 ReLU。优化器选择的也是 Adam它结合了 AdaGrad 和 RMSProp 的优点能自动调整学习率参数更新受梯度伸缩变换的影响小在缺陷预测这种特征分布差异大的任务上收敛更稳。关于 Inception 块的通道数原文没有公布每层具体数值只说了根据实际情况调整。复现时你需要根据自己整数向量的长度和长度分布去定这个属于工程调参的范畴。4. 避坑复现时最容易翻车的四处细节4.1 字典泄漏训练集和测试集一起建字典的隐患现象复现出来的 AUC 高得离谱比论文报告的结果还高同一份数据换任何模型都碾压基准。原因为了让字典覆盖面更大直接把训练集和测试集的代码全部提取表征向量后统计频数建字典。测试集的特征分布已经通过字典泄漏给了模型——字典里的特征编号不是纯训练集的分布而是全量数据的分布模型相当于提前见过了测试集的词汇表。论文的原文在 2.3 节确实是这样写的严格来说这属于数据泄漏会导致指标虚高。解决真正用于比较模型性能时只用训练集构建字典把频数统计限制在训练数据内。测试集映射时遇到没见过的特征统一映射到一个保留的 UNK 位比如编号 m1。这么做之后指标会掉一点但那是真实水平。如果你要复现论文数值按论文做法来做没问题如果要上线或写论文做对比实验按严格隔离来做更站得住。4.2 定长裁剪删掉低频特征召回率异常偏低现象模型训练很顺利loss 下降很快但测试集上召回率一直在 0.3 左右徘徊有缺陷的代码大量被预测成健康的。原因定长处理时超长向量删掉的是最大的正整数也就是频数最低的特征。这些低频特征往往是特殊的 API 调用、少见的关键字组合、异常的异常处理写法它们恰好和缺陷高发模式相关。裁剪动作把它们优先删除等于把最有判别力的信号提前丢掉了。解决先把所有样本的向量长度分布画出来选 90 分位数或者 95 分位数作为固定长度不要拍脑袋定。对确实超长的样本优先保留方法调用结点的序列因为方法调用集合是语义密度最高的部分对低频特征整体收进一个 UNK 位统一表达而不是在裁剪时逐个删掉。如果一个项目的代码普遍很长考虑增加固定长度设定代价是向量稀疏度上升、训练变慢但信息损失可控。4.3 推理时 Dropout 没关指标忽高忽低现象同一份测试集连续跑三次AUC 在 0.6 到 0.7 之间跳每次结果都不一样F1 更是波动明显。原因训练时 Dropout 随机丢弃部分神经元这个机制只在训练阶段生效。如果推理时没有把模型切到 eval 模式或者模型 forward 里手写了 Dropout 层但测试时忘了关Dropout 的随机性还会继续作用在推理上输出自然不稳定。解决PyTorch 里训练完必须走model.eval()同时用with torch.no_grad()包住推理过程。注意一个细节如果模型在 forward 里手动调用了nn.Dropoutmodel.eval()能关掉它如果代码里用的是F.dropout(x, p0.5)这种函数式调用model.eval()管不到必须把training参数显式传进去。论文的实验因为随机过采样和 Dropout 都有随机性所以每组任务跑了 10 次取平均。复现时可以把随机种子固定住保证只有网络初始化存在随机性10 次平均才有意义。4.4 随机过采样把记住当学会训练集和验证集差距过大现象训练集 F1 能到 0.85验证集只有 0.55两者差距像隔着一条河。原因随机过采样是所有过采样方法里最粗暴的一种——它只是把少数类样本原样复制。训练集里同一批有缺陷的样本出现很多遍模型学到的其实是记住这几条样本的答案而不是理解缺陷代码长什么样。复制出来的样本不携带任何新信息模型的泛化能力没有提高。解决过采样只允许作用在训练折内验证集和测试集永远保持原始分布。数据划分按版本切分而不是按样本随机切分——论文的跨版本时序本身就是天然的隔离方式旧版本训练、新版本测试这个协议不能破坏。如果实验环境允许可以对比一组过采样 Dropout和纯过采样的实验观察 Dropout 是否真的把过采样带来的过拟合压住了。论文在 3.3 节明确说 Dropout 是对随机过采样的制衡复现时两个组件要一起开只开一个会让你误判另一个组件的贡献。提示整篇论文最值得警惕的就是 4.1 和 4.2 这两条它们在原文里都被轻描淡写带过但在工程复现中直接决定指标高低。5. 最后一步验证Promise 数据集、AUC/F1 与跨版本预测的判定方法论文的实验数据来自 Promise 仓库选了 5 个工程的 11 个项目组成 6 组跨版本预测任务。Camel 1.4 训练、Camel 1.6 测试Lucene 2.0 训练、2.2 测试Synapse 1.0→1.1 和 1.1→1.2 两组Poi 2.5→3.0Xalan 2.6→2.7。平均代码数从 918 到 8444 不等平均缺陷率从 5.7% 到 27.5% 不等。这个协议有两个要点训练集和测试集来自同一工程的不同版本保证缺陷模式有延续性同时版本有跨度迫使模型面对代码演化带来的新模式。复现时不要自己重排数据按论文的分组来否则不同论文之间的数字不具备可比性。评估指标上AUC 是 ROC 曲线下的面积衡量模型把有缺陷样本排在无缺陷样本前面的能力F1-measure 是精确率和召回率的调和平均衡量模型在预测准和找得全之间的平衡。计算公式分别对应TPR TP / (TP FN)、FPR FP / (TN FP)以 FPR 为横轴、TPR 为纵轴画 ROC 曲线精确率 P TP / (TP FP)召回率 R TP / (TP FN)F1 2PR / (P R)。复现时直接调用 sklearn 的roc_auc_score和f1_score即可注意f1_score的pos_label参数要指定为有缺陷那一类否则默认取的是标签字典序。训练集测试集LRDBNCNN本文模型Camel 1.4Camel 1.60.5990.6410.6870.709Lucene 2.0Lucene 2.20.6280.6260.6350.641Synapse 1.0Synapse 1.10.6000.6390.5940.646Synapse 1.1Synapse 1.20.6370.6970.6220.674Poi 2.5Poi 3.00.6650.6510.7100.718Xalan 2.6Xalan 2.70.6510.6830.6750.674平均值0.6300.6560.6540.677报告指标时有个习惯会把每个测试任务单独列出来而不是只贴平均值。平均值掩盖的问题很大——比如 Synapse 1.1→1.2 这一组本文模型 AUC 0.674 其实低于 DBN 的 0.697如果只看均值 0.677 对 0.656你根本看不出模型在哪类任务上弱。我在复现别的缺陷预测论文时吃过这个亏只看平均值觉得模型完美一拆到单个任务发现它在低缺陷率的项目上全靠运气。从那以后我每次拿到缺陷预测论文第一件事就是把它报告 AUC 的表格逐行拆开看模型在最稀疏的数据集上是否依然稳定再决定要不要在项目里引它。这个习惯也推荐给你希望帮到你。本文还有配套的精品资源点击获取