"""
=============================================================================
CS525 · 第 2 讲共享模块：带"风格 / 领域"的视觉世界，以及网络图文对（domain_world.py）
=============================================================================

对应讲义：lectures/lecture02_reliable_generalization.md

-----------------------------------------------------------------------------
【为什么要在第 1 讲的世界上加东西】
-----------------------------------------------------------------------------
第 1 讲的视觉世界只有一种图片：照片，区别只在"物体有多清楚"。ImageNetV2 那样的偏移（新测试集稍难一点）
可以在里面演示，但更大的分布偏移——素描（ImageNet-Sketch）、油画和卡通（ImageNet-R）、
摆在奇怪位置的物体（ObjectNet）——需要"图片的风格"这个新变量。

这里的关键设计是把一张图的特征拆成两块：

    x = [ 形状块（32 维） | 纹理块（16 维） ]

    · 形状块 = 第 1 讲的特征：清晰度 × 类中心 + 第二个物体 + 噪声。它在所有风格里都存在——
      素描里的狗仍然是狗的形状。
    · 纹理块 = 风格增益 × 类别纹理 ν_c + 风格向量 σ_d + 噪声。
      照片里纹理很清楚（毛色、材质），而且对细粒度类别（husky vs malamute）非常有用；
      素描里根本没有纹理，只有一个所有类别共享的"素描风格"向量；油画、卡通介于两者之间。

于是一个只见过照片的模型，会很自然地依赖纹理——在照片上这是对的（纹理确实有用），
换到素描就出问题。这正是 Geirhos 等人（2019）发现的"ImageNet 训练的 CNN 偏向纹理"在合成世界里的样子。

-----------------------------------------------------------------------------
【这个模块提供什么】
-----------------------------------------------------------------------------
    DomainWorld                      世界本身：复用第 1 讲 VisionWorld 的类别和类中心，加上纹理和风格
    DomainWorld.sample(...)          生成指定类别、风格、清晰度、姿态的图片
    DomainWorld.curated(...)         "ImageNet 式"的精选照片：只有照片、常规姿态、偏清晰（经过共识筛选）
    DomainWorld.test_suite(...)      一组测试集：ImageNet（同分布）、V2、ObjectNet、Sketch、Rendition
    DomainWorld.web_pool(...)        "网络图文对"：各种风格混在一起，每张图配一段 alt-text（可能有错、有噪声）
    hardmatch_labels(captions)       从说明文字里"按类别名硬匹配"出标签（Fang et al. 的 YFCC-hardmatch）
    probit / fit_line / effective_robustness   "准确率在一条线上"和有效鲁棒性的计算

后面的讲次（第 3 讲 LAION 式过滤、第 5 讲 DataComp）会只读地导入这个模块。

【运行自检】
    python3 domain_world.py        # 约 1 秒：打印各风格的设定和一个网络图文对的样子
"""

import os
import sys
from statistics import NormalDist

import numpy as np

# 第 1 讲的模块：只读地导入（把 code/lecture01 加进模块搜索路径）
_L1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01")
if _L1 not in sys.path:
    sys.path.insert(0, _L1)
from vision_world import VisionWorld  # noqa: E402

SHAPE_DIM = 32           # 形状块的维数（= 第 1 讲 VisionWorld 的特征维数）
TEXTURE_DIM = 16         # 纹理块的维数
DIM = SHAPE_DIM + TEXTURE_DIM

# 5 种风格：(名字, 纹理增益, 风格向量长度)
#   纹理增益：类别纹理在这种风格里还剩多少（照片 1.0，素描 0）
#   风格向量：这种风格给所有图片加上的同一个"风格偏移"（素描的线条感、卡通的平涂色块）
DOMAINS = ["photo", "painting", "cartoon", "sketch", "render"]
DOMAIN_SPECS = {
    "photo":    (1.0, 0.0),
    "painting": (0.5, 2.0),
    "cartoon":  (0.3, 2.5),
    "sketch":   (0.0, 3.0),
    "render":   (0.4, 2.0),
}

