"""
=============================================================================
CS525 · 第 1 讲共享模块：一个"知道真相"的合成视觉世界（vision_world.py）
=============================================================================

对应讲义：lectures/lecture01_intro_imagenet.md（§3 ImageNet 的构建、§5 基准的可靠性）

-----------------------------------------------------------------------------
【为什么要造一个合成世界】
-----------------------------------------------------------------------------
这门课研究的是"训练数据怎样决定模型"。在真实数据上做这类研究有一个根本困难：
我们**不知道真相**——不知道 ImageNet 里哪张图的标签是错的、哪张图"难"、网络上还有多少没被收进来的图。

所以第 1 讲造了一个小世界，里面每一张"图片"的全部真相都是已知的：
    · 它的主体是哪一类物体（main），这个物体有多"清晰"（clarity，0 到 1：大小、遮挡、光照、角度的综合）；
    · 它里面有没有第二个物体（second）——例如"花上的蝴蝶"；
    · 它是不是一张垃圾图（junk：没有可识别的物体，例如 logo、文字截图）。
这样我们就能精确地回答："众包标注收进来的图片有多少是对的？""新测试集比旧测试集难在哪里？"

-----------------------------------------------------------------------------
【世界的结构：一个迷你 WordNet】
-----------------------------------------------------------------------------
ImageNet 的类别来自 WordNet 的名词层级（例如 dog → husky、malamute……）。这里模仿它：
    8 个超类（superclass）× 每个 5 个细类（fine class）= 40 个类别。
同一个超类里的细类彼此相似（husky 和 malamute 很像），不同超类之间差别很大（狗和卡车）。
不同超类的"细粒度程度"不同：dog、cat 最难区分，vehicle 最容易。

-----------------------------------------------------------------------------
【"图片"是什么】
-----------------------------------------------------------------------------
我们不生成像素，而是直接生成一个 DIM 维的**特征向量**——可以把它想成"一个完美的特征提取器从图片里读出的东西"：

    x = clarity_main · center[main] + clarity_2 · center[second] + 噪声 N(0, I)

    · center[c] = 超类原型 + 细类偏移：同一超类的类中心离得近，所以细粒度类别难分；
    · clarity 越小，信号越弱，图片越难（模型和人都更难认出来）；
    · 第二个物体会把特征"拉向"另一个类别，造成标签歧义（这就是 ImageNet 用 top-5 的原因之一）。

-----------------------------------------------------------------------------
【人（众包标注员）怎样看图】
-----------------------------------------------------------------------------
ImageNet 的标注方式是：给 Amazon Mechanical Turk（MTurk）的工人看一组候选图片和一个类别的定义，
问"哪些图片里有这个类别的物体？"。一张图被选中的比例叫**选择频率（selection frequency, sf）**。
这里的模型：
    · 图里确实有这个物体：清晰度越高越容易被选中（细粒度类别需要更高的清晰度才认得出来）；
    · 图里是它的"兄弟类"（同一超类的另一个细类）：有一定概率被误选（把 malamute 当成 husky）；
    · 无关物体或垃圾图：很少被选中；
    · 另外有一部分工人是"乱点的人"（spammer），以 0.5 的概率随便选。
每一票都是独立的伯努利试验，成功概率就是这张图的**真实选择频率**。真实 sf 我们知道，现实中只能用有限票数去估计。

-----------------------------------------------------------------------------
【这个模块提供什么】
-----------------------------------------------------------------------------
    VisionWorld                      世界本身：类别、类中心、标注员模型
    VisionWorld.search(q, n, rng)    模拟"用类别名去网络图片搜索"：返回 n 张候选图（大部分不是 q！）
    VisionWorld.sample_images(...)   直接生成指定类别 / 清晰度的图片（后面各讲构造测试集时用）
    VisionWorld.selection_frequency  每张候选图对它的查询类别的真实选择频率
    collect_votes(sf, k, rng)        模拟 k 个标注员各投一票
    confidence_table / dynamic_consensus   ImageNet 论文里的"动态共识"算法
    build_dataset(world, ...)        用"搜索 → 众包 → 动态共识"建一个 ImageNet 式的数据集

后面的讲次（第 2 讲的分布偏移、第 5 讲的过滤……）会只读地导入这个模块。

【运行自检】
    python3 vision_world.py        # 约 2 秒，打印世界的概况和一次小规模建库
"""

import math

import numpy as np

