"""
=============================================================================
CS525 · 第 5 讲模块：一个迷你 DataComp（datacomp_mini.py）
=============================================================================

对应讲义：lectures/lecture05_image_text_datasets.md

-----------------------------------------------------------------------------
【DataComp 是什么】
-----------------------------------------------------------------------------
传统的机器学习基准：数据固定，比模型。DataComp（Gadre et al. 2023）反过来：**训练代码和算力固定，比数据**。
它提供：
    · 一个从 Common Crawl 收集的候选池（CommonPool），四个规模：1280 万、1.28 亿、12.8 亿、128 亿对；
    · 标准化的 CLIP 训练代码（每个规模的模型大小、训练步数都固定）；
    · 38 个下游任务的评测（零样本分类、检索……），报告平均分。
参赛者提交的是"从候选池里选哪些样本"（过滤赛道），或者"加入自己找的数据"（自带数据赛道）。

-----------------------------------------------------------------------------
【这里的迷你版】
-----------------------------------------------------------------------------
    候选池   第 2 讲 domain_world 的网络图文对，外加两种真实网络数据里常见的"麻烦"：
             · 图里印着字（typographic）的样本：海报、截图、表情包、商品图……图里的文字常常就是说明文字本身。
               它们的 CLIP 分数很高，但图的"视觉内容"可能和文字无关（T-MARS 关注的问题，脚本 03）；
             · 长尾的类别分布：少数概念（husky、pizza）铺天盖地，多数概念很少出现（MetaCLIP 关注的问题，脚本 02）。
    特征     在第 2 讲的 48 维（形状 + 纹理）之后再加 8 维"文字块"：普通图片的文字块只是噪声；
             印着字的图片，文字块里有对应类别名的"字形"信号。
    教师     一个在"私有数据"上训练的 CLIP（扮演 OpenAI 的 CLIP），用来打 CLIP 分数。
    学生     固定的训练配方：迷你 CLIP，固定步数、固定 batch——不管过滤后剩多少数据，"看到的样本总数"都一样。
    评测     7 个零样本任务：ImageNet、V2、ObjectNet、Sketch、Rendition（第 2 讲的测试集组），
             狗的品种（5 选 1）、超类（8 选 1，用超类名做提示）。报告 7 个任务的平均准确率。

【运行自检】
    python3 datacomp_mini.py        # 约 10 秒：建候选池、训练一个不过滤的学生，打印池子的组成和学生的评测结果
"""

import os
import sys

import numpy as np
import torch
import torch.nn.functional as F

_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, "..", "lecture01"))
sys.path.insert(0, os.path.join(_HERE, "..", "lecture02"))
from classifiers import accuracy  # noqa: E402
from clip_lite import TinyCLIP, encode_captions, train_clip  # noqa: E402
from domain_world import DIM, DOMAINS, DomainWorld  # noqa: E402

TEXT_DIM = 8
FULL_DIM = DIM + TEXT_DIM            # 48 + 8 = 56
KIND_NAMES = ["好", "含糊", "错", "图里有字", "图文一致的字"]
KIND_TYPO = 3          # 图里印的字和图的内容无关（海报、截图、表情包……）
KIND_TEXTGOOD = 4      # 图里印的字就是图里的东西（贴着标签的商品图、带名字的路牌……）


class Pool:
    """
    候选池（或它的一个子集）：等长的 numpy 数组 + 说明文字列表。

    字段：x（n × 56）、label（图的真实类别）、domain、clarity、kind（0 好 / 1 含糊 / 2 错 / 3 图里有字）、
          text_cls（图里印的字对应的类别，没有字时为 −1）、captions（token 列表的列表）
    kind = 4（图文一致的字）只在 make_pool(p_textgood > 0) 时出现。
    """

    FIELDS = ("x", "label", "domain", "clarity", "kind", "text_cls")

    def __init__(self, captions, **arrays):
        for f in self.FIELDS:
            setattr(self, f, arrays[f])
        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)
        return Pool([self.captions[i] for i in index], **{f: getattr(self, f)[index] for f in self.FIELDS})


