🤖 第18章 迁移学习
📄 一句话概括

训练数据的输入集中在零点附近,要预测的数据集中在 2.5 附近,而两者的条件分布完全相同。 用直线去拟合平方:训练区误差只有 0.5438, 测试区 56.9349——是拿对的函数去预测时误差的 615 倍。 按密度比加权之后,测试区误差降到 6.7283。 代价是:300 个样本加权后只相当于 9.7 个。 移位从 0.5 拉到 3.0,收益先涨到 15.56 倍再回落到 5.76 倍,因为越远就越没有可用数据。

📝 本节和前面几节的关系

前面十几章都默认一件事:训练用的数据和将来要预测的数据是从同一个分布里抽的。 这个假设一旦不成立,训练得再好也白搭。 可现实里它经常不成立。 同一套模型,去年训练、今年上线,输入的样子变了;在一个城市采的数据,拿到另一个城市用; 实验室里标好的样本,部署到产线上。 这类问题统称迁移学习——把在一个分布上学到的东西,迁到另一个分布上用。 迁不迁得动,全看「什么变了、什么没变」。 这一节假设最温和的一种情形:y 给定 x 的条件分布没变,只有 x 自己的分布变了。 这叫协变量移位。 下一节换另一种:x 的分布没变,只是各个类别的比例变了。

一、什么变了,什么没变

把假设写清楚,这是整节的地基:

量 训练时 测试时 变没变
p(x) 均值 0,标准差 0.7 均值 2.5,标准差 0.7 变了
p(y 给定 x) 同一个平方函数 同一个平方函数 没变
条件分布没变,意味着「在某个 x 处 y 该是多少」这条规律是通用的。
变的只是「我们会遇到哪些 x」。
那么理论上完全可以把训练区学到的东西搬过去——前提是你只搬规律,不搬偏好。
问题在于,任何拟合过程都会偷偷带上偏好。
最小二乘只优化训练区上的误差,它在训练区没见过的位置,拟合出来的样子纯属外推,没有任何保证。
实测就撞在这个外推上。真函数是平方,模型族只许用直线:
量 数值
拟合出的斜率 -0.1290
拟合出的截距 +0.3911
训练区误差 0.5438
测试区误差 56.9349
拿真函数去预测的误差 0.0925
斜率是负的,而真函数在训练区(零点附近)两侧都是上升的。
这是因为平方函数在零点附近几乎对称,直线无论往哪边斜,在训练区上的误差都差不多,
于是拟合结果基本是随机的。
0.0925 那行是噪声方差本身(0.30 的平方),也就是「完全知道答案」时的极限。
56.9349 除以 0.0925 等于 615。
这个模型在测试区等于完全不知道答案。

二、密度比:每个训练样本值多少

既然只有 p(x) 变了,那就把训练样本按「它有多像测试样本」重新称重。 这个「有多像」的定量说法叫密度比:

w(x) = p_te(x) / p_tr(x)

w 大表示这个 x 在测试时常见、训练时少见,应该多听它的; w 小表示测试时基本不会遇到,可以忽略。 关键的一点:这个比值不用分别知道两个分布,可以直接估。 把训练样本标 0、测试样本标 1,拟合一个分类器, 它输出的后验概率之比就是密度比(再乘上两边样本量的比)。 为什么?因为按贝叶斯公式,后验正比于先验乘似然,两个类的后验一比,先验一比留下, 似然一比正好是 p_te(x) 除以 p_tr(x)。 这个技巧值得单独记一笔:估计两个分布的比值,比估计两个分布本身容易得多。 它把一个无监督问题(密度估计)变成了一个有监督问题(分类),于是逻辑回归、 随机森林这些现成工具全都能用上。 本节数据上的密度比长这样:

统计量 数值
最小 0.0002
中位数 0.0303
最大 16.3047
中位数只有 0.0303,意思是「典型的一个训练样本,到了测试区只值原来的百分之三」。
最大 16.3047 出现在 2.5 附近,那里测试样本密集、训练样本稀少。
两头差五个数量级,这就是有效样本量会暴跌的原因。