# =============================================================================
# 世界的"设定"：超类、细类和难度参数
# =============================================================================
# 每一行：(超类名, 细类之间的距离 fine_sep, 人需要多清晰才认得出 human_threshold,
#          认真的标注员最多有多大把握认出它 human_max, 把兄弟类误认成本类的概率 sibling_confusion, [5 个细类名])
#   · fine_sep 越小，同一超类里的细类越像（对模型越难）；
#   · human_threshold 越大，人需要看得越清楚才能认出来（对标注员越难）；
#   · human_max 越小，越多的标注员根本不认识这个类别（很多人分不清 husky 和 malamute，于是干脆不选）；
#   · sibling_confusion 越大，标注员越容易把兄弟类当成本类（标签越"脏"）。
# 这些数字是我设定的，只要求"像 ImageNet"：狗的品种最难，交通工具最容易。
SUPERCLASSES = [
    ("dog",        1.0, 0.45, 0.80, 0.25, ["husky", "malamute", "whippet", "greyhound", "beagle"]),
    ("cat",        1.2, 0.40, 0.85, 0.20, ["tabby", "burmese", "persian", "siamese", "egyptian"]),
    ("bird",       1.8, 0.30, 0.92, 0.10, ["robin", "jay", "magpie", "goldfinch", "ostrich"]),
    ("fish",       2.0, 0.30, 0.92, 0.08, ["tench", "goldfish", "shark", "stingray", "eel"]),
    ("vehicle",    2.6, 0.20, 0.97, 0.04, ["minibus", "ambulance", "jeep", "tractor", "forklift"]),
    ("food",       1.8, 0.30, 0.92, 0.12, ["bagel", "pretzel", "pizza", "burrito", "guacamole"]),
    ("instrument", 2.2, 0.25, 0.92, 0.08, ["banjo", "cello", "violin", "harp", "flute"]),
    ("household",  1.6, 0.30, 0.90, 0.15, ["teapot", "pitcher", "vase", "ladle", "wine_bottle"]),
]

# 清晰度的先验分布 Beta(a, b)：网络上的图片里，物体有大有小、有遮挡有不遮挡。
# Beta(1.5, 1.3) 的平均值约 0.54，分布很宽——既有大量清晰的图，也有不少"勉强能看出来"的图。
CLARITY_BETA = (1.5, 1.3)

# 候选图的 5 种"真实身份"（相对于搜索用的查询类别 q）
KIND_TRUE = 0        # 主体就是 q
KIND_SECONDARY = 1   # 主体是别的东西，q 只是图里的第二个物体（"花上的蝴蝶"）
KIND_SIBLING = 2     # 主体是 q 的兄弟类（搜 husky 搜到了 malamute）
KIND_OTHER = 3       # 主体是无关类别
KIND_JUNK = 4        # 没有可识别的物体（logo、文字截图、乱七八糟的图）
KIND_NAMES = ["true", "secondary", "sibling", "other", "junk"]

# 网络图片搜索返回的结果里，5 种身份各占多少（加起来是 1）。
# ImageNet 论文（Deng et al. 2009 §3.1）说网络图片搜索结果的平均准确率"约 10%"；
# 这里"主体就是 q"占 15%，再加上 3% 的"q 是第二个物体"，含有 q 的候选约 18%。
DEFAULT_SEARCH_MIX = (0.15, 0.03, 0.20, 0.17, 0.45)


def _sigmoid(z):
    """
    数值稳定的 sigmoid：1 / (1 + e^(−z))。

    【思路】直接写 1 / (1 + np.exp(-z)) 在 z 很负时 np.exp(-z) 会溢出（numpy 发出 overflow 警告，
            用 -W error 运行时会变成异常）。先把 z 截断到 [−50, 50]，结果在双精度下没有区别。
    【参数】z：numpy 数组或数
    【返回】同形状的数组，取值在 (0, 1)
    """
    z = np.clip(z, -50.0, 50.0)
    return 1.0 / (1.0 + np.exp(-z))


