"""
=============================================================================
第 8 讲 · 代码 5：数据划分、数据泄漏与分布偏移
=============================================================================

对应讲义：lectures/lecture08_practical_methods.md 第 3 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
测试集的唯一作用，是估计"模型在**新数据**上表现如何"。这个估计很容易被无意中破坏：

    演示 1：训练集和测试集里有重复的样本 → 测试分数虚高，甚至让"死记硬背"的模型看起来最好
    演示 2：反复在测试集上挑模型 → 测试分数虚高（即使所有模型都是瞎猜）
    演示 3：测试数据和真实使用的数据不一样（分布偏移）→ 测试分数不代表真实表现
    演示 4：数据很少时，用 k 折交叉验证得到更稳定的估计

本文件只用 numpy，复用 01 号文件的特征提取和逻辑回归。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 05_data_splits_and_leakage.py

约 20~40 秒。
"""

import importlib

import numpy as np

from toy_reviews import NEGATIVE, POSITIVE, make_dataset

base = importlib.import_module("01_baselines_and_metrics")     # 复用 featurize、train_lr 等


def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 70)
    print(f"  {title}")
    print("=" * 70)


def nearest_neighbor(train, n_max=2):
    """
    1-近邻分类器：找训练集里和测试样本**最像**的一条（余弦相似度），直接抄它的标签。

    【思路】这是"死记硬背"的极端：它不学任何规律，只记住训练集。
        测试样本在训练集里有一模一样的副本时，它一定能抄对（哪怕那个标签本身是错的）。

    【返回】一个函数 predict(word_lists) → np.ndarray(0/1)
    """
    index = base.build_index([w for w, _ in train], n_max)
    Xtr = base.featurize([w for w, _ in train], index, n_max)[:, :-1]
    Xtr /= np.linalg.norm(Xtr, axis=1, keepdims=True) + 1e-12
    ytr = np.array([y for _, y in train])

    def predict(word_lists):
        X = base.featurize(word_lists, index, n_max)[:, :-1]
        X /= np.linalg.norm(X, axis=1, keepdims=True) + 1e-12
        return ytr[np.argmax(X @ Xtr.T, axis=1)]
    return predict


def accuracy(predict, data):
    """准确率。"""
    return float(np.mean(predict([w for w, _ in data]) == np.array([y for _, y in data])))


# =============================================================================
# 演示 1：重复样本
# =============================================================================
def demo_duplicates():
    print_header("演示 1：训练集和测试集里有重复的样本")
    rng = np.random.default_rng(0)
    unique = make_dataset(800, seed=20, label_noise=0.15)          # 800 条不同的评论，15% 的标签是错的
    copies = rng.integers(1, 6, len(unique))                         # 每条重复 1~5 次（比如同一条评论被多次转发、抓取）
    items = [(unique[i], i) for i in range(len(unique)) for _ in range(copies[i])]
    print(f"\n    {len(unique)} 条不同的评论（15% 标签错误），每条重复 1~5 次，共 {len(items)} 条。按 7:3 划分训练 / 测试。")

    # (a) 按样本随机划分：同一条评论的副本可能一份在训练集、一份在测试集
    perm = rng.permutation(len(items))
    cut = int(0.7 * len(items))
    tr_a = [items[i][0] for i in perm[:cut]]
    te_a = [items[i][0] for i in perm[cut:]]
    seen = {items[i][1] for i in perm[:cut]}
    leaked = np.mean([items[i][1] in seen for i in perm[cut:]])
    # (b) 按"原始评论"分组划分：同一条评论的所有副本都在同一边
    groups = rng.permutation(len(unique))
    train_groups = set(groups[:int(0.7 * len(unique))].tolist())
    tr_b = [x for x, g in items if g in train_groups]
    te_b = [x for x, g in items if g not in train_groups]
    fresh = make_dataset(5000, seed=21)                               # 全新的、干净的数据：模型的"真实水平"

    print(f"    随机划分时，测试集里有 {leaked:.0%} 的样本在训练集里有一模一样的副本。\n")
    print(f"    {'模型':<20s}{'随机划分的测试集':>14s}{'分组划分的测试集':>16s}{'全新的数据':>12s}")
    for name, trainer in [("1-近邻（死记硬背）", nearest_neighbor), ("逻辑回归（单词 + 两词）", lambda d: base.train_lr(d, n_max=2))]:
        m_a, m_b = trainer(tr_a), trainer(tr_b)
        print(f"    {name:<16s}{accuracy(m_a, te_a):>16.1%}{accuracy(m_b, te_b):>18.1%}{accuracy(m_b, fresh):>14.1%}")
    print("""
  观察：
    - 随机划分时，测试集里大部分样本在训练集里"见过"。1-近邻直接抄标签 —— 连错误的标签也抄对了
      （测试集里的副本带着同样的错误标签），于是测试准确率高得离谱，看起来比逻辑回归还好。
    - 按原始评论分组划分之后，1-近邻的真实水平暴露出来，排名也反过来了。
    - "全新的数据"一列是没有标签噪声的干净数据，代表模型真正的泛化能力。
    - 真实世界里的泄漏往往更隐蔽：同一个用户的多条评论、同一篇新闻的不同版本、
      时间上的泄漏（用"未来"的数据训练、"过去"的数据测试）、预训练语料里混进了测试集（第 13 讲的"数据污染"）。
      **划分数据时要想清楚：测试集应该模拟什么样的"新数据"？**""")


