qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

同样的线索,换一批人就不该给同样的概率?

把类别比例从一半改成一成,用完整枚举核对先验校正,并亲手破坏它依赖的条件。

展开本文目录
  1. 下载完整练习包
  2. 先把三个概率的主语说清楚
  3. 先按类别重加权,再重新归一化
  4. 为什么要先除旧比例:换一组不对称的数字
  5. 完整枚举,比抽几条样本更容易看清账目
  6. 知道新比例,也救不了已经变样的类别
  7. 运行精确分布与反例
  8. 把可检查的条件列在修正公式旁边
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
新旧先验比例
先看某类在新环境里变多还是变少,再用这个比例调整旧概率,最后让所有类别重新加起来等于 1。
类内分布不变
人群比例虽然变了,但一旦知道属于哪一类,该类呈现各种线索的方式仍与以前一样。
打开完整术语小抄 ↗

一条线索在旧样本中对应八成正例,上线后还应该原样报八成吗?如果旧数据一半是正例,新数据只有一成,而每种类别产生这条线索的方式保持不变,同样线索对应的后验概率就会改变。

我们用红色、蓝色两种观察值代替真实特征,完整列出两类的联合概率。这既能核对“按新旧类别比例修正”的公式,也能明确展示:只知道比例变了,还不足以断定修正可靠。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • label_shift.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B label_shift.py

解压到独立练习文件夹,在含 label_shift.py 的目录打开终端运行。指定离散分布的精确贝叶斯计算与假设失效反例;先验直接给定,未训练模型、估计先验或评估真实漂移。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先把三个概率的主语说清楚

P(y) 是观察线索前的类别比例;P(x|y) 是已知类别时,看到某种线索的比例;P(y|x) 才是看到线索后,我们想报出的类别概率。三个方向不能互换。

本例只有正类 y=1、负类 y=0,线索 x 为 red 或 blue。旧数据 P(y=1)=0.5;正类中红色占 0.8,负类中红色占 0.2。用贝叶斯公式可得,看到红色时正类概率为 0.8,看到蓝色时为 0.2。

现在只把正类比例改为 0.1,仍让正类中八成红色、负类中两成红色。这样的改变称为标签漂移:P(y) 变化,P(x|y) 保持。它和“每类的样子也变了”不是同一个假设。

先按类别重加权,再重新归一化

若旧后验为 p_old(y|x),新旧先验分别为 π_new(y)、π_old(y),则新后验正比于 p_old(y|x)×π_new(y)/π_old(y)。对所有类别算完权重后,还要除以它们的总和,才能恢复成加起来等于 1 的概率。

红色样本的正类权重为 0.8×0.1/0.5=0.16,负类权重为 0.2×0.9/0.5=0.36;归一化后得到 0.16/(0.16+0.36)=0.307692。直接把 0.8 乘 0.1/0.5 后报告 0.16,会漏掉归一化。

蓝色对应的新正类概率为 0.027027。程序另外从新分布的联合概率重新使用贝叶斯公式计算,逐项核对两条路线相等。这里的旧后验是从明确分布精确算出的,不是把任意模型分数都当成可靠概率。

为什么要先除旧比例:换一组不对称的数字

原来的旧先验恰好是 0.5 对 0.5,这会藏住一种错误:两类都除以同一个 0.5,最后归一化时这个公共因子会消掉。因此,只乘新先验却忘记除旧先验,在这个特例里也可能碰巧得到正确结果。要检查公式,换一个不均衡的旧比例更有用。

现在让旧数据的正类比例为 0.2、负类为 0.8,仍保持正类八成红色、负类两成红色。旧数据中“正类且红色”的概率是 0.2×0.8=0.16,“负类且红色”也是 0.8×0.2=0.16,所以看到红色后的旧正类概率为 0.5。这个 0.5 已经混进了旧类别比例;它不是单独的红色线索强度。

新正类比例改为 0.1 时,正类权重应为 0.5×0.1/0.2=0.25,负类权重为 0.5×0.9/0.8=0.5625。归一化后,正类概率是 0.25/(0.25+0.5625)=4/13≈0.307692。直接在新分布重算也得到 (0.1×0.8)/(0.1×0.8+0.9×0.2)=0.307692,两条路线相同。

若只乘新先验,会得到 0.05 和 0.45,再归一化成 0.1,明显不等于上面的 0.307692。先除旧先验是在去掉旧类别比例的影响,再乘新先验放入新比例;两类共有的归一化因子最后一起消掉。这个换比例的解释依然要求每类内部的线索分布不变,也要求旧后验确实代表原分布概率。

完整枚举,比抽几条样本更容易看清账目

