口令破解课程作业:合成靶场与机器学习预测口令脆弱性

发布时间:2026/10/29 2:32:18
口令破解课程作业:合成靶场与机器学习预测口令脆弱性

简介:面向高校网络空间安全课程学习者与选修国科大 Web 安全技术的学生,这份口令破解方向的大作业资料围绕密码特征提取、特征统计与暴力猜测三条主线展开,可作为课程实验参考与机器学习结合安全分析的练习样本。压缩包共 155 个文件,约 170.54MB,以 txt 文本、csv 数据表、py 脚本为主,另含 xlsx 统计表、png 结果图与 sample 样本文件,兼顾数据处理、特征工程与实验记录。已有 369 人学习下载。读者可从中获取口令特征构造与统计思路、字典生成与猜测流程的参考代码、针对 csdn/yahoo 等数据集的训练与结构分析样例,以及结果图表和辅助文件,便于对照复现实验、理解从原始密码到特征再到猜测评估的完整链路,适合具备 Python 与基础安全知识的学习者按模块查阅。

1. 口令破解不是拿字典碰运气:这份课程作业资源到底解决了什么问题

某高校 web 安全技术 课的期末作业,题目叫 口令破解,很多人第一反应是找个字典工具跑一晚上看谁先出结果。真按这个思路做,交上去的报告基本只有一张截图和一句"跑出来了",分数不会高,因为整件事里最值钱的部分——命中率怎么定义、搜索空间怎么估、哪些口令会被优先攻破——全被跳过了。这份课程作业资源包把这三件事拆开做了:先用离线靶场造一批可控哈希,再用字典、规则、掩码三种模式跑对照实验,最后训练一个轻量 机器学习 模型,在真正跑破解之前就预测出哪些口令最脆弱。适合正在做安全课程设计的学生,也适合想把口令强度审计流程固化成脚本的运维和安全岗从业者。

2. 靶场搭建:为什么必须用合成数据而不是现成词表

2.1 依赖清单:哪些是必需,哪些是锦上添花

这套实验的依赖分成两层。底层是哈希计算和破解引擎,上层是数据分析和建模。我的建议是先把底层跑通,别一上来就 pip install 一堆东西,最后环境炸了都不知道是哪一层的问题。

# 底层:破解引擎(按自己的发行版选包管理器,这里用通用写法) # hashcat 提供 GPU/CPU 多后端,是三种攻击模式的主力 hashcat --version # 如果没有独显,确认 OpenCL 运行时是否装好,否则会报 no devices found hashcat -I # 上层:数据分析与建模 python3 -m venv venv && source venv/bin/activate pip install pandas scikit-learn numpy matplotlib joblib

hashcat -I这条命令很关键,它列出所有可用的计算设备。如果输出里只有 CPU 一个设备,说明 OpenCL 运行时没装或者显卡驱动不匹配,后面所有跑分都会慢一个数量级。真跑不动也别硬扛,加-D 1强制走 CPU 后端,小规模靶场足够用,只是别拿它去估真实环境的破解时长。

Python 这边我只留了五个包。pandas处理结果表,scikit-learn做模型,joblib存模型文件,matplotlib画命中率曲线。有人会想上 XGBoost 或者深度学习框架,说实话在这个数据规模下属于杀鸡用牛刀,几百上千条样本用随机森林就够,训练时间从分钟级降到秒级,调试体验完全不同。

2.2 用合成数据造靶场:模板化生成 + 可控分布

靶场数据的来源是整份作业里最容易翻车的地方。直接从网上下载所谓"真实泄露口令库",一是合规上有疑问,二是你根本不知道里面混了什么,三是没法控制分布,后面做机器学习的时候连分层抽样都做不了。我的做法是模板化生成:先定义若干口令构造模板,再在模板上做随机扰动,这样每一条数据的"生成来源"都是已知的,可以当分组切分的依据。

