"""
=============================================================================
第 20 讲 · 代码 1：一个端到端的"构建训练集"项目模板
=============================================================================

对应讲义：lectures/lecture20_building_your_training_set.md

-----------------------------------------------------------------------------
【这个文件是什么】
-----------------------------------------------------------------------------
CS525 的课程项目是"为一个你自己选的学习问题构建一个训练集"。这个脚本把整门课的方法串成一个可以直接改造的模板，
在第 6 讲的合成网页世界里走一遍完整的流程：

    第 1 步  定义问题和评测（先于看数据！）：把评测题分成"开发集"（用来做选择）和"测试集"（最后只看一次）
    第 2 步  收集候选数据，记录来源和规模
    第 3 步  搭建管线：抽取 → 启发式过滤 → 去重 → 质量分类器，每一步留下日志
    第 4 步  消融：固定训练代码和预算，只换数据；每个方案 3 个种子，报告均值、标准误、和基线的配对差
    第 5 步  规模检查：换一个训练预算，方案的排序还一样吗？
    第 6 步  在测试集上做一次最终报告
    第 7 步  生成数据卡

改造成你自己的项目时，替换的是：问题和评测（第 1 步）、数据来源（第 2 步）、管线的每一步（第 3 步）、训练和评测函数。
**流程本身——先定评测、记录一切、固定训练只换数据、多种子、留出测试集、写数据卡——是可以原样保留的部分。**

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_project_template.py        # 约 20 秒

依赖 numpy、第 6 讲的 webtext_world.py / textlab.py（只读导入）和第 1 讲的 console.py。
"""

import os
import sys
from collections import Counter

import numpy as np

_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, "..", "lecture06"))
from console import print_header, print_observations, row  # noqa: E402
from textlab import FastTextLike, Vocab, bff_dedup, doc_tokens, evaluate, make_eval, train_lm  # noqa: E402
from webtext_world import HEURISTIC_RULES, WebWorld, extract_main, heuristic_stats, passes  # noqa: E402

SHORT = {"WIKI": "百科", "FORUM": "论坛", "SPAM": "垃圾", "NAV": "导航", "GARBLED": "乱码", "FOREIGN": "外语"}

# =============================================================================
# 项目配置：改造成你自己的项目时，从这里开始改
# =============================================================================
PROJECT = {
    "问题": "训练一个小语言模型，让它尽可能多地记住虚构世界里 600 个国家的 3000 条事实（尤其是长尾的冷门国家）",
    "主要指标": "开发集上的知识题准确率（每个国家同等重要）",
    "次要指标": "长尾国家（热度排名 200 以后）的准确率；正确答案的平均对数损失",
    "训练预算": 30_000,          # 主要的消融在这个预算下做（看多少个 token）
    "规模检查的预算": [10_000, 60_000],
    "种子": [0, 1, 2],
}


def subset(ev, idx):
    """从 make_eval 的结果里取出一部分题目（开发集或测试集）。"""
    return {k: v[idx] for k, v in ev.items()}


def kinds_summary(kinds, top=4):
    """一组网页的真实类型的组成，例如 '论坛 0.88 乱码 0.12'。真实项目里没有这个标签——这里只用来解释结果。"""
    c = Counter(kinds)
    n = max(len(kinds), 1)
    return " ".join(f"{SHORT[k]} {v / n:.2f}" for k, v in c.most_common(top))


# =============================================================================
# 第 1 步：定义问题和评测
# =============================================================================
WORLD = WebWorld()
VOCAB = Vocab(WORLD)
EV_ALL = make_eval(WORLD, VOCAB)
_perm = np.random.default_rng(100).permutation(len(EV_ALL["a"]))
EV_DEV, EV_TEST = subset(EV_ALL, _perm[:1500]), subset(EV_ALL, _perm[1500:])


def step1():
    print_header("第 1 步：定义问题和评测（在看任何训练数据之前）")
    for k in ["问题", "主要指标", "次要指标"]:
        print(f"  {k}：{PROJECT[k]}")
    print(f"  评测题：3000 道，随机分成开发集 {len(EV_DEV['a'])} 道（做所有选择）和测试集 {len(EV_TEST['a'])} 道（最后只看一次）。")
    print(f"  训练：固定的模型和训练代码（第 6 讲的计数语言模型），固定预算 {PROJECT['训练预算']:,} 个 token；每个方案 {len(PROJECT['种子'])} 个种子。")
    print_observations([
        "先定评测，再看数据：否则很容易不自觉地按\"在评测上好看\"去调整数据（第 14 讲的适应性过拟合）。",
        "开发集用来做所有的选择（哪个过滤器、保留多少），测试集只在最后报告一次——它是第 1 讲 ImageNet 测试服务器的\"迷你版\"。",
    ])