联合空间只有四种事件:正类红、正类蓝、负类红、负类蓝。程序按事件概率计算期望指标,不抽样。因此输出中的 80% 或 90% 是这个指定分布的精确期望,不是真实验证集的实测准确率。

在目标正类比例 0.1 时,以 0.5 为分类阈值,旧概率给出期望准确率 0.8,修正后为 0.9;Brier 均方概率误差从 0.160000 变为 0.074844。修正后两种颜色都判为负类,但仍保留不同概率,不能把它简写成永远输出同一个概率。

这里采用等代价的 0.5 阈值。若漏判与误报的代价不同,决策阈值还要另外设定;概率校正、类别阈值和业务代价是相互关联但不同的问题。这一个分布上的改善不能作为某种真实分类器上线有效的证据。

知道新比例,也救不了已经变样的类别

反例把新正类比例设为 0.3,同时交换两类的红色概率:正类只有两成红色,负类有八成红色。旧模型仍认为红色像正类,单做先验校正得到红色 0.631579、蓝色 0.096774。

实际新后验恰好相反:红色 0.096774、蓝色 0.631579。按校正概率分类的期望准确率只有 0.2,按实际后验分类为 0.8。程序没有证明校正“总会变坏”,而是给出一个假设失效时足以推翻无条件保证的具体例子。

另一个边界是旧分布完全没有某类:π_old(y)=0 时比例无法相除,也无法从旧模型凭空恢复该类的线索关系。脚本明确拒绝零支持输入,避免用极小常数掩盖缺失的信息。

运行精确分布与反例

保存为 label_shift.py,运行 python -B label_shift.py。程序打印两种颜色的新旧概率、期望准确率与 Brier,并检查无变化时修正等于原值、联合概率总和以及零支持边界。report.json 写入当前目录。

本例把目标先验作为已知输入,没有从未标注目标数据估计它。实际应用中的 BBSE、最大似然标签漂移估计等方法还需要可识别性、校准或混淆矩阵等条件;本文没有实现这些估计器。