import random, hashlib, string, csv random.seed(20240501) # 固定随机种子,保证任何人复现都是同一批数据 # 五类口令构造模板,覆盖从弱到强的典型分布 TEMPLATES = { "pure_digit": lambda: "".join(random.choices(string.digits, k=random.randint(4, 8))), "lower_word": lambda: "".join(random.choices(string.ascii_lowercase, k=random.randint(4, 8))), "word_year": lambda: "".join(random.choices(string.ascii_lowercase, k=random.randint(3, 6))) + str(random.randint(1970, 2024)), "leet_suffix": lambda: "".join(random.choices(string.ascii_lowercase, k=random.randint(3, 6))) + random.choice(["@", "!", "#"]) + str(random.randint(0, 99)), "mixed_strong": lambda: "".join(random.choices( string.ascii_letters + string.digits + "!@#$%", k=random.randint(10, 14))), } def make_ntlm(plain: str) -> str: # NTLM 是 UTF-16LE 编码后取 MD4,直接用 md5 会全部对不上 return hashlib.new("md4", plain.encode("utf-16-le")).hexdigest() rows = [] for i in range(1200): tpl = random.choices(list(TEMPLATES), weights=[2, 3, 3, 2, 1])[0] plain = TEMPLATES[tpl]() rows.append({"id": i, "plain": plain, "hash": make_ntlm(plain), "alg": "ntlm", "template": tpl}) with open("lab_targets.csv", "w", newline="") as f: csv.DictWriter(f, fieldnames=rows[0].keys()).writeheader() csv.DictWriter(f, fieldnames=rows[0].keys()).writerows(rows)

这段脚本有三个地方值得单独说。第一是random.seed(),固定种子之后任何人跑出来的 1200 条数据完全一致,答辩的时候别人能当场复现你的命中率,这比任何文字描述都有说服力。第二是make_ntlm,很多人第一次写 NTLM 哈希会直接套 MD5,结果 hashcat 一条都命不中,原因是 NTLM 的算法是"明文转 UTF-16LE 再取 MD4",编码和摘要算法都和 MD5 不同。第三是权重参数weights=[2,3,3,2,1],它决定了弱口令和强口令的比例,默认让中等难度占多数,这样三种攻击模式的差异才能体现出来;如果全是纯数字,字典攻击一秒全中,后面的对照实验就没意义了。

生成的lab_targets.csv里同时有明文和哈希。明文这一列只在本地靶场用,做特征分析的时候需要它来算标签,但交作业的时候要单独说明"明文列不参与模型推理",这句话写在报告里能省掉答辩时一半的追问。

2.3 字段设计与哈希格式对齐

数据表设计看着琐碎,但它决定了后面所有统计口径能不能对上。我给每个字段定死用途:

字段类型用途是否进模型
idint唯一标识,串联破解结果否
plainstr靶场明文,用于算标签和特征否(仅离线标注)
hashstr交给破解引擎的目标否
algstr哈希算法标识,对齐 hashcat 的-m否
templatestr生成模板,做分组切分作为分组键
crack_timefloat实际破解耗时,后处理写入否

alg这个字段是给 hashcat 的-m参数用的。教学靶场用 NTLM 对应-m 1000,换成 MD5 就是-m 0,SHA1 是-m 100。算法一旦选错,引擎不会报错,它只会安静地一条都不命中,然后你会花两小时怀疑自己的字典有问题。template这一列在建模阶段至关重要,第 4 章会展开讲——如果按行随机切分训练测试集,同一模板生成的样本会同时出现在两边,模型只需要记住模板特征就能拿到接近 100% 的准确率,这个分数毫无意义。

再补一个容易忽略的细节:明文里如果出现中文或特殊符号,哈希计算前必须统一编码。我一般强制全部走 UTF-8 转 UTF-16LE 的路径,并且在数据生成脚本里加一行断言,检查生成的口令是否只包含可打印 ASCII,避免后面因为编码问题出现"明明口令对但哈希不匹配"的灵异现象。

3. 三种攻击模式的取舍:字典、规则、掩码怎么选

3.1 字典攻击:命中率的分子分母要先定义清楚

字典攻击是最容易理解也最容易被做烂的一种。跑一条hashcat -a 0谁都会,但报告里那句"字典攻击命中率 62%"如果不写清楚分母是什么,这个数字就是空的。分母有三个候选:全部靶场口令数、字典覆盖的口令数、以及按长度分桶后的子集数。我一般三个都算,因为它们的结论完全相反——全量口径下 62% 看着还行,但如果字典本身只覆盖了 70% 的靶场口令,那在"字典能覆盖的范围内"命中率其实接近 89%,说明字典质量不错,短板在覆盖面。

