Machine Learning in R: Algorithms, Mathematics and Packages

R 里的机器学习:算法、数学与包

从最小二乘到随机森林、从 PCA 到 UMAP、从 glmnet 到 torch:用统一的损失-正则-优化视角讲清十种常用算法的数学,全部配 R 代码与可复现的图,并对照 tidymodels、mlr3、caret 三种工作流。
Tooling
Author

Jie Hua

Published

Nov 1, 2026

写作说明 / Note

本文由 AI(DeepSeek-Flash) 检索、整理并撰写,尚未经过人工逐条校对。所有数字、曲线与图像都由文中 R 代码现场计算生成;随机过程统一设定了种子,因此结果可复现。包版本会随时间变化,函数参数以你安装的版本为准。

This article was researched and written by an AI system (DeepSeek-Flash) and has not been proofread line by line by a human. Every number, curve and figure is computed by the R code in this post; random procedures are seeded, so results reproduce. Package versions change over time — check the arguments against the version you have installed.

写一篇中英逐段对照的长文,面向有生物医学背景、会基础统计但不熟悉机器学习工程的研究者,讲清 R 生态里常用机器学习算法的数学原理与实现方式。覆盖:正则化线性模型、逻辑回归、KNN、SVM、决策树、随机森林、梯度提升、K-means、层次聚类、GMM、PCA、t-SNE/UMAP、交叉验证与调参、以及 torch/keras3 接口。每个算法要有目标函数、求解方法和一个能跑的 R 例子,并说明对应哪个包。要求 20 幅左右程序化生成的图,数字必须来自实际计算,案例在通用数据与生物学数据之间混合。

Write a bilingual, paragraph-by-paragraph long-form article for readers with a biomedical background and basic statistics but little ML engineering, explaining the mathematics and implementation of the common machine-learning algorithms available in R. Cover penalised linear models, logistic regression, KNN, SVM, decision trees, random forests, gradient boosting, k-means, hierarchical clustering, GMM, PCA, t-SNE/UMAP, cross-validation and tuning, and the torch/keras3 interfaces. Each algorithm needs its objective, its solver and a runnable R example, plus the package it lives in. Roughly 20 programmatically generated figures, every number from code that actually runs, with examples mixing general and biological data.

1. R 到底能不能做机器学习? / Can R actually do machine learning?

能,而且它缺的从来不是算法,是工程。R 的 stats 包里就躺着最小二乘、广义线性模型、K-means、层次聚类和 PCA,这些函数比大多数 Python 库出现得都早。真正被诟病的是另一件事:把数据切分、预处理、调参、评估串成一条不会出错的流水线,在 R 里曾经很笨重。

Yes — and what R has always lacked is not algorithms but plumbing. Base stats ships least squares, generalised linear models, k-means, hierarchical clustering and PCA; most of those functions predate the Python libraries. The real complaint is different: stringing splitting, preprocessing, tuning and evaluation into a pipeline that cannot silently go wrong used to be clumsy in R.

这十年这件事被补上了,而且补出了两套风格不同的方案。下图是现在 R 机器学习生态的分层:底下是算法实现,中间是统一接口,上面是工作流。

That gap has been closed over the last decade, in two quite different styles. The figure below layers the current R machine-learning stack: algorithms at the bottom, unified interfaces in the middle, workflows on top.

Code
lay <- data.frame(
  y = 4:1,
  head = c("工作流  workflow", "领域模型  domain models", "算法实现  algorithms", "底座  runtime"),
  body = c("tidymodels · mlr3 · caret",
           "survival · Bioconductor (limma, DESeq2) · forecast",
           "glmnet · rpart · ranger · xgboost · e1071 · kernlab · uwot · mclust",
           "stats · Matrix · Rcpp · torch · keras3"),
  fill = c("#FDF0D8", "#FBE3D9", "#D8EDE7", "#EAF0F5"),
  bar  = c(ml_pal[["orange"]], ml_pal[["coral"]], ml_pal[["teal"]], ml_pal[["grey"]])
)

ggplot(lay) +
  geom_rect(aes(xmin = 0, xmax = 11, ymin = y - 0.40, ymax = y + 0.40), fill = lay$fill) +
  geom_rect(aes(xmin = 0, xmax = 0.16, ymin = y - 0.40, ymax = y + 0.40),
            fill = lay$bar, colour = NA) +
  geom_text(aes(0.45, y + 0.13, label = head), hjust = 0, size = 3.1,
            fontface = "bold", colour = ml_pal[["ink"]]) +
  geom_text(aes(0.45, y - 0.16, label = body), hjust = 0, size = 2.6,
            colour = ml_pal[["blue"]]) +
  coord_cartesian(xlim = c(0, 11.2), ylim = c(0.45, 4.55)) +
  plain
Figure 1: R 机器学习生态的四层。本文用到的包在第三层,工作流框架在第四层;第一层是几乎所有包都依赖的底座。分层不是为了排名,而是为了说明:同一个算法可以有三种调用方式,选哪一种取决于你要跑一个模型还是一百个。 | Four layers of the R machine-learning stack. The packages used here sit in layer 3, workflow frameworks in layer 4, and everything rests on layer 1. The layering is not a ranking: it shows that one algorithm can be called three ways, and which you choose depends on whether you need one model or a hundred.

一个具体的对比能说明”分层”的实际意义。同一件事——在 Pima 糖尿病数据上做五折交叉验证的随机森林——在三种框架里长得完全不一样:

A concrete comparison shows what the layering means in practice. The same job — five-fold cross-validated random forest on the Pima diabetes data — looks completely different in the three frameworks:

Code
# tidymodels: declare, then fit a workflow
rf_spec <- rand_forest(trees = 500) |>
  set_engine("ranger") |>
  set_mode("classification")

rf_wf <- workflow() |>
  add_formula(type ~ .) |>
  add_model(rf_spec)

rf_res <- fit_resamples(rf_wf, folds = vfold_cv(Pima.tr, v = 5),
                        metrics = metric_set(roc_auc, accuracy))
collect_metrics(rf_res)
Code
# mlr3: build a task, a learner, then resample
task <- as_task_classif(Pima.tr, target = "type")
learner <- lrn("classif.ranger", num.trees = 500, predict_type = "prob")
rr <- resample(task, learner, rsmp("cv", folds = 5))
rr$aggregate(msr("classif.auc"))
Code
# caret: one call, many models
ctrl <- trainControl(method = "cv", number = 5,
                     summaryFunction = twoClassSummary,
                     classProbs = TRUE)
train(type ~ ., data = Pima.tr, method = "ranger",
      trControl = ctrl, metric = "ROC")

三段代码做的事完全一样。区别在于:tidymodels 把”模型”和”预处理”拆成可组合的对象,最适合需要严格记录每一步的流程;mlr3 用面向对象的 task/learner/measure 三件套,最适合在几十个数据集上批量跑 benchmark;caret 用一次 train() 调用包办调参与重采样,最适合”我只想赶紧得到一个能用的模型”。第 7 章会把这条比较做完整。

All three do the same thing. The difference is style: tidymodels splits model and preprocessing into composable objects, which suits pipelines that must record every step; mlr3’s task/learner/measure triple suits running benchmarks over dozens of datasets; caret’s single train() call handles tuning and resampling together, which suits “I just need a working model now”. Section 7 completes this comparison.

1.1 本文的例子用什么数据 / What data the examples use

两套数据,一套通用一套生物学,避免”只有生物学家看得懂”和”举的例子和自己无关”这两种失败:

Two datasets, one general and one biological, to avoid both failure modes — examples nobody outside biology understands, and examples unrelated to the reader’s own work:

  • Pima 糖尿病数据(MASS::Pima.tr / Pima.te):200 个训练样本、332 个测试样本,7 个临床指标,预测是否为糖尿病。用来演示分类与集成方法。
    Pima diabetes data (MASS::Pima.tr / Pima.te): 200 training and 332 test cases, seven clinical variables, predict diabetes. Used for classification and ensembles.
  • 模拟表达矩阵:80 个样本 × 500 个基因,只有 8 个基因与结局真正相关。用来演示高维正则化与特征选择——这是生信里最典型的”p 远大于 n”场景,而且因为真值已知,可以验证方法有没有找回正确的基因。
    A simulated expression matrix: 80 samples × 500 genes, of which 8 are truly associated. Used for high-dimensional penalisation and feature selection — the classic p ≫ n case in bioinformatics, and because the truth is known, we can check whether the method recovers the right genes.

模拟数据的好处是知道正确答案。真实数据上你只能比较预测误差,模拟数据上还能问”选出来的基因对不对”,这两个问题是不同的。

The advantage of simulation is that you know the right answer. On real data you can only compare predictive error; on simulated data you can also ask whether the selected genes are the right ones — two different questions.

2. 一个视角看穿所有监督学习 / One lens for all supervised learning

在写代码之前先建立这个视角,后面每一节都会回到它。几乎所有监督学习都在解同一个问题:

Build this lens before any code; every later section returns to it. Almost all supervised learning solves one problem:

\[\hat f=\arg\min_{f\in\mathcal{F}}\ \underbrace{\frac{1}{n}\sum_{i=1}^{n}L\big(y_i,f(x_i)\big)}_{\text{拟合数据 / fit the data}}+\ \underbrace{\lambda\,\Omega(f)}_{\text{别太复杂 / stay simple}}\]

三项各有分工:\(L\) 决定”错多少算错”,\(\Omega\) 决定”多复杂算复杂”,\(\lambda\) 决定两者怎么妥协。\(f\) 的取值范围 \(\mathcal{F}\) 就是模型族——线性函数、分段常数、树的和、神经网络的函数类。

Each term has a job: \(L\) says how wrong is wrong, \(\Omega\) says how complex is complex, and \(\lambda\) arbitrates. The set \(\mathcal{F}\) is the model family — linear functions, piecewise constants, sums of trees, the function class of a neural network.

Code
box <- data.frame(
  x = c(1.1, 3.0, 4.9, 6.8),
  head = c("模型族  F", "损失  L", "正则  Ω", "求解器"),
  body = c("线性 / 树 / 核 / 网络",
           "平方 / 对数 / hinge",
           "L1 / L2 / 深度惩罚",
           "闭式解 / 坐标下降 / SGD"),
  ex = c("y = Xβ", "(y - f)^2", "λ‖β‖₁", "argmin"),
  fill = c("#DCE8F5", "#FDF0D8", "#FBE3D9", "#D8EDE7")
)

ggplot(box) +
  geom_rect(aes(xmin = x - 0.78, xmax = x + 0.78, ymin = 0.55, ymax = 1.95),
            fill = box$fill, colour = ml_pal[["blue"]], linewidth = 0.35) +
  geom_text(aes(x, 1.72, label = head), size = 3.2, fontface = "bold",
            colour = ml_pal[["blue"]]) +
  geom_text(aes(x, 1.28, label = body), size = 2.4, lineheight = 1.1,
            colour = ml_pal[["ink"]]) +
  geom_text(aes(x, 0.86, label = ex), size = 2.6, colour = ml_pal[["coral"]]) +
  annotate("segment", x = 1.92, xend = 2.18, y = 1.25, yend = 1.25,
           arrow = arrow_ml, colour = ml_pal[["grey"]], linewidth = 0.45) +
  annotate("segment", x = 3.82, xend = 4.08, y = 1.25, yend = 1.25,
           arrow = arrow_ml, colour = ml_pal[["grey"]], linewidth = 0.45) +
  annotate("segment", x = 5.72, xend = 5.98, y = 1.25, yend = 1.25,
           arrow = arrow_ml, colour = ml_pal[["grey"]], linewidth = 0.45) +
  coord_cartesian(xlim = c(0.2, 7.9), ylim = c(0.4, 2.1)) +
  plain
Figure 2: 所有监督学习共用的一条流水线:模型族决定候选函数,损失函数决定怎么打分,正则项决定偏好哪一类解,优化算法负责把最小值找出来。换算法往往只是换了其中一格。 | Every supervised method shares one pipeline: the model family says which functions are candidates, the loss says how to score them, the penalty says which solutions to prefer, and the optimiser finds the minimum. Swapping algorithms usually swaps one cell.

把常见算法按这条流水线拆开,会看到它们其实很像:

Decompose the common algorithms along this pipeline and they look alike:

算法 / Method 损失 / Loss 正则 / Penalty 求解 / Solver
最小二乘 / OLS 平方 / squared 无 / none 闭式解 / closed form
岭回归 / ridge 平方 / squared \(L_2\) 闭式解 / closed form
LASSO 平方 / squared \(L_1\) 坐标下降 / coordinate descent
逻辑回归 / logistic 对数 / log-loss 可选 \(L_1,L_2\) / optional 迭代重加权 / IRLS
支持向量机 / SVM hinge \(L_2\) on \(w\) 二次规划 / QP
决策树 / tree 不纯度 / impurity 叶数、深度 / leaves, depth 贪心分裂 / greedy splits
随机森林 / random forest 不纯度 / impurity 同上 + 随机特征 / plus feature subsampling bagging
梯度提升 / boosting 任意可微 / any differentiable 树的复杂度 / tree complexity 前向分阶段 / stagewise
KNN 0–1 无 / none 无训练 / no training
神经网络 / neural net 交叉熵 / cross-entropy 权重衰减 / weight decay SGD、Adam

English

Method Loss Penalty Solver
OLS squared none closed form
ridge squared \(L_2\) closed form
LASSO squared \(L_1\) coordinate descent
logistic log-loss optional \(L_1,L_2\) IRLS
SVM hinge \(L_2\) on \(w\) QP
tree impurity leaves, depth greedy splits
random forest impurity plus feature subsampling bagging
boosting any differentiable tree complexity stagewise
KNN 0–1 none no training
neural net cross-entropy weight decay SGD, Adam

有了这张表,“算法”这个词就不再神秘:换算法就是换其中一格,而”调参”多数时候是在调第三格(正则强度)。下一篇(如果写)讲深度学习时你会发现,神经网络唯一真正新的是 \(\mathcal{F}\) 变大了,其他三格原封不动。

With this table, “algorithm” stops being mysterious: switching algorithms means switching one cell, and “tuning” is mostly tuning the third cell. In deep learning the only genuinely new thing is that \(\mathcal{F}\) becomes much larger; the other three cells are unchanged.

2.1 为什么需要正则:偏差与方差的真实形状 / Why regularise: the actual shape of bias and variance

\(\Omega\) 不是装饰。下面这个模拟把”偏差-方差权衡”算了出来,而不是画一条示意图:固定真函数 \(f(x)=\sin(2\pi x)\),用多项式回归拟合,改变阶数,重复 300 次抽样,在测试点上分别估计偏差的平方与方差。

\(\Omega\) is not decoration. The simulation below computes the bias–variance trade-off instead of sketching it: fix the truth \(f(x)=\sin(2\pi x)\), fit polynomials of increasing degree, repeat the sampling 300 times, and estimate squared bias and variance at the test points.

Code
truth <- function(x) sin(2 * pi * x)
xgrid <- seq(0.05, 0.95, length.out = 40)
deg   <- 1:12
R     <- 300
noise <- 0.25

