ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MalwareClassification 数据划分与泄漏:可信分类评估实战

MalwareClassification 数据划分与泄漏:可信分类评估实战

简介:MalwareClassification数据面向网络安全与机器学习初学者,聚焦病毒、蠕虫、木马、广告软件、勒索软件等类别的识别任务,提供样本特征、类别标签与配套分析脚本,可用于课程实验、分类模型训练及检测思路验证。压缩包为rar格式,共25个文件、约19.12MB,以9个csv特征表和6个Python脚本为主,另有png图像、asm汇编片段、bytes二进制片段、pyc缓存、ui界面文件及exe程序,覆盖特征汇总、样本处理、可视化与结果查看等环节。已有362人学习,适合希望从静态与动态特征入手搭建分类流程的读者。包内按特征、结果、样本与脚本拆分目录,csv中可见3-gram、GLC、图像纹理等多类特征,Python脚本承担文本、颜色、纹理、映射等提取与界面操作,便于理解从特征工程到模型评估的完整链路,并在此基础上做算法对比、参数调整与结果复现。

1. 高分不等于好用:MalwareClassification 数据先看划分再看模型

我第一次跑这份 MalwareClassification 数据,用的是默认参数的随机森林,十分钟出了 0.97 的准确率,当时还挺得意。后来把随机切分换成按首次出现时间切,同一个模型、同一份特征,准确率掉到 0.63,几个样本量小的家族召回直接归零。那次之后我才承认一件事:这批数据的难点从来不在模型选型,而在你打算怎么切、怎么去重、怎么定义“一条样本”。这篇按我实际的处理顺序走一遍——先对齐字段,再画长尾,再定特征层级,最后把划分和评估固定下来。适合两类人:能跑通 sklearn 但一换数据集分数就对不上的人;要拿这批数据做基线、需要一套别人能复现的口径的人。下面全是踩过之后才想明白的东西。

2. 摸数据的顺序:字段对齐、长尾分布与标签口径

拿到一批安全领域的样本数据,人的本能反应是赶紧读进 DataFrame 看一眼 shape 然后开训。这个习惯在干净数据集上没毛病,在这类数据上会让你在后面所有环节反复返工。我的顺序固定成三步:先把标签表和特征表对齐,再把类别分布画出来,最后把标签口径统一成一张可查的映射表。三步做完通常不超过半天,但能省掉后面两天的怀疑人生。

2.1 先核对文件清单和对齐关系

这类数据集的典型构成是三份东西:样本本体(或已经提取好的特征向量)、一份标签表、一份可选的家族说明。第一件事不是read_csv,而是数行数。标签表有多少行,特征矩阵有多少行,两边的主键能不能一一映射上,映射不上的有多少条、分布在哪几个家族——这几个数字必须先落到一张纸上。

对不上的情况十有八九是这两类:一类是特征提取阶段失败的样本,文件损坏、格式不识别、加密壳导致解析器直接抛异常,这类样本在特征表里根本不存在,但标签表里还留着;另一类是标签表本身有重复主键,同一个哈希出现两行,家族标注还不一样。前者要单独拉一个“提取失败清单”存下来,别静默丢弃,因为提取失败率本身就是一个信号,某些家族失败率异常高往往说明它们的构造方式和其他家族不一样。后者要先看重复主键对应的两条标签差在哪,是同家族不同别名,还是真的人工标注冲突。

抽样验证这一步很多人跳过,我建议强制做。随机抽 20 条,把原始文件、标签、提取出来的特征三者对着看一遍。我遇到过一次,标签表里的家族字段整体错位了一列,原因是上游导出时多了一个索引列,肉眼扫 CSV 前几行完全看不出来,但 20 条抽样一比对就露馅了。这种错误如果带到训练里,模型能学出很高的分数,因为特征和标签之间存在某种系统性关联,只是这个关联毫无意义。

提示:抽样验证时优先抽少数类和提取失败的样本,多数类抽两条看看就行,信息密度完全不一样。

2.2 类别分布:先画长尾再谈模型