# -a 0 字典模式;-m 1000 是 NTLM;--potfile-disable 防止上一轮结果污染本轮 hashcat -a 0 -m 1000 lab.hash wordlist.txt \ --potfile-disable \ --outfile result_dict.txt --outfile-format 2 \ --status --status-timer 5

参数逐个解释:-a 0指定字典模式,-m 1000是哈希类型,lab.hash是从 CSV 里抽出来的纯哈希列,一行一个。--potfile-disable是我强烈建议加上的,hashcat 默认会把已破解的结果写进 potfile,第二次跑同样的目标时它会直接跳过,导致你以为命中率突然变成 0——这个坑在第 5 章还会细说。--outfile-format 2表示输出"哈希:明文"格式,方便后面用 pandas 直接按:切开。

字典本身我建议自己构造而不是找现成的。构造方式是:从靶场的生成模板反推高频片段,比如年份段 1970-2024、常见后缀!@#加两位数、以及字母段的 n-gram 组合。这样构造出来的字典和目标分布是匹配的,但注意不要直接把靶场明文塞进去——那就成了开卷考试,命中率 100% 也证明不了任何事。我一般的做法是:用模板 A 生成靶场,用模板 A 的"参数空间"构造字典,但不使用任何一条靶场明文,这样字典和目标同分布但不同个体,实验才成立。

3.2 规则与掩码:把搜索空间换算成时间

规则攻击本质是字典的膨胀。best64.rule这类规则文件会对字典里每个词做大小写变换、末尾追加数字、字符替换(a→@,o→0),一条词能变出几十条候选。好处是不需要额外准备素材,坏处是搜索空间成倍膨胀,跑分不变的情况下总耗时直接乘以规则数。

# -a 0 + -r 规则文件:字典膨胀,每条词派生多条候选 hashcat -a 0 -m 1000 lab.hash wordlist.txt \ -r rules/best64.rule --potfile-disable \ --outfile result_rule.txt --outfile-format 2 # -a 3 掩码模式:直接枚举字符集组合 # ?l 小写 ?u 大写 ?d 数字 ?s 特殊字符 hashcat -a 3 -m 1000 lab.hash '?l?l?l?l?d?d' \ --potfile-disable --outfile result_mask.txt --outfile-format 2

掩码模式最容易让人上头。?l?l?l?l?d?d看起来只有 6 位,但空间是 26⁴ × 10² ≈ 4.57 亿条。这时候必须先用hashcat -b -m 1000跑一次基准测试,拿到当前设备每秒能算多少次,再用空间除以速率估时间。如果算出来要跑十几个小时,那就该缩小掩码范围,或者接受"掩码攻击只覆盖某一段长度区间"这个限制,并在报告里写清楚。

三种模式的核心参数我整理成一张表,选型的时候照着看就行:

模式参数适用场景空间量级主要风险
字典-a 0有高质量词表词表行数覆盖面不足
字典+规则-a 0 -r词表质量中等词表 × 规则数时间线性膨胀
掩码-a 3已知长度与字符集规律字符集^长度指数爆炸
混合-a 6词+固定后缀词表 × 后缀空间参数写错就白跑

实际跑的时候我一般按"字典 → 字典+规则 → 混合 → 掩码"的顺序递进,每一步只对前一步没命中的剩余目标跑。这样总耗时最低,而且报告里能画出一条很漂亮的累积命中率曲线,说明每一层策略各自的增量贡献。这比单跑一种模式然后说"命中率 60%"要有信息量得多。

3.3 结果落库与统计口径

破解跑完只是原料,真正要交的是分桶统计。我一般把 hashcat 的输出文件读进 pandas,按长度、字符类别数、是否含年份、生成模板四个维度分桶,算出每桶的命中率。

import pandas as pd targets = pd.read_csv("lab_targets.csv") cracked = pd.read_csv("result_all.txt", sep=":", header=None, names=["hash", "plain"]) # 用哈希做左连接,命中的打上标记 df = targets.merge(cracked, on="hash", how="left", suffixes=("", "_cracked")) df["cracked"] = df["plain_cracked"].notna().astype(int) # 派生统计维度 df["length"] = df["plain"].str.len() df["class_cnt"] = df["plain"].apply( lambda s: sum([any(c.islower() for c in s), any(c.isupper() for c in s), any(c.isdigit() for c in s), any(not c.isalnum() for c in s)]) ) report = df.groupby("length").agg(total=("id", "count"), hit=("cracked", "sum")) report["hit_rate"] = (report["hit"] / report["total"]).round(3) print(report)