bias2 <- numeric(length(deg)); vari <- numeric(length(deg)); mse <- numeric(length(deg))
for (k in seq_along(deg)) {
  preds <- matrix(NA_real_, R, length(xgrid))
  for (r in seq_len(R)) {
    x  <- runif(45)
    y  <- truth(x) + rnorm(45, 0, noise)
    fit <- lm(y ~ poly(x, deg[k], raw = TRUE))
    preds[r, ] <- predict(fit, newdata = data.frame(x = xgrid))
  }
  m  <- colMeans(preds)
  bias2[k] <- mean((m - truth(xgrid))^2)
  vari[k]  <- mean(apply(preds, 2, var))
  mse[k]   <- mean(apply(preds, 2, function(v) mean((v - truth(xgrid))^2)))
}

bv <- rbind(
  data.frame(deg = deg, value = bias2, term = "bias^2"),
  data.frame(deg = deg, value = vari,  term = "variance"),
  data.frame(deg = deg, value = mse,   term = "bias^2 + variance"),
  data.frame(deg = deg, value = rep(noise^2, length(deg)), term = "irreducible")
)
bv$term <- factor(bv$term, levels = c("bias^2", "variance", "bias^2 + variance", "irreducible"))

ggplot(bv, aes(deg, value, colour = term, linetype = term)) +
  geom_line(linewidth = 0.8) +
  geom_point(data = subset(bv, term != "irreducible"), size = 1.8) +
  scale_colour_manual(values = c("bias^2" = ml_pal[["blue"]], "variance" = ml_pal[["coral"]],
                                 "bias^2 + variance" = ml_pal[["ink"]],
                                 "irreducible" = ml_pal[["grey"]]), name = NULL) +
  scale_linetype_manual(values = c("bias^2" = "solid", "variance" = "solid",
                                   "bias^2 + variance" = "22", "irreducible" = "dotted"),
                        name = NULL) +
  scale_y_log10() +
  labs(x = "polynomial degree", y = "error at test points (log scale)") +
  theme(legend.position = "bottom", legend.text = element_text(size = 8))
Figure 3: 偏差-方差的真实分解。阶数从 1 增到 12:偏差平方单调下降(模型更灵活),方差单调上升(对训练集更敏感),两者相加在 4 阶附近最低。纵轴是对数刻度。 | A computed bias–variance decomposition. As the degree goes from 1 to 12, squared bias falls monotonically (more flexible) and variance rises monotonically (more sensitive to the training set); their sum is lowest near degree 4. The vertical axis is logarithmic.

三件事值得记住。第一,虚线(总误差)有一个明确的最低点,位置不在两端——这就是”模型不能太简单也不能太复杂”的定量版本。第二,方差随阶数指数式上升(注意纵轴是对数刻度),而偏差下降得越来越慢,所以过了最优点之后,误差上升得比下降时快。第三,噪声水平(点线)是一条地板,任何模型都跨不过去——它决定了你的问题本身的难度上限。

Three things to keep. First, the dashed total has a clear minimum, not at either end — the quantitative version of “not too simple, not too complex”. Second, variance grows roughly exponentially with degree (note the log axis) while bias falls ever more slowly, so past the optimum the error rises faster than it fell. Third, the noise floor (dotted) is a floor no model crosses — it sets the difficulty ceiling of the problem itself.

后面每种方法的”超参数”——多项式的阶数、KNN 的 \(k\)、树的深度、正则的 \(\lambda\)——本质上都在同一个轴上移动:往左加偏差减方差,往右加方差减偏差。所谓调参,就是在这条曲线上找最低点,而找的方法几乎只有一种:留出一部分数据不参与拟合。

Every later “hyperparameter” — polynomial degree, KNN’s \(k\), tree depth, the penalty \(\lambda\) — moves along the same axis: left adds bias and removes variance, right does the reverse. Tuning is finding the minimum of that curve, and there is essentially one way to find it: hold out data that never takes part in fitting.

3. 线性模型与正则化:从最小二乘到 LASSO / Linear models and penalisation

3.1 最小二乘的解,以及它什么时候会崩 / The least-squares solution, and when it collapses

线性模型假设 \(f(x)=x^\top\beta\),平方损失下目标函数是

A linear model assumes \(f(x)=x^\top\beta\); with squared loss the objective is

\[\hat\beta=\arg\min_\beta\ \|y-X\beta\|_2^2 .\]

对 \(\beta\) 求导令其为零,得到正规方程 \(X^\top X\beta=X^\top y\),解是 \(\hat\beta=(X^\top X)^{-1}X^\top y\)。这个式子漂亮,但有一个致命条件:\(X^\top X\) 必须可逆,也就是 \(p\) 个特征必须线性无关且 \(n\ge p\)。

Differentiate with respect to \(\beta\), set to zero, and the normal equations \(X^\top X\beta=X^\top y\) give \(\hat\beta=(X^\top X)^{-1}X^\top y\). Elegant — but it needs \(X^\top X\) to be invertible: the \(p\) features must be linearly independent and \(n\ge p\).

生信数据几乎从不满足这个条件。500 个基因、80 个样本时 \(X^\top X\) 是 500×500 的矩阵但秩最多 80,它有无穷多零空间方向;解不唯一,而教科书式的解会把权重摊到所有基因上,方差大到无法解释。这不是数值问题,是问题本身没有唯一答案——正则项的作用正是从这里开始:在无穷多个”拟合得一样好”的解里挑一个。

Bioinformatics data almost never satisfies that. With 500 genes and 80 samples, \(X^\top X\) is 500×500 but has rank at most 80, so it has infinitely many null directions; the solution is not unique, and the textbook one spreads weight over all genes with unusable variance. This is not a numerical nuisance — the problem has no unique answer. The penalty starts exactly here: among infinitely many equally good fits, pick one.

3.2 岭回归:给解加一条最小范数的偏好 / Ridge: prefer the smallest norm

\[\hat\beta_{\text{ridge}}=\arg\min_\beta\ \|y-X\beta\|_2^2+\lambda\|\beta\|_2^2 =\big(X^\top X+\lambda I\big)^{-1}X^\top y\]

加 \(\lambda I\) 让矩阵一定可逆,这是它数值稳定的原因。统计上它做的是收缩:所有系数按比例向零压,但不会压到恰好为零。\(\lambda\to0\) 退回最小二乘,\(\lambda\to\infty\) 全部归零。

Adding \(\lambda I\) makes the matrix invertible, which is why it is numerically stable. Statistically it shrinks: every coefficient is pulled toward zero proportionally, but never exactly to zero. As \(\lambda\to0\) it returns to least squares; as \(\lambda\to\infty\) everything goes to zero.

3.3 LASSO:让没用的变量变成精确的零 / LASSO: useless variables become exact zeros

\[\hat\beta_{\text{lasso}}=\arg\min_\beta\ \|y-X\beta\|_2^2+\lambda\|\beta\|_1, \qquad \|\beta\|_1=\sum_j|\beta_j|\]

只把 \(L_2\) 换成 \(L_1\),解的性质就完全变了:\(\hat\beta\) 会稀疏,很多分量恰好是 0。原因在几何上:\(L_1\) 球的”角”落在坐标轴上,而损失函数的等高线是椭圆——椭圆从外面撞上菱形时,最先接触的往往是角点。

Swapping \(L_2\) for \(L_1\) changes the solution completely: \(\hat\beta\) becomes sparse, with many components exactly zero. The reason is geometric — the \(L_1\) ball has corners on the axes, and an expanding elliptical loss contour usually touches a corner first.

Code
set.seed(20261101)
Xg <- matrix(rnorm(200 * 2), 200, 2) %*% matrix(c(1, 0.5, 0.5, 1), 2, 2)
Xg <- scale(Xg)
yg <- as.vector(Xg %*% c(2.2, 0.4)) + rnorm(200, 0, 0.55)
b_ols <- solve(crossprod(Xg), crossprod(Xg, yg))
XtX <- crossprod(Xg)

g <- expand.grid(b1 = seq(-1.7, 3.4, length.out = 200),
                 b2 = seq(-1.7, 2.2, length.out = 200))
d1 <- g$b1 - b_ols[1]; d2 <- g$b2 - b_ols[2]
g$rss <- XtX[1, 1] * d1^2 + 2 * XtX[1, 2] * d1 * d2 + XtX[2, 2] * d2^2

tt <- 1.5
in_l2 <- g$b1^2 + g$b2^2 <= tt^2
in_l1 <- abs(g$b1) + abs(g$b2) <= tt
sol_l2 <- g[in_l2, ][which.min(g$rss[in_l2]), c("b1", "b2")]
sol_l1 <- g[in_l1, ][which.min(g$rss[in_l1]), c("b1", "b2")]

th <- seq(0, 2 * pi, length.out = 300)
ball_l2 <- data.frame(b1 = tt * cos(th), b2 = tt * sin(th))
ball_l1 <- data.frame(b1 = c(tt, 0, -tt, 0, tt), b2 = c(0, tt, 0, -tt, 0))

mk_panel <- function(ball, sol, title) {
  ggplot() +
    geom_contour(data = g, aes(b1, b2, z = rss), colour = ml_pal[["grey"]],
                 linewidth = 0.32, bins = 9, alpha = 0.8) +
    geom_path(data = ball, aes(b1, b2), linetype = "22",
              colour = ml_pal[["blue"]], linewidth = 0.6) +
    geom_point(data = sol, aes(b1, b2), colour = ml_pal[["orange"]], size = 3) +
    geom_point(aes(x = b_ols[1], y = b_ols[2]), shape = 4, size = 2.2,
               colour = ml_pal[["ink"]], stroke = 0.6) +
    coord_cartesian(xlim = c(-1.7, 3.4), ylim = c(-1.7, 2.2)) +
    labs(title = title) +
    theme(axis.text = element_text(size = 7.5))
}
mk_panel(ball_l2, sol_l2, "L2: round, both non-zero") +
  mk_panel(ball_l1, sol_l1, "L1: corners, one exact zero")
Figure 4: 两种约束的几何。灰色同心椭圆是残差平方和的等高线,虚线是可行域边界,橙点是数值上找到的最优解,叉号是最小二乘解。左:\(L_2\) 球是圆的,切点一般不在坐标轴上,两个系数都非零。右:\(L_1\) 球是菱形,切点落在角上,于是有一个系数恰好为 0。这就是 LASSO 稀疏的全部来源。 | Geometry of the two constraints. Grey contours are residual sum of squares, the dashed outline is the feasible region, the orange dot the numerically located optimum and the cross the least-squares solution. Left: the \(L_2\) ball is round, the contact is generically off-axis and both coefficients are non-zero. Right: the \(L_1\) ball is a diamond, the contact lands on a corner, so one coefficient is exactly zero. That is the whole origin of LASSO sparsity.

叉号是最小二乘解(约 \((2.20,0.40)\)),橙点是加约束后的最优解。左图两个系数都被压小但都不为零;右图第二个系数被压到坐标轴上——这就是”变量选择”的来源。这不是玄学:\(|\beta_j|\) 在 0 点的导数是跳变的,损失函数在这一点无法用一个平滑方向把它推离零,所以只要约束足够紧,它就会停在零。

The cross is the least-squares solution (about \((2.20,0.40)\)) and the orange dot the constrained optimum. In the left panel both coefficients shrink but neither vanishes; in the right the second coefficient is pinned to an axis — that is where variable selection comes from. Not mysticism: the derivative of \(|\beta_j|\) jumps at zero, so no smooth direction can push the loss off that point once the constraint is tight enough.

实际求解不靠网格搜索。glmnet 用坐标下降:一次只优化一个系数、其余固定,这时有闭式解(软阈值),扫完一轮再扫下一轮,通常十几轮收敛。因为每个子问题便宜,且能用上一轮的解热启动,整条 \(\lambda\) 路径(从大到小)算下来反而比单个最小二乘还快。

Real solvers do not use a grid. glmnet uses coordinate descent: optimise one coefficient at a time with the rest fixed, which has a closed form (soft thresholding), sweep repeatedly, and it converges in a dozen passes. Because each subproblem is cheap and warm-starts from the previous solution, the whole \(\lambda\) path from large to small is often faster than one least-squares fit.

3.4 正则路径:把 λ 从大到小走一遍 / The regularisation path

把 \(\lambda\) 当作自变量,画出每个系数随它的变化,能一眼看出”哪些变量先进入模型”。

Plot each coefficient against \(\lambda\) and you see at a glance which variables enter the model first.

Code
data(BostonHousing, package = "mlbench")
bh <- BostonHousing
Xb <- model.matrix(medv ~ ., data = bh)[, -1]
yb <- bh$medv
fit_path <- glmnet::glmnet(Xb, yb, alpha = 1)

B <- as.matrix(fit_path$beta)
path <- data.frame(
  loglam = rep(log(fit_path$lambda), each = nrow(B)),
  var    = rep(rownames(B), times = ncol(B)),
  coef   = as.vector(B)
)
final <- B[, ncol(B)]
path$grp <- ifelse(abs(final[path$var]) == 0, "never selected",
                   ifelse(final[path$var] > 0, "positive at end", "negative at end"))
path$grp <- factor(path$grp,
                   levels = c("positive at end", "negative at end", "never selected"))

ggplot(path, aes(loglam, coef, group = var, colour = grp)) +
  geom_hline(yintercept = 0, colour = ml_pal[["grey"]], linewidth = 0.3) +
  geom_line(linewidth = 0.55, alpha = 0.9) +
  scale_colour_manual(values = c("positive at end" = ml_pal[["blue"]],
                                 "negative at end" = ml_pal[["coral"]],
                                 "never selected" = ml_pal[["grey"]]), name = NULL) +
  labs(x = expression(log(lambda)), y = "coefficient") +
  theme(legend.position = "bottom", legend.text = element_text(size = 8))
Figure 5: 波士顿房价数据上的 LASSO 路径。横轴是 \(\log\lambda\),从左往右正则越来越强;每条曲线是一个预测变量。右端所有系数归零,说明正则足够强时模型退化成只预测均值。落在零线上的灰线是全程都没被选中的变量。 | LASSO path on the Boston housing data. The horizontal axis is \(\log\lambda\), increasing left to right; each curve is one predictor. At the right every coefficient is zero, so a strong enough penalty reduces the model to predicting the mean. Grey lines lying on zero are variables never selected.

读这张图有三个层次。第一,右端全部为零。第二,每条曲线离开零的位置不同:越靠右离开的变量在更强正则下依然值得保留,通常也越重要。第三,曲线会互相挤压——LASSO 在高度相关的变量之间倾向于只留一个,换一批样本留下的可能是另一个。这种不稳定性是弹性网存在的直接理由。

Read it at three levels. First, everything is zero at the right end. Second, each curve leaves zero at a different point: variables leaving further right survive stronger penalties and are usually more important. Third, the curves push against each other — LASSO tends to keep only one of a highly correlated pair, and a different sample may keep the other. That instability is the direct reason the elastic net exists.

3.5 弹性网:两个惩罚项一起用 / Elastic net: both penalties at once

\[\hat\beta=\arg\min_\beta\ \|y-X\beta\|_2^2+\lambda\Big(\alpha\|\beta\|_1+\tfrac{1-\alpha}{2}\|\beta\|_2^2\Big)\]

\(\alpha=1\) 是 LASSO,\(\alpha=0\) 是岭回归。中间值同时具备”能选出变量”和”相关变量一起进出”两个性质。生信里这几乎是默认选择:基因按通路成组相关,纯 LASSO 会在一组里随机挑一个,解释性很差。

\(\alpha=1\) is LASSO and \(\alpha=0\) is ridge. Intermediate values both select variables and let correlated ones enter together. In bioinformatics this is nearly the default: genes are correlated within pathways, and pure LASSO picks one at random from each group, which reads badly.