class Images:
    """
    一批"图片"：每张图片的特征和全部真相，用一组等长的 numpy 数组保存（"结构的数组"）。

    字段（都是长度为 n 的数组，x 是 n × DIM）：
        x          特征向量（模型能看到的唯一东西）
        label      数据集给它的标签（对候选图来说就是搜索用的查询类别 q）
        main       主体物体的真实类别（junk 图为 −1）
        clarity    主体物体的清晰度（junk 图为 0）
        second     第二个物体的类别（没有则为 −1）
        clarity2   第二个物体的清晰度（没有则为 0）
        kind       相对于 label 的真实身份（KIND_TRUE … KIND_JUNK）
        sf         对 label 的真实选择频率（标注员投"是"的概率）

    【为什么不用"每张图一个对象"】几十万张图时，Python 对象的列表又慢又占内存；
    一组 numpy 数组可以整体做向量化运算（例如一次算出所有图的选择频率）。
    """

    FIELDS = ("x", "label", "main", "clarity", "second", "clarity2", "kind", "sf")

    def __init__(self, **arrays):
        for name in self.FIELDS:
            setattr(self, name, arrays[name])

    def __len__(self):
        return len(self.label)

    def subset(self, index):
        """
        取出一部分图片。

        【参数】index：布尔掩码（长度 n）或整数下标数组
        【返回】新的 Images（数组是拷贝，修改它不影响原对象）
        【numpy 小知识】a[mask] 用布尔数组选出 True 的位置；a[idx] 用整数数组按下标取，可以重复、可以乱序。
                        两种"花式索引"都会返回拷贝，而不是视图。
        """
        return Images(**{name: getattr(self, name)[index] for name in self.FIELDS})

    @staticmethod
    def concat(parts):
        """
        把几批图片首尾相接拼成一批。

        【参数】parts：Images 的列表（至少一个）
        【返回】新的 Images
        【边界】parts 为空时 np.concatenate 会报错——调用方保证非空。
        """
        return Images(**{name: np.concatenate([getattr(p, name) for p in parts]) for name in Images.FIELDS})

    @property
    def contains_label(self):
        """布尔数组：图里是否**真的有** label 这个物体（主体或第二个物体）。这是"标签正确"的定义。"""
        return (self.kind == KIND_TRUE) | (self.kind == KIND_SECONDARY)