这段代码的关键是merge用哈希而不是 id 做连接键。因为破解引擎的输出里只有哈希,没有你的业务 id,用 id 连接必然对不上。class_cnt这个派生特征算的是口令里出现了几类字符(小写、大写、数字、符号),值域 1 到 4,它和命中率的相关性通常比长度还强,第 4 章会直接拿它当模型特征。

统计口径上有个细节要提前定死:同一轮实验中,一个目标只算一次命中,不管它被字典还是规则先命中。如果不去重,规则攻击会把字典已经破出来的目标再算一遍,导致"总命中数"超过靶场总数这种荒谬结论。我在 merge 前会对cracked做drop_duplicates(subset="hash"),这行代码不起眼,但它能让你的报告数字自洽。

4. 机器学习建模:把"哪些口令先倒"变成可预测的分数

4.1 特征工程:不碰明文,只用可观测的结构信息

建模这一步最容易走偏的地方是特征选得太"富"。有人会把明文直接扔进字符级 n-gram 做 TF-IDF,效果确实好,但这就等于模型见过答案,实际部署时你拿不到明文——线上只能拿到"用户设置口令时的输入流"或者"已有哈希"。所以我的原则是:特征只能来自明文的结构属性,不能来自明文字符本身的可逆信息。

具体抽这几类:

import re import numpy as np import pandas as pd KEYBOARD_ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890"] def has_keyboard_run(s: str, min_len: int = 3) -> int: # 检测是否存在键盘上连续相邻的片段,如 qwe / asd / 789 low = s.lower() for row in KEYBOARD_ROWS: for i in range(len(row) - min_len + 1): if row[i:i + min_len] in low: return 1 return 0 def extract_features(plain: str) -> dict: n = len(plain) lower = any(c.islower() for c in plain) upper = any(c.isupper() for c in plain) digit = any(c.isdigit() for c in plain) symbol = any(not c.isalnum() for c in plain) return { "length": n, "class_cnt": sum([lower, upper, digit, symbol]), "digit_ratio": sum(c.isdigit() for c in plain) / n, "has_year": int(bool(re.search(r"(19|20)\d{2}", plain))), "has_kbd_run": has_keyboard_run(plain), "repeat_ratio": 1 - len(set(plain)) / n, # 字符重复度 "is_pure_digit": int(plain.isdigit()), "tail_digit_cnt": len(re.search(r"\d*$", plain).group()), # 末尾连数字长度 } feat = pd.DataFrame([extract_features(p) for p in df["plain"]])

八个特征,每一个都有明确的安全解释。has_year抓的是"生日/年份后缀"这种最常见的弱口令模式;has_kbd_run抓键盘滑动;tail_digit_cnt抓末尾补数字的习惯;repeat_ratio抓111111、aaaaaa这类。它们共同的优点是:在不知道明文的情况下,这些属性也能从口令策略日志或者用户输入行为里近似推断出来,模型才有落地的可能。

标签怎么来?这是这套设计里我最满意的一点。标签直接用第 3 章的破解结果——被字典或规则在限定时间内破出来的记 1,没破出来的记 0。这样标签不是人拍的,而是有客观依据的"实际脆弱性"。代价是标签和攻击策略强相关:换一套字典,标签就会变。这一点必须在报告里写清楚,否则别人复现时数字对不上会以为你造假。

4.2 训练与评估:准确率在这里是会骗人的

数据分布是典型的不平衡样本,弱口令占多数。如果直接看准确率,一个"全部预测为脆弱"的模型也能拿到 65% 以上的分数,但这个模型毫无用处。所以评估指标我固定用三个:AUC、PR-AUC、以及 Top-K 召回率。前两个是常规操作,第三个才是真正贴合业务的——安全团队不可能对所有口令都强制重置,只能处理风险分数最高的一批,那 Top-K 召回率回答的就是"如果我只看分数最高的 30%,能覆盖多少真正脆弱的口令"。