3.6 高维案例:从 500 个基因里找回 8 个 / A p ≫ n case: recovering 8 genes out of 500

现在做那个真正的问题:80 个样本、500 个基因,只有 8 个基因与结局相关,其余是纯噪声。

Now the real question: 80 samples, 500 genes, only 8 associated with the outcome, the rest pure noise.

Code
set.seed(20261101)
n_g <- 80; p_g <- 500
Xg2 <- matrix(rnorm(n_g * p_g), n_g, p_g)
Xg2[, 11:25] <- Xg2[, 11:25] + matrix(rnorm(n_g), n_g, 1) %*% rep(0.8, 15)
Xg2 <- scale(Xg2)
truth_idx <- c(7, 43, 88, 120, 201, 305, 410, 488)
beta_true <- c(1.15, -0.95, 0.85, 0.75, -0.65, 0.55, -0.50, 0.45)
yg2 <- as.vector(Xg2[, truth_idx] %*% beta_true) + rnorm(n_g, 0, 0.8)

cvfit <- glmnet::cv.glmnet(Xg2, yg2, alpha = 1, nfolds = 5)
b_hat <- as.vector(coef(cvfit, s = "lambda.min"))[-1]
names(b_hat) <- paste0("g", seq_len(p_g))
sel <- which(b_hat != 0)
tp <- sum(sel %in% truth_idx); fp <- sum(!(sel %in% truth_idx))
fn <- length(truth_idx) - tp

b_1se <- as.vector(coef(cvfit, s = "lambda.1se"))[-1]
sel1 <- which(b_1se != 0)
tp1 <- sum(sel1 %in% truth_idx); fp1 <- sum(!(sel1 %in% truth_idx))

cvdf <- data.frame(loglam = log(cvfit$lambda), cvm = cvfit$cvm, cvsd = cvfit$cvsd)
p_cv <- ggplot(cvdf, aes(loglam, cvm)) +
  geom_errorbar(aes(ymin = cvm - cvsd, ymax = cvm + cvsd), width = 0.06,
                colour = ml_pal[["grey"]], linewidth = 0.3) +
  geom_point(size = 1.1, colour = ml_pal[["blue"]]) +
  geom_vline(xintercept = log(cvfit$lambda.min), linetype = "22",
             colour = ml_pal[["orange"]], linewidth = 0.55) +
  geom_vline(xintercept = log(cvfit$lambda.1se), linetype = "dotted",
             colour = ml_pal[["teal"]], linewidth = 0.55) +
  labs(x = expression(log(lambda)), y = "CV error",
       title = sprintf("min at %.2f, 1se at %.2f",
                       log(cvfit$lambda.min), log(cvfit$lambda.1se))) +
  theme(axis.text = element_text(size = 7.5))

seldf <- data.frame(gene = names(b_hat)[sel], coef = b_hat[sel],
                    kind = ifelse(sel %in% truth_idx, "true signal", "false positive"))
seldf$gene <- factor(seldf$gene, levels = seldf$gene[order(seldf$coef)])
p_sel <- ggplot(seldf, aes(coef, gene, fill = kind)) +
  geom_col(width = 0.7) +
  geom_vline(xintercept = 0, colour = ml_pal[["grey"]], linewidth = 0.3) +
  scale_fill_manual(values = c("true signal" = ml_pal[["orange"]],
                               "false positive" = ml_pal[["grey"]]), name = NULL) +
  labs(x = "coefficient at lambda.min", y = NULL,
       title = sprintf("%d selected: %d true, %d false", length(sel), tp, fp)) +
  theme(axis.text.y = element_text(size = 6.5), legend.position = "bottom",
        legend.text = element_text(size = 8))

p_cv + p_sel + plot_layout(widths = c(1, 1.05))
Figure 6: 高维情形下的交叉验证与变量选择。左:五折交叉验证曲线,虚线是最优 \(\lambda\)。右:该 \(\lambda\) 下系数非零的基因,橙色是真正有效的 8 个,灰色是被误选的。稀疏方法在相关设计下的典型表现是找回大部分真信号、同时带进少量假阳性。 | Cross-validation and selection in the p ≫ n case. Left: the five-fold CV curve with the optimal \(\lambda\) marked. Right: genes with non-zero coefficients at that \(\lambda\); orange are the 8 truly associated, grey are false positives. Sparse methods under correlated designs typically recover most of the signal and admit a few false positives.

结果是:在 \(\lambda_{\min}\) 处选出 26 个基因,其中 8 个是真正的信号、18 个是假阳性、漏掉 0 个。改用”一倍标准误”规则(\(\lambda_{1se}\),图里那条点线)后,选出的基因降到 12 个,仍然包含 8 个真信号。这三组数字放在一起,正好说明后面的三点。

The result: at \(\lambda_{\min}\), 26 genes are selected — 8 true, 18 false positives, 0 missed. Switching to the one-standard-error rule (\(\lambda_{1se}\), the dotted line) shrinks the set to 12 genes, still containing 8 of the true ones. Those numbers set up the three points below.

第一,假阳性不是 bug。LASSO 的理论保证是在预测意义上(\(\lambda\) 选得好时预测误差接近最优),不是”选出的集合等于真实集合”。后者需要更强的条件(不可表示条件、信号足够强)和更专门的方法(stability selection、knockoff)。第二,\(\lambda\) 的取法直接决定结论:lambda.min 优化的是预测误差,因此倾向多留变量;lambda.1se 选的是”误差在最小值一个标准误以内最简洁的模型”,通常更少、更稳。两者都对,取决于你要预测还是要解释。第三,最重要的一点——真实数据里没有真值可比,所以应该报告”选出的基因是否富集在已知通路”,而不是宣称找回了”正确的”基因。

First, false positives are not a bug. LASSO’s guarantee is about prediction error (near-optimal when \(\lambda\) is chosen well), not “the selected set equals the true set”. The latter needs stronger conditions and dedicated machinery (stability selection, knockoffs). Second, how you pick \(\lambda\) decides the conclusion: lambda.min optimises prediction error and therefore keeps more variables; lambda.1se picks the simplest model within one standard error of the minimum, which is usually smaller and more stable. Both are right — it depends on whether you want prediction or explanation. Third, and most important, real data give you no ground truth — so report whether the selected genes enrich for known pathways instead of claiming to have found “the right” ones.

对应到包:lm() 与 glm() 是不带惩罚的基线;glmnet 覆盖岭、LASSO、弹性网及其广义线性版本(family = "binomial" 就是惩罚逻辑回归);ncvreg、grpreg 处理非凸惩罚与分组结构。日常一个 glmnet 就够。

The package mapping: lm() and glm() are the unpenalised baselines; glmnet covers ridge, LASSO, elastic net and their generalised-linear versions (family = "binomial" gives penalised logistic regression); ncvreg and grpreg handle non-convex penalties and group structure. In daily work glmnet alone is enough.

4. 分类:从概率到边界 / Classification: from probabilities to boundaries

分类要预测的不是一个数,而是一个概率或一个类别。这一节按”模型怎么定义边界”来分:逻辑回归给出线性边界,KNN 给出局部边界,SVM 给出最大间隔边界。三种思路对应三种归纳偏置,没有哪一种普遍更好。

Classification predicts a probability or a label, not a number. This section groups methods by how they define the boundary: logistic regression gives a linear boundary, KNN a local one, and SVM a maximum-margin one. Three inductive biases, none universally better.

4.1 逻辑回归:把线性输出压成概率 / Logistic regression: squashing a linear output into a probability

先用线性函数算一个分数 \(z=w^\top x+b\),再用 sigmoid 把它压进 \((0,1)\):

Compute a score \(z=w^\top x+b\), then squash it into \((0,1)\) with the sigmoid:

\[p(y=1\mid x)=\sigma(z)=\frac{1}{1+e^{-z}},\qquad \mathcal{L}(w)=-\sum_{i=1}^{n}\Big[y_i\log p_i+(1-y_i)\log(1-p_i)\Big]\]

这个损失叫对数损失(log-loss),也叫交叉熵。为什么不用平方损失?因为 \(\sigma\) 在 \(|z|\) 很大时几乎水平,平方损失对 \(z\) 的梯度会趋近于 0——预测错得最离谱的时候反而学不动。对数损失对 \(z\) 的梯度是 \((\sigma(z)-y)\),错得越远梯度越大,正好补上这一点。

This loss is the log-loss, or cross-entropy. Why not squared error? Because \(\sigma\) is nearly flat for large \(|z|\), so the gradient of a squared loss with respect to \(z\) vanishes — the model learns slowest exactly when it is most wrong. Log-loss has gradient \((\sigma(z)-y)\) with respect to \(z\): the further off, the larger the push.

求解用迭代重加权最小二乘(IRLS),本质是牛顿法;glm() 一行就够。系数有直接的解释:\(e^{\beta_j}\) 是 \(x_j\) 每增加一个单位时的优势比(odds ratio)。对医学读者,这比”特征重要性”有用得多——它给出方向和倍数。

The solver is iteratively reweighted least squares, i.e. Newton’s method; one glm() call does it. The coefficients read directly: \(e^{\beta_j}\) is the odds ratio for a one-unit increase in \(x_j\). For a clinical audience that beats “feature importance” — it gives a direction and a multiplier.

逻辑回归的边界是 \(w^\top x+b=0\),一个超平面。所以它能表达的只有”线性可分”的关系;要弯的边界,要么手工加交互项与样条,要么换下面的方法。

The boundary is \(w^\top x+b=0\), a hyperplane. So it can only express linearly separable structure; for a curved boundary you either add interactions and splines by hand, or change method.

4.2 KNN:不训练,只查邻居 / KNN: no training, just neighbours

KNN 把训练集原样存下来。预测新样本时,找距离最近的 \(k\) 个训练点,多数票决定类别:

KNN stores the training set as-is. To predict, find the \(k\) nearest training points and take a majority vote:

\[\hat y(x)=\mathrm{mode}\big\{y_i:\ x_i\in \mathcal{N}_k(x)\big\}\]

它估计的是 \(P(y=1\mid x)\) 在 \(x\) 附近的局部频率,所以 \(k\) 越大越平滑、偏差越大;\(k=1\) 时训练误差恒为 0,边界却随样本剧烈抖动。

It estimates the local frequency of \(P(y=1\mid x)\) near \(x\), so larger \(k\) means smoother and more biased; at \(k=1\) the training error is exactly zero while the boundary jitters with every sample.

Code
set.seed(20261101)
n_knn <- 320
Xk <- matrix(runif(n_knn * 2, -2, 2), ncol = 2)
prob <- plogis(2.6 * Xk[, 1] * Xk[, 2])
yk <- factor(ifelse(runif(n_knn) < prob, "same sign", "opposite"))

gx <- expand.grid(x = seq(-2.2, 2.2, length.out = 170), y = seq(-2.2, 2.2, length.out = 170))
ks <- c(1, 9, 51)
reg <- do.call(rbind, lapply(ks, function(k) {
  cl <- class::knn(train = Xk, test = as.matrix(gx), cl = yk, k = k)
  data.frame(gx, class = cl, k = paste0("k = ", k))
}))
reg$k <- factor(reg$k, levels = paste0("k = ", ks))
pts <- data.frame(x = Xk[, 1], y = Xk[, 2], cls = yk)

ggplot(reg, aes(x, y, fill = class)) +
  geom_raster(alpha = 0.55) +
  geom_point(data = pts, aes(x, y, colour = cls), size = 0.75, inherit.aes = FALSE) +
  scale_fill_manual(values = c(`same sign` = "#FBE3D9", `opposite` = "#DCE8F5"), guide = "none") +
  scale_colour_manual(values = c(`same sign` = ml_pal[["coral"]],
                                 `opposite` = ml_pal[["blue"]]), guide = "none") +
  facet_wrap(~ k) +
  coord_cartesian(xlim = c(-2.2, 2.2), ylim = c(-2.2, 2.2)) +
  plain
Figure 7: 同一个数据集、同一个距离度量,只改 \(k\)。真实规律是「两个变量同号 vs 异号」的四象限结构,并带有标签噪声。\(k=1\) 时边界被单个噪声点拉扯,出现碎片与孤岛;\(k=9\) 时已经接近真实的四象限;\(k=51\) 时过度平滑,象限结构只剩下粗略的东西分界。三张图用的是同一份训练数据。 | Same data, same distance, only \(k\) changes. The true rule is a four-quadrant pattern (same sign versus opposite signs of the two variables) with label noise. At \(k=1\) the boundary is dragged by individual noisy points and fragments into islands; at \(k=9\) it approaches the true quadrants; at \(k=51\) it is over-smoothed and only a crude left-right split survives. All three use the same training data.

KNN 有两个必须知道的限制。第一,必须先标准化:否则量纲大的变量会独占距离。第二,高维会失效:维度升高时,最近邻与最远邻的距离比趋近于 1,“最近的邻居”不再近,这就是所谓的维度灾难。500 个基因的距离几乎都一样,所以 KNN 在表达谱上通常表现很差——它适合低维、边界不规则的问题。

Two limits must be known. First, standardise first: otherwise the variable with the largest scale owns the distance. Second, high dimensions break it: as dimension grows, the ratio of nearest to farthest distance approaches 1, so the “nearest” neighbour stops being near — the curse of dimensionality. With 500 genes all distances look alike, which is why KNN usually does badly on expression data; it suits low-dimensional problems with irregular boundaries.

4.3 SVM:把边界推到最宽 / SVM: push the boundary as far as it goes

同样找线性边界,SVM 加了一条要求:边界到两类最近点之间的间隔要最大。写成优化问题是

SVM also seeks a linear boundary, with one extra requirement: maximise the margin between the boundary and the nearest points of each class. As an optimisation problem:

\[\min_{w,b}\ \tfrac{1}{2}\|w\|^2\quad\text{s.t.}\quad y_i(w^\top x_i+b)\ge 1\ \ \forall i\]

约束里取等号的点就是支持向量,边界完全由它们决定,其余样本删掉结果不变——这是 SVM 与逻辑回归最本质的差别:它只看”最难分的那些点”。允许少量越界时把约束放松,等价于用 hinge 损失 \(\max(0,1-y f(x))\) 代替 log-loss 做正则化经验风险最小化。

The points where the constraint is tight are the support vectors; the boundary is determined entirely by them, and deleting the other samples changes nothing. That is the essential difference from logistic regression: it looks only at the hardest points. Allowing some violations relaxes the constraint and is equivalent to using the hinge loss \(\max(0,1-y f(x))\) inside regularised empirical risk minimisation.

Code
set.seed(20261101)
Xs <- matrix(rnorm(120), 60, 2)
ys <- factor(ifelse(Xs[, 1] + Xs[, 2] + rnorm(60, 0, 0.25) > 0, "up", "down"),
             levels = c("down", "up"))
fit_lin <- e1071::svm(Xs, ys, kernel = "linear", cost = 50, scale = FALSE)

gd <- expand.grid(V1 = seq(-3, 3, length.out = 150), V2 = seq(-3, 3, length.out = 150))
gd$dv <- as.numeric(attr(predict(fit_lin, gd, decision.values = TRUE), "decision.values"))
sv <- data.frame(V1 = fit_lin$SV[, 1], V2 = fit_lin$SV[, 2])
lin_df <- data.frame(V1 = Xs[, 1], V2 = Xs[, 2], cls = ys)