class VisionWorld:
    """
    合成视觉世界：40 个类别的类中心 + 标注员模型。

    【参数】
        seed           决定类中心的随机种子（同一个 seed 永远是同一个世界）
        dim            特征维数
        super_norm     超类原型的长度（越大，超类之间越好分）
        noise          特征噪声的标准差
        spammer_rate   乱点的标注员比例（他们以 0.5 的概率随便选）
        clutter_rate   一张普通图片里出现第二个（无关）物体的概率
        search_mix     网络搜索结果里 5 种身份的比例（见 DEFAULT_SEARCH_MIX）
        recog_width    标注员"认出来的概率"随清晰度上升的快慢（越小越陡，见 recognize_prob）

    【数据联系】这个世界把"数据集是怎样被造出来的"变成了可以控制的变量：
        第 1 讲用它复现 ImageNet 的构建和 ImageNetV2 的复现实验；
        第 2 讲会在上面加"风格 / 领域"，研究分布偏移；第 5 讲研究过滤。
    """

    def __init__(self, seed=0, dim=32, super_norm=5.0, noise=0.45, spammer_rate=0.10,
                 clutter_rate=0.25, search_mix=DEFAULT_SEARCH_MIX, recog_width=0.2):
        rng = np.random.default_rng(seed)
        self.dim = dim
        self.noise = noise
        self.spammer_rate = spammer_rate
        self.clutter_rate = clutter_rate
        self.recog_width = recog_width
        self.search_mix = np.asarray(search_mix, dtype=float)
        if abs(self.search_mix.sum() - 1.0) > 1e-9:
            raise ValueError("search_mix 的 5 个比例加起来必须等于 1")

        # ---- 类别表 ----
        self.super_names = [s[0] for s in SUPERCLASSES]
        self.class_names = []
        super_of, fine_sep, threshold, top, confusion = [], [], [], [], []
        for g, (_, sep, thr, hmax, conf, fines) in enumerate(SUPERCLASSES):
            for name in fines:
                self.class_names.append(name)
                super_of.append(g)
                fine_sep.append(sep)
                threshold.append(thr)
                top.append(hmax)
                confusion.append(conf)
        self.n_classes = len(self.class_names)
        self.n_super = len(self.super_names)
        self.super_of = np.array(super_of)                 # 每个类属于哪个超类
        self.human_threshold = np.array(threshold)        # 每个类：人需要多清晰才认得出
        self.human_max = np.array(top)                    # 每个类：认真的标注员最多有多大把握认出它
        self.sibling_confusion = np.array(confusion)      # 每个类：兄弟类被误选的概率

        # ---- 类中心 = 超类原型 + 细类偏移 ----
        # 随机方向在 32 维空间里几乎互相垂直，所以不同超类的原型自然分得很开。
        protos = rng.standard_normal((self.n_super, dim))
        protos *= super_norm / np.linalg.norm(protos, axis=1, keepdims=True)
        offsets = rng.standard_normal((self.n_classes, dim))
        offsets *= np.array(fine_sep)[:, None] / np.linalg.norm(offsets, axis=1, keepdims=True)
        self.centers = protos[self.super_of] + offsets    # 形状 (40, dim)

        # 每个类的兄弟类列表（同一超类、不是自己）
        self.siblings = [np.flatnonzero((self.super_of == self.super_of[c]) & (np.arange(self.n_classes) != c))
                         for c in range(self.n_classes)]

    # -------------------------------------------------------------------------
    # 生成图片
    # -------------------------------------------------------------------------
    def render(self, main, clarity, second, clarity2, rng):
        """
        把"真相"变成模型能看到的特征向量：x = clarity·center[main] + clarity2·center[second] + 噪声。

        【参数】main / second：类别下标数组（−1 表示没有）；clarity / clarity2：清晰度数组；rng：随机数生成器
        【返回】形状 (n, dim) 的特征矩阵
        【边界】main = −1 的图（junk）没有主体信号，只有噪声和一点点随机的弱物体（见 search）。
        【numpy 小知识】np.where(cond, a, b) 逐元素地"cond 为真取 a，否则取 b"。
                        这里先把 −1 换成 0 去取类中心（避免下标越界），再把对应的清晰度乘成 0。
        """
        n = len(main)
        m = np.where(main >= 0, main, 0)
        s = np.where(second >= 0, second, 0)
        x = (np.where(main >= 0, clarity, 0.0)[:, None] * self.centers[m]
             + np.where(second >= 0, clarity2, 0.0)[:, None] * self.centers[s])
        return x + self.noise * rng.standard_normal((n, self.dim))

    def _random_other(self, q, n, rng, allow_siblings=True):
        """
        随机抽 n 个"不是 q"的类别。

        【参数】q：类别下标（整数）或长度 n 的数组；allow_siblings：False 时也排除 q 的兄弟类
        【返回】长度 n 的整数数组
        【思路】先在全部类别里均匀抽，再把撞上 q（或兄弟类）的位置重抽，直到没有冲突。
                40 个类里冲突的概率很小，循环通常只走一两轮。
        """
        q = np.broadcast_to(np.asarray(q), (n,))
        out = rng.integers(0, self.n_classes, size=n)
        while True:
            bad = out == q
            if not allow_siblings:
                bad |= self.super_of[out] == self.super_of[q]
            if not bad.any():
                return out
            out[bad] = rng.integers(0, self.n_classes, size=int(bad.sum()))

    def search(self, q, n, rng):
        """
        模拟"用类别 q 的名字去网络图片搜索"，返回 n 张候选图片。

        【思路】真实的网络搜索结果大部分**不是**你要的东西（ImageNet 论文估计平均只有约 10% 是对的）。
                按 search_mix 的比例，每张候选图随机成为 5 种身份之一，再按身份生成主体、清晰度和第二个物体：
                    true       主体 = q，清晰度 ~ Beta(1.5, 1.3)（平均约 0.54，有很多半遮挡、远景的图）
                    secondary  主体 = 别的类别，q 作为第二个物体出现，清晰度 ~ Beta(1.5, 2.5)（通常较小）
                    sibling    主体 = q 的一个兄弟类
                    other      主体 = 与 q 无关的类别
                    junk       没有物体（只有一个清晰度 0.1 的随机弱信号，代表杂乱背景）
                另外，true / sibling / other 图以 clutter_rate 的概率带一个无关的第二个物体。
        【参数】q：查询类别下标；n：候选图数量；rng：np.random.Generator
        【返回】Images，label 全是 q，sf 已经算好（真实选择频率）
        【边界】n = 0 时返回空的 Images（各数组长度为 0）。

        【数据联系】这一步决定了"数据集能收进什么"：搜索引擎没返回的图，后面的任何清洗都救不回来。
                    第 3 讲（LAION）和第 6 讲（Common Crawl）会看到同样的结构：先有一个巨大、脏的候选池，再过滤。
        """
        kind = rng.choice(5, size=n, p=self.search_mix)
        main = np.full(n, q)
        clarity = rng.beta(*CLARITY_BETA, size=n)
        second = np.full(n, -1)
        clarity2 = np.zeros(n)

        # sibling：主体换成 q 的某个兄弟类
        sib = kind == KIND_SIBLING
        main[sib] = rng.choice(self.siblings[q], size=int(sib.sum()))
        # other / secondary：主体换成与 q 无关的类别（不含兄弟类，避免和 sibling 混在一起）
        oth = (kind == KIND_OTHER) | (kind == KIND_SECONDARY)
        main[oth] = self._random_other(q, int(oth.sum()), rng, allow_siblings=False)
        # secondary：q 作为第二个物体
        sec = kind == KIND_SECONDARY
        second[sec] = q
        clarity2[sec] = rng.beta(1.5, 2.5, size=int(sec.sum()))
        # junk：没有主体，只有一个很弱的随机物体（代表杂乱的背景）
        junk = kind == KIND_JUNK
        main[junk] = -1
        clarity[junk] = 0.0
        second[junk] = rng.integers(0, self.n_classes, size=int(junk.sum()))
        clarity2[junk] = 0.1
        # 普通图片里的杂物：以 clutter_rate 的概率出现一个与 q 无关的第二个物体
        clutter = (~sec) & (~junk) & (rng.random(n) < self.clutter_rate)
        second[clutter] = self._random_other(q, int(clutter.sum()), rng, allow_siblings=False)
        clarity2[clutter] = rng.beta(1.5, 3.0, size=int(clutter.sum()))

        x = self.render(main, clarity, second, clarity2, rng)
        imgs = Images(x=x, label=np.full(n, q), main=main, clarity=clarity, second=second,
                      clarity2=clarity2, kind=kind, sf=np.zeros(n))
        imgs.sf = self.selection_frequency(imgs)
        return imgs

    def sample_images(self, classes, rng, clarity=None, clutter_rate=None):
        """
        直接生成"主体就是指定类别"的图片（跳过搜索和标注）。

        【用途】需要一个"干净"的分布时用：例如第 2 讲构造领域偏移的测试集，或者在练习里做对照实验。
        【参数】classes：每张图的类别（整数数组）；rng；
                clarity：None 表示从清晰度先验 Beta(1.5, 1.3) 抽，或者给一个数 / 数组；
                clutter_rate：None 表示用世界默认值
        【返回】Images（kind 全是 KIND_TRUE，label = main = classes）
        """
        classes = np.asarray(classes)
        n = len(classes)
        if clarity is None:
            clarity = rng.beta(*CLARITY_BETA, size=n)
        clarity = np.broadcast_to(np.asarray(clarity, dtype=float), (n,)).copy()
        rate = self.clutter_rate if clutter_rate is None else clutter_rate
        second = np.full(n, -1)
        clarity2 = np.zeros(n)
        clutter = rng.random(n) < rate
        second[clutter] = self._random_other(classes[clutter], int(clutter.sum()), rng, allow_siblings=False)
        clarity2[clutter] = rng.beta(1.5, 3.0, size=int(clutter.sum()))
        x = self.render(classes, clarity, second, clarity2, rng)
        imgs = Images(x=x, label=classes.copy(), main=classes.copy(), clarity=clarity, second=second,
                      clarity2=clarity2, kind=np.full(n, KIND_TRUE), sf=np.zeros(n))
        imgs.sf = self.selection_frequency(imgs)
        return imgs

    # -------------------------------------------------------------------------
    # 标注员模型
    # -------------------------------------------------------------------------
    def recognize_prob(self, clarity, q):
        """
        一个认真的标注员看到"清晰度为 clarity 的 q"时，认出它的概率。

        【思路】0.02 + (human_max_q − 0.02) · sigmoid((clarity − 阈值_q) / recog_width)：
                清晰度远高于阈值时接近 human_max_q，远低于阈值时约 0.02，在阈值附近（宽度约 ±0.2）逐渐过渡。
                细粒度类别（dog、cat）的阈值更高、human_max 更低——要看清楚才分得出 husky 和 malamute，
                而且有相当一部分标注员根本不认识这些品种。
                所以即使是完全正确、清晰的图，选择频率也到不了 1：ImageNetV2 论文测得原版验证集的
                平均选择频率只有 0.71。
        【参数】clarity：清晰度数组；q：类别下标（整数或同长数组）
        【返回】概率数组
        """
        return 0.02 + (self.human_max[q] - 0.02) * _sigmoid((clarity - self.human_threshold[q]) / self.recog_width)

    def selection_frequency(self, imgs):
        """
        每张图对它的 label 的**真实**选择频率：一个随机抽来的标注员投"是"的概率。

        【思路】先算认真的标注员投"是"的概率 p（按身份分情况），再混入 spammer：
                    sf = (1 − spammer_rate) · p + spammer_rate · 0.5
                    true       p = 认出主体的概率
                    secondary  p = 0.85 × 认出第二个物体的概率（小物体更容易被忽略）
                    sibling    p = 兄弟类误认率 × 看清主体的概率（看都看不清，也就谈不上认错）
                    other/junk p = 0.02（偶尔手滑）
        【参数】imgs：Images
        【返回】长度 n 的数组，取值在 (0, 1)
        【边界】真实的选择频率也取决于任务界面、报酬、工人群体……这些都被压缩进了几个常数（见讲义 §5.4 的讨论）。
        """
        q = imgs.label
        p = np.full(len(q), 0.02)
        k = imgs.kind
        t = k == KIND_TRUE
        p[t] = self.recognize_prob(imgs.clarity[t], q[t])
        s = k == KIND_SECONDARY
        p[s] = 0.85 * self.recognize_prob(imgs.clarity2[s], q[s])
        b = k == KIND_SIBLING
        p[b] = self.sibling_confusion[q[b]] * self.recognize_prob(imgs.clarity[b], q[b])
        return (1 - self.spammer_rate) * p + self.spammer_rate * 0.5

    def describe(self):
        """把世界的设定整理成几行文字（自检和讲义里用）。"""
        lines = [f"{self.n_classes} 个类别 = {self.n_super} 个超类 × 5 个细类；特征 {self.dim} 维；"
                 f"spammer 比例 {self.spammer_rate:.2f}；杂物率 {self.clutter_rate:.2f}"]
        for g, name in enumerate(self.super_names):
            cs = np.flatnonzero(self.super_of == g)
            c0 = cs[0]
            lines.append(f"  {name:<11} 细类 {', '.join(self.class_names[c] for c in cs):<44} "
                         f"人眼阈值 {self.human_threshold[c0]:.2f}  最高认出率 {self.human_max[c0]:.2f}  "
                         f"兄弟类误认率 {self.sibling_confusion[c0]:.2f}")
        return lines