from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GroupKFold from sklearn.metrics import roc_auc_score, average_precision_score X, y, groups = feat.values, df["cracked"].values, df["template"].values gkf = GroupKFold(n_splits=5) scores = [] for tr, te in gkf.split(X, y, groups): clf = RandomForestClassifier(n_estimators=300, max_depth=8, class_weight="balanced", random_state=42) clf.fit(X[tr], y[tr]) prob = clf.predict_proba(X[te])[:, 1] # Top-30% 召回:按预测分数排序,取前 30% 的样本,看覆盖多少真实命中 k = int(len(prob) * 0.3) topk_recall = y[te][np.argsort(prob)[-k:]].sum() / max(y[te].sum(), 1) scores.append([roc_auc_score(y[te], prob), average_precision_score(y[te], prob), topk_recall]) print(pd.DataFrame(scores, columns=["AUC", "PR-AUC", "Top30%Recall"]).mean().round(3))

GroupKFold配合groups=template是这段代码的核心。用普通 KFold 的话,同一模板生成的样本会跨训练测试集泄漏,AUC 会虚高到 0.98 以上,看着很爽,一换数据就崩。按模板分组之后,模型必须在"没见过某种构造方式"的前提下做预测,AUC 通常会掉到 0.80 到 0.88 之间——这个掉下来的过程,恰好就是报告里最有价值的一段分析。

class_weight="balanced"用来对抗样本不平衡,max_depth=8是防过拟合的手动刹车。树模型在这个特征维度下不需要调太多参,我试过把深度放到 20,训练集 AUC 逼近 1.0,测试集反而降了两个点,典型的过拟合信号。

4.3 模型输出接进审计流水线

模型的最终产物不是一个分数,而是一张排好序的处理清单。落地时我会把预测概率按 0.7 / 0.4 两个阈值切成三档:高风险直接建议强制重置,中风险要求下次登录时修改,低风险只做记录。这套分级比"全部强制改密码"现实得多,也比"一律不管"有用得多。

clf_full = RandomForestClassifier(n_estimators=300, max_depth=8, class_weight="balanced", random_state=42) clf_full.fit(X, y) df["risk_score"] = clf_full.predict_proba(X)[:, 1].round(4) df["action"] = pd.cut(df["risk_score"], bins=[-0.01, 0.4, 0.7, 1.0], labels=["record_only", "force_on_next_login", "force_reset"]) df.sort_values("risk_score", ascending=False)[ ["id", "risk_score", "action", "template"]].to_csv("audit_plan.csv", index=False)

注意这里用全量数据重新训练了一次模型再出清单,而评估阶段用的是交叉验证的结果。这两件事不能混:评估要严格隔离,出清单可以用全量数据。有人把评估阶段的折外预测直接拿去当最终分数,样本外和样本内的分数混在一张表里,排序就失真了。

pd.cut的两个阈值不是随便定的,一般按业务能承受的重置量倒推:如果强制重置会影响 15% 的用户,那就把 0.7 这个阈值调到让高风险档正好占 15%。这个反推过程写进报告,比单纯堆模型指标更能体现工程思维。

5. 避坑与排查:五个真实翻车记录

5.1 环境和数据层的坑

现象:hashcat 启动直接报No devices found,或者识别到设备但速度只有几百 H/s。原因通常有两个:一是 OpenCL 运行时没装,引擎找不到任何计算后端;二是装了但显卡驱动版本和运行时对不上,设备被识别成 fallback CPU。解决方式是先跑hashcat -I看设备列表,如果只有 CPU,先补运行时;如果连 CPU 都没有,那是安装包不完整。实在修不好驱动,加-D 1强制 CPU 后端,小靶场完全够用,别在环境上耗一整天。

现象:字典攻击第一轮命中 62%,第二轮同样的命令跑出来是 0%。这是 potfile 干的。hashcat 默认把已破解结果写进~/.hashcat/hashcat.potfile,下次遇到相同哈希直接跳过不输出。做多轮对照实验时,这个机制会让后面每一轮的命中率都被前一轮吃掉。解决办法是每次实验都加--potfile-disable,或者每轮开始前手动清理 potfile。我现在的习惯是把"清 potfile"写进实验脚本第一行,血泪经验。

现象:手动用 Python 算了哈希去比对,明明明文一致,引擎就是不命中。九成是编码问题。NTLM 要求明文先转 UTF-16LE 再取 MD4,如果直接对 UTF-8 字节做摘要,哈希值完全不同。另一个变种是往靶场里塞了带全角字符或空格的口令,生成和解析两端编码不一致。排查方法是拿一条已知口令,用两种方式各算一次哈希,肉眼比对前 8 位,不一样就说明编码路径错了。