class DataCompWorld:
    """
    迷你 DataComp 的世界：第 2 讲的 DomainWorld + 文字块 + 候选池生成器 + 评测任务。

    【参数】seed：世界的随机种子；text_strength：图里印着字时，文字块信号的长度
    """

    def __init__(self, seed=0, text_strength=3.0):
        self.dw = DomainWorld(seed=seed)
        self.class_names = self.dw.class_names
        self.super_names = self.dw.super_names
        self.super_of = self.dw.super_of
        self.vocab, self.word_id = self.dw.vocab, self.dw.word_id
        rng = np.random.default_rng(2000 + seed)
        g = rng.standard_normal((len(self.class_names), TEXT_DIM))
        self.glyphs = text_strength * g / np.linalg.norm(g, axis=1, keepdims=True)   # 每个类别名的"字形"信号

    def add_text_block(self, x, text_cls, rng, noise=0.3):
        """
        在 48 维特征后面拼上 8 维文字块：text_cls ≥ 0 的图印着那个类别名（字形信号 + 噪声），其余只有噪声。

        【参数】x：(n, 48)；text_cls：长度 n 的整数数组（−1 = 没有字）；rng；noise：文字块的噪声标准差
        【返回】(n, 56)
        """
        n = len(x)
        tb = noise * rng.standard_normal((n, TEXT_DIM))
        has = text_cls >= 0
        tb[has] += self.glyphs[text_cls[has]]
        return np.concatenate([x, tb], axis=1)

    def make_pool(self, n, rng, p_vague=0.35, p_wrong=0.15, p_typo=0.10, class_probs=None, mix=None, p_textgood=0.0):
        """
        生成候选池：n 个图文对。

        【参数】n：数量；rng；p_vague / p_wrong / p_typo：说明文字含糊 / 错误 / 图里有字的比例；
                class_probs：类别分布（None = 均匀）；mix：风格比例（None = 第 2 讲的默认值）；
                p_textgood：额外把这么多比例的"好"样本改成"图里印着自己的名字"（图文一致的字；默认 0，脚本 03 用到）
        【返回】Pool
        【思路】先用第 2 讲的 web_pool 生成普通的图文对（好 / 含糊 / 错），再把其中 p_typo 的样本改造成"图里有字"：
                图的内容换成一个随机的别的类别（或模糊得几乎看不清），图里印上说明文字提到的类别名，
                说明文字照抄图里的字——这类样本 CLIP 分数很高，但"图"和"字"说的不是一回事。
        """
        base = self.dw.web_pool(n, rng, mix=mix, p_vague=p_vague / (1 - p_typo), p_wrong=p_wrong / (1 - p_typo),
                                class_probs=class_probs)
        kind = base.cap_kind.copy()
        text_cls = np.full(n, -1)
        typo = rng.random(n) < p_typo
        idx = np.flatnonzero(typo)
        if len(idx):
            # 图里印的字：一个类别名（按同样的类别分布抽）；图的内容：另一个随机类别，清晰度偏低
            words = rng.choice(len(self.class_names), size=len(idx), p=class_probs)
            other = self.dw.base._random_other(words, len(idx), rng)
            fresh = self.dw.sample(other, "photo", rng, clarity_beta=(1.2, 2.5))
            base.x[idx] = fresh.x
            base.label[idx] = other
            base.clarity[idx] = fresh.clarity
            text_cls[idx] = words
            kind[idx] = KIND_TYPO
            for j, w in zip(idx, words):
                base.captions[j] = [self.class_names[w]] + [f for f in base.captions[j] if f not in self.class_names][:2]
        if p_textgood > 0:
            cand = np.flatnonzero((kind == 0) & (rng.random(n) < p_textgood / max(np.mean(kind == 0), 1e-9)))
            text_cls[cand] = base.label[cand]
            kind[cand] = KIND_TEXTGOOD
        x = self.add_text_block(base.x, text_cls, rng)
        return Pool(base.captions, x=x, label=base.label, domain=base.domain, clarity=base.clarity, kind=kind,
                    text_cls=text_cls)

    def eval_tasks(self, rng, n_per_class=60):
        """
        7 个零样本评测任务。每个任务：(特征, 标签, 候选名字列表, 提示词)。

        【思路】前 5 个是第 2 讲的测试集组（40 类，提示 "<类别名> photo"）；
                "狗的品种"只在 5 个狗的类别里选；"超类"用 8 个超类名做提示，把 40 类图片分到 8 个超类。
        【返回】字典：任务名 → (x, y, names, prompt_words)
        """
        suite = self.dw.test_suite(n_per_class, rng)
        tasks = {}
        for name, ts in suite.items():
            tasks[name] = (self.add_text_block(ts.x, np.full(len(ts), -1), rng), ts.label, self.class_names, ("photo",))
        dogs = np.flatnonzero(self.super_of == self.super_names.index("dog"))
        ims = suite["ImageNet"]
        m = np.isin(ims.label, dogs)
        tasks["Dogs"] = (tasks["ImageNet"][0][m], np.searchsorted(dogs, ims.label[m]),
                         [self.class_names[c] for c in dogs], ("photo",))
        tasks["Superclass"] = (tasks["ImageNet"][0], self.super_of[ims.label], self.super_names, ())
        return tasks