# =============================================================================
# 众包标注：投票、置信表与动态共识
# =============================================================================
def collect_votes(sf, k, rng):
    """
    模拟 k 个标注员各看一次图、各投一票。

    【参数】sf：每张图的真实选择频率（长度 n）；k：每张图收集几票；rng
    【返回】形状 (n, k) 的布尔矩阵，True 表示这一票是"是"
    【边界】k = 0 时返回形状 (n, 0) 的空矩阵。
    【思路】每一票独立：rng.random((n, k)) < sf[:, None] 的每个元素以 sf 的概率为 True。
    【numpy 小知识】sf[:, None] 把形状 (n,) 变成 (n, 1)，和 (n, k) 比较时会"广播"成 (n, k)：
                    第 i 行的每一列都和 sf[i] 比较。
    """
    return rng.random((len(sf), k)) < sf[:, None]


def confidence_table(votes, prior=0.2):
    """
    ImageNet 的"置信表"：给定前 n 票里有 y 票是"是"，这张图是好图的概率 P(好 | y, n)。

    【思路】Deng et al.（2009）§3.2：对每个类别，先随机抽一小批候选图，每张图至少收集 10 票，
            由此得到一张置信表；之后其余的图边收票边查表，直到置信度达到预定阈值。
            论文没有说明这一小批图的"真相"从哪里来；这里用最自然的做法——
            **全部 K 票的多数（> K/2 票为"是"）当作"好图"的伪真相**，然后对每个 (n, y) 数数：
                P(好 | 前 n 票里 y 票"是") ≈ (#好图 + prior·p0) / (#图 + prior)
            其中 p0 是这批图里好图的总比例（数据少的格子会被拉向 p0，这叫加性平滑 / 收缩）。
            prior 取得很小（0.2）：只有"一张图都没有"的格子才基本等于 p0；只要有几张图，就主要听数据的。
    【参数】votes：形状 (m, K) 的布尔矩阵（m 张初始图、每张 K 票）；prior：平滑强度
    【返回】形状 (K+1, K+1) 的数组 table，table[n, y] = P(好 | 前 n 票里 y 票"是")（y > n 的格子无意义，填 nan）
    【边界】m 很小时表很粗糙：例如细粒度类别里很少有图能连续得到 6 票"是"，这些格子几乎没有数据，
            只能退回到 p0（"不知道"）——这时算法会继续收票，直到收满 K 票再按 0.5 判定。
            prior 取大（例如 1.0）会把这些稀疏的格子都拉向很低的 p0，难的类别就永远达不到接受阈值。
            伪真相本身也会出错（例如兄弟类误认率很高时，多数票可能把兄弟类当成好图）——
            这正是"标签质量取决于标注流程"的一个缩影。
    """
    m, K = votes.shape
    good = votes.sum(axis=1) > K / 2
    p0 = (good.sum() + 0.5) / (m + 1.0)
    table = np.full((K + 1, K + 1), np.nan)
    cum = np.cumsum(votes, axis=1)                      # cum[:, n-1] = 前 n 票里"是"的票数
    table[0, 0] = p0
    for n in range(1, K + 1):
        yes = cum[:, n - 1]
        for y in range(n + 1):
            sel = yes == y
            table[n, y] = (good[sel].sum() + prior * p0) / (sel.sum() + prior)
    return table