展开收起代码 · 小实验python · 85 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Exact discrete label-shift examples; no fitting, sampling, or network."""
import json
import math
from pathlib import Path


def posterior(prior_one, red_given_one, red_given_zero, color):
    likelihood_one = red_given_one if color == 'red' else 1 - red_given_one
    likelihood_zero = red_given_zero if color == 'red' else 1 - red_given_zero
    numerator = prior_one * likelihood_one
    denominator = numerator + (1 - prior_one) * likelihood_zero
    if denominator <= 0:
        raise ValueError('The observation has zero probability.')
    return numerator / denominator


def correct(source_probability, source_prior, target_prior):
    if not 0 < source_prior < 1:
        raise ValueError('Source must support both classes.')
    if not 0 <= target_prior <= 1 or not 0 <= source_probability <= 1:
        raise ValueError('Probabilities must be in [0, 1].')
    one = source_probability * target_prior / source_prior
    zero = (1 - source_probability) * (1 - target_prior) / (1 - source_prior)
    if one + zero == 0:
        raise ValueError('The reweighted observation has zero probability.')
    return one / (one + zero)


def evaluate(probabilities, prior, red_one, red_zero):
    accuracy = brier = total = 0.0
    for y, weight, p_red in [(1, prior, red_one), (0, 1 - prior, red_zero)]:
        for color, likelihood in [('red', p_red), ('blue', 1 - p_red)]:
            mass = weight * likelihood
            prediction = int(probabilities[color] >= 0.5)
            total += mass
            accuracy += mass * (prediction == y)
            brier += mass * (probabilities[color] - y) ** 2
    assert math.isclose(total, 1.0, abs_tol=1e-12)
    return {'accuracy': accuracy, 'brier': brier}


def main():
    source_prior = 0.5
    source = {c: posterior(source_prior, 0.8, 0.2, c) for c in ['red', 'blue']}
    target_prior = 0.1
    corrected = {c: correct(p, source_prior, target_prior) for c, p in source.items()}
    exact = {c: posterior(target_prior, 0.8, 0.2, c) for c in source}
    for c in source:
        assert math.isclose(corrected[c], exact[c], abs_tol=1e-12)
        assert math.isclose(correct(source[c], source_prior, source_prior), source[c])
    before = evaluate(source, target_prior, 0.8, 0.2)
    after = evaluate(corrected, target_prior, 0.8, 0.2)
    assert after['brier'] < before['brier']
    print('valid label shift: prior 0.5 -> 0.1; class-conditional likelihoods unchanged')
    for c in source:
        print(f'{c}: source={source[c]:.6f}, corrected={corrected[c]:.6f}, exact={exact[c]:.6f}')
    print(f"expected accuracy: {before['accuracy']:.6f} -> {after['accuracy']:.6f}")
    print(f"expected Brier: {before['brier']:.6f} -> {after['brier']:.6f}")
    # The target now reverses class-conditional color probabilities.
    broken_prior = 0.3
    wrong = {c: correct(p, source_prior, broken_prior) for c, p in source.items()}
    oracle = {c: posterior(broken_prior, 0.2, 0.8, c) for c in source}
    wrong_metrics = evaluate(wrong, broken_prior, 0.2, 0.8)
    oracle_metrics = evaluate(oracle, broken_prior, 0.2, 0.8)
    assert wrong_metrics['accuracy'] < oracle_metrics['accuracy']
    print('invalid label shift: class-conditional likelihoods reversed')
    for c in source:
        print(f'{c}: prior-only={wrong[c]:.6f}, actual={oracle[c]:.6f}')
    print(f"expected accuracy: prior-only={wrong_metrics['accuracy']:.6f}, actual-posterior={oracle_metrics['accuracy']:.6f}")
    try:
        correct(0.5, 0.0, 0.1)
    except ValueError:
        print('zero source support: rejected')
    else:
        raise AssertionError('Missing source-support check.')
    report = {'source':source, 'valid_shift':{'prior':target_prior,'corrected':corrected,
        'exact':exact,'before':before,'after':after}, 'invalid_shift':{'prior':broken_prior,
        'prior_only':wrong,'actual':oracle,'prior_only_metrics':wrong_metrics,'actual_metrics':oracle_metrics},
        'scope':'Exact enumeration of specified distributions; target priors supplied, not estimated; no trained model.'}
    Path('report.json').write_text(json.dumps(report, indent=2, allow_nan=False)+'\n', encoding='utf-8')
    print('checks passed: exact Bayes match, identity shift, normalized mass, violated-assumption counterexample')


if __name__ == '__main__':
    main()

怎样运行:在独立练习文件夹中执行 python -B label_shift.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。指定离散分布的精确贝叶斯计算与假设失效反例;先验直接给定,未训练模型、估计先验或评估真实漂移。

运行后对照结果
valid label shift: prior 0.5 -> 0.1; class-conditional likelihoods unchanged
red: source=0.800000, corrected=0.307692, exact=0.307692
blue: source=0.200000, corrected=0.027027, exact=0.027027
expected accuracy: 0.800000 -> 0.900000
expected Brier: 0.160000 -> 0.074844
invalid label shift: class-conditional likelihoods reversed
red: prior-only=0.631579, actual=0.096774
blue: prior-only=0.096774, actual=0.631579
expected accuracy: prior-only=0.200000, actual-posterior=0.800000
zero source support: rejected
checks passed: exact Bayes match, identity shift, normalized mass, violated-assumption counterexample

把可检查的条件列在修正公式旁边

上线前至少分开核对:目标类别比例从哪里来,旧后验是否经过独立校准,两边是否仍在预测同一套标签,以及 P(x|y) 不变有什么证据。仅从输入直方图变化不能独自确认标签漂移,因为多种变化方式会产生类似现象。

本文核对了标签漂移与概率校准的一手研究,但本次执行只包含标准库的有限分布计算;没有训练模型、访问业务数据、估计真实先验或验证线上漂移。这种刻意缩小的模型适合先把公式与失败边界弄明白。

记住这些要点

  • 标签漂移要求类别比例变化而类内线索分布保持。
  • 后验乘新旧先验比以后仍要归一化。
  • 精确先验不能补救错误的类内分布假设。
  • 本文先验已知;从无标签数据估计先验是额外任务。

别把例子用过头

  • 把模型分数未加核对地当作旧后验概率。
  • 只修正正类项,忘记同时重加权负类并归一化。
  • 把任意输入漂移都归为标签漂移。
  • 将四种事件的期望计算写成真实线上评测。

自己试一小步

把正类先验从 0.05 扫到 0.95,哪些区间会让红、蓝两种线索改变分类决定?

  1. 保留类内颜色概率,打印每个目标先验下的两个后验。
  2. 同时记录固定 0.5 阈值下的期望准确率与 Brier。
  3. 另设一个误报和漏判代价不对称的决策规则。
  4. 再逐渐改变正类红色概率,检查先验校正与实际后验的差距。

做到这些,就算完成

  • 每个目标先验都从联合分布独立核对后验。
  • 区分概率是否准确与决策是否符合代价。
  • 不把指定先验的扫描解释为真实先验估计。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。