# =============================================================================
# 训练与评测
# =============================================================================
def train_student(world, pool, steps=6000, seed=0, hidden=0):
    """
    固定配方的学生 CLIP：不管 pool 有多大，都训练 steps 步、每步 256 对（DataComp 的"固定算力"）。

    【参数】world：DataCompWorld；pool：Pool（过滤后的训练集）；steps；seed；hidden：图片编码器隐层宽度（0 = 线性）
    【返回】TinyCLIP
    【边界】pool 太小（例如只剩几百对）时，同样的步数意味着每个样本被看很多遍——过拟合；这正是"过滤太狠"的代价之一。
    """
    clip = TinyCLIP(FULL_DIM, len(world.vocab), hidden=hidden, seed=seed)
    return train_clip(clip, pool.x, pool.captions, world.word_id, steps=steps, seed=seed)


def zero_shot_acc(world, clip, task):
    """一个任务上的零样本准确率：用任务的候选名字和提示词造分类头，再算准确率。"""
    x, y, names, words = task
    with torch.no_grad():
        ids, offsets = encode_captions([[n] + list(words) for n in names], world.word_id)
        head = F.normalize(clip.text(ids, offsets), dim=1)
        img = F.normalize(clip.image(torch.tensor(x, dtype=torch.float32)), dim=1)
        scores = (img @ head.T).numpy()
    return accuracy(scores, y)


def evaluate(world, clip, tasks):
    """在全部任务上评测，返回 (每个任务的准确率字典, 平均准确率)。"""
    accs = {name: zero_shot_acc(world, clip, t) for name, t in tasks.items()}
    return accs, float(np.mean(list(accs.values())))


def clip_scores(world, clip, pool, mask_text=False):
    """
    CLIP 分数（图片向量和文字向量的余弦相似度）。

    【参数】mask_text：True 时先把文字块清零再算图片向量——T-MARS 的"遮掉图里的字"（脚本 03）
    【返回】长度 n 的 numpy 数组
    """
    x = pool.x.copy()
    if mask_text:
        x[:, DIM:] = 0.0
    with torch.no_grad():
        img = F.normalize(clip.image(torch.tensor(x, dtype=torch.float32)), dim=1)
        ids, offsets = encode_captions(pool.captions, world.word_id)
        txt = F.normalize(clip.text(ids, offsets), dim=1)
        return (img * txt).sum(dim=1).numpy()


def image_embeddings(clip, x):
    """教师的图片嵌入（归一化），用于"按图片过滤"（聚类）。"""
    with torch.no_grad():
        return F.normalize(clip.image(torch.tensor(x, dtype=torch.float32)), dim=1).numpy()


def kmeans(z, k, rng, iters=20):
    """
    最朴素的 k-means：随机挑 k 个点当初始中心，交替"分配到最近的中心"和"中心 = 分到它的点的平均"。

    【参数】z：(n, d)；k：簇数；rng；iters：迭代次数
    【返回】(中心 (k, d), 每个点所属的簇 (n,))
    【边界】某个簇变空时，中心保持不变（不重新初始化——这里只是一个演示用的过滤器）。
    """
    centers = z[rng.choice(len(z), size=k, replace=False)].copy()
    for _ in range(iters):
        assign = np.argmax(z @ centers.T, axis=1)          # 向量已归一化：点积最大 = 余弦距离最近
        for c in range(k):
            m = assign == c
            if m.any():
                centers[c] = z[m].mean(axis=0)
        centers /= np.linalg.norm(centers, axis=1, keepdims=True)
    return centers, np.argmax(z @ centers.T, axis=1)


def make_teacher(world, seed=0, n=40000, steps=6000):
    """
    教师 CLIP（扮演 OpenAI 的 CLIP）：在一份"私有"的、说明文字更干净的数据上训练；
    私有数据里也有 10% 图里有字的样本，所以教师学会了"读字"——真实的 CLIP 也有这种能力。
    """
    priv = world.make_pool(n, np.random.default_rng(500 + seed), p_vague=0.15, p_wrong=0.03, p_typo=0.10)
    teacher = TinyCLIP(FULL_DIM, len(world.vocab), seed=100 + seed)
    return train_clip(teacher, priv.x, priv.captions, world.word_id, steps=steps, seed=100 + seed)


if __name__ == "__main__":
    W = DataCompWorld()
    pool = W.make_pool(60000, np.random.default_rng(1))
    print("候选池组成：", {KIND_NAMES[k]: round(float(np.mean(pool.kind == k)), 3) for k in range(4)})
    print("风格：", {d: round(float(np.mean(pool.domain == i)), 3) for i, d in enumerate(DOMAINS)})
    tasks = W.eval_tasks(np.random.default_rng(2))
    student = train_student(W, pool)
    accs, avg = evaluate(W, student, tasks)
    print("不过滤的学生：", {k: round(v, 3) for k, v in accs.items()}, "平均", round(avg, 3))