p_lin <- ggplot(gd, aes(V1, V2, z = dv)) +
  geom_contour(breaks = -1, colour = ml_pal[["grey"]], linetype = "22", linewidth = 0.45) +
  geom_contour(breaks = 1, colour = ml_pal[["grey"]], linetype = "22", linewidth = 0.45) +
  geom_contour(breaks = 0, colour = ml_pal[["ink"]], linewidth = 0.55) +
  geom_point(data = lin_df, aes(V1, V2, colour = cls),
             size = 1.7, inherit.aes = FALSE) +
  geom_point(data = sv, aes(V1, V2), shape = 1, size = 4.2,
             colour = ml_pal[["ink"]], stroke = 0.7, inherit.aes = FALSE) +
  scale_colour_manual(values = c(down = ml_pal[["blue"]], up = ml_pal[["coral"]]),
                      guide = "none") +
  coord_cartesian(xlim = c(-3, 3), ylim = c(-3, 3)) +
  labs(title = "linear kernel") +
  theme(axis.text = element_text(size = 7.5))

moon <- function(n, noise = 0.09) {
  n1 <- n %/% 2; n2 <- n - n1
  t1 <- runif(n1, 0, pi); t2 <- runif(n2, 0, pi)
  x <- rbind(cbind(cos(t1) - 0.5, sin(t1) - 0.3),
             cbind(1 - cos(t2) - 0.5, 1 - sin(t2) - 1.1))
  x + matrix(rnorm(length(x), 0, noise), ncol = 2)
}
Xm <- moon(200)
ym <- factor(rep(c("A", "B"), c(100, 100)))
fit_rbf <- e1071::svm(Xm, ym, kernel = "radial", cost = 10, gamma = 2, scale = FALSE)

gm <- expand.grid(V1 = seq(-2.1, 1.6, length.out = 150), V2 = seq(-1.3, 1.0, length.out = 150))
gm$dv <- as.numeric(attr(predict(fit_rbf, gm, decision.values = TRUE), "decision.values"))
svm_df <- data.frame(V1 = Xm[, 1], V2 = Xm[, 2], cls = ym)

p_rbf <- ggplot(gm, aes(V1, V2, z = dv)) +
  geom_contour(breaks = -1, colour = ml_pal[["grey"]], linetype = "22", linewidth = 0.45) +
  geom_contour(breaks = 1, colour = ml_pal[["grey"]], linetype = "22", linewidth = 0.45) +
  geom_contour(breaks = 0, colour = ml_pal[["ink"]], linewidth = 0.55) +
  geom_point(data = svm_df, aes(V1, V2, colour = cls), size = 1.2, inherit.aes = FALSE) +
  scale_colour_manual(values = c(A = ml_pal[["blue"]], B = ml_pal[["coral"]]),
                      guide = "none") +
  coord_cartesian(xlim = c(-2.1, 1.6), ylim = c(-1.3, 1.0)) +
  labs(title = "RBF kernel") +
  theme(axis.text = element_text(size = 7.5))

p_lin + p_rbf
Figure 8: 左:线性核的间隔。实线是决策边界,两条虚线是间隔边界(决策值 \(\pm1\)),圈出的点是支持向量——把其他点全删掉,边界不变。右:换成 RBF 核之后,同一个算法在两个交错的半月形上给出弯曲边界,而不需要手工造特征。 | Left: a linear-kernel margin. The solid line is the boundary, the dashed lines are the margin edges (decision value \(\pm1\)), and the circled points are support vectors — delete every other point and the boundary does not move. Right: with an RBF kernel the same algorithm produces a curved boundary on two interleaved moons, with no hand-built features.

右图之所以可能,靠的是核技巧:把内积 \(x_i^\top x_j\) 换成核函数 \(k(x_i,x_j)\),等价于把数据映射到一个更高维的空间再求线性边界,但从不真的算那个映射。RBF 核 \(k(x,x')=\exp(-\gamma\|x-x'\|^2)\) 对应无穷维特征空间。代价是核矩阵是 \(n\times n\),样本量大时显存和时间都吃不消——这也是大样本问题上 SVM 不如树集成流行的原因之一。

The right panel is possible because of the kernel trick: replace the inner product \(x_i^\top x_j\) with a kernel \(k(x_i,x_j)\), which is equivalent to mapping into a higher-dimensional space and separating there, without ever computing the map. The RBF kernel \(k(x,x')=\exp(-\gamma\|x-x'\|^2)\) corresponds to an infinite-dimensional feature space. The price is an \(n\times n\) kernel matrix, which on large samples costs both memory and time — one reason SVMs lost ground to tree ensembles as datasets grew.

实现上,e1071::svm 是最常用的接口(分类、回归、一类判别都有),底层是 libsvm;kernlab 提供更”R 风格”的公式接口和更多核;线性核的大样本情形可以用 LiblineaR。调参主要是 cost 与 gamma 两个:cost 控制容忍多少越界,gamma 控制边界的弯曲程度。

In practice e1071::svm is the usual interface (classification, regression, one-class), wrapping libsvm; kernlab offers a more R-idiomatic formula interface and more kernels; for large samples with a linear kernel, LiblineaR is faster. Tuning is mostly two numbers: cost controls how many violations are tolerated, gamma controls how curved the boundary gets.

4.4 判别分析:一类被低估的线性方法 / Discriminant analysis: an underrated linear method

MASS::lda() 走的是另一条路:假设每一类内部服从多元正态、协方差相同,用贝叶斯公式反推后验概率。数学上它和逻辑回归给出的线性边界几乎一样,差别在估计方式:逻辑回归是判别式的(直接拟合 \(P(y\mid x)\)),LDA 是生成式的(先拟合 \(P(x\mid y)\) 再颠倒)。当类别分得很开、或样本量小、或 \(p\) 接近 \(n\) 时,生成式假设反而更省数据;当正态假设明显不成立时,逻辑回归更稳。qda() 放松”协方差相同”,得到二次边界,参数更多。

MASS::lda() takes another route: assume each class is multivariate normal with a shared covariance, then invert Bayes to get the posterior. Mathematically the resulting boundary is nearly the same linear one as logistic regression; the difference is how it is estimated. Logistic regression is discriminative (fit \(P(y\mid x)\) directly), LDA is generative (fit \(P(x\mid y)\) and invert). When classes are well separated, samples are few, or \(p\) approaches \(n\), the generative assumption can be more data-efficient; when normality clearly fails, logistic regression is safer. qda() drops the shared covariance and yields a quadratic boundary with more parameters.

4.5 同一个测试集上比一比 / Four models on one test set

理论说完,看数字。用 Pima 的 200 个训练样本拟合四种模型,在 332 个独立测试样本上算 ROC 曲线与 AUC。所有预处理参数只用训练集估计——这一点第 7 章会专门讲。

Enough theory; look at numbers. Fit four models on the 200 Pima training cases and evaluate ROC and AUC on the 332 held-out cases. Every preprocessing parameter is estimated on the training set only — section 7 returns to why.

Code
tr <- MASS::Pima.tr
te <- MASS::Pima.te
num <- names(tr)[1:7]
mu <- sapply(tr[num], mean); sdev <- sapply(tr[num], sd)
trz <- as.data.frame(scale(tr[num], mu, sdev)); trz$type <- tr$type
tez <- as.data.frame(scale(te[num], mu, sdev)); tez$type <- te$type

m_glm <- glm(type ~ ., data = trz, family = binomial)
p_glm <- predict(m_glm, tez, type = "response")

knn_out <- class::knn(train = trz[num], test = tez[num], cl = tr$type, k = 15, prob = TRUE)
kp <- attr(knn_out, "prob")
p_knn <- ifelse(knn_out == "Yes", kp, 1 - kp)

m_svm <- e1071::svm(type ~ ., data = trz, kernel = "radial",
                    cost = 1, gamma = 0.08, probability = TRUE)
p_svm <- attr(predict(m_svm, tez, probability = TRUE), "probabilities")[, "Yes"]

m_rf <- ranger::ranger(type ~ ., data = trz, probability = TRUE,
                       num.trees = 500, seed = 1)
p_rf <- predict(m_rf, tez)$predictions[, "Yes"]

roc_df <- function(score, truth, label) {
  o <- order(score, decreasing = TRUE)
  hit <- as.integer(truth[o] == "Yes")
  tp <- cumsum(hit); fp <- cumsum(1 - hit)
  data.frame(fpr = c(0, fp / sum(1 - hit)), tpr = c(0, tp / sum(hit)), model = label)
}
auc_of <- function(d) sum(diff(d$fpr) * (head(d$tpr, -1) + tail(d$tpr, -1)) / 2)

aucs <- c(
  logistic = auc_of(roc_df(p_glm, tez$type, "x")),
  knn      = auc_of(roc_df(p_knn, tez$type, "x")),
  svm      = auc_of(roc_df(p_svm, tez$type, "x")),
  rf       = auc_of(roc_df(p_rf,  tez$type, "x"))
)
lab <- sprintf("%s (AUC %.3f)", names(aucs), aucs)

roc_all <- rbind(
  roc_df(p_glm, tez$type, lab[1]),
  roc_df(p_knn, tez$type, lab[2]),
  roc_df(p_svm, tez$type, lab[3]),
  roc_df(p_rf,  tez$type, lab[4])
)
roc_all$model <- factor(roc_all$model, levels = lab)

ggplot(roc_all, aes(fpr, tpr, colour = model)) +
  geom_abline(slope = 1, intercept = 0, linetype = "22",
              colour = ml_pal[["grey"]], linewidth = 0.4) +
  geom_step(linewidth = 0.75) +
  scale_colour_manual(values = unname(c(ml_pal[c("blue", "teal", "orange", "coral")])),
                      name = NULL) +
  coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
  labs(x = "false positive rate", y = "true positive rate") +
  theme(legend.position = c(0.62, 0.18), legend.text = element_text(size = 8),
        legend.background = element_rect(fill = "white", colour = NA))
Figure 9: 四个模型在同一个测试集上的 ROC。对角线是随机猜测。最朴素的逻辑回归反而最高(AUC 0.866),三个更复杂的模型都落在 0.83 附近,彼此相差不到 0.02。在 332 个测试样本上,这个量级的差别与置信区间同阶——它说明的是「复杂度没有换来提升」,而不是「逻辑回归更好」。 | ROC for four models on the same test set. The diagonal is chance. The plainest model, logistic regression, comes out highest (AUC 0.866); the three more complex ones sit near 0.83, within 0.02 of each other. On 332 test cases a difference of this size is the same order as the confidence interval — the figure says that complexity bought nothing, not that logistic regression is better.

四个模型的 AUC 分别是 0.866(逻辑回归)、0.826(KNN)、0.840(SVM)和 0.825(随机森林)。结果有点反直觉:最朴素的那个赢了。三个更复杂的模型挤在 0.83 附近,彼此相差不到 0.02,而逻辑回归高出约 0.04。

The AUCs are 0.866 (logistic regression), 0.826 (KNN), 0.840 (SVM) and 0.825 (random forest). The result is mildly counter-intuitive: the plainest model wins. The three complex ones cluster near 0.83 within 0.02 of each other, while logistic regression is about 0.04 ahead.

在下结论之前先算一下误差:332 个测试样本、阳性约占三分之一时,AUC 的标准误大约在 0.02–0.03 之间。所以 0.866 与 0.840 的差别只能算”可能”,不能算”证实”;真正稳妥的结论是这一句话:在这个数据上,复杂模型没有带来提升。

Before concluding, do the arithmetic: with 332 test cases and about a third of them positive, the standard error of an AUC is roughly 0.02–0.03. So the gap between 0.866 and 0.840 is “possible”, not “established”; what the data do support is the weaker statement: on this problem, complexity brought no gain.

这不是”随机森林不好”,而是模型复杂度要匹配数据的信息量。同样的随机森林放到 500 个基因、800 个样本的表达谱上,或者放到有强交互的表格上,就会明显领先。所以看到”某模型 AUC 更高”时,第一个问题永远是:在什么数据上、样本量多少、差别有没有超过置信区间。

This is not “random forests are bad” but that model complexity must match the information in the data. The same forest on 500 genes and 800 samples, or on a table with strong interactions, will lead clearly. So when you read “model X has higher AUC”, the first question is always: on what data, at what sample size, and is the gap larger than the confidence interval.

5. 树与集成:分段常数为什么这么能打 / Trees and ensembles

5.1 CART:递归地把空间切成方块 / CART: recursively cut the space into boxes

决策树假设 \(f\) 是分段常数:把特征空间切成若干矩形,每个矩形里给一个常数。切法是递归的——每次在当前区域内选一个变量和一个阈值,把区域一分为二。

A decision tree assumes \(f\) is piecewise constant: cut the feature space into boxes and assign one constant per box. The cutting is recursive — at each step choose one variable and one threshold, and split the current region in two.

切在哪里的判据是”不纯度下降最多”。分类问题常用 Gini 指数

The criterion is the largest drop in impurity. For classification the usual choice is the Gini index

\[G(\text{node})=\sum_{k=1}^{K}\hat p_k\,(1-\hat p_k)=1-\sum_{k=1}^{K}\hat p_k^2, \qquad \Delta G=G(\text{parent})-\frac{n_L}{n}G_L-\frac{n_R}{n}G_R\]

回归树则用平方误差的减少量。两者都是贪心的:每一步只看当前最优分裂,不回溯。正因为贪心,树很便宜(\(O(np\log n)\) 量级),也正因为贪心,它极不稳定——训练集里挪动几个点,整棵树可能长得完全不同。

Regression trees use the reduction in squared error. Both are greedy: each step takes the locally best split with no backtracking. Greedy is why trees are cheap (roughly \(O(np\log n)\)) and why they are unstable — move a few points and the whole tree can change.

Code
set.seed(20261101)
tr2 <- MASS::Pima.tr[, c("glu", "bmi", "type")]
fit_tree <- rpart::rpart(type ~ ., data = tr2, method = "class",
                         control = rpart::rpart.control(maxdepth = 3, cp = -0.01,
                                                       minsplit = 5, minbucket = 2))

grid <- expand.grid(glu = seq(55, 200, length.out = 160),
                    bmi = seq(15, 50, length.out = 160))
grid$pred <- predict(fit_tree, grid, type = "class")

pA <- ggplot(grid, aes(glu, bmi, fill = pred)) +
  geom_raster(alpha = 0.45) +
  geom_point(data = tr2, aes(glu, bmi, colour = type), size = 0.9, inherit.aes = FALSE) +
  scale_fill_manual(values = c(No = "#DCE8F5", Yes = "#FBE3D9"), guide = "none") +
  scale_colour_manual(values = c(No = ml_pal[["blue"]], Yes = ml_pal[["coral"]]),
                      name = NULL) +
  coord_cartesian(xlim = c(55, 200), ylim = c(15, 50)) +
  theme(legend.position = "bottom", legend.text = element_text(size = 8),
        axis.text = element_text(size = 7))

fr <- fit_tree$frame
fr$id <- as.integer(rownames(fr))
fr$d  <- floor(log2(fr$id))
fr$px <- (fr$id - 2^fr$d + 0.5) / 2^fr$d
fr$py <- -fr$d
fr$leaf <- fr$`var` == "<leaf>"
lev <- levels(tr2$type)
fr$lab <- ifelse(fr$leaf,
                 sprintf("%s\nn=%d", lev[fr$yval], fr$n),
                 as.character(fr$`var`))
fr$kind <- ifelse(fr$leaf, lev[fr$yval], "split")