# =============================================================================
# 演示 2：反复在测试集上挑模型
# =============================================================================
def demo_test_reuse():
    print_header("演示 2：反复在测试集上挑模型")
    rng = np.random.default_rng(0)
    print("\n  (a) 1000 个\"模型\"都是瞎猜（每个样本随机猜 0 或 1），挑出在测试集上准确率最高的那个：")
    print(f"    {'测试集大小':<10s}{'挑出的模型在这个测试集上':>22s}{'同一个模型在 1 万条新数据上':>26s}")
    for n in [100, 1000, 10000]:
        y = rng.integers(0, 2, n)
        guesses = rng.integers(0, 2, (1000, n))
        best = np.argmax((guesses == y).mean(axis=1))
        y_new = rng.integers(0, 2, 10000)
        new_acc = (rng.integers(0, 2, 10000) == y_new).mean()           # 瞎猜的模型在新数据上还是瞎猜
        print(f"    {n:<12d}{(guesses[best] == y).mean():>22.1%}{new_acc:>28.1%}")

    print("\n  (b) 更真实的情形：50 个逻辑回归，每个只用随机选出的 30% 的特征（相当于 50 组不同的\"超参数\"）")
    train = make_dataset(300, seed=30)
    big_test = make_dataset(10000, seed=32)
    index = base.build_index([w for w, _ in train], 2)
    Xtr = base.featurize([w for w, _ in train], index, 2)
    ytr = np.array([y for _, y in train])
    Xb, yb = base.featurize([w for w, _ in big_test], index, 2), np.array([y for _, y in big_test])
    models, big_accs = [], []
    for _ in range(50):
        keep = rng.random(Xtr.shape[1]) < 0.3
        keep[-1] = True                                                # 偏置项总是保留
        clf = base.LogisticRegression().fit(Xtr[:, keep], ytr)
        models.append((clf, keep))
        big_accs.append((clf.predict(Xb[:, keep]) == yb).mean())       # 每个模型的"真实水平"
    reported, true = [], []
    for rep in range(100):                                             # 换 100 个不同的 200 条测试集，各挑一次
        small = make_dataset(200, seed=1000 + rep)
        Xs, ys = base.featurize([w for w, _ in small], index, 2), np.array([y for _, y in small])
        small_accs = [(clf.predict(Xs[:, keep]) == ys).mean() for clf, keep in models]
        best = int(np.argmax(small_accs))
        reported.append(small_accs[best])
        true.append(big_accs[best])
    print(f"    在 200 条的测试集上挑出最好的一个、报告它在这个测试集上的准确率；换 100 个不同的测试集重复这件事：")
    print(f"      报告的准确率平均 {np.mean(reported):.1%}，这些模型在 1 万条新数据上的真实准确率平均 {np.mean(true):.1%}"
          f"（虚高 {np.mean(reported) - np.mean(true):+.1%}）")
    print(f"      作为参照：50 个模型真实准确率的平均 {np.mean(big_accs):.1%}，最高 {max(big_accs):.1%}")
    print("""
  观察：
    - 1000 个瞎猜的模型里，总有一些"碰巧"在 100 条的测试集上猜对了 60% 以上；换到新数据上，它们还是 50%。
      测试集越小、挑选的次数越多，"挑出来的最好成绩"就越虚高。
    - (b) 里的模型都是正经训练出来的，真实水平各不相同，挑选确实有用（挑出来的模型比平均水平好）；
      但报告出来的数字仍然比它们的真实水平高出好几个百分点 —— 这就是 03 号文件说的"赢家的诅咒"。
    - 真实的研究里也一样：在同一个测试集上反复试验、挑最好的结果发表，测试集就慢慢变成了"训练集"的一部分
      （这在一些被反复使用多年的公开基准上确实发生过）。
    - 对策：**所有的选择（模型、超参数、特征、早停轮次）都在验证集上做，测试集只在最后用一次**；
      公开基准上，可以用一个不公开标签的测试集（排行榜服务器只返回分数，并限制提交次数）。""")