把每个家族的样本数统计出来、降序排、画成条形图,这一步谁都做。真正要看的不是图好不好看,是头尾比。下面这张表是我在同类数据上见过的几种典型形态,只用来演示该怎么读,具体数字以你手上那份为准:

形态头部家族占比尾部家族数量尾部合计占比处理倾向
温和长尾前 5 名约 55%30 个左右8% 上下分层切分 + 类别权重即可
陡峭长尾前 3 名超过 70%50 个以上3% 以内先合并稀有家族,再谈分类
双峰两头各占 40%中间稀疏20% 左右检查是否存在标注粒度不一致
近似均衡前 10 名约 60%20 个以内15% 上下常规流程,重点防泄漏

“双峰”这种形态特别值得警惕。它经常不是真实的分布,而是标注粒度混用造成的:一部分样本按家族标,另一部分按行为类型标。你按家族做分类,模型实际在学两套互不兼容的规则,最后哪个类都不准。

看完分布就要定评估指标。头部占比超过 70% 的数据,准确率这个数字基本没有信息量,全预测成头部家族也能拿到七十几。我一般同时看三个:macro-F1(每个家族等权,暴露尾部表现)、weighted-F1(按样本数加权,反映整体)、以及尾部家族的 per-class recall 列表。三个数字一起看,才能判断模型是真学到了东西,还是只会押注多数类。

尾部家族的处理有个经验阈值:样本数低于某个量级(我通常用 30 条作为观察线,50 条作为可建模线)的家族,单独建类意义不大。三个选择——合并成一个“其他”类、向上合并到父级类型、或者干脆从训练集剔除但在测试集保留用于观察。我倾向第二种,向上合并到父级粒度,这样既保留了样本,又不至于让模型去学一个只有十几条样本的类。合并之后一定要重新画一次分布,确认没有把长尾问题转移到新的层级上。

2.3 标签口径:同一家族多个别名怎么合并

这是最容易被低估的一步。同一个家族在不同来源、不同引擎、不同时期的标注里可能有多个写法,大小写不同、分隔符不同、带不带平台前缀不同、有没有版本后缀也不同。直接拿原始字符串当类别,你会得到一个类别数虚高、每个类样本都很少的分布,然后误以为是数据本身长尾严重。

处理方式是先做字符串归一化:统一小写、统一分隔符、剥掉平台前缀和版本后缀、去掉括号里的补充说明。归一化之后再统计一次类别数,通常会比原始少两到三成。归一化规则要写成一张显式的映射表存成文件,不要散在代码里的 if/else 分支中,理由很简单——三个月后你要跟别人解释为什么这两个家族合并了,翻代码比翻表格痛苦得多。

归一化之后还要做一次冲突检查:归一化后同一个键对应多个原始标签,这些原始标签是不是真的同一个东西。有的是纯拼写差异,直接合;有的其实是两个行为差异很大的家族被同一个粗糙规则归到了一起,这种就得拆回来。判断依据我一般看两点:这两个原始标签的样本在字节层面的相似度分布有没有明显分离,以及它们在时间上的出现区间是否重叠。分离明显且时间不重叠的,八成是两个东西。

最后强调一次粒度选择的因果关系:粒度定得越细,类别越多,尾部越薄,评估越不稳定;粒度定得越粗,类别越少,指标越好看,但下游能做的事越少。这个取舍要在动手训练之前定下来,训到一半再改粒度,之前所有实验记录全部作废。

3. 特征取哪几层:从 PE 头到字节 n-gram 的性价比排序

特征工程的常见误区是上来就堆最贵的那一层。字节 n-gram 听起来最有“原教旨”味道,实际上在很多场景里,PE 结构特征加上导入表信息就能拿到八成的效果,而成本差着一个数量级。我按性价比从高到低排,每层都说清楚什么时候该上、什么时候不该上。

3.1 结构特征与节表:性价比最高的一层

静态解析文件头拿到的这组特征,提取成本极低(单文件毫秒级),维度小(几十维),可解释性强,而且在样本量不大的时候表现异常稳。要看的东西包括:节的数量和名字、各节熵的最大值和均值、入口点落在哪个节、镜像大小、导入函数总数、有没有签名目录、时间戳是否存在异常。这些字段单独看都平平无奇,组合起来对“是否加壳”“是否被改写过”这类判断非常敏感。