edg <- data.frame(id = fr$id[fr$id > 1])
edg$par <- edg$id %/% 2
edg$x    <- fr$px[match(edg$par, fr$id)]
edg$y    <- fr$py[match(edg$par, fr$id)]
edg$xend <- fr$px[match(edg$id, fr$id)]
edg$yend <- fr$py[match(edg$id, fr$id)]

pB <- ggplot() +
  geom_segment(data = edg, aes(x, y, xend = xend, yend = yend),
               colour = ml_pal[["grey"]], linewidth = 0.4) +
  geom_label(data = fr, aes(px, py, label = lab, fill = kind),
             size = 2.0, lineheight = 0.92, label.size = 0,
             label.padding = unit(0.10, "lines")) +
  scale_fill_manual(values = c(split = "#EDF2F7", No = "#DCE8F5", Yes = "#FBE3D9"),
                    guide = "none") +
  coord_cartesian(xlim = c(-0.02, 1.02), ylim = c(-max(fr$d) - 0.55, 0.35)) +
  plain

pA + pB + plot_layout(widths = c(1, 1.15))
Figure 10: 左:一棵深度 3 的树在 (glu, bmi) 平面上划出的区域,颜色是预测类别,点是训练样本。右:同一棵树的结构,内部节点标出用来分裂的变量,叶子给出类别与该叶子的样本数。分段常数的边界一定是横平竖直的矩形——这是树能表达什么、不能表达什么的根源。 | Left: the regions a depth-3 tree carves out in the (glu, bmi) plane, coloured by predicted class, with training points on top. Right: the same tree; internal nodes show the splitting variable, leaves show the class and the number of cases. Piecewise-constant boundaries are always axis-aligned rectangles — the root of what a tree can and cannot express.

这张图暴露了树的根本限制:边界只能横平竖直,要表达一条斜的边界得靠很多个小方块去逼近。好处是尺度不变(换单位不影响分裂)、能自动处理交互(右图每条从根到叶的路径就是一个”且”条件)、且对单个特征里的离群点不敏感。坏处是单棵树方差极大——这正是下一节要解决的问题。

The figure exposes the fundamental limit: boundaries can only be axis-aligned, so a diagonal boundary needs many small boxes to approximate. The benefits are scale invariance (changing units does not change the splits), automatic interactions (each root-to-leaf path is a conjunction of conditions), and robustness to outliers in a single feature. The cost is that one tree has enormous variance — which is what the next section fixes.

5.2 Bagging 与随机森林:用平均换方差 / Bagging and random forests: trading variance for an average

如果单棵树方差大,最直接的办法是训练很多棵,然后平均。Bagging 的做法是:对训练集做 \(B\) 次有放回抽样,每次训一棵深树,预测时取平均。

If one tree has high variance, the direct fix is to train many and average. Bagging draws \(B\) bootstrap samples of the training set, fits a deep tree on each, and averages at prediction time.

\[\hat f_{\text{bag}}(x)=\frac{1}{B}\sum_{b=1}^{B}\hat f_b(x)\]

方差的变化有个简洁的公式。设单棵树的方差是 \(\sigma^2\)、任意两棵树之间的相关系数是 \(\rho\),则平均后方差为

The variance has a compact formula. With single-tree variance \(\sigma^2\) and correlation \(\rho\) between any two trees, the averaged variance is

\[\mathrm{Var}\big[\hat f_{\text{bag}}\big]=\rho\,\sigma^2+\frac{1-\rho}{B}\,\sigma^2\]

第二项随 \(B\) 增大趋于零,第一项不会——这就是随机森林存在的理由:光靠加树,方差会被树之间的相关性卡住。随机森林在每次分裂时只从 \(p\) 个变量里随机抽 \(m\approx\sqrt p\) 个候选,人为降低 \(\rho\),把第一项也压下去。

The second term vanishes as \(B\) grows; the first does not — which is why random forests exist. Adding trees alone leaves variance stuck at the correlation floor. A random forest draws only \(m\approx\sqrt p\) candidate variables at each split, deliberately lowering \(\rho\) so the first term shrinks too.

Code
set.seed(20261101)
true_f <- function(x) 0.8 * sin(2 * pi * x) + 0.3 * x
xgrid <- seq(0, 1, length.out = 200)
n_tr <- 60; R <- 25; B <- 30
ctrl <- rpart::rpart.control(maxdepth = 5, cp = 0.001, minsplit = 5)

single <- matrix(NA_real_, R, length(xgrid))
bagged <- single
for (r in seq_len(R)) {
  x <- runif(n_tr); y <- true_f(x) + rnorm(n_tr, 0, 0.35)
  d <- data.frame(x = x, y = y)
  single[r, ] <- predict(rpart::rpart(y ~ x, data = d, control = ctrl),
                         data.frame(x = xgrid))
  boot <- matrix(NA_real_, B, length(xgrid))
  for (b in seq_len(B)) {
    db <- d[sample(n_tr, replace = TRUE), ]
    boot[b, ] <- predict(rpart::rpart(y ~ x, data = db, control = ctrl),
                         data.frame(x = xgrid))
  }
  bagged[r, ] <- colMeans(boot)
}
var_single <- mean(apply(single, 2, var))
var_bag    <- mean(apply(bagged, 2, var))

pd <- rbind(
  data.frame(x = xgrid, v = sqrt(apply(single, 2, var)), model = "one tree",
             q = "sd across replications"),
  data.frame(x = xgrid, v = sqrt(apply(bagged, 2, var)), model = "bagged, 30 trees",
             q = "sd across replications"),
  data.frame(x = xgrid, v = abs(colMeans(single) - true_f(xgrid)), model = "one tree",
             q = "|bias|"),
  data.frame(x = xgrid, v = abs(colMeans(bagged) - true_f(xgrid)), model = "bagged, 30 trees",
             q = "|bias|")
)
pd$model <- factor(pd$model, levels = c("one tree", "bagged, 30 trees"))

ggplot(pd, aes(x, v, colour = model)) +
  geom_line(linewidth = 0.7) +
  facet_wrap(~ q, scales = "free_y") +
  scale_colour_manual(values = c("one tree" = ml_pal[["grey"]],
                                 "bagged, 30 trees" = ml_pal[["teal"]]), name = NULL) +
  labs(x = "x", y = NULL) +
  theme(legend.position = "bottom", legend.text = element_text(size = 8))
Figure 11: 重复 25 次独立抽样,在 200 个测试点上分别估计两件事。左:预测的标准差(方差的平方根),单棵树明显更抖,平均 30 棵之后降到约七成。右:偏差的绝对值,两条曲线几乎重合——平均没有改变偏差。纵轴各自独立,因为要看的是同侧两条曲线的相对位置。 | Twenty-five independent samples, with two quantities estimated at 200 test points. Left: the standard deviation of the prediction; a single tree is visibly noisier and averaging 30 trees cuts it to about seventy percent. Right: absolute bias, where the two curves nearly coincide — averaging does not change bias. The vertical axes are independent because the comparison of interest is between the two curves within each panel.

在同一批测试点上,单棵树预测的方差是 0.072,30 棵树平均之后降到 0.040,只剩原来的 55%;而偏差那两条线几乎叠在一起。平均只动方差,不动偏差,正是偏差-方差分解的直接推论。

On the same test grid the prediction variance of a single tree is 0.072; averaging 30 trees reduces it to 0.040, i.e. 55% of the original, while the two bias curves virtually overlap. Averaging touches variance, not bias, exactly as the bias–variance decomposition says.

注意这个下降只有一半左右,而且再加树也不会继续降。这正是公式里 \(\rho\sigma^2\) 那一项在说话:同一批训练数据上长出的树高度相关,\(\rho\) 不因为 \(B\) 变大而变小。随机森林的 mtry 就是专门用来降 \(\rho\) 的旋钮——每棵树只看随机的一部分变量,树之间才会真正”意见不同”。

Note the drop is only around half, and adding more trees will not push it further. That is the \(\rho\sigma^2\) term talking: trees grown on the same training data are highly correlated, and \(\rho\) does not shrink as \(B\) grows. The mtry knob in a random forest exists precisely to lower \(\rho\) — each tree sees a random subset of variables, so the trees genuinely disagree.

这也意味着 bagging 不会救一个偏差很大的模型。如果单棵树太浅(只切两刀),平均一千棵还是那两刀。所以随机森林用的都是深树(低偏差、高方差),靠平均把方差压回来。

That also means bagging cannot rescue a badly biased model. If each tree is too shallow (two cuts only), averaging a thousand of them gives the same two cuts. Random forests therefore use deep trees — low bias, high variance — and buy the variance back with averaging.

5.3 梯度提升:一再加一点点 / Gradient boosting: add a little, repeatedly

Boosting 反过来做:不并行训练很多棵,而是串行地一棵接一棵,每棵只负责修正前面所有树的残差。

Boosting works the other way: instead of training many trees in parallel, it fits them in sequence, each one correcting the residual left by all previous trees.

\[f_0=\bar y,\qquad f_m=f_{m-1}+\eta\, h_m,\qquad h_m\approx\arg\min_h\sum_i\big(r_i^{(m)}-h(x_i)\big)^2,\quad r_i^{(m)}=y_i-f_{m-1}(x_i)\]

\(\eta\) 是学习率(收缩系数)。\(h_m\) 通常是很浅的树(深度 1–3),因为它只需要拟合”剩下的那部分”。这就是梯度提升:残差是平方损失下的负梯度,把损失换成别的(如 logistic)时,“拟合残差”就变成”拟合负梯度”,算法结构不变。

\(\eta\) is the learning rate (shrinkage). Each \(h_m\) is usually a very shallow tree (depth 1–3) because it only has to fit “what is left”. This is gradient boosting: the residual is the negative gradient under squared loss, and swapping the loss (to logistic, say) turns “fit the residual” into “fit the negative gradient” with the same structure.

Code
set.seed(20261101)
gb_true <- function(x) 0.9 * sin(2 * pi * x) + 0.4 * x
x <- sort(runif(80))
y <- gb_true(x) + rnorm(80, 0, 0.3)
xgr <- data.frame(x = seq(0, 1, length.out = 300))
ctrl1 <- rpart::rpart.control(maxdepth = 1, cp = 0, minsplit = 5)
eta <- 0.2; M <- 400
g <- rep(mean(y), nrow(xgr))
f <- rep(mean(y), length(x))
store <- list()
for (m in seq_len(M)) {
  r <- y - f
  h <- rpart::rpart(r ~ x, data = data.frame(x = x, r = r), control = ctrl1)
  f <- f + eta * predict(h, data.frame(x = x))
  g <- g + eta * predict(h, xgr)
  if (m %in% c(1, 5, 50, 400)) {
    store[[as.character(m)]] <- data.frame(x = xgr$x, y = g, stage = paste0("round ", m))
  }
}
gbdf <- do.call(rbind, store)
gbdf$stage <- factor(gbdf$stage,
                     levels = c("round 1", "round 5", "round 50", "round 400"))

ggplot(gbdf, aes(x, y, colour = stage)) +
  geom_point(data = data.frame(x = x, y = y), aes(x, y), inherit.aes = FALSE,
             colour = ml_pal[["grey"]], size = 0.9, alpha = 0.7) +
  geom_line(data = data.frame(x = xgr$x, y = gb_true(xgr$x)),
            aes(x, y), inherit.aes = FALSE,
            colour = ml_pal[["ink"]], linetype = "22", linewidth = 0.55) +
  geom_line(linewidth = 0.7) +
  scale_colour_manual(values = unname(ml_pal[c("blue", "teal", "orange", "coral")]),
                      name = NULL) +
  coord_cartesian(ylim = c(-1.7, 1.7)) +
  labs(x = "x", y = "y") +
  theme(legend.position = "bottom", legend.text = element_text(size = 8))
Figure 12: 梯度提升的逐步拟合。每一轮加一棵深度 1 的树,学习率 0.2。第 1 轮只能给出一个阶梯,第 5 轮已经抓住大致形状,第 50 轮贴住数据,第 400 轮开始把噪声也学了进去。树的数量在这里就是光滑度参数。 | Stagewise fitting in gradient boosting. One depth-1 tree is added per round with learning rate 0.2. After one round the fit is a single step; by round 5 it has the broad shape; by round 50 it tracks the data; by round 400 it starts fitting noise. The number of trees is a smoothness parameter here.

把这张图和上一张对照,能看到两种集成走的是相反方向:bagging 让每棵树都过拟合再用平均抵消方差;boosting 让每棵树都很弱再逐步累加降低偏差。所以随机森林调的是 mtry(树间相关性)与树深;boosting 调的是学习率与轮数,而且必须用早停——第 400 轮那条线已经在学噪声。

Compare this with the previous figure and the two ensembles move in opposite directions: bagging lets every tree overfit and cancels the variance by averaging; boosting keeps every tree weak and reduces bias by accumulation. So a forest tunes mtry (tree correlation) and depth, while boosting tunes the learning rate and the number of rounds — and needs early stopping, because the round-400 curve is already learning noise.

5.4 变量重要性与部分依赖:集成模型怎么解释 / Importance and partial dependence

森林和提升机不再有系数可读,但仍有两个可用的解释工具。变量重要性统计每个变量在所有分裂里带来的不纯度下降总和;部分依赖图(PDP)把某个变量强行取遍一组值、其余变量保持原样,看平均预测怎么变。

Forests and boosted models have no coefficients to read, but two tools still work. Variable importance sums the impurity decrease each variable contributes across all splits; a partial dependence plot forces one variable through a range of values while holding the others as they are, and tracks the average prediction.

Code
set.seed(20261101)
rf_full <- ranger::ranger(type ~ ., data = MASS::Pima.tr, importance = "impurity",
                          probability = TRUE, num.trees = 500, seed = 1)
imp <- data.frame(var = names(rf_full$variable.importance),
                  value = as.numeric(rf_full$variable.importance))
imp$var <- factor(imp$var, levels = imp$var[order(imp$value)])

p_imp <- ggplot(imp, aes(value, var)) +
  geom_col(fill = ml_pal[["blue"]], width = 0.62) +
  labs(x = "impurity decrease", y = NULL, title = "importance") +
  theme(axis.text = element_text(size = 7.5))

pd_of <- function(v, gr) {
  sapply(gr, function(g) {
    d <- MASS::Pima.tr
    d[[v]] <- g
    mean(predict(rf_full, d)$predictions[, "Yes"])
  })
}
pd_glu <- data.frame(value = seq(60, 190, by = 10))
pd_glu$p <- pd_of("glu", pd_glu$value)
pd_bmi <- data.frame(value = seq(20, 45, by = 2.5))
pd_bmi$p <- pd_of("bmi", pd_bmi$value)

p_glu <- ggplot(pd_glu, aes(value, p)) +
  geom_line(colour = ml_pal[["orange"]], linewidth = 0.8) +
  geom_point(colour = ml_pal[["orange"]], size = 1.4) +
  labs(x = "glu", y = "P(diabetes)", title = "partial dependence: glu") +
  theme(axis.text = element_text(size = 7.5))

p_bmi <- ggplot(pd_bmi, aes(value, p)) +
  geom_line(colour = ml_pal[["teal"]], linewidth = 0.8) +
  geom_point(colour = ml_pal[["teal"]], size = 1.4) +
  labs(x = "bmi", y = "P(diabetes)", title = "partial dependence: bmi") +
  theme(axis.text = element_text(size = 7.5))