三、加权最小二乘

有了权重,拟合就变成加权最小二乘:

最小化   sum_i  w(x_i) * ( a + b * x_i - y_i ) ^ 2

形式没变,只是每个样本的误差乘上自己的权重。 原来训练区里那些 w 接近零的样本,无论拟合得多差都不再计入; 测试区附近那几个 w 很大的样本,拟合得差一点就要付很大代价。 实测:

做法 斜率 截距 训练区误差 测试区误差
不加权 -0.1290 +0.3911 0.5438 56.9349
按密度比加权 +2.3377 -0.8977 5.2261 6.7283
测试区误差从 56.93 降到 6.73,八点五倍。
而训练区误差从 0.54 涨到 5.23,涨了将近十倍。
这一涨一落正是全部的要义。
加权不是为了在训练集上做得更好,恰恰相反,它故意牺牲训练集上的表现,去换测试集上的表现。
如果只看训练误差挑模型,加权版永远选不上。

四、代价:有效样本量

权重不是白拿的。极端不均的权重会让「样本多」变成假象。 衡量办法叫有效样本量:

ESS = ( sum_i w_i ) ^ 2 / sum_i ( w_i ^ 2 )

权重全相等时它等于样本数,权重越不均它越小。 本节 300 个样本,按密度比加权后只剩 9.7 个。 这个数字解释了后面那张表为什么会拐弯:

测试区离多远 不加权误差 加权误差 改善倍数 有效样本量
0.5 1.136 0.519 2.19 140.7
1.0 3.881 0.490 7.91 32.3
1.5 11.196 0.720 15.56 13.2
2.0 27.055 2.106 12.84 9.7
2.5 56.935 6.728 8.46 9.7
3.0 107.810 18.720 5.76 11.5
改善倍数在 1.5 处见顶(15.56 倍),之后回落。
原因有两个,方向相反。
往左看:移位太小,不加权本来就不算差,加权没有发挥空间(0.5 处只有 2.19 倍)。
往右看:移位太大,有效样本量掉到十以下,加权后的估计开始被少数几个样本主导,
自己的方差上来了(3.0 处回落到 5.76 倍)。
两头夹出来的这个峰值,就是迁移学习最真实的样子:
偏离越远越需要迁移,但也越没有本钱迁移。

五、什么时候它不管用

协变量移位的方法有一个硬前提:条件分布真的没变。 如果 p(y 给定 x) 也变了——比如同一个位置的样本, 标注标准改了——那么按密度比加权只会把一个错的规律搬得更用力。 这种情况叫概念漂移,密度比解决不了,得重新标数据。 第二个前提是密度比估得准。 逻辑回归这种线性分类器只能表达单调的比值,遇到复杂的移位形状会估偏。 估偏的后果比不估更严重:

做法 测试区误差
正确方向加权 6.728
不加权 56.935
权重方向搞反 187.471
搞反比不加权还差三倍多。
所以实际做的时候,密度比一定要先在验证集上验一遍,别闭着眼睛套。
第三个坑是极端权重。
很多人第一反应是把大权重截断,图个稳。实测截断确实能抬高有效样本量,但也确实会让效果变差:
密度比截断上限 测试区误差 有效样本量
1 13.384 56.8
3 9.936 31.4
10 7.112 13.1
不截断 6.728 9.7
在这里,「更稳」和「更准」是矛盾的,得按业务挑。
偏差和方差的老取舍,换了个马甲又出现了。

六、动手 Demo:协变量移位:训练区和测试区错开时,把样本按密度比重新称重

这段代码造两段错开的输入,条件分布完全相同,真函数是平方而模型只许用直线; 然后估密度比、做加权最小二乘,最后扫一遍移位距离和几种做错的方式。 重点看三处。第一段训练区和测试区的均值差 2.58,但条件是同一个; 第二段测试区误差 56.9349 对「知道答案」的 0.0925;第三段加权后 6.7283 和有效样本量 9.7。

# -*- coding: utf-8 -*-
import numpy as np