# 网络图文对里各种风格的比例（"web_pool" 的默认值）：照片仍然最多，但其他风格加起来占了大半。
# （这个比例是我设定的。照片占 0.55 时，零样本 CLIP 的有效鲁棒性只有 +0.01 左右，几乎看不出来；
#   占 0.35 时约 +0.03。真实的网络图片里照片的比例更高，但风格、内容、拍摄条件的多样性远比这里丰富。）
DEFAULT_WEB_MIX = {"photo": 0.35, "painting": 0.15, "cartoon": 0.15, "sketch": 0.15, "render": 0.20}

# 说明文字（alt-text）用到的词
DOMAIN_WORDS = {"photo": "photo", "painting": "painting", "cartoon": "cartoon", "sketch": "drawing",
                "render": "render"}
FILLER = ["my", "the", "new", "best", "old", "from", "trip", "day", "home", "cute", "big", "little",
          "blue", "red", "green", "summer", "winter", "vintage", "free", "hd", "art", "image", "view",
          "near", "park", "city", "beach", "store", "sale", "gift", "family", "friends", "weekend", "2019",
          "2020", "2021", "jpg", "img", "dsc", "upload"]


class DomainImages:
    """
    一批"图片"（可能带说明文字）：等长的 numpy 数组 + 一个说明文字列表。

    字段：
        x            特征，形状 (n, 48)：前 32 维是形状块，后 16 维是纹理块
        label        真实类别（0–39）
        domain       风格编号（DOMAINS 里的下标）
        clarity      主体的清晰度
        pose         姿态偏转角（弧度；0 = 最常见的姿态）
        captions     说明文字（token 列表的列表），没有时为 None
        cap_kind     说明文字的"真实质量"：0 = 提到了正确的类别名，1 = 含糊（没有类别名），2 = 错的（提到别的类别）

    【为什么不直接复用第 1 讲的 Images】那个类的字段是为"搜索 + 众包"设计的（kind、sf……），
    这里需要的是风格、姿态和说明文字。两者互不依赖，各自简单。
    """

    FIELDS = ("x", "label", "domain", "clarity", "pose", "cap_kind")

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

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

    def subset(self, index):
        """取出一部分（布尔掩码或整数下标）。说明文字列表也按同样的下标取。"""
        index = np.asarray(index)
        if index.dtype == bool:
            index = np.flatnonzero(index)
        caps = None if self.captions is None else [self.captions[i] for i in index]
        return DomainImages(captions=caps, **{f: getattr(self, f)[index] for f in self.FIELDS})

    @staticmethod
    def concat(parts):
        """首尾相接拼成一批（说明文字要么全有、要么全没有）。"""
        caps = None
        if all(p.captions is not None for p in parts):
            caps = [c for p in parts for c in p.captions]
        return DomainImages(captions=caps, **{f: np.concatenate([getattr(p, f) for p in parts])
                                              for f in DomainImages.FIELDS})