p_imp + p_glu + p_bmi + plot_layout(widths = c(1.05, 1, 1))
Figure 13: 左:随机森林在不纯度意义下的变量重要性,血糖(glu)遥遥领先。中、右:glu 与 bmi 的部分依赖曲线,纵轴是预测为糖尿病的平均概率。注意 PDP 是平均效应,当变量与其他变量有强交互时,它可能掩盖掉个体层面的相反趋势。 | Left: impurity-based importance from a random forest; glucose (glu) leads by a wide margin. Middle and right: partial dependence for glu and bmi, with the vertical axis showing the average predicted probability of diabetes. Note that a PDP is an average effect; under strong interactions it can hide opposite trends at the individual level.

不纯度重要性有一个已知偏差:偏好取值多的变量。连续变量有无数个切点,随机森林更容易在它上面找到”看起来有用”的分裂,所以它的分数天然偏高。更可靠的做法是置换重要性(importance = "permutation"):打乱一个变量的取值,看模型性能掉多少。代价是要多跑几遍预测,但结论更接近”这个变量对预测真的有用”。

Impurity importance has a known bias: it favours variables with many distinct values. A continuous variable offers countless cut points, so a forest more easily finds a split that looks useful, and its score inflates. The more reliable option is permutation importance (importance = "permutation"): shuffle one variable and measure how much performance drops. It costs extra prediction passes but answers a question closer to “does this variable actually help predict”.

5.5 四个包的取舍 / Choosing among four packages

包 / Package 能做什么 / What it does 优点 / Strength 注意 / Caveat
rpart 单棵 CART,分类与回归 可解释、能画树、带代价矩阵 方差大,别单独用来预测
ranger 随机森林(C++ 后端) 快、支持 OOB 与 case weights、内存省 分类概率需校准
xgboost 梯度提升 正则化目标、早停、原生缺失值处理、SHAP 参数多,调参成本高
gbm 梯度提升(较老) 接口简单、支持分布族 慢,社区活跃度低

English

Package What it does Strength Caveat
rpart single CART tree, classification and regression interpretable, plottable, cost matrices high variance; do not use alone for prediction
ranger random forest (C++ backend) fast, out-of-bag estimates, case weights probabilities need calibration
xgboost gradient boosting regularised objective, early stopping, native missing values, SHAP many hyperparameters
gbm gradient boosting (older) simple interface, many distributions slow, less maintained

在表格数据上,xgboost 或 ranger 通常是第一个该试的模型:它们对特征缩放不敏感、能自动处理交互、对缺失值宽容。但它们也最容易被误用——不是模型太强,而是调参与验证没做对(第 7 章)。

On tabular data, xgboost or ranger is usually the first model to try: insensitive to feature scaling, automatic interactions, tolerant of missing values. They are also the easiest to misuse — not because the model is too strong, but because tuning and validation were not done properly (section 7).

6. 无监督:没有标签时在优化什么 / Unsupervised learning: what is optimised without labels

没有 \(y\),就没有”预测得对不对”。无监督方法只能优化数据自身的结构:点与点之间的距离、簇内的紧致程度、方差的方向。理解这一点,就不会指望它给出”正确答案”。

Without \(y\) there is no “predicted correctly”. Unsupervised methods can only optimise structure in the data itself: distances between points, tightness within clusters, directions of variance. Keep that in mind and you will not expect a “correct answer”.

6.1 K-means:最小化簇内平方和 / K-means: minimise within-cluster sum of squares

K-means 的目标函数很朴素:把 \(n\) 个点分成 \(K\) 簇,让每簇内部到中心的平方距离之和最小。

The objective is plain: split \(n\) points into \(K\) clusters so that the total squared distance to the cluster centres is minimal.

\[\min_{\{C_k\},\{\mu_k\}}\ \sum_{k=1}^{K}\sum_{i\in C_k}\|x_i-\mu_k\|^2\]

求解用 Lloyd 算法,两步交替:分配(每个点归到最近的中心)与更新(每个中心移到所属点的均值)。两步都让目标函数单调下降,所以一定收敛——但只保证收敛到局部最优,结果依赖初始化,这也是 nstart 参数存在的原因。

The solver is Lloyd’s algorithm, alternating two steps: assign each point to the nearest centre, then update each centre to the mean of its members. Both steps decrease the objective, so it always converges — but only to a local optimum, which depends on the initialisation. That is why nstart exists.

Code
set.seed(20261101)
X <- rbind(
  MASS::mvrnorm(80, c(-2.2, -1.0), matrix(c(1, 0.5, 0.5, 1), 2)),
  MASS::mvrnorm(80, c( 2.2,  0.6), matrix(c(1, -0.3, -0.3, 0.8), 2)),
  MASS::mvrnorm(80, c( 0.1,  2.8), matrix(c(0.7, 0, 0, 0.7), 2))
)
cent <- X[sample(nrow(X), 3), ]
snap <- list()
for (it in 1:6) {
  D <- as.matrix(dist(rbind(X, cent)))
  cl <- max.col(-D[1:nrow(X), nrow(X) + 1:3])
  for (k in 1:3) cent[k, ] <- colMeans(X[cl == k, , drop = FALSE])
  if (it %in% c(1, 2, 6)) {
    snap[[length(snap) + 1]] <- list(cl = cl, cent = cent, it = it)
  }
}
panels <- lapply(snap, function(s) {
  ggplot() +
    geom_point(aes(x = X[, 1], y = X[, 2], colour = factor(s$cl)), size = 0.9, alpha = 0.75) +
    geom_point(aes(x = s$cent[, 1], y = s$cent[, 2]), shape = 4, size = 3.4,
               stroke = 0.9, colour = ml_pal[["ink"]]) +
    scale_colour_manual(values = unname(ml_pal[c("blue", "teal", "coral")]), guide = "none") +
    coord_cartesian(xlim = range(X[, 1]) + c(-0.3, 0.3),
                    ylim = range(X[, 2]) + c(-0.3, 0.3)) +
    labs(title = paste0("iteration ", s$it)) +
    theme(axis.text = element_text(size = 6.5))
})
panels[[1]] + panels[[2]] + panels[[3]]
Figure 14: K-means 的三次迭代。灰叉是当前的中心,颜色是当前的分配。第 1 次迭代的边界还很粗糙,到第 6 次已经稳定。注意边界始终是直线段——这来自欧氏距离的几何,不是数据本身的性质。 | Three iterations of K-means. Grey crosses are the current centres, colours the current assignment. After one iteration the boundary is still crude; by iteration 6 it is stable. Note the boundaries are always straight segments — that comes from Euclidean geometry, not from the data.

三个必须知道的限制。第一,\(K\) 要你自己给,算法不会告诉你”应该是几”。第二,它隐含假设簇是球形、大小相近:两个长条形且互相缠绕的簇,K-means 会从中间切一刀。第三,它对尺度敏感,跑之前要标准化。

Three limits must be known. First, you supply \(K\); the algorithm will not tell you how many clusters there are. Second, it implicitly assumes spherical, similar-sized clusters: two elongated, interleaved clusters get cut down the middle. Third, it is scale-sensitive — standardise first.

6.2 层次聚类:先造一棵树,再决定切几刀 / Hierarchical clustering: build a tree, then cut it

层次聚类不做”分成几簇”的决定,而是先构造一棵树:每次把最接近的两个簇合并,直到只剩一个。两种簇之间的距离定义(linkage)会得到完全不同的树:complete 用最远点,average 用平均距离,Ward 用合并后平方和增量最小。

Hierarchical clustering does not decide the number of clusters up front; it builds a tree, merging the closest pair of clusters until one remains. The definition of cluster-to-cluster distance (linkage) changes the tree completely: complete uses the farthest pair, average uses the mean distance, Ward merges where the increase in within-cluster sum of squares is smallest.

Code
set.seed(20261101)
Hc <- rbind(
  matrix(rnorm(15 * 5, 0, 1), 15, 5),
  matrix(rnorm(13 * 5, 3, 1), 13, 5),
  matrix(rnorm(12 * 5, -2.5, 1.2), 12, 5)
)
rownames(Hc) <- sprintf("s%02d", seq_len(nrow(Hc)))
hc <- hclust(dist(scale(Hc)), method = "ward.D2")
dd <- ggdendro::dendro_data(hc, type = "rectangle")
cut_h <- 8.5

ggplot() +
  geom_segment(data = dd$segments,
               aes(x = x, y = y, xend = xend, yend = yend),
               colour = ml_pal[["blue"]], linewidth = 0.45) +
  geom_hline(yintercept = cut_h, linetype = "22", colour = ml_pal[["orange"]],
             linewidth = 0.6) +
  annotate("text", x = 39, y = cut_h + 1.2, label = "cut into 3", size = 2.8,
           colour = ml_pal[["orange"]], hjust = 1) +
  scale_x_continuous(expand = expansion(mult = c(0.01, 0.01))) +
  labs(x = NULL, y = "merge dissimilarity") +
  theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())
Figure 15: 在 40 个样本、5 个变量上的 Ward 层次聚类。纵轴是合并时的不相似度,虚线标出切成 3 簇的位置。树一旦建好,切 2 刀还是 3 刀只是看图决定,不必重新计算——这是它与 K-means 最实用的差别。 | Ward hierarchical clustering on 40 samples and 5 variables. The vertical axis is the dissimilarity at each merge; the dashed line marks the cut that yields three clusters. Once the tree exists, cutting at two or three clusters is a visual decision with no recomputation — the most practical difference from K-means.

树状图最有用的一点是不用预先决定簇数:先看树的结构,再决定在哪里切。缺点是它同样是贪心的——一旦两个簇合并,就不会再拆开,早期的一个错误合并会一直留在树里。样本量也是 \(O(n^2)\) 的内存,上万个细胞就不适合直接用了。

The most useful property is that the number of clusters need not be fixed in advance: look at the tree, then decide where to cut. The drawback is that it is also greedy — once two clusters merge they never separate, so an early mistake stays in the tree. Memory is \(O(n^2)\), so tens of thousands of cells are not practical directly.

6.3 高斯混合:把”属于哪一簇”变成概率 / Gaussian mixtures: membership becomes a probability

K-means 给每个点一个硬标签。高斯混合模型(GMM)假设数据来自 \(K\) 个高斯分布的混合,输出的是后验概率:

K-means gives each point a hard label. A Gaussian mixture model (GMM) assumes the data come from a mixture of \(K\) Gaussians and outputs a posterior probability:

\[p(x)=\sum_{k=1}^{K}\pi_k\,\mathcal{N}(x\mid\mu_k,\Sigma_k),\qquad \gamma_{ik}=\frac{\pi_k\,\mathcal{N}(x_i\mid\mu_k,\Sigma_k)}{\sum_{j}\pi_j\,\mathcal{N}(x_i\mid\mu_j,\Sigma_j)}\]

\(\gamma_{ik}\) 是”第 \(i\) 个点属于第 \(k\) 个成分”的责任度。参数用 EM 算法求:E 步算 \(\gamma\),M 步用 \(\gamma\) 加权更新 \(\pi,\mu,\Sigma\),交替直到对数似然不再上升。相比 K-means,它多了两件事:软分配与每个簇自己的协方差(可以是椭圆的,不必是圆的)。

\(\gamma_{ik}\) is the responsibility of component \(k\) for point \(i\). Parameters are fitted by EM: the E-step computes \(\gamma\), the M-step re-estimates \(\pi,\mu,\Sigma\) with those weights, alternating until the log-likelihood stops rising. Compared with K-means this adds two things: soft assignment and a covariance per component (ellipses, not just circles).

Code
set.seed(20261101)
Xg <- rbind(
  MASS::mvrnorm(200, c(-1.3, 0.0), matrix(c(1.7, 0.75, 0.75, 0.55), 2)),
  MASS::mvrnorm(150, c( 1.4, 0.9), matrix(c(0.75, -0.4, -0.4, 1.0), 2))
)

# --- EM for a two-component Gaussian mixture (density written out to avoid a dependency) ---
dmv2 <- function(X, mu, S) {
  Z <- sweep(X, 2, mu)
  Sinv <- solve(S)
  exp(-0.5 * rowSums((Z %*% Sinv) * Z)) / sqrt((2 * pi)^ncol(X) * det(S))
}
mu <- Xg[sample(nrow(Xg), 2), ]
Sig <- list(diag(2), diag(2)); pie <- c(0.5, 0.5)
for (it in 1:80) {
  dens <- sapply(1:2, function(k) pie[k] * dmv2(Xg, mu[k, ], Sig[[k]]))
  gam <- dens / rowSums(dens)
  Nk <- colSums(gam)
  for (k in 1:2) {
    mu[k, ] <- colSums(gam[, k] * Xg) / Nk[k]
    Z <- sweep(Xg, 2, mu[k, ])
    Sig[[k]] <- crossprod(sqrt(gam[, k]) * Z) / Nk[k]
  }
  pie <- Nk / nrow(Xg)
}

ell <- function(m, S, lev = 0.95) {
  e <- eigen(S); r <- sqrt(qchisq(lev, df = 2))
  tt <- seq(0, 2 * pi, length.out = 240)
  pts <- t(e$vectors %*% (r * sqrt(e$values) * rbind(cos(tt), sin(tt))))
  data.frame(x = pts[, 1] + m[1], y = pts[, 2] + m[2])
}
el <- rbind(cbind(ell(mu[1, ], Sig[[1]]), k = "1"), cbind(ell(mu[2, ], Sig[[2]]), k = "2"))

p_gmm <- ggplot() +
  geom_point(aes(x = Xg[, 1], y = Xg[, 2], colour = gam[, 1]), size = 0.9, alpha = 0.85) +
  geom_path(data = el, aes(x, y, group = k), linetype = "22",
            colour = ml_pal[["ink"]], linewidth = 0.45) +
  scale_colour_gradient2(low = ml_pal[["blue"]], mid = "#D9D9D9", high = ml_pal[["coral"]],
                         midpoint = 0.5, name = "P(comp 1)") +
  coord_cartesian(xlim = range(Xg[, 1]) + c(-0.3, 0.3),
                  ylim = range(Xg[, 2]) + c(-0.3, 0.3)) +
  labs(x = "x1", y = "x2", title = "Gaussian mixture (soft)") +
  theme(axis.text = element_text(size = 7), legend.key.width = unit(0.5, "cm"),
        legend.text = element_text(size = 7))

km <- kmeans(Xg, centers = 2, nstart = 10)
p_km <- ggplot() +
  geom_point(aes(x = Xg[, 1], y = Xg[, 2], colour = factor(km$cluster)), size = 0.9, alpha = 0.85) +
  scale_colour_manual(values = unname(ml_pal[c("blue", "coral")]), guide = "none") +
  coord_cartesian(xlim = range(Xg[, 1]) + c(-0.3, 0.3),
                  ylim = range(Xg[, 2]) + c(-0.3, 0.3)) +
  labs(x = "x1", y = "x2", title = "K-means (hard)") +
  theme(axis.text = element_text(size = 7))

p_km + p_gmm
Figure 16: 同一批数据,两种模型。左:K-means 只能给出直的分界,两个明显拉长的簇被从中间切开。右:GMM 的软分配,颜色深浅是后验概率,虚线圈是各成分的 95% 椭圆。注意力被放在「哪些点真的模糊」上,而不是被迫押注。 | The same data under two models. Left: K-means can only give a straight cut, slicing two visibly elongated clusters down the middle. Right: GMM’s soft assignment, with colour intensity showing the posterior and dashed rings the 95% ellipses of each component. Attention goes to the points that really are ambiguous instead of being forced into a bet.