# =============================================================================
# 演示 3：分布偏移
# =============================================================================
def demo_shift():
    print_header("演示 3：分布偏移 —— 在影评上训练，用在餐厅点评上")
    train = make_dataset(2000, seed=0)
    movie_test = make_dataset(2000, seed=2)
    rest_test = make_dataset(2000, seed=40, domain="restaurant")
    model = base.train_lr(train, n_max=2)
    special = set(POSITIVE[18:]) | set(NEGATIVE[18:])                 # 餐厅领域特有的形容词
    has_special = [any(w in special for w in words) for words, _ in rest_test]
    rest_sp = [x for x, h in zip(rest_test, has_special) if h]
    rest_gen = [x for x, h in zip(rest_test, has_special) if not h]
    print(f"\n    在 2000 条影评上训练的逻辑回归：")
    print(f"      影评测试集                                   {accuracy(model, movie_test):.1%}")
    print(f"      餐厅点评测试集                               {accuracy(model, rest_test):.1%}")
    print(f"        其中只用通用形容词的（{len(rest_gen)} 条）            {accuracy(model, rest_gen):.1%}")
    print(f"        含有餐厅特有形容词的（delicious、rude……，{len(rest_sp)} 条）{accuracy(model, rest_sp):.1%}")
    mixed = train + make_dataset(200, seed=41, domain="restaurant")
    model2 = base.train_lr(mixed, n_max=2)
    print(f"\n    在训练集里加入 200 条餐厅点评之后：影评 {accuracy(model2, movie_test):.1%}，餐厅点评 {accuracy(model2, rest_test):.1%}")
    print("""
  观察：
    - 在影评上训练、在影评上测试，结果很好；换到餐厅点评上，准确率明显下降。
      下降主要来自模型**从没见过**的词（delicious、rude……）：对它来说这些词没有任何情感倾向。
      总结句也不一样（餐厅点评里没有 "i would watch it again"）。
    - 如果你的模型要用在餐厅点评上，在影评测试集上的 90% 就是一个虚假的承诺。
      **测试集要尽可能像模型真正要面对的数据**。
    - 加入少量目标领域的标注数据，效果明显回升。第 9 讲会看到另一种办法：
      在大量**没有标签**的目标领域文本上预训练。""")


# =============================================================================
# 演示 4：交叉验证
# =============================================================================
def estimate(data, rng):
    """
    对同一份小数据集，用两种方法估计"用全部数据训练的模型"的准确率。

    【返回】(随机留出 20% 做测试的估计, 5 折交叉验证的估计)
    """
    perm = rng.permutation(len(data))
    cut = int(0.8 * len(data))
    holdout = accuracy(base.train_lr([data[i] for i in perm[:cut]]), [data[i] for i in perm[cut:]])
    folds = np.array_split(perm, 5)
    accs = []
    for k in range(5):
        te = [data[i] for i in folds[k]]
        tr = [data[i] for j in range(5) if j != k for i in folds[j]]
        accs.append(accuracy(base.train_lr(tr), te))
    return holdout, float(np.mean(accs))


def demo_cross_validation():
    print_header("演示 4：只有 100 条标注数据时，怎样估计模型的准确率？")
    rng = np.random.default_rng(0)
    fresh = make_dataset(10000, seed=51)
    print(f"\n    10 份不同的 100 条数据集。\"真实水平\" = 用全部 100 条训练、在 1 万条新数据上的准确率。")
    print(f"    {'数据集':<8s}{'真实水平':>9s}{'留出 20 条':>12s}{'5 折交叉验证':>14s}")
    rows = []
    for k in range(10):
        data = make_dataset(100, seed=50 + k)
        truth = accuracy(base.train_lr(data), fresh)
        h, cv = estimate(data, rng)
        rows.append((truth, h, cv))
        print(f"    {k:<10d}{truth:>9.1%}{h:>12.1%}{cv:>14.1%}")
    rows = np.array(rows)
    err_h, err_cv = rows[:, 1] - rows[:, 0], rows[:, 2] - rows[:, 0]
    print(f"\n    估计误差（估计值 − 真实水平）：留出法 平均 {err_h.mean():+.1%}、平均绝对误差 {np.abs(err_h).mean():.1%}；"
          f"5 折交叉验证 平均 {err_cv.mean():+.1%}、平均绝对误差 {np.abs(err_cv).mean():.1%}")
    print("""
  观察（具体数字以你运行的结果为准）：
    - 只有 100 条数据时，留出 20 条做测试，估计值很不稳定：20 条测试数据，猜对或猜错一条就是 5 个百分点。
    - k 折交叉验证：把数据分成 k 份，轮流用其中一份测试、其余训练，取 k 次的平均。
      每条数据都当过一次测试数据，估计的误差通常小得多。
    - 但即使是交叉验证，估计值和真实水平之间仍然有不小的差距：数据少的时候，**数据集本身**就有随机性
      （这 100 条可能碰巧比较容易或比较难），交叉验证只能消除"怎么划分"带来的随机性，消除不了这一点。
    - 数据多的时候（深度学习通常如此），训练一次就很贵，一般只用一个固定的验证集；
      数据很少、模型训练很快时，交叉验证更可靠。""")


def main():
    demo_duplicates()
    demo_test_reuse()
    demo_shift()
    demo_cross_validation()


if __name__ == "__main__":
    main()