class DomainWorld:
    """
    带风格的视觉世界。

    【参数】seed：世界的随机种子（决定类中心、纹理原型、风格向量）；
            texture_norm：类别纹理原型的长度（越大，纹理在照片里越好用）；
            texture_noise：纹理块的噪声标准差；
            shape_noise：形状块的噪声标准差（第 1 讲是 0.45；这里取 0.7，让照片上的准确率不至于饱和，
                         模型之间能拉开差距——ImageNet 上十年间的模型也是从 20% 多到 80% 多）
    【思路】形状块完全复用第 1 讲的 VisionWorld（同一个 seed 就是同样的 40 个类中心），
            所以第 1 讲里"细粒度类别更难"的结构在这里仍然成立；纹理块是新加的。
    【数据联系】这个世界把"训练分布"变成了一个可以控制的旋钮：只给模型看照片，还是各种风格都给它看？
            第 2 讲用它复现"准确率在一条线上"和"数据决定鲁棒性"；第 3、5 讲用 web_pool 做图文数据的过滤。
    """

    def __init__(self, seed=0, texture_norm=1.5, texture_noise=1.0, shape_noise=0.7):
        self.base = VisionWorld(seed=seed, noise=shape_noise)
        self.class_names = self.base.class_names
        self.super_names = self.base.super_names
        self.super_of = self.base.super_of
        self.n_classes = self.base.n_classes
        rng = np.random.default_rng(1000 + seed)
        tex = rng.standard_normal((self.n_classes, TEXTURE_DIM))
        self.textures = texture_norm * tex / np.linalg.norm(tex, axis=1, keepdims=True)
        self.styles = {}
        for d in DOMAINS:
            v = rng.standard_normal(TEXTURE_DIM)
            self.styles[d] = DOMAIN_SPECS[d][1] * v / np.linalg.norm(v)
        self.texture_noise = texture_noise
        # 说明文字的词表：类别名、超类名、风格词、填充词
        self.vocab = sorted(set(self.class_names) | set(self.super_names) | set(DOMAIN_WORDS.values())
                            | set(FILLER) | {"a", "of"})
        self.word_id = {w: i for i, w in enumerate(self.vocab)}

    # -------------------------------------------------------------------------
    # 生成图片
    # -------------------------------------------------------------------------
    def sample(self, classes, domain, rng, clarity_beta=(1.5, 1.3), pose_sd=0.15, unusual_pose=False,
               odd_background=False, clutter_rate=0.25):
        """
        生成一批图片。

        【参数】classes：每张图的类别（整数数组）；domain：风格名（字符串）或每张图一个风格名的数组；rng；
                clarity_beta：清晰度的 Beta 分布参数；
                pose_sd：姿态偏转角（弧度）的标准差——常见照片的物体大多是"标准姿态"；
                unusual_pose：True 时姿态偏转角 ~ U(0.6, 1.2) 弧度（约 35°–70°），模仿 ObjectNet 里倒放的椅子、侧躺的锅；
                odd_background：True 时纹理块里额外混入一个随机别的类别的纹理（ObjectNet 的物体出现在不寻常的背景里）；
                clutter_rate：出现第二个物体的概率
        【返回】DomainImages（没有说明文字）
        【思路】形状块：把第 1 讲的类中心 μ_c 按姿态角 θ "转"向一个随机方向：
                    μ' = cos θ · μ_c + sin θ · ‖μ_c‖ · u（u 是随机单位向量）
                θ 越大，形状越不像这个类别的"标准样子"。然后乘清晰度、加噪声（第 1 讲的 render）。
                纹理块：风格增益 × ν_c + 风格向量 + 噪声。
        【边界】classes 为空时返回空的 DomainImages。
        """
        classes = np.asarray(classes)
        n = len(classes)
        doms = np.broadcast_to(np.asarray(domain), (n,)) if np.ndim(domain) else np.full(n, domain)
        dom_idx = np.array([DOMAINS.index(d) for d in doms], dtype=int)
        clarity = rng.beta(*clarity_beta, size=n)
        if unusual_pose:
            pose = rng.uniform(0.6, 1.2, size=n)
        else:
            pose = np.abs(rng.normal(0.0, pose_sd, size=n))
        # ---- 形状块 ----
        centers = self.base.centers[classes]
        u = rng.standard_normal((n, SHAPE_DIM))
        u /= np.linalg.norm(u, axis=1, keepdims=True)
        rotated = np.cos(pose)[:, None] * centers + (np.sin(pose) * np.linalg.norm(centers, axis=1))[:, None] * u
        second = np.full(n, -1)
        clarity2 = np.zeros(n)
        clutter = rng.random(n) < clutter_rate
        second[clutter] = self.base._random_other(classes[clutter], int(clutter.sum()), rng, allow_siblings=False)
        clarity2[clutter] = rng.beta(1.5, 3.0, size=int(clutter.sum()))
        s = np.where(second >= 0, second, 0)
        shape = (clarity[:, None] * rotated + np.where(second >= 0, clarity2, 0.0)[:, None] * self.base.centers[s]
                 + self.base.noise * rng.standard_normal((n, SHAPE_DIM)))
        # ---- 纹理块 ----
        gain = np.array([DOMAIN_SPECS[DOMAINS[i]][0] for i in dom_idx])
        style = np.stack([self.styles[DOMAINS[i]] for i in dom_idx]) if n else np.zeros((0, TEXTURE_DIM))
        texture = gain[:, None] * self.textures[classes] + style
        texture += (gain * np.where(second >= 0, 0.5 * clarity2, 0.0))[:, None] * self.textures[s]
        if odd_background:
            other = self.base._random_other(classes, n, rng, allow_siblings=False)
            texture += 0.7 * self.textures[other]
        texture += self.texture_noise * rng.standard_normal((n, TEXTURE_DIM))
        x = np.concatenate([shape, texture], axis=1)
        return DomainImages(x=x, label=classes.copy(), domain=dom_idx, clarity=clarity, pose=pose,
                            cap_kind=np.zeros(n, dtype=int))

    def curated(self, n_per_class, rng):
        """
        "ImageNet 式"的精选照片：只有照片，常规姿态，清晰度偏高（Beta(3, 1.5)，平均约 0.67——
        第 1 讲代码 02 里经过共识筛选的数据集平均清晰度是 0.66）。

        【参数】n_per_class：每类几张；rng
        【返回】DomainImages，类别均衡、按类别排好
        """
        classes = np.repeat(np.arange(self.n_classes), n_per_class)
        return self.sample(classes, "photo", rng, clarity_beta=(3.0, 1.5), pose_sd=0.15)

    def test_suite(self, n_per_class, rng):
        """
        一组测试集，模仿 ImageNet 及其分布偏移测试集：

            ImageNet    同分布：精选照片（和 curated 一样）
            V2          照片，清晰度稍低（Beta(2.4, 1.5)）——"几乎相同的流程，稍难一点的图"
            ObjectNet   照片，但物体姿态不寻常（35°–70°）、背景里混着别的东西的纹理
            Sketch      素描：没有类别纹理，只有素描风格
            Rendition   油画 / 卡通 / 渲染各占三分之一：纹理很弱、风格各异

        【参数】n_per_class：每个测试集每类几张；rng
        【返回】字典：名字 → DomainImages（按上面的顺序）
        """
        C = self.n_classes
        cls = np.repeat(np.arange(C), n_per_class)
        rend_dom = np.array(["painting", "cartoon", "render"])[rng.integers(0, 3, size=len(cls))]
        return {
            "ImageNet": self.sample(cls, "photo", rng, clarity_beta=(3.0, 1.5)),
            "V2": self.sample(cls, "photo", rng, clarity_beta=(2.4, 1.5)),
            "ObjectNet": self.sample(cls, "photo", rng, clarity_beta=(3.0, 1.5), unusual_pose=True,
                                     odd_background=True),
            "Sketch": self.sample(cls, "sketch", rng, clarity_beta=(3.0, 1.5)),
            "Rendition": self.sample(cls, rend_dom, rng, clarity_beta=(3.0, 1.5)),
        }

    # -------------------------------------------------------------------------
    # 网络图文对
    # -------------------------------------------------------------------------
    def caption(self, label, domain, kind, rng):
        """
        给一张图写一段 alt-text（token 列表）。

        【参数】label：类别；domain：风格名；kind：0 = 好的（提到类别名），1 = 含糊（只有填充词，有时带超类名），
                2 = 错的（提到另一个随机类别的名字）；rng
        【返回】token 列表，例如 ["my", "husky", "photo", "park"]
        【思路】真实的 alt-text 五花八门："IMG_2034.jpg"、"Buy cheap shoes"、"a husky in the snow"……
                这里用三种"质量"概括：好的说明文字才对学习有用；含糊的没有坏处但也没用；错的会教错。
        """
        words = [FILLER[i] for i in rng.choice(len(FILLER), size=rng.integers(1, 4), replace=False)]
        if kind == 0:
            words.append(self.class_names[label])
            if rng.random() < 0.5:
                words.append(DOMAIN_WORDS[domain])
            if rng.random() < 0.2:
                words.append(self.super_names[self.super_of[label]])
        elif kind == 1:
            if rng.random() < 0.4:
                words.append(self.super_names[self.super_of[label]])
        else:
            words.append(self.class_names[self.base._random_other(np.array([label]), 1, rng)[0]])
        order = rng.permutation(len(words))
        return [words[i] for i in order]

    def web_pool(self, n, rng, mix=None, p_vague=0.30, p_wrong=0.10, class_probs=None, p_unusual=0.25,
                 p_odd_bg=0.25):
        """
        生成 n 个"网络图文对"：风格混在一起，清晰度、姿态都更随意，说明文字有好有坏。

        【参数】n：数量；rng；mix：风格比例字典（默认 DEFAULT_WEB_MIX）；
                p_vague / p_wrong：说明文字含糊 / 错误的比例；
                class_probs：40 个类别的出现概率（默认均匀；第 5 讲用它模仿网络数据的长尾）；
                p_unusual / p_odd_bg：照片里"姿态不寻常" / "背景不寻常"的比例（网络照片不像精选照片那样规整）
        【返回】DomainImages，带 captions 和 cap_kind
        【思路】和精选照片比，网络数据有三个不同：
                (1) 风格多样（mix）；(2) 清晰度分布更宽（Beta(1.5, 1.3)，没有经过共识筛选），
                照片里有一部分姿态或背景不寻常；(3) 说明文字有噪声。
        【数据联系】CLIP 的 4 亿图文对、YFCC、LAION 都是这一类数据。第 2 讲的结论是：(1) 才是鲁棒性的来源。
        """
        mix = DEFAULT_WEB_MIX if mix is None else mix
        names = list(mix)
        probs = np.array([mix[k] for k in names], dtype=float)
        probs /= probs.sum()
        doms = np.array(names)[rng.choice(len(names), size=n, p=probs)]
        if class_probs is None:
            classes = rng.integers(0, self.n_classes, size=n)
        else:
            classes = rng.choice(self.n_classes, size=n, p=class_probs)
        is_photo = doms == "photo"
        unusual = is_photo & (rng.random(n) < p_unusual)
        odd_bg = is_photo & (rng.random(n) < p_odd_bg)
        # 四种组合分别生成，再按原来的顺序拼回去
        parts, order = [], []
        for un in (False, True):
            for ob in (False, True):
                m = (unusual == un) & (odd_bg == ob)
                if m.any():
                    parts.append(self.sample(classes[m], doms[m], rng, clarity_beta=(1.5, 1.3), pose_sd=0.3,
                                             unusual_pose=un, odd_background=ob))
                    order.append(np.flatnonzero(m))
        order = np.concatenate(order)
        imgs = DomainImages.concat(parts).subset(np.argsort(order, kind="stable"))
        u = rng.random(n)
        kind = np.where(u < p_wrong, 2, np.where(u < p_wrong + p_vague, 1, 0))
        imgs.cap_kind = kind
        imgs.captions = [self.caption(int(imgs.label[i]), DOMAINS[imgs.domain[i]], int(kind[i]), rng)
                         for i in range(n)]
        return imgs

    def describe(self):
        """把世界的设定整理成几行文字。"""
        lines = [f"{self.n_classes} 个类别；特征 {DIM} 维 = 形状 {SHAPE_DIM} 维 + 纹理 {TEXTURE_DIM} 维；"
                 f"词表 {len(self.vocab)} 个词"]
        for d in DOMAINS:
            g, s = DOMAIN_SPECS[d]
            lines.append(f"  {d:<9} 纹理增益 {g:.1f}   风格向量长度 {s:.1f}   网络数据里占 {DEFAULT_WEB_MIX[d]:.2f}")
        return lines