import pefile def pe_features(path): """纯静态解析,不执行样本,只读头部信息""" feats = {} try: # fast_load=True:只解析头部,不加载导入表,速度快一个量级 pe = pefile.PE(path, fast_load=True) except pefile.PEFormatError: return None # 非 PE 直接返回 None,调用方单独计数,别填默认值 feats["num_sections"] = len(pe.sections) feats["size_of_image"] = pe.OPTIONAL_HEADER.SizeOfImage feats["entry_point"] = pe.OPTIONAL_HEADER.AddressOfEntryPoint feats["timestamp"] = pe.FILE_HEADER.TimeDateStamp # 节熵:加壳样本的节熵普遍接近理论上限 ent = [s.get_entropy() for s in pe.sections] feats["sec_entropy_max"] = max(ent) if ent else 0.0 feats["sec_entropy_mean"] = sum(ent) / len(ent) if ent else 0.0 # 索引 4 是证书表,Size 大于 0 说明带签名目录 feats["has_signature"] = int(pe.OPTIONAL_HEADER.DATA_DIRECTORY[4].Size > 0) # 导入表需要单独触发解析,只点这一张表 pe.parse_data_directories( directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]] ) feats["import_dll_count"] = 0 feats["import_func_count"] = 0 if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"): feats["import_dll_count"] = len(pe.DIRECTORY_ENTRY_IMPORT) feats["import_func_count"] = sum( len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT ) pe.close() return feats

几个参数值得多说两句。fast_load=True是必须的,不加的话每个文件都会把全部数据目录解析一遍,批量跑十几万样本时时间差得非常明显。parse_data_directories只点导入表这一张,其他的资源表、重定位表对分类贡献很小,解析它们的开销却实打实。返回None的分支一定要在调用方单独统计,返回 None 的比例突然升高,往往说明这批样本里有大量非 PE 或者头部被破坏的样本,这时候要停下来看,而不是继续往下跑。

这层特征有个隐性好处:它对样本量不敏感。字节序列模型在每类几百条样本时基本学不出东西,而结构特征在同样数据量下还能给出可用的结果。所以我一般把它作为必选层,后面两层作为可选增强。

3.2 字节 n-gram 与熵序列:贵,但补得上结构特征看不到的东西

结构特征看不到的是内容层面的重复模式和加密数据的统计特性。字节 n-gram 加上熵序列能补上这块,代价是内存和训练时间都会上升一个档。做的时候有三个决定要做:n 取多大、词表怎么控制、要不要在切分前 fit。

n 的取法,3 到 5 是常见起点。太小学不到序列模式,太大稀疏到没法用,而且对样本长度敏感。词表控制我习惯用哈希技巧,把 n-gram 映射到固定维度的桶里,好处是完全无状态——没有词表要维护,也就不存在“在切分之前 fit 了词表”这种泄漏。

from sklearn.feature_extraction.text import HashingVectorizer # analyzer="char" 按字符切,对二进制内容相当于逐字节滑窗 # n_features=2**20 是内存与碰撞率之间的折中,样本量翻倍时可以往上提一档 # alternate_sign=False:分类任务用无符号计数,避免正负抵消丢掉信号 hv = HashingVectorizer( analyzer="char", ngram_range=(3, 5), n_features=2 ** 20, alternate_sign=False, norm=None, dtype="float32", ) # 只 transform 不 fit:本身无状态,天然不会跨切分泄漏 X = hv.transform(byte_strings) # byte_strings 是 latin-1 解码后的字符串列表

哈希维度怎么定?我的经验是先用 2 的 20 次方跑一遍,看特征矩阵的非零密度,密度低于千分之一说明维度给多了浪费内存,高于百分之一说明碰撞严重、信号被稀释,这时候往上提一档再试。别一上来就设成 2 的 24 次方,内存会直接打爆。