def dynamic_consensus(votes, table, accept=0.95, reject=0.02):
    """
    动态共识：每张图一票一票地收，查置信表，够有把握就停。

    【思路】对每张图，第 n 票之后查 table[n, y]：
                ≥ accept → 接受，停止收票；
                ≤ reject → 拒绝，停止收票；
                否则继续收下一票。票收完（n = K）还没停的，按 table ≥ 0.5 决定。
            好处：容易的类别（"cat"）一两票就能定，难的类别（"Burmese cat"）自动多收几票；
                  垃圾图第一票说"不是"往往就能拒绝。钱花在真正有分歧的图上。
    【参数】votes：形状 (n, K) 的布尔矩阵（这里预先抽好了全部 K 票，算法只"看"它需要的前几票）；
            table：confidence_table 的输出（K 必须一致）；accept / reject：两个阈值
    【返回】(accepted, n_used)：布尔数组（是否被接受）和整数数组（每张图实际用了几票）
    【边界】table 里某个格子是 nan（初始样本里从没出现过的 (n, y)）时，比较结果为 False，等于"继续收票"。
    【numpy 小知识】这里没有对每张图写 Python 循环，而是对"第几票"循环（最多 K 轮），
                    每轮用布尔掩码一次处理所有还没决定的图，速度快得多。
    """
    n_img, K = votes.shape
    decided = np.zeros(n_img, dtype=bool)
    accepted = np.zeros(n_img, dtype=bool)
    n_used = np.full(n_img, K)
    cum = np.cumsum(votes, axis=1)
    for n in range(1, K + 1):
        conf = table[n, cum[:, n - 1]]
        with np.errstate(invalid="ignore"):            # nan 参与比较时不报警告，结果当作 False
            acc_now = (~decided) & (conf >= accept)
            rej_now = (~decided) & (conf <= reject)
        accepted |= acc_now
        stop = acc_now | rej_now
        n_used[stop] = n
        decided |= stop
    # 收满 K 票仍未决定的：按 0.5 判定
    last = table[K, cum[:, K - 1]]
    with np.errstate(invalid="ignore"):
        accepted |= (~decided) & (last >= 0.5)
    return accepted, n_used


