# 第 7 章 · 机器学习基础（写给第一次学的人）（第 7–12 周）

<!-- lang-switch -->
> [🌐 English version](https://yukinoshita-lin.github.io/nsf5-steganography/en/content/ch07.html)




> **动手做｜** 本章目标：完整、扎实地学会四件事——**一张图怎么变成数字（特征）→ 模型怎么学会判断 → 怎么防止“作弊” → 怎么判断模型好不好**。我们从一个完全没接触过机器学习的人也能读懂的角度讲起，配合真实数据生成的图，循序渐进，不赶时间。

## 7.0 本章路线图：建议怎么学（约 3–6 周）

如果你有半年到一年的学习周期，完全可以把这一章当成**第一次认真学机器学习**的入门课，而不仅仅是“看懂项目代码”的速成。建议这样走：

1. **第 1 步（约 1 周）**：只读 7.1~7.2，建立「数据→特征→标签」的整体印象，能说出自己平时见过哪些“用数字描述一张图”的例子。
2. **第 2 步（约 1 周）**：读 7.3~7.5，配合 图 7-1/7-2/7-3，理解“画一条线 + 把线变成概率 + 让概率尽量对”。**这里不要求会推公式**，能看着图复述大意即可。
3. **第 3 步（约 1 周）**：读 7.6~7.8，重点理解**数据泄漏**和**怎么评估**。这是最容易在真实项目里踩坑的地方，也最值得多花时间。
4. **第 4 步（约 1 周）**：把本章所有代码摘录在你自己电脑上跑一遍（`python src\train_model.py`），对着输出确认每行数字的来历。

> **学习方法建议｜** 每节末尾都有「想一想」。请把答案写下来，而不是只在心里过一遍——写出来的过程才是真正在学。

---

## 7.1 隐写检测，其实就是“猜一张图藏没藏东西”

换一个角度看问题：一张照片，要么是干净的（标成 0），要么往里面藏过消息（标成 1）。机器学习要做的，就是学会一个判断：给它一张图，尽量猜对是 0 还是 1。

这和“老师把很多带答案的题讲给学生，学生考试时对没见过的题也能做对”是一回事——机器学习不会“背”，它学的是**规律**。整个监督学习只有四样东西：**数据、特征、模型、评估**。下面一个个来，每一个都对应到项目代码。

> **术语小字典｜** 本章你会反复看到这四个词，先记住它们的“人话版”：
> - **数据 Data**：一堆“带答案的例子”。
> - **特征 Feature**：把每张图压缩成的一串数字（相当于“用尺子量图”）。
> - **模型 Model**：一个“从特征到答案”的判断规则。
> - **评估 Evaluation**：判断这个规则到底靠不靠谱。

## 7.2 一张图怎么变成“数字”：特征与标签

电脑不认识“图片”，只认识数字。所以第一步是把一张图**压缩成一小串数字**。项目 v1 版把每张图压成 **11 个数字**，v1.4 又扩展成 143 个（第 8 章再讲）。这 11 个数字就叫**特征向量**，它等于“用 11 把尺子去量这张图”。

- **特征（feature）**：这 11 个数字。比如“像素差值乱不乱”“亮暗分布对不对”。
- **标签（label）**：这张图到底藏没藏，0 = 干净，1 = 含密。
- **样本（sample）**：一行 = 一张图，11 个特征数字 + 1 个标签。

*数据集长什么样（前几列是特征，`label` 是答案，`photo_id` 先别管）*

```python
Rm,Sm,Rn,Sn,RS_Gr,RS_Gn,chi2_pvalue,diff_entropy,lsb_diff_entropy,median_prefix_p,chi2_stat,label,photo_id,...
41316,4574,36608,5057,0.56,0.48,0.56,1.99,0.986,2.7e-166,1719.3,0,0,...
40488,4907,35701,5342,0.54,0.46,0.61,2.02,0.989,2.2e-155,1675.9,1,0,...
```

data/dataset.csv（真实前两行，数值截断显示）

> **新手提示｜** 看到 `chi2_pvalue`、`RS_Gn` 这种名字不用慌，它们就是“第几把尺子量出来的数”。第 8 章会逐个说每把尺子量的是什么。

把表写成数学记号，后面所有公式都用它（别怕，就是“第 i 行、第 j 列”的写法）：

- **样本**：一行一个样本，共 $N$ 个，记 $i=1,\dots,N$；
- **特征**：第 $i$ 个样本的 11 个数字记为向量 $\mathbf{x}_i$；全部行拼成矩阵 $X$（$N$ 行 × 11 列）；
- **标签**：$y_i\in\{0,1\}$，拼成向量 $\mathbf{y}$。

## 7.3 分类 = 画一条线，把两类分开

有了一堆“带答案的数”，模型要学的是什么？就是**在数字空间里画一条线，一边是干净、一边是含密**。可以想象成：横轴是“乱的程度”，纵轴是“亮暗是否均匀”，干净图大多在一角，含密图在另一角。模型学的就是那条分界线。

![图 7-1 决策边界](../assets/ml_decision_boundary.png)

*图 7-1（真实数据：用 RS_Gn 与 chi2_pvalue 两个特征，逻辑回归学出的决策边界；蓝=干净，红=含密，色块=模型认为的含密概率）*

把这张图看明白，是理解整章的关键。注意几点：

1. **两类不是完全分开的**：边界附近蓝点和红点混在一起。说明这两个特征还不足以完美区分，但已经能“大致”分开了。
2. **色块是“概率”**：颜色越红，模型越认为含密；越蓝越认为干净。中间那条黑线就是“概率=0.5”的分界。
3. **维度变多也一样**：真实模型用 11（或 143）个特征，就是在这 11（或 143）维空间里画一个“超平面”。维度高了画不出来，但道理和这张二维图完全一样。

这条线在数学上就叫**线性判别函数**，其实就是一个“打分式子”：

$$
z = w_1x_1+w_2x_2+\cdots+w_{11}x_{11}+b .
$$

**别被符号吓到**，它就是一个加权求和：

- $x_1,\dots,x_{11}$ 是那 11 个特征数字；
- $w_1,\dots,w_{11}$ 是**权重**，决定“哪个特征重要、往哪个方向倾斜”。比如某个特征对判断特别有用，它的 $w$ 就大；
- $b$ 是**偏置**，相当于把整条线上移或下移一点，用来定位置。

判的时候很简单：$z$ 大于等于 0 判成含密，小于 0 判成干净。**这一整行代码 `LogisticRegression` 就是在自动找最合适的 $w$ 和 $b$。**

> **想一想｜** 图 7-1 里，你觉得“RS_Gn”和“chi2_pvalue”哪个对判断更重要？如果只保留一个特征，你会选哪个？—— 这个直觉，会在 7.7 的“特征重要度”图里被验证。

## 7.4 从“分数”到“概率”：为啥要套一层 sigmoid

上面那个 $z$ 是一个可正可负、没有上限的“分数”。但用户更想知道的是一个 0 到 1 之间的**概率**：“这张图有多大可能是含密”。于是加一层 **sigmoid** 把分数压到 0~1：

$$
\hat p = \frac{1}{1+e^{-z}} .
$$

- $z$ 很大（分数高）→ $\hat p$ 接近 1（多半是含密）；
- $z$ 很小 → $\hat p$ 接近 0（多半干净）；
- $z=0$ → 恰好 0.5（一半一半）。

![图 7-2 sigmoid](../assets/ml_sigmoid.png)

*图 7-2（sigmoid 把无上界、可正可负的“得分 z”压缩成 0~1 的“概率”。左半蓝区=更像干净，右半红区=更像含密）*

> **新手提示｜** 你可以把 sigmoid 想成一个“百分比换算器”：把没边界的分数，换成很好懂的 0%~100%。**名字叫“逻辑回归”，其实干的还是“画一条线 + 输出概率”。**

### 7.4.1 为什么不直接用 0/1，而要用“概率”？

好问题。因为真实世界没有“非黑即白”。图 7-1 里边界附近那些点，模型也不确定。与其让它勉强说“0”或“1”，不如老老实实给出“我有 73% 把握认为含密”。这样：

- 用户可以自己决定**阈值**（加到 7.7 讲）；
- 可以评估模型**到底有多确定**（概率校准）；
- 可以画出 ROC / AUC 这种**与阈值无关**的指标（7.7.1）。

> **术语小字典｜** 输出概率的分类器叫**软分类**（soft classification）；直接输出 0/1 的叫**硬分类**（hard classification）。逻辑回归是软分类，这也是它能算 **AUC**（曲线下面积，衡量模型把含密排在干净前面的能力，越大越好，详见 7.7.1）的前提。

*对照项目代码——`src/train_model.py`：*

```python
def lr(seed=0):  return make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.1, random_state=seed))
```

| 代码 | 人话解释 |
| --- | --- |
| `StandardScaler()` | 先把每个特征调成“差不多大”，免得某个数字太大把分界线带偏（见 7.4.2） |
| `LogisticRegression` | 就是上面“画线 + sigmoid 输出概率”那套，自动找 $w$、$b$ |
| `max_iter=2000` | 最多调整 2000 次，防止它没算完就停 |
| `C=0.1` | 一个“让它别太用力记答案”的旋钮（正则化，见 7.5.2） |

> **想了解再点｜** 为什么先标准化？因为“卡方统计量”可能是几千，“Gn”却只有 0~1。权重对它俩一样敏感的话，梯度会拼命去迁就那个大数字，线就被带歪了。标准化的公式是 `x'=(x-μ)/σ`，把每个特征变成“均值 0、标准差 1”，大家就公平了。注意 μ、σ 只能从训练集算，别把测试集偷偷用进去。

## 7.5 模型怎么“学会”：损失与训练

“学会”到底在干嘛？就是**不断调整 $w$ 和 $b$，让输出概率尽量接近正确答案**。怎么衡量“接近”呢？用一个**损失（loss）**——你可以理解成“猜错要扣多少分”。经典做法是交叉熵（log loss）：

$$
\mathcal{L} = -\frac{1}{N}\sum_{i=1}^{N}\Big[ y_i\log \hat p_i + (1-y_i)\log(1-\hat p_i)\Big].
$$

**大白话**：当正确答案是 1（藏了）时，只看 $\hat p_i$——它越接近 1，扣分越少；当正确答案是 0 时，只看 $1-\hat p_i$——它越接近 0，扣分越少。**猜得越离谱，扣得越狠。**

那怎么让扣分变少？**一点点试**：算一下“往哪个方向微调、扣分降得最快”，就沿那个方向挪一小步。这叫**梯度下降**：

$$
\theta \leftarrow \theta - \eta\,\frac{\partial \mathcal{L}}{\partial \theta}.
$$

![图 7-3 梯度下降](../assets/ml_loss_descent.png)

*图 7-3（一个简单的“损失 vs 参数”曲线。红色阶梯就是梯度下降：从左边出发，一步步走到谷底——损失最小的那个 $w^*$。蓝线是损失函数，谷底就是最优参数）*

> **新手提示｜** 你完全不用会推导数。只要记住三句话：**损失 = 模型错得有多离谱；训练 = 找参数让损失最小；梯度下降 = 顺着“让损失变小最快的方向”一点点挪**。这些 scikit-learn 都帮你封装好了。

### 7.5.1 从“一步到位”到“一步步走”，为什么非得这么绕？

理论上，逻辑回归的损失是“凸函数”，可以一步算出最优解。但真实项目里：
- 数据量一大，直接求逆矩阵慢且不稳定；
- 我们要的是**能把问题推广到很多模型**（树、神经网络）的通用方法；
- 梯度下降是**任何**把“最小化损失”当成目标的模型的共同引擎。

所以理解梯度下降，等于同时理解了线性模型、树模型、甚至神经网络的“训练”是怎么一回事。这就是它的价值。

### 7.5.2 一个“防过头”的旋钮：C 与正则化

如果特征之间高度相关或样本很少，模型可能把权重 $w$ 调得巨大，去死记硬背训练数据——这是**过拟合**的征兆。防的办法之一是给损失加一项“惩罚”，要求权重别太大（L2 正则）：

$$
\mathcal{L}_{\text{reg}}(\mathbf w)=\mathcal{L}(\mathbf w)+\lambda\sum_{j}w_j^2 .
$$

scikit-learn 用的是**逆正则化参数 $C=1/\lambda$**：

- `C=0.1` → $\lambda=10$，强正则，权重被狠狠压缩，模型偏保守；
- `C=1.0` → $\lambda=1$，中等；`C` 越大正则越弱。

> **回到项目看代码｜** `train_model.py` 里基模型用 `LogisticRegression(C=0.1)`（想让基模型“克制、别乱记”），而堆叠器的 meta 学习器用 `C=1.0`（放松一点，让它自由综合）。同一个类，两个 $C$，体现“基模型要稳、meta 学习器要灵”的取舍。

## 7.6 怎么不让模型“作弊”：交叉验证与分组

模型在见过的题上拿高分很容易，真正要看的是**没见过的题**。如果模型把训练样本硬背下来，遇到新题就崩——这叫**过拟合**。

防过拟合的标准办法是**交叉验证**：把数据切成几份，轮流拿一份当“小考”，其他当“复习”。但这里有个特别容易踩的坑。

> **避坑提醒｜** 同一张照片有 7 个变体（1 个干净 + 6 个藏过东西），它们互相**太像了**。如果随便切，训练里可能会混进某张测试照片的“亲兄弟”，等于提前把答案告诉了模型——这叫**数据泄漏**，分数会虚高到离谱。项目的做法是**按 `photo_id` 分组**：同一张照片的所有变体，要么全部进训练、要么全部进测试，绝不分开。

![图 7-4 GroupKFold](../assets/ml_groupkfold.png)

*图 7-4（示意：每个 P 是一张照片，一行有 7 个变体。上排“随机切分”会把同一张照片的 7 个变体拆进不同折——漏题；下排“GroupKFold”让同一张照片的 7 个变体进同一折——诚实）*

*对照代码——`src/train_model.py::cv_oof()` 的核心：*

```python
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=5)
for tr, va in gkf.split(X, y, groups):   # groups = photo_id，按照片分组再切
    clf.fit(X[tr], y[tr])
    p = clf.predict_proba(X[va])[:, 1]   # 只在这折“小考”上打分
```

关键就是 `groups` 这个参数。它告诉切分器：“同一组的样本不能拆散”。换成普通的 `KFold` 就会把同一张照片的“亲兄弟”拆到两边，分数就作弊了。

> **想一想｜** 为什么“同一张照片的 7 个变体”算不上一份独立样本？试着设计一个最小例子：假如只有 10 张照片，随机切分有多大概率把某张照片的“亲兄弟”拆到训练/测试两边？这样的“泄漏”，对分数影响有多大？（提示：想想 7.7.1 的 0.5 随机线）

## 7.7 评估：别只盯“准确率”

模型输出的是概率，得定一个**阈值**才能说 0 还是 1。评估一套指标，先看**混淆矩阵**：

![图 7-5 混淆矩阵](../assets/ml_confusion_matrix.png)

*图 7-5（真实数据在 Youden 阈值下的混淆矩阵：TN=正确拒绝、FP=误报、FN=漏报、TP=正确检出。数字是真实样本数）*

由这四个数（TN、FP、FN、TP）定义的常用指标：

$$
\text{accuracy}=\frac{TN+TP}{TN+FP+FN+TP},\quad
\text{precision}=\frac{TP}{TP+FP},\quad
\text{recall}=\frac{TP}{TP+FN},\quad
F_1=\frac{2\cdot precision\cdot recall}{precision+recall}.
$$

- **准确率**：整体猜对的比例。**坑**：数据里含密多、干净少，模型全部判“含密”，准确率照样很高，但把所有干净图都冤枉了。
- **精确率**：被判成含密的人里，有多少是真的。
- **召回率**：真正的含密图，抓到了多少。
- **F1**：精确率和召回率的折中。

| 指标 | 回答的问题 | 直觉 |
| --- | --- | --- |
| 准确率 | 整体猜对多少 | 不平衡时骗人 |
| 精确率 | 判为含密里真含密 | 误报低→精确率高 |
| 召回率 | 真含密里抓到多少 | 漏报低→召回率高 |
| F1 | 两者都要时 | 折中 |
| ROC / AUC | 排序能力强不强、跟阈值无关 | 0.5 等于瞎猜 |

## 7.7.1 ROC 曲线与 AUC：只看“能不能把含密排前面”

**核心思想**：一个好模型，应该把含密的图排在干净图前面。把阈值从高到低扫一遍，就能画出一条 **ROC 曲线**，横轴是“误报率”，纵轴是“检出率”。曲线越靠近左上角越好。

![图 7-6 ROC + AUC](../assets/ml_roc_auc.png)

*图 7-6（真实数据生成的 OOF ROC 曲线：AUC≈0.70。红色阴影区域就是 AUC——“随机抽一张含密图、一张干净图，模型把含密图排在前面”的概率。红色圆点=Youden 最佳操作点）*

**AUC** 就是曲线下面积：0.5 是瞎猜（对角虚线），1.0 是完美排序。**0.7 意味着“多数时候能把含密排在干净前面”，但还不够好**——这正对应弱密度难检出的现实。

> **关键点｜** AUC 与阈值无关！不管你把阈值定在 0.5 还是 0.7，AUC 都不变。因为 AUC 只关心“**排序**”，不关心“具体在哪个点切”。这在下图（7-7）能直接看出来。

### 7.7.2 阈值怎么选：ROC 上的“操作点”

虽然 AUC 与阈值无关，但**真正部署时**你必须选一个阈值。选高→保守（误报少、漏报多）；选低→激进（检出多、误报多）。看下图就一目了然。

![图 7-7 ROC 操作点](../assets/ml_roc_operating_points.png)

*图 7-7（同一组测试，左图：阈值分别取 0.3/0.5/0.7 时在 ROC 上的三个点；右图：同一阈值下“检出率 TPR(■)”和“误报率 FPR(●)”怎么变。阈值越低，检出越高但误报也越高——这就是一对不可避免的矛盾）*

> **回到项目代码｜** `train_model.py` 里用 Youden 准则自动选阈值，就是找“检出率 − 误报率”最大的那个点（离随机线最远的点），再另算一个“低误报”阈值（误报率≤10%）供严格档使用：

```python
from sklearn.metrics import roc_curve
fpr, tpr, th = roc_curve(y_true, proba)
j = tpr - fpr             # Youden J = 检出率 - 误报率
best = float(th[np.argmax(j)])   # 让 J 最大的阈值，就是离随机线最远的点
```

### 7.7.3 为什么“准确率”在这里会骗人

想象 2898 个样本中 2484 个是含密：只要模型**全部判含密**，准确率就是 $2484/2898\approx85.7\%$，看起来很高，但它是把 414 张干净图**全误报**了。所以准确率在类别不平衡时没意义，要看 AUC、召回率、F1 这类指标。

我特别画了一张图，把“阈值怎么影响各项指标”完整展示出来，请看：

![图 7-8 指标随阈值变化](../assets/ml_metrics_vs_threshold.png)

*图 7-8（真实数据：随阈值从 1 降到 0，精确率(绿)、召回率(蓝)、误报率(红)的变化。你选的阈值，就是一个“操作点”——在这里做取舍）*

**读图要点**：
- 阈值很低时：召回率高（几乎都抓到），但精确率低、误报率高（误杀很多干净图）；
- 阈值很高时：精确率高（判为含密的基本都是真的），但召回率低（漏掉很多）；
- 中间某处，你会选一个符合自己需求的“折中点”。

> **动手做｜** 运行一次 `python src\train_model.py`。不用看懂全部，先找三行：CV-AUC、测试集 AUC、低误报点检出率。第 8 章会逐行解释。

## 7.8 进阶：项目还做了两个“加分项”（看得懂就行）

`train_model.py` 里还有两件事，第一次学知道个大概即可：

- **概率校准**：逻辑回归常“过分自信”（该 0.3 的说成 0.95）。项目加了一层校准，让概率更接近真实比例，这样定阈值才有意义。
- **堆叠（stacking）**：它不用一个模型打天下，而是把“逻辑回归、随机森林、梯度提升、XGBoost”这 4 个模型的答案都拿出来，再让一个小模型（逻辑回归）学“该听谁的”。

> **新手提示｜** 堆叠有点像“评委团”：每个评委（基模型）先独立打分，再由一个“组长”（meta 学习器）综合定结论。它不挑单个最好，而是把几种思路的优势合并。第 8 章的 图 8-4 会把这四个模型横向比一比，你就知道为什么它们能“互补”了。

## 7.9 小结与自我检查

**一句话总结本章**：机器学习 = 用「数据 → 特征 → 模型 → 评估」，把“一张图藏没藏东西”变成一个可判断、可评估、可解释的过程。

- 监督学习 = 用“带答案的样本”学一个从特征到答案的判断；
- 分类 = 用一条分界线把两类分开，逻辑回归 = 画线 + sigmoid 输出概率（图 7-1、7-2）；
- 损失 = 猜错扣多少分；训练 = 让损失变小；梯度下降 = 一点点朝下走（图 7-3）；
- 同源样本别随便切分，`GroupKFold` 按 `photo_id` 分组才是诚实的（图 7-4）；
- 准确率在不平衡时失真，看 AUC / 精确率 / 召回率；阈值决定“严一点还是松一点”（图 7-5 ~ 7-8）。

> **想一想｜** 如果一篇论文说“隐写检测 AUC=0.95”，但没说数据是怎么切的，你会先怀疑什么？写下来——这就是你评估一切机器学习实验的起点。再想深一层：它有没有做概率校准？它的阈值是怎么选的？这些细节会不会也在“挑”一个好看的结果？

> **动手做｜** 把图 7-6、7-8 看懂后，试着回答：如果领导要求“误报必须低于 5%”，你应该把阈值往高还是往低调？代价是什么？