def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -60.0, 60.0)))


def fit_logistic(X, t, w=None, l2=1e-2, iters=600, lr=0.5):
    """带样本权重的逻辑回归,梯度下降。t 取 0 或 1。"""
    n, d = X.shape
    if w is None:
        w = np.ones(n)
    w = w / w.sum() * n
    beta = np.zeros(d + 1)
    Z = np.hstack([np.ones((n, 1)), X])
    for _ in range(iters):
        p = sigmoid(Z @ beta)
        g = Z.T @ (w * (p - t)) / n
        g[1:] += l2 * beta[1:]
        beta = beta - lr * g
    return beta


def density_ratio(Xtr, Xte):
    """用逻辑回归估 p_te(x) / p_tr(x):训练样本标 0、测试样本标 1。"""
    ntr, nte = len(Xtr), len(Xte)
    X = np.vstack([Xtr, Xte])
    t = np.concatenate([np.zeros(ntr), np.ones(nte)])
    beta = fit_logistic(X, t)
    p = sigmoid(beta[0] + beta[1] * Xtr[:, 0])
    return p / np.maximum(1.0 - p, 1e-12) * (ntr / float(nte))


def wls(X, y, w=None):
    """加权最小二乘,返回斜率与截距。"""
    n = len(X)
    if w is None:
        w = np.ones(n)
    A = np.column_stack([np.ones(n), X])
    W = np.diag(w / w.mean())
    coef = np.linalg.solve(A.T @ W @ A + 1e-12 * np.eye(2), A.T @ W @ y)
    return coef[1], coef[0]


def mse(b, a, X, y):
    return float(np.mean((a + b * X - y) ** 2))


def ess(w):
    """有效样本量:权重越不均,剩下的信息越少。"""
    return float(w.sum() ** 2 / np.sum(w ** 2))


def shift_data(n=300, seed=1, mu_tr=0.0, mu_te=2.5, sd=0.7, noise=0.3):
    """训练区和测试区错开,条件分布不变,真函数是平方。"""
    rg = np.random.default_rng(seed)
    xtr = rg.normal(mu_tr, sd, n)
    xte = rg.normal(mu_te, sd, n)
    ytr = xtr ** 2 + rg.normal(0.0, noise, n)
    yte = xte ** 2 + rg.normal(0.0, noise, n)
    return xtr, ytr, xte, yte


xtr, ytr, xte, yte = shift_data()
b0, a0 = wls(xtr, ytr)
w = density_ratio(xtr[:, None], xte[:, None])
b1, a1 = wls(xtr, ytr, w)

print('[1] 训练区和测试区错开,条件分布是同一个')
print('  训练 x 均值 %+.2f  标准差 %.2f' % (xtr.mean(), xtr.std()))
print('  测试 x 均值 %+.2f  标准差 %.2f' % (xte.mean(), xte.std()))
print('  真函数是平方,噪声标准差 0.30')

print('')
print('[2] 只许用直线去拟合')
print('  普通最小二乘  斜率 %+.4f  截距 %+.4f' % (b0, a0))
print('  训练区误差 %.4f   测试区误差 %.4f' % (mse(b0, a0, xtr, ytr), mse(b0, a0, xte, yte)))
print('  直接拿真函数去预测,测试区只有 %.4f,就是噪声本身' % np.mean((xte ** 2 - yte) ** 2))
print('  直线在这里的误差是真函数的 %d 倍,等于什么都没学到' % round(mse(b0, a0, xte, yte) / np.mean((xte ** 2 - yte) ** 2)))

print('')
print('[3] 用密度比把训练样本按「有多像测试样本」重新称重')
print('  密度比  最小 %.4f  中位 %.4f  最大 %.4f' % (w.min(), np.median(w), w.max()))
print('  加权最小二乘  斜率 %+.4f  截距 %+.4f' % (b1, a1))
print('  训练区误差 %.4f   测试区误差 %.4f' % (mse(b1, a1, xtr, ytr), mse(b1, a1, xte, yte)))
print('  300 个样本加权后只相当于 %.1f 个' % ess(w))