def build_dataset(world, per_class, rng, init_per_class=200, max_votes=10, accept=0.95, reject=0.02,
                  classes=None, return_stats=False):
    """
    用 ImageNet 的流程建一个数据集：网络搜索 → 众包投票 → 动态共识 → 每类随机保留 per_class 张。

    【思路】对每个类别：
            1) 搜索 init_per_class 张候选图，每张收满 max_votes 票，建置信表；
            2) 再一批一批地搜索候选图，用动态共识决定收不收，直到收够 per_class 张
               （初始那批图里通过的也算进去——它们也是正常的候选图）；
            3) 收到的图里随机保留 per_class 张。
    【参数】world：VisionWorld；per_class：每类最终保留几张；rng；
            init_per_class：建置信表用的初始候选图数量；max_votes：每张图最多几票；
            accept / reject：动态共识的阈值；classes：只建哪些类别（None = 全部）；
            return_stats：True 时额外返回每类的统计（候选数、投票数、置信表）
    【返回】Images（label = 类别），或 (Images, stats 列表)
    【边界】如果某个类别的候选图里几乎没有好图（搜索精度极低或阈值极高），循环会一直搜下去；
            这里设了一个上限（候选图超过 per_class × 200 张就报错），避免死循环。

    【数据联系】这里每一个参数都会改变"数据集是什么分布"：
            accept 阈值越高，收进来的图越"典型"（清晰、容易）；max_votes 越多，标签越干净但越贵。
            第 1 讲代码 02 研究这些取舍，代码 03 用它建"原版 ImageNet"，再用另一种流程建"复现版"。
    """
    if classes is None:
        classes = range(world.n_classes)
    parts, stats = [], []
    for q in classes:
        init = world.search(q, init_per_class, rng)
        v0 = collect_votes(init.sf, max_votes, rng)
        table = confidence_table(v0)
        acc0, _ = dynamic_consensus(v0, table, accept, reject)
        got = [init.subset(acc0)]
        n_got = int(acc0.sum())
        n_cand, n_votes = init_per_class, init_per_class * max_votes
        while n_got < per_class:
            batch = world.search(q, max(4 * (per_class - n_got), 200), rng)
            v = collect_votes(batch.sf, max_votes, rng)
            acc, used = dynamic_consensus(v, table, accept, reject)
            got.append(batch.subset(acc))
            n_got += int(acc.sum())
            n_cand += len(batch)
            n_votes += int(used.sum())
            if n_cand > per_class * 200:
                raise RuntimeError(f"类别 {world.class_names[q]} 收不够图片：搜索精度太低或阈值太高")
        pool = Images.concat(got)
        keep = rng.choice(len(pool), size=per_class, replace=False)
        parts.append(pool.subset(np.sort(keep)))
        stats.append({"class": q, "candidates": n_cand, "votes": n_votes, "accepted": n_got, "table": table})
    data = Images.concat(parts)
    return (data, stats) if return_stats else data