def hardmatch_labels(world, captions):
    """
    "硬匹配"标签：说明文字里恰好出现一个类别名时，把它当作标签；否则返回 −1（丢弃）。

    【思路】Fang et al.（2022）的 YFCC-hardmatch：用 ImageNet 的类别名在 YFCC 的说明文字里做子串匹配，
            构造一个"不用对比学习、只用分类损失"的训练集。这是整个流程里唯一用到语言的一步。
    【参数】world：DomainWorld；captions：token 列表的列表
    【返回】长度 n 的整数数组（−1 表示没匹配上或匹配到多个类别）
    【边界】错误的说明文字（提到别的类别）会得到错误的标签——硬匹配继承了 alt-text 的噪声。
    """
    name_to_id = {name: i for i, name in enumerate(world.class_names)}
    out = np.full(len(captions), -1)
    for i, cap in enumerate(captions):
        hits = sorted({name_to_id[w] for w in cap if w in name_to_id})
        if len(hits) == 1:
            out[i] = hits[0]
    return out


# =============================================================================
# "准确率在一条线上"与有效鲁棒性
# =============================================================================
_ND = NormalDist()


def probit(p):
    """Φ⁻¹(p)：把准确率映射到 probit 尺度。p 先截到 [1e-4, 1 − 1e-4]，避免无穷大。"""
    return np.array([_ND.inv_cdf(min(max(float(v), 1e-4), 1 - 1e-4)) for v in np.atleast_1d(p)])