还有一个细节:把整份文件直接转成字符串做 n-gram,会让特征被文件长度主导。两万条样本里有几条体积特别大的,它们贡献的 n-gram 数量能顶几百条正常样本。处理办法是截断——每个样本只取前 N 个字节和后 N 个字节,N 取 64KB 到 256KB 之间,具体看你的平均文件大小。这个截断量的选择会影响结果,做对比实验时要固定住,别在不同实验之间偷偷改。

3.3 导入调用序列与字符串:语义信息从哪来

如果数据里包含动态行为日志(进程、文件、注册表、网络这几类操作的序列),那是另一层信息源,价值很高但处理起来琐碎。序列要转成定长表示,常用做法是 n-gram 计数或者 TF-IDF,把每条调用转成一个 token,再当作文本处理。这块最大的坑是日志长度方差极大,有的样本只有几十条事件,有的几万条,直接截断会丢掉尾部行为,不截断又没法批量处理。我的做法是保留前 200 个事件加上全序列的去重调用集合,两个特征拼起来,兼顾顺序和覆盖度。

字符串提取相对简单,但坑集中在编码上。二进制里混着 ASCII、UTF-16LE 和各种自定义编码,提取时要至少试这两种,然后按可打印字符比例过滤。长度阈值别设太高,很多关键路径字符串很短。还有一个细节是去重——同一份样本里重复出现的字符串对分类没有额外贡献,但会把向量撑得很大。

三层特征的组合方式我一般这样定:

特征层单样本耗时维度量级小样本表现补足什么
结构 / 节表毫秒级几十稳加壳、异常头部
字节 n-gram十毫秒级十万到百万一般内容重复模式、加密统计
调用序列 / 字符串依赖日志千到万看数据行为语义、路径线索

先只上第一层跑一个基线,记住这个分数。第二层加上去如果提升不到三个点,就别再往上堆了,把时间花在划分和去重上收益大得多。

4. 划分与泄漏:决定分数真假的两道闸

模型选错了顶多损失几个点,划分错了可以把 0.6 做成 0.98。这类数据泄漏的来源比通用数据集多得多,因为样本之间存在大量真实的衍生关系——同一个原始文件被不同方式改写、同一个家族的多个变种、同一批样本在不同时间被重复收录。下面三道闸必须按顺序过。

4.1 重复与近似重复:先删再谈分数

完全重复先处理,这一步没有争议。按内容哈希去重,保留一条,把重复的主键记下来。注意别只对一个文件做哈希,有的数据集里同一份样本以不同文件名存在多个副本。

import hashlib def sha256_of(path, chunk=1 << 20): """分块读取,避免大文件一次性载入内存""" h = hashlib.sha256() with open(path, "rb") as f: for blk in iter(lambda: f.read(chunk), b""): h.update(blk) return h.hexdigest()

麻烦的是近似重复。同一家族的变种之间字节相似度可能很高,但哈希完全不同。这类样本如果分到训练集和测试集两边,模型实际上是在测试集上做最近邻检索,分数虚高得离谱。做法是用模糊哈希做聚类:对每个样本文本算一个相似度指纹,两两比较超过阈值的连成一组,然后按“组”而不是按“样本”做切分。

阈值怎么定?我的经验是从严不从宽。同一个簇里的样本只能出现在切分的一侧,宁可少用一些样本,也不要让测试集里混进训练集的近亲。簇的规模也要看一眼,如果出现一个几千条样本的超级簇,说明这批数据里有大规模的同源样本,这时候要重新考虑这批样本到底该不该留——它们对模型的贡献可能远小于它们对指标的污染。

4.2 分层切分与时间切分:两种口径,两个分数

随机切分给出的分数是“同分布下的上限”,时间切分给出的分数是“面对新样本的估计”。两个都有用,但不能混着报。我的做法是两个都跑,随机切分的结果写进实验记录用于横向比模型,时间切分的写进对外口径。

按时间切分要求数据里有可用的时间字段——首次收录时间、编译时间戳、或者提交时间。字段选了哪个要在文档里写清楚,因为编译时间戳可以被人为修改甚至伪造,用它做切分等于把切分逻辑交给了样本自己。我优先用收录时间,没有的话退而求其次用提交时间,编译时间戳只作为最后选项并且要在报告里标注。