def split_by_class(imgs, sizes, rng):
    """
    把数据集按类别随机切成几份（例如训练 / 验证 / 测试），每份每类的数量由 sizes 给定。

    【参数】imgs：Images；sizes：每份每类的图片数，例如 (300, 100)；rng
    【返回】Images 的列表，长度 = len(sizes)
    【边界】某个类别的图片数少于 sum(sizes) 时报错。
    【数据联系】ImageNet 的验证集是从同一批收集到的图片里**随机**分出来的——
                所以原版验证集和训练集是"同分布"的，这是代码 03 的出发点。
    """
    out = [[] for _ in sizes]
    for c in np.unique(imgs.label):
        idx = np.flatnonzero(imgs.label == c)
        if len(idx) < sum(sizes):
            raise ValueError(f"类别 {c} 只有 {len(idx)} 张，不够切成 {sizes}")
        idx = rng.permutation(idx)
        start = 0
        for j, s in enumerate(sizes):
            out[j].append(idx[start:start + s])
            start += s
    return [imgs.subset(np.sort(np.concatenate(o))) for o in out]


# =============================================================================
# 自检
# =============================================================================
if __name__ == "__main__":
    world = VisionWorld(seed=0)
    for line in world.describe():
        print(line)
    rng = np.random.default_rng(1)
    cands = world.search(0, 20000, rng)
    print("\n搜索 'husky' 得到 20000 张候选图，5 种身份的比例：",
          {KIND_NAMES[k]: round(float(np.mean(cands.kind == k)), 3) for k in range(5)})
    print("  含有 husky 的比例（“含 q” = true 或 secondary）：", round(float(cands.contains_label.mean()), 3))
    for k in range(5):
        sel = cands.kind == k
        print(f"  {KIND_NAMES[k]:<10} 平均真实选择频率 {cands.sf[sel].mean():.3f}")
    data, stats = build_dataset(world, 50, np.random.default_rng(2), classes=[0, 22], return_stats=True)
    for st in stats:
        q = st["class"]
        part = data.subset(data.label == q)
        print(f"\n建库 {world.class_names[q]:<8}：候选 {st['candidates']} 张、共 {st['votes']} 票，"
              f"通过 {st['accepted']} 张，保留 50 张；标签正确率 {part.contains_label.mean():.3f}")
        t = st["table"]
        print("  置信表 P(好 | n 票里 y 票'是')，n = 1..3：",
              [(n, y, round(float(t[n, y]), 2)) for n in (1, 2, 3) for y in range(n + 1)])
