简介:一份发表于《计算机系统应用》的学术论文PDF,面向软件工程、机器学习与数据挖掘领域的研究者、开发人员及高年级学生,系统阐述了如何利用深度学习提高软件缺陷预测的效果。针对传统静态代码度量难以充分挖掘代码深层语义特征的问题,论文提出基于卷积神经网络(CNN)的缺陷预测模型:先从源代码抽象语法树中提取表征向量,构建字典映射为整数向量,再基于GoogLeNet设计网络结构,利用CNN的深度挖掘能力提取语法与语义特征,并通过随机过采样与丢弃法解决数据不均衡和过拟合问题。实验使用Promise历史工程数据,以AUC和F1-measure为指标与3种方法对比,验证了模型性能提升。资源为1个PDF文件,约994KB,包含问题背景、模型设计、实验对比与完整参考文献,既有理论推导又有数据支撑,便于读者快速掌握从AST表征到CNN训练的关键流程。目前已有359人学习,适合作为软件缺陷预测、深度学习应用、数据挖掘等方向的参考文献或专业指导材料。
1. 基于深度学习的软件缺陷预测:先想清楚是定位高风险模块,而不是预测缺陷数
团队给一个三万行的老仓库做回归测试,预算只够跑一轮,却发现按复杂度排序筛出的高风险文件,真正出缺陷的比例并不高——复杂度从来不等于变更频度。基于深度学习的软件缺陷预测要解决的就是这个错位:从代码 token 序列和变更历史中,把“下一个迭代哪个模块最容易出问题”以风险排序的方式输出,而不是预测缺陷总数。和早期机器学习方案比,它不需要人工提炼几十个复杂度、耦合度指标,而是让模型直接学代码的局部表示,跨项目换语言时只换词表。适合给测试资源有限的团队排回归优先级,也适合研发负责人量化历史缺陷密集区。门槛就三样:一个有足够 Git 历史的仓库、一台训练 GPU、一组愿意复核 5% 标注样本的工程师。下面从数据标签、模型基线、训练参数到验证口径四层,给你一条最小可复现的路线。
2. 训练数据怎么来:标签、代码表示和时间切分,三者互相牵连
2.1 缺陷标签的最小可信做法:从修复提交反推引入提交
模型的第一口奶是 y:哪个文件在哪个时间点被判定为“缺陷引入”。论文里最常见的做法是 SZZ 算法及其变体,核心思路是:修复提交里改动的行既然修了缺陷,那这些行上一次是被哪个提交改的,哪个提交就有引入嫌疑。工程上一般不照搬学术清洗流程,常用简化版:先把提交信息里带 fix、bug、close、resolve 等关键词的提交拣出来当修复提交,再让 git blame 定位到嫌疑引入提交。
import subprocess import re def get_fix_commits(repo_path, since="2020-01-01"): cmd = ["git", "-C", repo_path, "log", "--since", since, "--pretty=%H|%s"] out = subprocess.check_output(cmd, text=True, errors="ignore") for line in out.splitlines(): commit_hash, subject = line.split("|", 1) # 常见 fix 类关键词,按团队习惯再扩展 if re.search(r"fix|bug|bugfix|defect|close|resolve", subject, re.I): yield commit_hash, subject def blame_lines(repo_path, commit_hash, file_path): cmd = ["git", "-C", repo_path, "blame", "-l", commit_hash, "--", file_path] out = subprocess.check_output(cmd, text=True, errors="ignore") lines = [] for line in out.splitlines(): m = re.match(r"([0-9a-f]{7,40})", line) if m: lines.append(m.group(1)) return linesget_fix_commits 用正则把 commit subject 里的修复关键词过滤出来,blame_lines 则对修复提交改动过的文件逐行 blame,拿到每行最近一次被改动的 commit hash。聚合时按文件维度计数,把嫌疑引入 commit 涉及的文件标成正类,其余文件标成负类。这里故意没做文本相似度匹配,因为几十万条 commit 上跑 NLP 清洗成本高,收益有限。
参数说明要盯三个地方。一是关键词列表按团队习惯扩展,如果 commit message 习惯写 issue 编号,比如“#1234”,得把#[0-9]+加进正则,否则召回率掉到没法训练。二是过滤规则:只保留代码文件,删掉 pom.xml、package.json 这类依赖描述、测试文件和纯文档,它们要么不参与产品逻辑,要么本身就在做断言,会把特征空间搅浑。三是重构型提交不要标记:一次改动超过 20 个文件的提交,SZZ 无法判断哪一行真正引入缺陷,直接排除,宁缺毋滥。
注意:简化 SZZ 只适合用来构造排序模型的训练标签,不适合直接当缺陷管理系统的告警真值。
还要保底做一轮人工复核:从训练集随机抽 5% 样本,让工程师对照 diff 判断正类样本是不是真的缺陷引入。误标率超过 20% 就回去调关键词和过滤规则,而不是调模型。我见过好几个项目在这点上返工,因为他们把“修复提交关联到引入提交”做得太乐观,模型学的全是噪声。
2.2 代码表示选 token 序列还是 AST 路径:看你的显存预算
标签就绪后,特征就是代码本身。主流论文里被反复使用的两种表示是 token 序列和 AST 路径,各有各的适用边界。
| 表示方式 | 训练成本 | 语义能力 | 落地难点 |
|---|---|---|---|
| token 序列 | 低,只需词法分析 | 能捕捉局部结构,但不能捕捉远程调用关系 | 序列截断策略难定 |
| AST 路径 | 高,需要语法解析树 | 能捕捉作用域和嵌套层次,语义更强 | 长路径截断多,解析失败处理复杂 |
我的一般做法是:显存只有 24GB 级别时,先走 token 序列。缺陷预测目标是“哪个模块容易出问题”,不是自动修复缺陷,对语义细节的要求没有代码补全那么苛刻,token 序列的性价比最高。AST 路径的坑在于仓库里总有少量语法过不了的文件,一次解析失败整个文件就没法进模型,你得额外写容错,成本不小。
token 化不必自己写词法器,tree-sitter 对各语言的支持最省事。下面是抽取叶子节点并丢弃注释和字符串的最小脚本:
from tree_sitter import Language, Parser def parse_tokens_by_language(source_bytes: bytes, grammar_path: str) -> list[str]: lang = Language(grammar_path, "java") parser = Parser() parser.set_language(lang) tree = parser.parse(source_bytes) tokens = [] def walk(node): if node.child_count == 0 and node.type not in {"comment", "string"}: tokens.append(source_bytes[node.start_byte:node.end_byte].decode("utf-8", "ignore")) return for child in node.children: walk(child) walk(tree.root_node) return tokens代码说明:用 tree-sitter 把源码解析成语法树,只保留叶子节点,通过 node.type 过滤 comment 和 string,避免大段字符串字面量污染嵌入层。这个脚本对 Java、C、Python 都适用,只要换 grammar 文件。丢弃字符串是个反直觉选择,因为字符串内容通常和业务文案绑定,对缺陷判断没有区分度,留着反而让词表膨胀、模型去背字符串常量;如果做的是注入漏洞检测这类任务,再考虑保留。
词表大小建议控制在 2 万以内。超过 2 万之后,embedding 表占显存变大,训练变慢,而模型对 OOV 的处理会直接决定跨项目效果。另一个容易忽略的点是:token 切分时要固定最小值频率,比如出现次数少于 3 的 token 直接映射成 unk,否则词表里堆满拼写变体,模型很快过拟合。
2.3 一个能落地的数据切分:按时间戳,而不是随机种子
缺陷预测最容易出现论文分数好看、上线没用,主因之一就是随机切分。随机切分把同一时间段内互相耦合的文件拆进训练和验证,时间相关的演化信号会被模型当特征记住,验证集自然好看。真实使用场景永远是:拿历史预测未来。所以切分必须按 commit 时间,训练集全部早于验证集,验证集全部早于测试集。
import pandas as pd payload = pd.read_json("defect_dataset.jsonl", lines=True) # 字段约定: file_path, commit_time, tokens, label payload["ts"] = pd.to_datetime(payload["commit_time"], unit="s") payload = payload.sort_values("ts") split_by_time = 0.8 cutoff_index = int(len(payload) * split_by_time) train_df = payload.iloc[:cutoff_index] test_df = payload.iloc[cutoff_index:] # 验证集从训练序列尾部再切 10% val_cutoff = cutoff_index - int(len(payload) * 0.1) val_df = train_df.iloc[val_cutoff:] train_df = train_df.iloc[:val_cutoff] print(f"train {len(train_df)}, val {len(val_df)}, test {len(test_df)}")代码说明:按 commit 时间排序后按位置截断,比例 8:1:1 先试跑。验证集从训练序列尾部切出,保证时间上紧邻训练数据,但两者之间仍有真实时间差,缺陷率不同是正常的,不要试图用增强手段去弥合。参数说明:如果仓库只有几千条样本,按比例切会让验证集只有几百条,AUC 波动很大,改成固定时间窗口——最后 6 个月做测试,往前 6 个月做验证,再往前全是训练。还要顺手去掉 commit hash、作者姓名这类 ID 化特征,模型一旦见到 commit hash 就会去背 ID,时间切分全被绕过。
3. 第一版能跑通的模型:PyTorch + Bi-GRU 从零训练
3.1 为什么第一版用 Bi-GRU 而不是 Transformer
这个方向早期论文用 CNN、RNN,近两年开始出现 Transformer 和 GNN 方案,但第一版我建议 Bi-GRU。原因很具体:缺陷预测数据集通常几千到几万条样本,和自然语言预训练语料差了三四个数量级。Transformer 的 attention 需要足够数据才能把远程依赖学到有意义,小数据下更容易过拟合;Bi-GRU 参数少、训练快,对“缺陷由 3 到 5 行局部逻辑引起”这种任务也够用。环境上建议直接装 GPU 版 PyTorch 镜像,CUDA 版本和显卡驱动对齐,别在 CPU 上试跑全量数据,否则一个 epoch 跑十几分钟,调参效率太低。选单层还是双层:第一版单层,双层 GRU 在小数据上收益不明显,还徒增训练时间和过拟合风险。
3.2 完整训练脚本的最小集:从 vocab 到训练循环
先准备 vocab.txt。常见做法是把训练文件 token 统计后取 top 20000,按行写入:
from collections import Counter counter = Counter() for toks in all_train_tokens: counter.update(toks) with open("vocab.txt", "w", encoding="utf-8") as f: for tok, _ in counter.most_common(20000): f.write(tok + "\n")逻辑说明:只统计训练集,防止测试集 token 污染词表,这也是一种数据泄漏。参数说明:20000 是经验值,如果仓库跨多种语言且单文件 token 很多,可以放宽到 30000,但 embedding 表会变大,显存敏感。
模型定义和数据集类如下:
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torch.nn.utils.rnn import pad_sequence import numpy as np with open("vocab.txt", "r", encoding="utf-8") as f: vocab = [line.strip() for line in f] # 0 留给 pad,1 留给 unk token2id = {t: i + 2 for i, t in enumerate(vocab)} class DefectDataset(Dataset): def __init__(self, tokens_list, labels, token2id, max_len=256): self.seqs, self.labels = [], [] for toks, y in zip(tokens_list, labels): ids = [token2id.get(t, 1) for t in toks][:max_len] self.seqs.append(torch.tensor(ids, dtype=torch.long)) self.labels.append(torch.tensor(y, dtype=torch.float)) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.seqs[idx], self.labels[idx] class BiGRUDefectModel(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_layers=1): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.gru = nn.GRU(embed_dim, hidden_dim, num_layers=num_layers, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden_dim * 2, 1) def forward(self, x): emb = self.embed(x) # [batch, seq_len, embed_dim] out, _ = self.gru(emb) # [batch, seq_len, hidden*2] last = out[:, -1, :] # 取最后一个时间步双向拼接 return self.fc(last).squeeze(-1) def collate_fn(batch): seqs, labels = zip(*batch) seqs = pad_sequence(seqs, batch_first=True, padding_value=0) labels = torch.tensor(labels, dtype=torch.float) return seqs, labels代码说明:DefectDataset 把 token 序列转成 id 序列,超过 max_len 截断;模型用 embedding、双向 GRU、线性头输出 logits。forward 里没有包 sigmoid,因为后面用 BCEWithLogitsLoss,它在内部做了 log-sum-exp 稳定化,提前 sigmoid 会让数值精度变差。取最后一个时间步而不是 mean pooling,是因为缺陷高发区通常集中在函数尾部 return 附近;如果数据里缺陷大量出现在函数开头(比如入参空指针判断),改成 mean pooling 更稳。参数说明:hidden_dim 128 适合一万条样本规模,本地小项目降到 64,AUC 会低一点;padding_idx=0 必须和填充值一致,否则 pad 位被当成真实 token 参与训练。
训练循环放在下面:
import torch.optim as optim from sklearn.metrics import roc_auc_score device = torch.device("cuda" if torch.cuda.is_available() else "cpu") train_loader = DataLoader(DefectDataset(train_tokens, train_labels, token2id), batch_size=64, shuffle=True, collate_fn=collate_fn, num_workers=4, pin_memory=True) val_loader = DataLoader(DefectDataset(val_tokens, val_labels, token2id), batch_size=64, shuffle=False, collate_fn=collate_fn) model = BiGRUDefectModel(len(token2id) + 2).to(device) opt = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) # 处理类别不平衡:正负样本比例倒数 pos_w = np.mean(train_labels == 0) / max(np.mean(train_labels == 1), 1e-6) criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([pos_w])) best_auc = 0.0 for epoch in range(1, 31): model.train() total_loss, batches = 0, 0 for seqs, labels in train_loader: seqs, labels = seqs.to(device), labels.to(device) opt.zero_grad() logits = model(seqs) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() total_loss += loss.item() batches += 1 model.eval() preds, truths = [], [] with torch.no_grad(): for seqs, labels in val_loader: seqs = seqs.to(device) p = torch.sigmoid(model(seqs)).cpu().numpy() preds.extend(p) truths.extend(labels.numpy()) auc = roc_auc_score(truths, preds) print(f"epoch {epoch} | loss {total_loss / batches:.4f} | val AUC {auc:.4f}") if auc > best_auc: best_auc = auc torch.save(model.state_dict(), "defect_model_best.pt")代码说明:训练循环做了三件关键事。一是用 pos_weight 加权的 BCEWithLogitsLoss 应对缺陷样本比例低,比 SMOTE 可靠,原因在 4.1 展开。二是梯度裁剪 clip_grad_norm=1.0,GRU 在长序列上梯度爆炸是常态,这个参数对模型收敛几乎是必加的。三是每个 epoch 评估验证集 AUC 并记录最优 checkpoint。epoch 数从 30 起跑,配合早停不用死等跑满。参数说明:batch_size 64 是在 24GB 显存上的稳妥值,如果 max_len 超过 512,batch 降到 32 更安全。
3.3 训练产物怎么排风险,而不是硬判缺陷
第一版跑完,手里有 best checkpoint 和验证集 AUC。真正的使用方式是:对仓库所有待测文件跑一遍 forward,得到 0 到 1 的风险分,按降序取前 20% 作为高风险池。不要拿 0.5 当硬阈值做正负判定,缺陷率可能不到 15%,0.5 阈值会输出一个几乎全负的预测。风险排序天然贴合测试排优先级:高风险 20% 优先进自动化测试和人工评审,剩下 80% 按抽样回归。模型输出的 logits 进 sigmoid 后解释为风险分即可,不要把它当成概率校准值,缺陷预测的 score 只用于排序。
4. 影响模型上限的四个训练参数:类别不平衡、序列长度、早停与学习率
4.1 类别不平衡:为什么 SMOTE 会在离散 token 序列上失效
不少论文用 SMOTE 处理缺陷数据不平衡,但 SMOTE 的逻辑是找 k 近邻样本做线性插值,只适用于连续特征。token 序列是离散整数 id,插值会产生根本不存在的 token id,模型学到的是“这些不存在的 token 对应正类”,一上真实样本就露馅。常见做法是:先算 pos_w = 负样本数 / 正样本数,直接塞给 BCEWithLogitsLoss。缺陷率在 10% 到 20% 时,pos_w 大致是 5 到 9。需要警惕的是:缺陷率低于 5% 时,pos_w 会飙到 20 以上,少数类样本上的梯度过大,模型容易把正类概率推向极端,这时配合简单过采样——对正样本做随机复制,不引入插值,反而更稳。
4.2 序列长度:先看长度分布,而不是直接取 128 或 256
max_len 是这组模型里影响最大却最容易被随手填的参数。工程代码文件的 token 长度分布极不均匀,取 128 会把一大半文件截断,截掉的部分绝大多数是函数尾部 return 语句,恰恰是缺陷高发区。正确做法是先统计训练集 token 长度的 95 分位,再按它设 max_len:
import numpy as np lens = [len(t) for t in train_tokens] p50 = int(np.percentile(lens, 50)) p95 = int(np.percentile(lens, 95)) print(f"p50={p50}, p95={p95}")代码说明:p95 是训练数据自己给出的参数。如果 p50 只有 200,p95 到了 800,这种分布下 max_len 取 512、batch_size 调小一点,别硬扛 256 把尾部信息压坏。如果显存真的扛不住,用分层截断而不是全局截断:保留每个样本的尾部 token,去掉头部一部分,让模型至少总能见到 return 附近的上下文。这个选择对最终 AUC 的影响可能比换模型结构还大。
4.3 早停必须配 checkpoint:只保存验证集 AUC 最好的 epoch
小数据训练时验证集 AUC 曲线会有抖动,patience 一般设 5。AUC 的噪声在 0.02 到 0.03 很常见,设 3 容易过早停。早停不是目的,目的是拿到最好的产物:val AUC 最高的 epoch 对应的权重,而不是最后一个 epoch 的权重。第 3 章训练循环里已经写了,只要把判断和保存放在 val 计算之后,部署时永远用defect_model_best.pt,不要用训练最后一个 epoch 的权重。
4.4 学习率:1e-3 起步,warmup 在几千样本时是多余的
对单层 Bi-GRU 这类小模型,把 Transformer 那套 5e-5 学习率搬过来,收敛慢到怀疑人生。第一版用 Adam 1e-3 起步,weight_decay 设 1e-4 控制过拟合。warmup 是为大模型稳定训练用的,缺陷预测几千到几万样本的时候完全不需要,固定 lr 配 early stopping 够用。四个参数的调试起点放一张表:
| 参数 | 推荐起点 | 调优范围 | 常见失败特征 |
|---|---|---|---|
| pos_weight | 缺陷率倒数(约 5) | 3 ~ 9 | 训练 loss 下降但验证 recall 为 0,权重过大 |
| max_len | 训练序列长度 p95 | 128 ~ 512 | AUC 不动,多半是截断掉了尾部 |
| patience | 5 | 3 ~ 10 | 3 时 AUC 反复横跳,过早停 |
| lr | 1e-3 | 3e-4 ~ 1e-3 | 首 epoch loss 就 NaN,或收敛极慢 |
注意:推理时忘了 model.eval() 会让 Dropout 和 BatchNorm 失效;这个模型没有 BatchNorm,但保留这个习惯能避免后面换结构时踩坑。
5. 避坑与排查:缺陷预测最常见的五个翻车现象
5.1 验证集 AUC 0.85,上线后人工 review 没抓到新缺陷
现象:模型在验证集上表现优秀,但投到下一个迭代,按风险分排出来的前 20% 文件里,真正出缺陷的不多。
原因:数据切分用了随机切分,同一时间段内的文件被拆进训练和验证,时间相关偏差被模型学走,它记住的是“这个时段提交的文件普遍带缺陷”,而不是代码本身的风险模式。
解决:改成第 2.3 节的时间戳切分,并从特征里删掉 commit hash、作者姓名、提交时间这类与标签高度共变的 ID 型字段。改完以后,AUC 大概率会掉 0.05 到 0.1,这是正常的,那个虚高的分数本来就不代表真实泛化能力。
5.2 在 A 项目上 AUC 0.82,换到 B 项目直接掉到 0.58
现象:模型在同一语言、相近业务的两个项目之间迁移,效果断崖式下跌。
原因:token 嵌入表是在 A 项目词表上训的,B 项目的代码风格、框架调用、命名习惯不同,OOV 比例高,模型看到的大半是 unk。
解决:先做一个离线测试,拿 B 项目一千份文件跑 tokenizer,统计 OOV 比例。超过 30% 就放弃直接迁移,用 B 项目数据重训词表和 embedding;如果还想复用已训练模型,先用 B 项目源码做 mask language model 预训练重建 embedding,再在缺陷任务上微调。这个动作成本不高,但效果比硬迁移好得多。
5.3 SZZ 标签大量误标,训练 loss 停在 0.7 附近不下降
现象:训练曲线稳定但 loss 下不去,验证集 AUC 接近 0.5。
原因:简化 SZZ 没有过滤大规模重构和文档、测试文件,正样本里混入大量无关改动。比如一次 commit 同时重构了 30 个文件,其中只有 1 行修了缺陷,SZZ 会把 29 个无辜文件全部标记为正类。
解决:补三道过滤。第一,剔除测试文件、文档、构建配置文件;第二,剔除单次 commit 改动超过 20 个文件的样本;第三,在训练集上抽 5% 做人工复核,误标率超过 20% 就把标签生成流程回炉,不要尝试用更强的模型去扛噪声。
5.4 24GB 显存训练时 OOM
现象:batch_size 64,max_len 256,24GB 显存居然不够,程序崩在 dataloader 加载下一批样本的时候。
原因:batch 内 padding 的浪费远超预期。一个 batch 里只要有一条 900 token 的序列,整批矩阵会 padding 到 900,其他 63 条短样本全被拖长,显存消耗成倍增加。
解决:用 bucket 采样,按序列长度把样本分成几个桶,桶内组 batch。具体做法是 dataset 构造时按长度排序,dataloader 的 batch_sampler 在每个桶内取 batch。epoch 内 shuffle 时注意桶边界,别把长样本和短样本混回同一个 batch。这个改动通常能让显存峰值直接削掉一半,训练速度也同步提升。
5.5 第一个 epoch loss 就是 NaN,换随机种子也一样
现象:训练一开始 loss 就是 NaN,调整随机种子没有变化,排除偶发因素。
原因:大概率是学习率过大加上长序列上的梯度爆炸,小概率是 embedding 的 padding_idx 没设,pad 位参与了梯度回传。还有一种情况是输入 token 序列里混入了 NaN,来自异常 JSON 数据。
解决:三步走。确认 Embedding 参数是 padding_idx=0;把 lr 从 1e-3 调到 3e-4;检查 GRU 后是否用了 clip_grad_norm_ 1.0。三步做完还没好,检查数据清洗,把非字符串 token 统一映射成 unk 再重跑。
6. 落地前最后一步:用时间切片验证法检验模型值不值得上线
6.1 时间切片验证,不是只切一次
一次时间切分只能证明模型在单一时刻的表现,代码库持续演进,缺陷模式会漂移。落地前多做三组时间切片验证:第一组用 t-12 到 t-6 训练,t-6 到 t 测试;第二组用 t-6 到 t 训练,t 到 t+3 测试;第三组从中间再切一个窗口滑动过去。三组 AUC 都稳定在 0.7 以上,才向上级输出“值得上生产”的结论。这个验证成本很低,同一份 pipeline 换日期参数重跑,半天能出结果。如果三组结果差异很大,说明缺陷模式不稳定,先别急着上线。
6.2 用行动指标验收,别只看 AUC
AUC 是研究者的对比口径,研发负责人问的是“你说的高风险模块里,到底有多少真的出缺陷”。验收时把输出排序后取 top 20% 池,计算召回率和命中率:
top_pool = valid_pool.nlargest(int(len(valid_pool) * 0.2), "score") recall = (top_pool["label"] == 1).sum() / max((valid_pool["label"] == 1).sum(), 1) prec = (top_pool["label"] == 1).sum() / max(len(top_pool), 1) print(f"Top20% 覆盖 {recall:.2%} 的已知缺陷,命中率 {prec:.2%}")一个可参考的信号:top 20% 池能覆盖 70% 以上已知缺陷时,测试团队把预算集中在这部分模块就是划算的。没有硬性标准,但低于 50% 覆盖率时,上线价值就很可疑。
6.3 什么时候不建议投入这个方向
如果时间切片验证三轮 AUC 都低于 0.65,同时历史缺陷率又很低,说明这个项目当下缺乏可预测性。常见原因一是团队处于重构期,同一段代码被不同人反复改动,git blame 给出的引入提交不稳定;二是缺陷记录本身漏记严重,标签质量支撑不起监督学习。这时候不要再纠结更复杂的模型,换个方向做“变更热点统计”:记录最近三个月变更最频繁的 10 个文件,手动跟进,等重构节奏放缓后再重新洗数据训练。
我的习惯是把每次训练使用的数据时间窗写进 checkpoint 文件名前缀,下次重训时对比窗口漂移引起的 AUC 变化,漂移超过 0.1 就主动回头重洗数据。这是让我吃过一次亏后不想再吃第二次的防御动作。希望这条路径能帮到你的项目落地。
本文还有配套的精品资源,点击获取