代价是参数多了很多:每个成分有 \(d(d+1)/2\) 个协方差参数,维度高时极易过拟合,实践中要加正则(mclust 的 VEE、VVV 等模型族就是控制这个的)。另外它同样需要你给 \(K\),只是可以用 BIC 来选。

The price is many more parameters: each component has \(d(d+1)/2\) covariance entries, which overfits badly in high dimensions, so in practice you regularise (the VEE/VVV model families in mclust exist for exactly this). It also still needs \(K\) from you, though BIC gives a principled way to choose it.

6.4 PCA:找方差最大的方向 / PCA: find the directions of most variance

PCA 是纯线性的:找一组正交方向,把数据投影上去之后方差依次最大。数学上就是对协方差矩阵做特征分解。

PCA is purely linear: find an orthogonal set of directions such that projections onto them have maximal, decreasing variance. Mathematically it is an eigendecomposition of the covariance matrix.

\[\Sigma=V\Lambda V^\top,\qquad z=V^\top(x-\bar x)\]

Code
set.seed(20261101)
Xp <- MASS::mvrnorm(300, c(0, 0), matrix(c(2.6, 1.9, 1.9, 1.8), 2))
pc <- prcomp(Xp, center = TRUE, scale. = FALSE)
ax <- data.frame(x = 0, y = 0, xend = pc$rotation[1, ] * pc$sdev * 2.2,
                 yend = pc$rotation[2, ] * pc$sdev * 2.2,
                 lab = c("PC1", "PC2"))

pA <- ggplot() +
  geom_point(aes(x = Xp[, 1], y = Xp[, 2]), colour = ml_pal[["blue"]],
             size = 0.7, alpha = 0.45) +
  geom_segment(data = ax, aes(x, y, xend = xend, yend = yend),
               arrow = arrow_ml, colour = ml_pal[["coral"]], linewidth = 0.7) +
  annotate("text", x = ax$xend * 1.08, y = ax$yend * 1.08, label = ax$lab,
           size = 2.8, colour = ml_pal[["coral"]]) +
  coord_equal(xlim = c(-4, 4), ylim = c(-3.2, 3.2)) +
  labs(title = "principal axes") +
  theme(axis.text = element_text(size = 7))

ve <- data.frame(pc = factor(paste0("PC", 1:2), levels = paste0("PC", 1:2)),
                 prop = pc$sdev^2 / sum(pc$sdev^2))
pB <- ggplot(ve, aes(pc, prop)) +
  geom_col(fill = ml_pal[["teal"]], width = 0.55) +
  geom_text(aes(label = sprintf("%.0f%%", 100 * prop)), vjust = -0.4, size = 2.8) +
  scale_y_continuous(limits = c(0, 1), labels = scales::percent) +
  labs(x = NULL, y = "variance explained", title = "scree") +
  theme(axis.text = element_text(size = 7.5))

pA + pB + plot_layout(widths = c(1.25, 1))
Figure 17: 左:PCA 的两个主轴,长度正比于该方向的标准差;数据在 PC1 上的投影保留了大部分变化。右:各主成分解释的方差比例,前两个成分合计超过九成,后面的基本是噪声。降维的依据就是这张图,而不是「习惯降到二维」。 | Left: the two principal axes, with lengths proportional to the standard deviation along each; projecting onto PC1 keeps most of the variation. Right: the proportion of variance explained by each component — the first two account for more than ninety percent and the rest is noise. This panel, not habit, is the justification for reducing dimensions.

PCA 有两个常被误用的地方。第一,必须中心化,否则第一主成分会被均值的方向占据;是否标准化取决于变量单位是否可比(表达谱通常标准化,同一台仪器的同一批通道可以不标准化)。第二,主成分不是”潜在变量”,它只是一个正交方向;把 PC1 解释成”某种生物学过程”需要额外的证据,不能因为载荷大就这么叫。

Two common misuses. First, centring is mandatory, otherwise the first component is dominated by the mean; whether to standardise depends on whether the units are comparable (expression data usually standardised, same-instrument channels possibly not). Second, a component is not a latent variable — it is an orthogonal direction, and calling PC1 “some biological process” needs evidence beyond a large loading.

6.5 t-SNE 与 UMAP:只保证邻域,不保证全局 / t-SNE and UMAP: neighbourhoods, not distances

这两个方法是非线性降维,目标不是保留方差,而是保留邻域关系:原来相近的点,在二维图里也相近。它们都先构造一个”谁是谁的邻居”的概率分布,再找一个低维布局让两个分布尽量一致。

Both are nonlinear embeddings. The goal is not to preserve variance but neighbourhood structure: points that were close stay close in the plane. Each builds a probability distribution over “who is whose neighbour”, then finds a low-dimensional layout whose distribution matches it.

Code
set.seed(20261101)
n_cell <- 150; n_gene <- 300
types <- rep(c("T cell", "B cell", "Myeloid"), each = n_cell)
E <- matrix(rnorm(length(types) * n_gene, 0, 0.6), length(types), n_gene)
marker <- list(`T cell` = 1:40, `B cell` = 41:80, Myeloid = 81:120)
for (t in names(marker)) {
  idx <- which(types == t)
  E[idx, marker[[t]]] <- E[idx, marker[[t]]] + 1.9
}
E <- E + matrix(rnorm(length(types)), length(types), 1) %*% rnorm(n_gene, 0, 0.5)

pcs <- prcomp(E, center = TRUE, scale. = TRUE)$x[, 1:30]
um <- uwot::umap(pcs, n_neighbors = 20, min_dist = 0.25, metric = "euclidean", verbose = FALSE)

emb <- rbind(
  data.frame(x = pcs[, 1], y = pcs[, 2], type = types, method = "PCA (PC1-PC2)"),
  data.frame(x = um[, 1], y = um[, 2], type = types, method = "UMAP")
)
emb$method <- factor(emb$method, levels = c("PCA (PC1-PC2)", "UMAP"))

ggplot(emb, aes(x, y, colour = type)) +
  geom_point(size = 0.75, alpha = 0.85) +
  facet_wrap(~ method, scales = "free") +
  scale_colour_manual(values = unname(ml_pal[c("blue", "teal", "coral")]), name = NULL) +
  labs(x = NULL, y = NULL) +
  theme(legend.position = "bottom", legend.text = element_text(size = 8),
        axis.text = element_blank(), axis.ticks = element_blank())
Figure 18: 模拟的 450 个细胞、300 个基因,三类细胞各有 40 个标记基因。左:PCA 前两维,三类已经分开但仍有重叠。右:UMAP,类内更紧凑、类间边界更清晰。两图坐标轴的刻度都没有意义——只能读相对位置。 | 450 simulated cells and 300 genes, with 40 marker genes per cell type. Left: the first two principal components; the three types separate but still overlap. Right: UMAP, tighter within types and cleaner between them. The axis ticks in both panels are meaningless — only relative position can be read.

这张图最常见的误读,是把簇间距离当成生物学距离。UMAP 只保留邻域,两个簇在图上的远近几乎不携带信息;改变 n_neighbors 或 min_dist,布局会显著变化,而簇的成员基本不变。所以正确的用法是:用 UMAP 看”有没有分群”,再回到原空间(或 PCA)去算差异表达、做统计检验。把 UMAP 图上的距离当结论,是单细胞文献里最常见的错误之一。

The most common misreading is treating between-cluster distances as biological distances. UMAP preserves neighbourhoods, so how far apart two clusters sit carries almost no information; change n_neighbors or min_dist and the layout moves while membership barely changes. Use UMAP to ask “are there groups”, then go back to the original space (or PCA) for differential expression and tests. Reading conclusions off UMAP distances is one of the most common errors in the single-cell literature.

7. 验证与调参:模型好不好,取决于你怎么测 / Validation and tuning

前面几章的所有结论——“这个模型更好”、“这个超参数最优”——都建立在一件事上:用来评估的数据没有参与训练。这一章讲怎么保证这一点,以及它有多容易被破坏。

Every conclusion in the previous chapters — “this model is better”, “this hyperparameter is optimal” — rests on one thing: the data used for evaluation took no part in fitting. This chapter is about guaranteeing that, and how easily it breaks.

7.1 重采样的几种切法 / Resampling schemes

留出法最简单:切一次,训练集拟合、测试集评估。缺点是结果依赖这一次切分,样本少时波动很大。K 折交叉验证把数据分成 \(K\) 份,轮流用 \(K-1\) 份训练、1 份评估,最后取平均——每个样本都被评估恰好一次,方差比单次留出小得多。

A single hold-out is simplest: split once, fit on one part, evaluate on the other. Its weakness is dependence on that one split, which is noisy when \(n\) is small. \(K\)-fold cross-validation splits the data into \(K\) parts, trains on \(K-1\) and evaluates on the remaining one in turn, then averages — every sample is evaluated exactly once, with much lower variance than a single hold-out.

方案 / Scheme 做法 / How 何时用 / When
留出 / hold-out 一次 70/30 或 80/20 切分 样本多、只想要一个快速估计 / large \(n\), quick estimate
K 折 / k-fold 轮流留出 1 份,\(K=5\) 或 10 默认选择 / the default
分层 K 折 / stratified 每折里类别比例与总体一致 类别不平衡 / imbalanced classes
留一 / leave-one-out \(K=n\) 极小样本,但方差反而可能更大 / tiny \(n\); variance can be worse
自助法 / bootstrap 有放回抽样,约 63% 的样本被抽中 想估计不确定性 / uncertainty estimates

English

Scheme How When
hold-out one 70/30 or 80/20 split large \(n\), quick estimate
k-fold hold out 1 of \(K\) parts, \(K=5\) or 10 the default
stratified class ratio preserved in each fold imbalanced classes
leave-one-out \(K=n\) tiny \(n\); variance can be worse
bootstrap sample with replacement, ~63% drawn uncertainty estimates

7.2 泄漏:最常见、也最不容易察觉的错误 / Leakage: the most common silent error

交叉验证本身不难写,难的是每一折里只做该做的事。凡是”用了全部数据”的步骤——标准化、填补缺失、降维、特征选择、选择批次校正参数——都会把测试折的信息漏进训练折,让评估结果偏乐观。

Writing the loop is easy; keeping each fold self-contained is hard. Any step that touches all the data — standardisation, imputation, dimensionality reduction, feature selection, batch-correction parameters — leaks information from the held-out fold into training and makes the estimate optimistic.

Code
set.seed(20261101)
nL <- 60; pL <- 5000
XL <- matrix(rnorm(nL * pL), nL, pL)
yL <- rnorm(nL)
foldL <- sample(rep(1:5, length.out = nL))
sel_all <- order(-abs(cor(XL, yL)))[1:20]

cv_r2 <- function(leaky) {
  pred <- numeric(nL)
  for (k in 1:5) {
    in_tr <- foldL != k; in_te <- foldL == k
    sel <- if (leaky) sel_all else order(-abs(cor(XL[in_tr, ], yL[in_tr])))[1:20]
    dtr <- as.data.frame(XL[in_tr, sel]); dtr$y <- yL[in_tr]
    fit <- lm(y ~ ., data = dtr)
    pred[in_te] <- predict(fit, newdata = as.data.frame(XL[in_te, sel]))
  }
  1 - sum((yL - pred)^2) / sum((yL - mean(yL))^2)
}
r2_leak <- cv_r2(TRUE)
r2_hon  <- cv_r2(FALSE)

dd <- data.frame(
  protocol = factor(c("select on all data,\nthen cross-validate",
                      "select inside\neach fold"),
                    levels = c("select on all data,\nthen cross-validate",
                               "select inside\neach fold")),
  r2 = c(r2_leak, r2_hon)
)
ggplot(dd, aes(protocol, r2, fill = r2 > 0.15)) +
  geom_hline(yintercept = 0, colour = ml_pal[["ink"]], linewidth = 0.4) +
  geom_col(width = 0.5) +
  geom_text(aes(label = sprintf("R2 = %.2f", r2)), vjust = ifelse(dd$r2 > 0, -0.5, 1.6),
            size = 3.2) +
  scale_fill_manual(values = c(`TRUE` = ml_pal[["coral"]], `FALSE` = ml_pal[["teal"]]),
                    guide = "none") +
  scale_y_continuous(limits = c(-0.45, 0.75)) +
  labs(x = NULL, y = expression(R^2 ~ "on held-out folds")) +
  theme(axis.text.x = element_text(size = 8.5))
Figure 19: 一次刻意设计的泄漏。60 个样本、5000 个与结局完全无关的随机基因,所以真实的 \(R^2\) 应该是 0。若先在全部数据上挑出与 \(y\) 相关性最强的 20 个基因、再做交叉验证,\(R^2\) 会明显偏高;把挑选放进每一折内部,\(R^2\) 回到 0 附近甚至为负。差别完全来自那一步「提前看了测试集」。 | A deliberately constructed leak. Sixty samples and five thousand random genes with no relation to the outcome, so the true \(R^2\) is 0. Selecting the 20 genes most correlated with \(y\) on all the data and then cross-validating inflates \(R^2\) well above zero; doing the selection inside each fold brings it back to about zero or below. The entire difference comes from one step that 「peeked at the test set」.

这张图里的数据完全随机,所以任何正的真 \(R^2\) 都是假的。泄漏版给出 0.61,正确版给出 -0.19。注意正确版是负数——这不是错误,而是”这个模型不比预测均值好”的正确表达,\(R^2\) 的定义允许它为负。

The data here are pure noise, so any positive true \(R^2\) is fake. The leaky protocol reports 0.61; the correct one reports -0.19. The negative value is not a bug — it is the correct statement that the model is no better than predicting the mean, and \(R^2\) is allowed to be negative.

现实中的泄漏往往更隐蔽:用全队列的均值填补缺失、用全部样本做 PCA 再取前十个成分、用全部数据估计批次效应、在先做差异分析再划分训练集。判断标准只有一条:这一步在真实使用场景里,能不能在拿到新样本时独立完成?如果能,它就必须在每一折内部重新做。

Real leaks are subtler: imputing with the whole-cohort mean, running PCA on all samples and keeping ten components, estimating batch effects from everything, doing differential expression before splitting. There is one test: could this step be done independently when a new sample arrives? If yes, it must be redone inside every fold.

7.3 嵌套交叉验证:调参与评估不能共用同一批折 / Nested CV: tuning and evaluation need different folds

如果调参也用交叉验证,再用同一个交叉验证结果报告性能,就犯了和上一节同类的错误:超参数是”看着测试折”选出来的,报告的数字必然偏乐观。正确做法是嵌套:外层 K 折负责评估,每一折内部再做一次内层交叉验证来选超参数。

If tuning also uses cross-validation and you then report that same CV number, you repeat the mistake: the hyperparameter was chosen while looking at the evaluation folds, so the number is optimistic. The fix is nesting: an outer \(K\)-fold loop for evaluation, with an inner cross-validation inside each outer training fold to pick the hyperparameter.

\[\text{outer error}=\frac{1}{K}\sum_{k=1}^{K}L\Big(y_{\text{test}_k},\ \hat f^{(\lambda_k^\star)}\big(x_{\text{test}_k}\big)\Big), \qquad \lambda_k^\star=\arg\min_\lambda\ \text{CV}_{\text{inner}}\big(\lambda;\ \mathcal{D}_{\text{train}_k}\big)\]

代价是计算量乘以 \(K\):外层 5 折、内层 5 折,就是 25 次拟合。所以实践中常见的折中是:用嵌套 CV 得到一个可信的性能数字,再用全部数据做一次普通 CV 选出最终超参数。