def inv_probit(z):
    """Φ(z)：probit 尺度 → 准确率。"""
    return np.array([_ND.cdf(float(v)) for v in np.atleast_1d(z)])


def fit_line(acc_id, acc_ood):
    """
    在 probit 尺度上拟合 Φ⁻¹(acc_ood) ≈ a · Φ⁻¹(acc_id) + b，返回 (a, b, R²)。

    【思路】Taori et al.（2020）和 Miller et al.（2021）发现：在同一个训练分布上训练的各种模型，
            分布内准确率和分布外准确率在 probit（或 logit）尺度上几乎落在一条直线上——"准确率在一条线上"。
            这条线就是"基线"：给定分布内准确率，一个"普通"模型在分布外应该有多少。
    【参数】acc_id / acc_ood：两组准确率（同样长度的数组，至少 2 个模型）
    【返回】(斜率 a, 截距 b, R²)
    """
    xi, yi = probit(acc_id), probit(acc_ood)
    a, b = np.polyfit(xi, yi, 1)
    resid = yi - (a * xi + b)
    r2 = 1 - (resid ** 2).sum() / ((yi - yi.mean()) ** 2).sum()
    return float(a), float(b), float(r2)


def effective_robustness(acc_id, acc_ood, line):
    """
    有效鲁棒性（effective robustness，Taori et al. 2020）：实际的分布外准确率，减去基线直线预测的分布外准确率。

        ER(f) = acc_ood(f) − Φ(a · Φ⁻¹(acc_id(f)) + b)

    【为什么要这样定义】分布内更准的模型，分布外通常也更准——这不叫"更鲁棒"，只是"更好"。
            有效鲁棒性问的是：在分布内准确率相同的前提下，分布外是否更好？也就是"在直线上方多少"。
    【参数】acc_id / acc_ood：一个或一组模型的准确率；line：fit_line 的返回值
    【返回】与输入同形状的数组（正 = 在线上方）
    """
    a, b, _ = line
    return np.atleast_1d(acc_ood) - inv_probit(a * probit(acc_id) + b)


# =============================================================================
# 自检
# =============================================================================
if __name__ == "__main__":
    world = DomainWorld(seed=0)
    for line in world.describe():
        print(line)
    rng = np.random.default_rng(1)
    web = world.web_pool(8, rng)
    print("\n8 个网络图文对的样子：")
    kinds = ["好", "含糊", "错"]
    for i in range(8):
        print(f"  类别 {world.class_names[web.label[i]]:<11} 风格 {DOMAINS[web.domain[i]]:<9} "
              f"说明文字（{kinds[web.cap_kind[i]]}）：{' '.join(web.captions[i])}")
    print("  硬匹配标签：", [world.class_names[k] if k >= 0 else "—" for k in hardmatch_labels(world, web.captions)])