# =============================================================================
# 第 2 步：收集候选数据
# =============================================================================
POOL = WORLD.make_pool(20000, np.random.default_rng(1))       # 2 万个原创网页 + 复制品
KINDS = [d.kind for d in POOL]


def step2():
    print_header("第 2 步：收集候选数据 —— 记录来源、规模和你知道的问题")
    n_dup = sum(1 for d in POOL if d.dup_of >= 0)
    print(f"  候选池：{len(POOL):,} 个网页（其中 {n_dup:,} 个是复制品），{sum(len(t) for d in POOL for t in d.lines):,} 个 token。")
    print(f"  真实组成（只有模拟才知道）：{kinds_summary(KINDS, top=6)}")
    print_observations([
        "真实项目里，这一步要记录：每个来源的名字、获取方式和时间、许可证、robots.txt / 服务条款的状态、规模、"
        "你已知的问题（重复、语言、个人信息）。这些会直接进入第 7 步的数据卡（第 19 讲）。",
        "在过滤之前先\"看\"数据：随机读几十个样本，往往比任何统计量都更快地发现问题（第 6 讲 Shankar 的建议）。",
    ])


# =============================================================================
# 第 3 步：管线
# =============================================================================
LOG = {}


def run_pipeline():
    """
    抽取 → 启发式过滤 → 段落 + 文档级去重 → 质量分类器打分。每一步把留下的数量记进 LOG。

    【返回】dict：每个方案的文档列表和真实类型（类型只用于解释）
    【思路】管线的每一步都应该可以单独开关——这样第 4 步才能做消融。
    """
    main = [extract_main(d, WORLD) for d in POOL]
    checks = [passes(heuristic_stats(x, WORLD)) for x in main]
    h_idx = [i for i, (ok, _) in enumerate(checks) if ok]
    LOG["抽取后的非空文档"] = sum(1 for x in main if x)
    LOG["启发式过滤：每条规则拦下的文档"] = Counter(why for ok, why in checks if not ok)
    LOG["通过启发式过滤"] = len(h_idx)
    h_docs = [main[i] for i in h_idx]
    dedup = bff_dedup(h_docs, n=5, min_len=5, threshold=0.8)
    keep = [j for j, d in enumerate(dedup) if d]
    LOG["去重后留下"] = len(keep)
    base = [dedup[j] for j in keep]
    base_kinds = [KINDS[h_idx[j]] for j in keep]
    rng = np.random.default_rng(7)
    ref = [WORLD.instruction_doc(rng) for _ in range(2000)]          # 质量分类器的正例：高质量的问答文本
    neg = [base[i] for i in rng.choice(len(base), size=2000, replace=False)]
    scores = FastTextLike().fit(ref, neg).score(base)
    order = np.argsort(-scores, kind="stable")
    recipes = {
        "A 抽取 + 启发式": (h_docs, [KINDS[i] for i in h_idx]),
        "B A + 去重": (base, base_kinds),
    }
    for frac in [0.2, 0.1]:
        top = order[:int(frac * len(base))]
        recipes[f"{'C' if frac == 0.2 else 'D'} B + 分类器前 {frac:.0%}"] = ([base[i] for i in top], [base_kinds[i] for i in top])
    return recipes


def step3(recipes):
    print_header("第 3 步：管线 —— 每一步留下多少、拦下了什么")
    n = len(POOL)
    print(f"  候选池 {n:,} → 抽取后非空 {LOG['抽取后的非空文档']:,} → 启发式过滤后 {LOG['通过启发式过滤']:,}"
          f"（{LOG['通过启发式过滤'] / n:.1%}）→ 去重后 {LOG['去重后留下']:,}（{LOG['去重后留下'] / n:.1%}）")
    print("  启发式过滤：每条规则拦下的文档（一个文档只算第一条没通过的规则）")
    for name, _, _, _ in HEURISTIC_RULES:
        print(f"    {name}：{LOG['启发式过滤：每条规则拦下的文档'].get(name, 0):,}")
    w = [26, 10, 12, 34]
    print()
    print(row(["方案", "文档数", "token 数", "真实组成（只用于解释）"], w))
    for name, (docs, kinds) in recipes.items():
        print(row([name, len(docs), f"{sum(doc_tokens(d) for d in docs):,}", kinds_summary(kinds)], w))
    print_observations([
        "每一步都要留下数字：拦下了多少、因为哪条规则。没有这些日志，最后既写不出数据卡，也没法解释结果。",
        "质量分类器的正例（\"什么样的数据是好的\"）是一个设计决策，要在报告里说明（第 6 讲：OpenHermes + ELI5）。",
    ])