print('')
print('[4] 移位越远,加权越值钱,但手里的数据也越少')
print('  移位   不加权    加权    倍数   有效样本量')
for mu in (0.5, 1.0, 1.5, 2.0, 2.5, 3.0):
    xa, ya, xb, yb = shift_data(mu_te=mu)
    u0, v0 = wls(xa, ya)
    ww = density_ratio(xa[:, None], xb[:, None])
    u1, v1 = wls(xa, ya, ww)
    m0 = mse(u0, v0, xb, yb)
    m1 = mse(u1, v1, xb, yb)
    print('  %.1f  %8.3f  %6.3f  %6.2f  %8.1f' % (mu, m0, m1, m0 / m1, ess(ww)))

print('')
print('[5] 两种把它做错的方式')
xb, yb = xte, yte
br, ar = wls(xtr, ytr, 1.0 / np.maximum(w, 1e-12))
print('  权重方向搞反   测试 %.3f' % mse(br, ar, xb, yb))
for cap in (1.0, 3.0, 10.0):
    wc = np.minimum(w, cap)
    bc, ac = wls(xtr, ytr, wc)
    print('  密度比截在 %.0f 以下  测试 %.3f  有效样本量 %.1f'
          % (cap, mse(bc, ac, xb, yb), ess(wc)))

实跑输出:

--------------------------------------------------------------------
协变量移位:训练区和测试区错开时,把样本按密度比重新称重
--------------------------------------------------------------------
[1] 训练区和测试区错开,条件分布是同一个
  训练 x 均值 -0.07  标准差 0.65
  测试 x 均值 +2.51  标准差 0.68
  真函数是平方,噪声标准差 0.30

[2] 只许用直线去拟合
  普通最小二乘  斜率 -0.1290  截距 +0.3911
  训练区误差 0.5438   测试区误差 56.9349
  直接拿真函数去预测,测试区只有 0.0925,就是噪声本身
  直线在这里的误差是真函数的 615 倍,等于什么都没学到

[3] 用密度比把训练样本按「有多像测试样本」重新称重
  密度比  最小 0.0002  中位 0.0303  最大 16.3047
  加权最小二乘  斜率 +2.3377  截距 -0.8977
  训练区误差 5.2261   测试区误差 6.7283
  300 个样本加权后只相当于 9.7 个

[4] 移位越远,加权越值钱,但手里的数据也越少
  移位   不加权    加权    倍数   有效样本量
  0.5     1.136   0.519    2.19     140.7
  1.0     3.881   0.490    7.91      32.3
  1.5    11.196   0.720   15.56      13.2
  2.0    27.055   2.106   12.84       9.7
  2.5    56.935   6.728    8.46       9.7
  3.0   107.810  18.720    5.76      11.5

[5] 两种把它做错的方式
  权重方向搞反   测试 187.471
  密度比截在 1 以下  测试 13.384  有效样本量 56.8
  密度比截在 3 以下  测试 9.936  有效样本量 31.4
  密度比截在 10 以下  测试 7.112  有效样本量 13.1

第一段的两行标准差都是 0.65 和 0.68,几乎一样,只有均值差 2.58。 这是刻意造的:分布形状不变、位置平移,是协变量移位最干净的形式。 第二段那 615 倍要说清楚它意味着什么。 0.0925 是噪声方差本身,也就是「完全知道真函数」时的误差下限。 直线给出的 56.9349 是这个下限的 615 倍——不是「差一点」,是「完全无效」。 斜率 -0.1290 更能说明问题:真函数在测试区明明一路陡升,直线却在往下走。 第三段密度比的中位数 0.0303 是最该停一下看的数。 它意味着有一半的训练样本,到了测试区只值原来的百分之三。 加权后的斜率 +2.3377 总算把方向摆正了——真函数在 2.5 附近,斜率约等于 5, 线性近似给出 2.34 已经算合理。 第四段那张表的列要看全。 改善倍数 2.19 涨到 15.56 再掉到 5.76,同时有效样本量从 140.7 一路掉到 9.7。 如果只盯「改善倍数」挑最大的那个点,就会选到 1.5; 可实际部署时还要问一句:13.2 个有效样本,估出来的参数稳吗? 第五段三种做错的方式里,权重搞反的 187.471 最值得记。 它比不加权的 56.935 还差三倍多——加权不是「加了总比不加好」,方向错了会更糟。 代码里有一处容易踩坑:density_ratio 最后要乘 ntr / nte。 因为逻辑回归拟合的是「这个样本属于测试集的概率」,而两边的样本量一般不等,不补上这个比值, 算出来的不是密度比。