The price is a factor of \(K\) in compute: outer 5 by inner 5 means 25 fits. A common compromise is to use nested CV for one trustworthy performance number, then run ordinary CV on all the data to pick the final hyperparameters.

7.4 三个工作流框架,怎么选 / Three workflow frameworks

caret tidymodels mlr3
首次发布 / first release 2007 2018 起逐步成型 2019
设计 / design 一个大 train() 函数 管道与对象组合(recipes + parsnip + workflows) 面向对象的 task / learner / measure
强项 / strength 上手最快,模型最多 流程可读、可版本化,与 tidyverse 一致 批量 benchmark、超参数调优、并行
弱项 / weakness 接口偏老,扩展性一般 概念多,初学门槛高 语法重,小项目显繁琐
适合 / fits 一次性分析、教学 需要长期维护的分析流水线 多数据集、多模型的比较实验

English

caret tidymodels mlr3
first release 2007 assembled from 2018 2019
design one big train() composable pipes (recipes + parsnip + workflows) object-oriented task / learner / measure
strength fastest to start, widest model coverage readable, versionable pipelines that match the tidyverse batch benchmarks, tuning, parallelism
weakness dated interface, limited extensibility many concepts, steeper start heavy syntax for small jobs
fits one-off analyses, teaching pipelines that must be maintained comparisons across datasets and models

三个框架的共同点是都把”预处理 + 模型 + 重采样”绑定成一个对象,这正是防止泄漏的机制:预处理被放进流程里,就自动在每一折内部重新拟合。第 1 章那三段代码做的事完全一样,差别只在抽象方式。我的建议很简单:只做一次分析用 caret,要长期维护用 tidymodels,要做几十个数据集的比较实验用 mlr3。不要三个都用。

What the three share is that they bind preprocessing, model and resampling into one object, which is exactly the anti-leak mechanism: once preprocessing lives inside the workflow, it is refitted inside every fold automatically. The three snippets in section 1 do the same job and differ only in abstraction. The advice is simple: caret for a one-off analysis, tidymodels for something maintained, mlr3 for benchmarks over dozens of datasets. Do not use all three.

8. R 里的深度学习接口 / Deep learning from R

8.1 torch:三件事——张量、梯度、优化器 / torch: tensors, gradients, optimisers

R 版 torch 和 PyTorch 共用同一套 libtorch 后端,所以 API 几乎是镜像的:nn_sequential() 搭网络,loss$backward() 自动求导,optim_adam() 更新参数。下面这段代码是一个真正的训练循环,不是伪代码——它会在本文渲染时实际跑一遍。

R’s torch shares the libtorch backend with PyTorch, so the API mirrors it: nn_sequential() builds the network, loss$backward() differentiates automatically, optim_adam() updates parameters. The code below is a real training loop, not pseudocode — it runs when this page is rendered.

Code
Sys.setenv(PATH = paste(file.path(find.package("torch"), "lib"),
                        Sys.getenv("PATH"), sep = .Platform$path.sep))
suppressMessages(library(torch))

set.seed(20261101)
idx <- sample(nrow(trz), 140)
Xtr <- as.matrix(trz[idx, num]);  ytr <- as.numeric(trz$type[idx]  == "Yes")
Xva <- as.matrix(trz[-idx, num]); yva <- as.numeric(trz$type[-idx] == "Yes")

torch_manual_seed(1)
net <- nn_sequential(
  nn_linear(length(num), 16), nn_relu(),
  nn_linear(16, 8),            nn_relu(),
  nn_linear(8, 1)
)
opt <- optim_adam(net$parameters, lr = 0.01)
loss_fn <- nn_bce_with_logits_loss()

xt <- torch_tensor(Xtr, dtype = torch_float())
yt <- torch_tensor(ytr, dtype = torch_float())$unsqueeze(2)
xv <- torch_tensor(Xva, dtype = torch_float())
yv <- torch_tensor(yva, dtype = torch_float())$unsqueeze(2)

ep <- 200
hist <- data.frame(epoch = seq_len(ep), train = NA_real_, val = NA_real_)
for (i in seq_len(ep)) {
  net$train()
  opt$zero_grad()
  loss <- loss_fn(net(xt), yt)
  loss$backward()
  opt$step()
  net$eval()
  hist$train[i] <- as.numeric(loss)
  with_no_grad({
    hist$val[i] <- as.numeric(loss_fn(net(xv), yv))
  })
}

hl <- tidyr::pivot_longer(hist, c("train", "val"), names_to = "split", values_to = "loss")
best <- which.min(hist$val)

ggplot(hl, aes(epoch, loss, colour = split)) +
  geom_line(linewidth = 0.7) +
  geom_vline(xintercept = best, linetype = "22", colour = ml_pal[["grey"]], linewidth = 0.45) +
  annotate("text", x = best + 6, y = max(hl$loss) * 0.95,
           label = paste0("best val: epoch ", best), size = 2.6,
           colour = ml_pal[["grey"]], hjust = 0) +
  scale_colour_manual(values = c(train = ml_pal[["blue"]], val = ml_pal[["coral"]]),
                      name = NULL) +
  labs(x = "epoch", y = "cross-entropy") +
  theme(legend.position = "bottom", legend.text = element_text(size = 8))
Figure 20: 在 Pima 训练集内部切分出的 140/60 上训练一个 7-16-8-1 的小网络,200 轮,Adam,学习率 0.01。两条曲线都是交叉熵:训练损失单调下降,验证损失在第 28 轮触底后一路回升——这就是过拟合的曲线形状,也是早停的依据。 | A 7-16-8-1 network trained on a 140/60 split inside the Pima training set for 200 epochs with Adam at learning rate 0.01. Both curves are cross-entropy: training loss falls monotonically while validation loss bottoms out at epoch 28 and then climbs — the shape of overfitting, and the justification for early stopping.

这段代码里没有一行是在”实现”反向传播——loss$backward() 一行就完成了。真正的实现藏在 libtorch 里:每个算子都注册了自己的前向计算和反向函数(这正是上一篇讲算子的文章里第 1.4 节的那个要求)。用 R 写深度学习,你写的是计算图,不是梯度公式。

Not one line here “implements” backpropagation — loss$backward() does it. The implementation lives inside libtorch: every operator registers a forward pass and a backward rule (exactly the requirement discussed in section 1.4 of the operators article). Writing deep learning in R means writing computation graphs, not gradient formulae.

8.2 keras3:更高层的封装 / keras3: a higher-level wrapper

keras3 把训练循环也收进了 fit(),适合快速搭原型;代价是控制粒度粗,自定义损失或复杂的多输入模型要绕路。

keras3 folds the training loop into fit(), which suits quick prototypes; the price is coarser control, and custom losses or complex multi-input models need workarounds.

Code
library(keras3)

model <- keras_model_sequential(input_shape = 7) |>
  layer_dense(16, activation = "relu") |>
  layer_dense(8,  activation = "relu") |>
  layer_dense(1,  activation = "sigmoid")

model |>
  compile(optimizer = optimizer_adam(0.01),
          loss = "binary_crossentropy",
          metrics = "auc") |>
  fit(Xtr, ytr, epochs = 200, batch_size = 32,
      validation_data = list(Xva, yva),
      callbacks = callback_early_stopping(patience = 20))

8.3 什么时候不值得用深度学习 / When deep learning is not worth it

在表格数据上,一个调好参的 xgboost 通常与神经网络打平甚至更好,而训练成本低一个数量级——这不是偏见,是反复被 benchmark 验证的结论。深度学习真正拉开差距的场景有几个共同点:样本量极大、输入是高维的原始信号(像素、波形、字符、碱基)、可以利用预训练。Pima 只有 200 个样本、7 个变量,这两个条件一个都不满足,所以上面那个网络的意义是演示 API,不是”更好的模型”。

On tabular data a well-tuned xgboost usually ties or beats a neural network at an order of magnitude less compute — not a prejudice but a repeatedly benchmarked result. Deep learning pulls ahead when several things hold at once: very large samples, high-dimensional raw input (pixels, waveforms, characters, bases), and usable pretraining. Pima has 200 cases and seven variables, satisfying none of them, so the network above is an API demonstration, not a better model.

9. 怎么选:一张图和一个清单 / Choosing a method

Code
rec <- data.frame(
  y = 4:1,
  task = c("回归  regression", "二分类  binary", "多分类  multiclass", "无标签  unsupervised"),
  first = c("glmnet(先看正则路径)", "glm(要解释)/ xgboost(要精度)",
            "xgboost / ranger", "PCA + k-means"),
  alt = c("ranger、GAM、xgboost", "ranger、SVM、LDA",
          "multinom、SVM", "hclust、GMM、UMAP"),
  caveat = c("p 远大于 n 时要正则化", "类别不平衡看 PR-AUC",
             "类别有层级时用层次模型", "UMAP 距离不做统计")
)
rec$fill <- c("#FDF0D8", "#DCE8F5", "#D8EDE7", "#FBE3D9")

ggplot(rec) +
  geom_rect(aes(xmin = 0, xmax = 10.6, ymin = y - 0.42, ymax = y + 0.42), fill = rec$fill) +
  geom_text(aes(0.12, y, label = task), hjust = 0, size = 3.0, fontface = "bold",
            colour = ml_pal[["ink"]]) +
  geom_text(aes(2.55, y, label = first), hjust = 0, size = 2.6, colour = ml_pal[["blue"]]) +
  geom_text(aes(5.55, y, label = alt), hjust = 0, size = 2.6, colour = ml_pal[["teal"]]) +
  geom_text(aes(7.85, y, label = caveat), hjust = 0, size = 2.6, colour = ml_pal[["coral"]]) +
  annotate("text", x = 2.55, y = 4.62, label = "first try", size = 2.5, hjust = 0,
           colour = ml_pal[["grey"]]) +
  annotate("text", x = 5.55, y = 4.62, label = "alternative", size = 2.5, hjust = 0,
           colour = ml_pal[["grey"]]) +
  annotate("text", x = 7.85, y = 4.62, label = "watch out", size = 2.5, hjust = 0,
           colour = ml_pal[["grey"]]) +
  coord_cartesian(xlim = c(0, 10.7), ylim = c(0.45, 4.85)) +
  plain
Figure 21: 按任务类型给出的起点建议。左列是问题,中间三列是从「先试什么」到「什么时候不该用」。这不是规则,而是默认起点:任何一行都可能因为样本量、维度或数据结构而被推翻。 | Starting points by task type. The left column is the problem, the next three run from 「try first」 to 「when not to」. These are defaults, not rules: any row can be overturned by sample size, dimensionality or data structure.

选完模型之后,还有八件事决定你的结论可不可信。这份清单可以贴在墙上:

Once the model is chosen, eight things decide whether your conclusion is trustworthy. Pin this list to the wall:

  1. 先看数据,再看模型。分布、缺失、异常值、批次;一张 UMAP 或一组箱线图往往比换模型更有用。
    Look at the data before the model. Distributions, missingness, outliers, batches; one UMAP or a boxplot often helps more than a new algorithm.
  2. 说清楚预测单位是什么。一个样本是一个病人、一个细胞还是一个技术重复?单位错了,交叉验证就错了。
    State the unit of prediction. Is a sample a patient, a cell, a technical replicate? Wrong unit, wrong cross-validation.
  3. 划分要模拟真实使用。同一病人的多个样本不能分到训练和测试两边;时间序列不能随机打乱。
    Split the way the model will be used. Samples from one patient must not straddle train and test; time series must not be shuffled.
  4. 预处理必须在折内做。标准化、填补、降维、特征选择,一个都不能提前。
    Preprocessing belongs inside the fold. Standardisation, imputation, reduction, selection — none may be done in advance.
  5. 报告不确定度。单个 AUC 没有意义,要给置信区间或多个随机种子的分布。
    Report uncertainty. A single AUC means little; give a confidence interval or a spread over seeds.
  6. 给基线。至少给一个”什么都不做”的基线(预测均值、多数类)和一个简单模型(逻辑回归)。
    Always include a baseline: predict the mean or the majority class, plus one simple model.
  7. 解释要落在数据上。重要性和 PDP 是描述性的,不是因果的;要说因果得另做设计。
    Keep interpretation descriptive. Importance and PDP are not causal; causal claims need a different design.
  8. 写下失败的条件。在什么数据上这个方法会失效,比”我们的方法更好”更有信息量。
    Write down where it fails. That is more informative than “our method is better”.

参考文献 / References

  1. Hastie, T., Tibshirani, R. & Friedman, J. (2009). The Elements of Statistical Learning (2nd ed.). Springer. https://hastie.su.domains/ElemStatLearn/
  2. James, G., Witten, D., Hastie, T. & Tibshirani, R. (2021). An Introduction to Statistical Learning (2nd ed.). Springer. https://www.statlearning.com
  3. Tibshirani, R. (1996). Regression shrinkage and selection via the lasso. Journal of the Royal Statistical Society B 58, 267–288.
  4. Zou, H. & Hastie, T. (2005). Regularization and variable selection via the elastic net. JRSS B 67, 301–320.
  5. Friedman, J., Hastie, T. & Tibshirani, R. (2010). Regularization paths for generalized linear models via coordinate descent. Journal of Statistical Software 33(1), 1–22.
  6. Breiman, L. (2001). Random forests. Machine Learning 45, 5–32.
  7. Friedman, J. (2001). Greedy function approximation: a gradient boosting machine. Annals of Statistics 29, 1189–1232.
  8. Chen, T. & Guestrin, C. (2016). XGBoost: a scalable tree boosting system. KDD. arXiv:1603.02754
  9. Wright, M. N. & Ziegler, A. (2017). ranger: a fast implementation of random forests for high dimensional data in C++ and R. Journal of Statistical Software 77(1), 1–17.
  10. Cortes, C. & Vapnik, V. (1995). Support-vector networks. Machine Learning 20, 273–297.
  11. Lloyd, S. P. (1982). Least squares quantization in PCM. IEEE Transactions on Information Theory 28, 129–137.
  12. Dempster, A. P., Laird, N. M. & Rubin, D. B. (1977). Maximum likelihood from incomplete data via the EM algorithm. JRSS B 39, 1–38.
  13. van der Maaten, L. & Hinton, G. (2008). Visualizing data using t-SNE. Journal of Machine Learning Research 9, 2579–2605.
  14. McInnes, L., Healy, J. & Melville, J. (2018). UMAP: uniform manifold approximation and projection for dimension reduction. arXiv:1802.03426
  15. Kuhn, M. (2008). Building predictive models in R using the caret package. Journal of Statistical Software 28(5), 1–26.
  16. Kuhn, M. & Wickham, H. (2020). tidymodels: a collection of packages for modeling and machine learning. https://www.tidymodels.org
  17. Lang, M., et al. (2019). mlr3: a modern object-oriented machine learning framework in R. https://mlr-org.com
  18. Paszke, A., et al. (2019). PyTorch: an imperative style, high-performance deep learning library. NeurIPS. arXiv:1912.01703
  19. R Core Team (2025). R: A Language and Environment for Statistical Computing. https://www.R-project.org

引用说明:第 3–15、18 条给出卷页或 arXiv 编号;第 1、2、16、17、19 条为书籍、网站或软件主页,请以官方页面为准。文中所有数值示例均由随文代码生成,未引用他人数据。

Citation note: entries 3–15 and 18 give volume/pages or arXiv identifiers; entries 1, 2, 16, 17 and 19 are books, websites or software home pages — refer to the official pages. Every numeric example in this post is generated by the accompanying code; no third-party data are reused.