import numpy as np from sklearn.model_selection import GroupShuffleSplit # df 里必须有 dup_cluster(4.1 得到的重复簇编号)和 family 两列 # 按簇分组切分,保证同一个簇不会被劈成两半 gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(df, groups=df["dup_cluster"])) # 切完立刻验一遍两个集合的家族分布,尾部家族要两边都有 for name, idx in (("train", train_idx), ("test", test_idx)): vc = df.loc[idx, "family"].value_counts() print(name, "n=", len(idx), "families=", len(vc), "min=", vc.min())

这段代码的关键在groups参数,它保证同一个重复簇只落到一侧。切完打印分布是必要动作——如果某个家族只出现在测试集里,模型对它的召回必然是零,你会在调参上白花很多时间。遇到这种情况,要么把这个家族从测试集里去掉单独观察,要么在切分前把它合并到父级粒度。

比例怎么分?样本量在一万以内我通常 7:1:2,样本量大、家族多的可以用 8:1:1。验证集的比例别压到 5% 以下,尾部家族在验证集里会彻底消失,早停和调参都会失去依据。

4.3 提取器必须在切分之后 fit

这条是通用机器学习常识,但在这类数据上翻车率特别高,因为特征提取流程通常很长,很多人为了省事会先把整份数据的 TF-IDF 或标准化跑完,再切分。这么做等于让测试集的词频统计、均值方差提前进入了训练过程。

正确的组织方式是把所有有状态的步骤塞进 Pipeline,让它在每折内部 fit:

from sklearn.pipeline import Pipeline from sklearn.feature_extraction import DictVectorizer from sklearn.ensemble import RandomForestClassifier pipe = Pipeline([ # DictVectorizer 会把字典特征展开成稀疏矩阵,词表只从训练折里学 ("vec", DictVectorizer(sparse=True)), ("clf", RandomForestClassifier( n_estimators=300, class_weight="balanced_subsample", # 缓解长尾,比手写权重省事 min_samples_leaf=2, # 小样本家族容易过拟合,卡一下叶子 n_jobs=-1, random_state=42, )), ]) pipe.fit(train_feats, y_train) # fit 只发生在训练折内部 score = pipe.score(test_feats, y_test)

class_weight="balanced_subsample"和"balanced"的区别值得记一下:前者在每棵树的 bootstrap 样本上单独算权重,对长尾数据更稳;后者用全量统计,家族极少时容易把权重推到极端值。min_samples_leaf=2是我在小样本家族上的默认设置,能让树不去为了一两条样本切分出孤立的叶子。

如果特征提取本身就很慢(比如要跑字节 n-gram),做不到放进 Pipeline 反复 fit,那就退一步:用哈希特征,也就是 3.2 里那种无状态方案。无状态 = 没有可泄漏的东西,这是用工程手段绕开问题,比小心翼翼地在代码里保证顺序靠谱得多。

5. 避坑与排查:五个现场,五个改法

下面五条都是我在真实项目里撞过的,每条按现象、原因、解决写。你的现象可能不完全一样,但排查路径是通用的。

5.1 现象:本地 0.97,换个测试集 0.62

原因:测试集和训练集之间存在样本级近亲关系。完全重复只是一部分,更多是同一批样本的不同变种被分到了两侧。随机切分时这种情况几乎必然发生,因为同一家族的样本在数据文件里通常是连续存放的,随机打散后两侧都有一堆近亲。

解决:按 4.1 的做法先做完全去重,再做模糊哈希聚类,按簇做分组切分。做完之后分数通常会掉五到十五个点,掉下来的这些点本来就不属于你。改完再跑一次时间切分,两个口径的分数差在两三个点以内,说明泄漏基本清干净了;差二十个点以上,回去继续查簇。

5.2 现象:换个随机种子,macro-F1 抖动八到十个点

原因:尾部家族样本太少,切分时某些家族在验证集里只剩两三条,甚至一条都没有。这种情况下宏平均指标完全由少数几个样本决定,抖动是必然的。很多人会以为是模型不稳定,开始调超参、换算法,方向从一开始就错了。

解决:先把家族样本数排序,看有多少个家族低于 50 条。低于这个量级的先合并到父级,合并之后重新切分。合并完还抖,就固定切分随机种子,做多折交叉(组感知的 K 折),报均值和标准差,而不是报单次结果。报单个数字在这种数据上没有任何说服力。

5.3 现象:某个家族 recall 恒为 0,怎么调都上不去

原因:三种可能,按概率排序。一是这个家族在训练集里根本不存在,只出现在测试集,切分时没检查分布;二是特征层完全覆盖不到这个家族的特点,比如它是加壳的而你的特征只有导入表;三是这个家族的标签本身就是错的,一批别的家族的样本被误标成了它。

解决:先打印训练集和测试集的家族列表做差集,排除第一种。再抽十条样本手工看,对比它们的结构特征和其他家族有没有明显差异,排除第二种。前两种都排除后,检查这个家族的样本和其他某个家族的字节相似度是不是异常高,是的话基本就是误标,从训练集剔除并在文档里记录。

5.4 现象:训练十几分钟,特征提取跑了六个小时还没完

原因:单进程解析加上全量字节 n-gram。PE 解析本身是毫秒级,但几十万次调度开销会放大成小时级;字节 n-gram 的内存占用和文件大小线性相关,遇到几条超大文件直接把内存打满,开始走 swap,速度断崖式下跌。

解决:解析改成进程池,进程数取 CPU 核数的七八成,别拉满,留点余量给 IO。字节处理按文件大小分档,超过阈值的先截断再算 n-gram,截断量固定住写进配置。再加一层缓存——以文件哈希为键把提取结果落盘,调特征的时候只重算改动的部分。这层缓存我在第二个项目里才加上,早加能省好几天。

5.5 现象:验证集完美,换成半年后的数据全线崩

原因:模型在学“这批样本的时间特征”而不是“这个家族的行为特征”。典型表现是编译器版本、文件大小分布、某几个节的默认配置这些和时间强相关的字段占了主导权重。这在随机切分下完全看不出来,只有时间切分才暴露。

解决:做一次特征重要性排查,把排名前十的特征列出来逐个人工判断——这个特征和家族有关系,还是和采集时间有关系?和时间强相关的直接删掉。删完重训,验证集分数会掉,时间切分的分数通常会涨。这个操作我现在的固定流程里已经变成必做项了,不做心里不踏实。

6. 落到评测协议:三个基线加一张对照表

处理到这一步,数据干净了、特征定了、切分固定了,剩下的事情是把结果固化成别人能复现的形式。我的做法是固定三个基线,每个基线都跑随机切分和时间切分两个口径,结果进同一张表。

基线特征层关键参数随机切分 macro-F1时间切分 macro-F1
多数类无恒预测头部家族观察用观察用
随机森林结构 / 节表300 树,balanced_subsample,min_samples_leaf=2记录实测记录实测
线性模型结构 + 哈希 n-gram哈希 2^20,n=3~5记录实测记录实测

“多数类”这个基线经常被跳过,我建议留着。它的意义是给所有其他数字提供下界——如果你的模型在时间切分上的 macro-F1 还不如多数类基线,说明模型在长尾上产生了负迁移,这时候该回头看特征而不是调参。表里后两行的分数我没有填具体数字,因为每批数据的分布、家族数、去重强度都不一样,填上去反而会误导。你要做的是把自己实测的数字填进去,并且把去重阈值、切分种子、特征维度这三个参数一起写进实验记录,缺一个别人就复现不出来。

还有一个小技巧值得单独说:把尾部家族的 per-class recall 单独拉一张清单,跟着主表一起存。主表告诉你整体做得怎么样,这张清单告诉你哪几个家族还是零。零召回的家族列表比 macro-F1 更能指导下一步动作——它会直接指向“这批样本特征覆盖不到”或者“这个家族标注有问题”这两个具体结论。

从那以后我每次拿到一批新的分类数据,不管是不是安全领域的,都强制走一遍这个顺序:对齐字段、画长尾、定口径、去重聚类、分组切分、三基线双口径。这套流程不能保证分数好看,但能保证报出去的每个数字都经得起别人拿同样数据复核。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进