# =============================================================================
# 第 4 步：消融
# =============================================================================
def train_eval(docs, budget, seed, ev):
    """固定的训练代码 + 固定预算 → 评测。只有数据在变。"""
    lm, _ = train_lm(docs, budget, VOCAB, seed=seed)
    return evaluate(lm, ev)


def step4(recipes):
    print_header(f"第 4 步：消融 —— 固定训练，只换数据（预算 {PROJECT['训练预算']:,}，{len(PROJECT['种子'])} 个种子，开发集）")
    seeds = PROJECT["种子"]
    res = {name: [train_eval(docs, PROJECT["训练预算"], s, EV_DEV) for s in seeds] for name, (docs, _) in recipes.items()}
    base = list(recipes)[0]
    w = [26, 20, 20, 16, 22]
    print(row(["方案", "准确率（均值 ± 标准误）", "长尾准确率", "答案损失", "和 A 的配对差（± 标准误）"], w))
    summary = {}
    for name, rs in res.items():
        acc = np.array([r["acc"] for r in rs])
        tail = np.array([r["acc_tail"] for r in rs])
        loss = np.array([r["ans_loss"] for r in rs])
        diff = acc - np.array([r["acc"] for r in res[base]])
        se = lambda x: x.std(ddof=1) / np.sqrt(len(x))     # noqa: E731
        summary[name] = (acc.mean(), se(acc), tail.mean(), loss.mean(), diff.mean(), se(diff))
        s = summary[name]
        print(row([name, f"{s[0]:.3f} ± {s[1]:.4f}", f"{s[2]:.3f}", f"{s[3]:.3f}",
                   "—" if name == base else f"{s[4]:+.3f} ± {s[5]:.4f}"], w))
    best = max(summary, key=lambda k: summary[k][0])
    print(f"\n  开发集上最好的方案：{best}")
    print_observations([
        "\"固定训练、只换数据\"是 DataComp / DCLM 的核心方法论（第 5、6 讲）：任何差别都只能归因于数据。",
        "报告均值和标准误，并和基线做**配对**比较（同一个种子下相减）：种子之间的波动比方案之间的差别还大时，结论不可信（第 14 讲）。"
        f"D 的标准误是 0：它只有 {sum(doc_tokens(d) for d in recipes[list(recipes)[-1]][0]):,} 个 token，比预算还少，整个数据集都被用上，种子不起作用。"
        "这个玩具模型的随机性只来自\"抽哪些文档\"；真实的训练还有初始化和数据顺序的随机性，标准误会大得多。",
        "同时看几个指标：总体准确率、长尾准确率、更连续的答案损失。它们可能给出不同的结论——要在报告里说明你以哪个为准、为什么。",
    ])
    return summary, best


# =============================================================================
# 第 5 步：规模检查
# =============================================================================
def spearman(a, b):
    ra, rb = np.argsort(np.argsort(a)), np.argsort(np.argsort(b))
    return float(np.corrcoef(ra, rb)[0, 1])