30 秒实验

  1. 把 shift_data 的 mu_te 从 2.5 改成 0.8,重跑。 看改善倍数掉到多少(本节表里 1.0 处是 7.91 倍)。 移位越小,加权越没必要——因为不加权本来就在对的区域拟合。
  2. 把 shift_data 的 noise 从 0.3 改成 1.0,重跑。 噪声大了以后,「知道答案」的误差下限从 0.0925 涨到 1.0, 直线那 56.93 的相对差距就没那么吓人了。 这提醒一件事:比误差一定要和该问题的噪声水平比,不然数字没有意义。
  3. 把 density_ratio 里的 l2 从 1e-2 改成 1e-6,再看权重最大最小差多少。 正则去掉之后,逻辑回归会把训练样本的权重推到更极端,有效样本量掉得更狠。 密度比估计本身就是一个需要正则化的问题。

常见误区

  1. 「加权会让模型变好」。 加权只会让模型在「被加权的那个分布」上变好。 本节训练区误差从 0.5438 涨到 5.2261,涨了近十倍——如果评测集恰好是训练分布, 加权版会被判定为更差的模型。
  2. 「密度比越大越好,不截断最准」。 不截断确实在这份数据上误差最低(6.728 对 13.384),但有效样本量只剩 9.7。 换一份噪声更大的数据,不截断很可能直接崩——因为个别样本的权重会大到掩盖其余全部。 截不截断是按数据决定的,没有通用答案。
  3. 「只要分布不同就该加权」。 前提是条件分布没变。 如果标注标准也变了(概念漂移),密度比只会把错的规律搬得更用力。 判断依据是:同一个 x 上的 y 还一样吗?不一样就别用这套。
  4. 「密度比估不准也没关系,反正只是权重」。 实测方向搞反会到 187.471,比不加权的 56.935 还差三倍多。 权重是乘法,估错方向的破坏力远大于估错大小。
自测三问(点开对答案) 1. 协变量移位假设了什么不变、什么变了? —— 假设条件分布 p(y 给定 x) 不变,只有输入的边缘分布 p(x) 变了。 所以「规律通用,遇到的输入不同」。 本节训练区均值 -0.07、测试区 +2.51,标准差几乎一样,就是这个设定。 2. 密度比 w(x) 是怎么估出来的?为什么不用分别估两个分布? —— 把训练样本标 0、测试样本标 1,拟合一个二分类器,两类后验概率之比再乘上两边样本量的比, 就是密度比。因为贝叶斯公式里后验之比等于先验之比乘似然之比,似然之比正好是 p_te 除以 p_tr。 这样把无监督的密度估计变成了有监督的分类,逻辑回归就能直接用。 3. 为什么移位从 1.5 拉到 3.0,加权的改善倍数反而从 15.56 掉到 5.76? —— 因为有效样本量从 13.2 掉到 11.5,然后更低——权重越来越集中到极少数样本上。 这些样本自己的噪声被放大,估计的方差上来了。 移位越远越需要迁移,但也越没有本钱迁移,两头夹出一个峰值。

这一节的前提是「x 的分布变了」。 还有一种更常见的情形:x 根本没变,只是各个类别在数据里的比例变了。 比如训练集里正类只占两成,可上线后正类会占到五成。 下一节处理这一种,它比协变量移位简单得多——因为权重只跟标签有关,跟 x 无关。

相关笔记