5.2 算法和结果解读层的坑

现象:模型 AUC 跑到 0.99,换一批数据掉到 0.6。典型的切分泄漏。同一模板生成的样本在特征空间里高度相似,随机切分会让训练集和测试集包含"同源"样本,模型只需记住模板特征就能满分。解决办法是用GroupKFold按模板分组切分,让测试集里的构造方式在训练时完全没出现过。改完之后 AUC 会明显下降,但那个数字才是真的。

现象:掩码攻击跑了一整晚还没结束,日志里进度条几乎不动。掩码长度和字符集一起膨胀,空间是指数级的。?l?l?l?l?l?l?d?d看着只有 8 位,实际是 26⁶ × 100 ≈ 3×10¹⁰ 条。开工前必须先hashcat -b -m <算法>拿速率,用空间除以速率估时间,超过可接受范围就砍字符集或者缩长度。另外慢哈希(比如成本因子较高的 bcrypt)速率会低好几个数量级,这类目标本来就不该用掩码硬啃,报告里应该转而讨论"为什么慢哈希能有效抬高攻击成本",这才是加分项。

现象:统计出来的总命中数比靶场总数还多。多轮攻击的结果文件被直接拼接,同一个目标被字典和规则各算了一次命中。修复方式是在合并前按哈希去重:cracked.drop_duplicates(subset="hash")。这个错误极其常见,而且从最终数字上很容易看出来——命中率超过 100% 的报表,一眼就知道口径没统一。

5.3 复现和协作层的坑

现象:别人照你的脚本跑,结果每次都不一样。随机种子没固定,或者固定了但只固定了 Python 的random,没固定 numpy 的。数据生成、样本切分、模型初始化三处都要设 seed,缺一处就会漂。我在脚本开头统一写一个set_all_seeds(42)函数,三行代码解决后续所有扯皮。

现象:报告里的命中率和代码跑出来的对不上,差几个百分点。多半是靶场数据版本不一致。合成数据脚本改过一版但没重新生成 CSV,或者生成后手动编辑过几行。解决办法是把数据生成脚本和 CSV 一起纳入版本管理,报告里标注数据文件的哈希值,别人复现时先校验文件指纹再跑。

6. 复现验证与进阶技巧:用一份自测清单卡住交付质量

这套实验做完,最容易出现的状态是"能跑通但说不清楚"。我在交付前会强制走一遍自测清单,五项全过才算完成:

检查项判定标准不过怎么办
数据可复现同一种子重跑,CSV 哈希值一致检查所有随机源
攻击分层清晰每轮只对剩余目标跑拆分结果文件
统计口径自洽各桶命中数之和等于总命中数按哈希去重
切分无泄漏训练测试集模板不重叠换 GroupKFold
结论可落地有分档处置建议补阈值反推

清单之外,有一个技巧我觉得比模型本身更值得写进报告:用累积命中率曲线代替单点命中率。横轴是攻击轮次(字典、字典+规则、混合、掩码),纵轴是累积命中率,画出来是一条单调上升但边际递减的曲线。这条曲线同时回答了两个问题——哪种策略性价比最高(看斜率),以及继续投入还有多少收益(看尾部是否趋平)。如果第三轮之后曲线基本躺平,说明剩下的目标已经超出这套攻击的覆盖范围,再堆算力也是白烧。

另一个技巧是给模型加一个"解释层"。随机森林自带feature_importances_,把八个特征的重要性排序打出来,通常length、class_cnt、has_year排前三,这和常识完全吻合,说明模型学到了真东西而不是噪声。如果有人问"你的模型凭什么说这条口令弱",直接甩这张图,比任何口头解释都硬。

最后一个习惯:每做完一轮实验,先把结果文件归档到带时间戳的目录,再改脚本。我吃过一次亏,脚本改了三版之后想回头对比第一版的结果,发现输出文件被覆盖了,只能重跑。从那以后我每次跑实验前都强制走一遍"建目录、定版本、留原始日志"这三步,哪怕只是改一个阈值也不省。这套流程看着笨,但它保证了报告里每一个数字都能追溯到某一次具体的运行。

希望这些拆解能帮到你,把这份作业从"跑出来了"做成"说得清楚、别人还能复现"。

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