def step5(recipes, summary):
    print_header("第 5 步：规模检查 —— 换一个训练预算，方案的排序还一样吗？")
    names = list(recipes)
    accs = {PROJECT["训练预算"]: [summary[n][0] for n in names]}
    for B in PROJECT["规模检查的预算"]:
        accs[B] = [np.mean([train_eval(recipes[n][0], B, s, EV_DEV)["acc"] for s in PROJECT["种子"]]) for n in names]
    budgets = sorted(accs)
    w = [26] + [14] * len(budgets)
    print(row(["方案"] + [f"预算 {B:,}" for B in budgets], w))
    for i, n in enumerate(names):
        print(row([n] + [f"{accs[B][i]:.3f}" for B in budgets], w))
    rho_small = spearman(accs[budgets[0]], accs[budgets[-1]])
    rho_mid = spearman(accs[PROJECT["训练预算"]], accs[budgets[-1]])
    iC, iD = names.index([n for n in names if n.startswith("C")][0]), names.index([n for n in names if n.startswith("D")][0])
    d_tokens = sum(doc_tokens(d) for d in recipes[names[iD]][0])
    print(f"\n  排序的 Spearman 相关：预算 {budgets[0]:,} vs {budgets[-1]:,}：{rho_small:.2f}；"
          f"{PROJECT['训练预算']:,} vs {budgets[-1]:,}：{rho_mid:.2f}")
    print_observations([
        f"最激进的过滤（D，只留前 10%）在小预算时最好（{accs[budgets[0]][iD]:.3f}），但它只有 {d_tokens:,} 个 token："
        f"预算加到 {budgets[-1]:,} 时它不再提高（{accs[budgets[-1]][iD]:.3f}），被保留更多数据的 C 超过（{accs[budgets[-1]][iC]:.3f}）。"
        "这就是第 7 讲\"过滤的扩展律\"：最优的过滤强度随算力变化，预算越大，越需要更多的数据。",
        "小规模的结论要在更大的规模上验证。DCLM 发现排序在不同规模之间大体一致（第 6 讲），"
        "DataDecide 发现 1.5 亿参数上的排序约 80% 的时候能选对 10 亿参数上更好的数据（第 14 讲）。",
        "如果你的项目最终要在大规模上用，规模检查不能省；在报告里写清楚结论在哪个规模范围里检查过。",
    ])


# =============================================================================
# 第 6、7 步：最终报告与数据卡
# =============================================================================
def step6_7(recipes, best, summary):
    print_header("第 6 步：在测试集上做一次最终报告；第 7 步：数据卡")
    docs, kinds = recipes[best]
    base = list(recipes)[0]
    test = {n: [train_eval(recipes[n][0], PROJECT["训练预算"], s, EV_TEST)["acc"] for s in PROJECT["种子"]] for n in [base, best]}
    t_best, t_base = np.mean(test[best]), np.mean(test[base])
    print(f"  测试集（只看这一次）：{best} {t_best:.3f}；基线 {base} {t_base:.3f}；开发集上是 {summary[best][0]:.3f} / {summary[base][0]:.3f}。")
    card = f"""
  ----------------------------------------------------------------------------------------------
  数据卡（Data Card）· {best}
  ----------------------------------------------------------------------------------------------
  1. 动机          {PROJECT['问题']}
  2. 组成          {len(docs):,} 个文档、{sum(doc_tokens(d) for d in docs):,} 个 token；真实组成（模拟才知道）：{kinds_summary(kinds)}
  3. 收集          合成网页世界的一次"抓取"：{len(POOL):,} 个网页（含复制品）；真实项目要写来源、时间、许可证、robots.txt 状态
  4. 预处理        好的抽取器（去掉导航栏、页脚）→ 6 条启发式规则（留下 {LOG['通过启发式过滤']:,}）
                   → 段落 + 文档级去重（留下 {LOG['去重后留下']:,}）→ fastText 式质量分类器（正例：问答文本）
  5. 评测          3000 道知识题，开发集 1500 / 测试集 1500；固定预算 {PROJECT['训练预算']:,} token、{len(PROJECT['种子'])} 个种子
                   开发集 {summary[best][0]:.3f} ± {summary[best][1]:.3f}；测试集 {t_best:.3f}（基线 {t_base:.3f}）
  6. 已知的局限    质量分类器偏好问答式文本，可能系统性地排除百科式的长文；只测了"记住事实"这一种能力；
                   结论在 {min(PROJECT['规模检查的预算']):,}–{max(PROJECT['规模检查的预算']):,} token 的预算里检查过，更大的规模未验证
  7. 维护与许可    （真实项目：谁维护、怎样报告问题和请求移除、以什么许可证发布）
  ----------------------------------------------------------------------------------------------"""
    print(card)
    print_observations([
        f"测试集上的结果（{t_best:.3f}）和开发集（{summary[best][0]:.3f}）接近：我们只在开发集上做了选择，测试集没有被\"用掉\"。"
        "如果在测试集上反复挑方案，最后报告的数字就会偏乐观（第 1、14 讲）。",
        "数据卡里最重要的是\"已知的局限\"：它告诉读者这个数据集**不**适合用来做什么。第 19 讲的数据表模板有更完整的问题清单。",
    ])


if __name__ == "__main__":
    step1()
    step2()
    RECIPES = run_pipeline()
    step3(RECIPES)
    SUMMARY, BEST = step4(RECIPES)
    step5(RECIPES, SUMMARY)
    step6_7(RECIPES, BEST, SUMMARY)
