"""
=============================================================================
第 1 讲 · 代码 3：适配（adaptation）——预训练带来了什么，又遗传了什么
=============================================================================

对应讲义：lectures/lecture01_intro_foundation_models.md 第 3.4、5 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
代码 02 用"提示"让 base model 做任务。另一类适配方法是：把预训练模型的内部表示（hidden states）拿出来，
在上面用**很少的标签**训练一个小分类器——这叫"探针（probing）"或"线性探测（linear probing）"。
这个文件用它回答三个问题：

    演示 1：预训练表示里带着"标签给不了的知识"吗？
            任务：给出一个人的名字，判断他住在北方还是南方。region 这个词从没在语料里出现过，
            名字本身也是随机音节——只看名字的"从零训练"模型不可能做到；
            预训练模型知道"谁住在哪个城市"，几个标签就够把"城市 → 北 / 南"读出来。
            同时比较：从模型的**哪个位置**读表示，结果差多少。
    演示 2：标签很少时，预训练有多大帮助？标签多了以后呢？
            任务：评论里的**食物**好不好（不管服务）——预训练时从没有这个标签。
            比较：预训练表示 + 线性探针 vs 从零训练的词袋（bag-of-words）逻辑回归，标签数 4 → 512。
            再做一个更难的版本：测试评论里的食物形容词，在标注数据里**一个也没出现过**。
    演示 3：缺陷也会被继承——同质化（homogenization）的另一面。
            迷你互联网里，有几个职业后面的代词（he / she）比例是偏斜的（比例是随意设定的，见 fm_world.py）。
            base model 学到了这些偏斜，并且在 4 个不同的"应用"（4 种句式）里都表现出来；
            贪心解码还会把它**放大**：数据里 85 : 15 的比例，贪心生成时变成 100 : 0。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_adapt_and_inherit.py          # 约 30 秒（读取代码 02 保存的模型）

需要 PyTorch。依赖本目录的 fm_world.py、tiny_gpt.py、console.py。
如果还没运行过代码 02，本脚本会先自动预训练 base model（多花约 1 分钟），得到的模型和代码 02 完全相同。
"""

import math
import random

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

from console import pad, print_header
from fm_world import (ALPHABET, CITIES, FOOD_NEG, FOOD_POS, JOBS, P_SHE, REGION, STORY_TEMPLATES, build_world,
                      make_review)
from tiny_gpt import CharTokenizer, completion_logprob, generate, hidden_states, load_or_train_base_model


# =============================================================================
# 工具：一个很小的逻辑回归（线性探针 / 词袋分类器都用它）
# =============================================================================
def fit_logreg(X, y, l2=1e-2):
    """
    训练一个带 L2 正则的逻辑回归：p(y=1|x) = sigmoid(w·x + b)。

    【思路】特征维度很小（几十到几百），用 L-BFGS（一种拟牛顿法）几十步就能收敛到最优。
            L2 正则让 w 不会在样本很少（例如 4 个）、数据线性可分时跑到无穷大。
    【参数】X：(k, d) numpy 数组；y：(k,) 的 0/1 数组；l2：正则强度
    【返回】一个函数 predict(X_test) -> 0/1 预测数组
    【边界】训练集只有一个类别时（比如 4 个样本恰好都是 north），模型会把所有测试样本都判成那个类别——
            这不是 bug，而是"标签太少"的真实后果。
    【PyTorch 小知识】torch.optim.LBFGS 需要一个 closure 函数：每次被调用时重新算一遍损失和梯度。
                      用 float64（double）是为了让结果在不同机器上更稳定。
    """
    Xt = torch.tensor(X, dtype=torch.float64)
    yt = torch.tensor(y, dtype=torch.float64)
    w = torch.zeros(Xt.shape[1], dtype=torch.float64, requires_grad=True)
    b = torch.zeros(1, dtype=torch.float64, requires_grad=True)
    opt = torch.optim.LBFGS([w, b], max_iter=200, line_search_fn="strong_wolfe")

    def closure():
        opt.zero_grad()
        loss = F.binary_cross_entropy_with_logits(Xt @ w + b, yt) + l2 * (w ** 2).sum()
        loss.backward()
        return loss

    opt.step(closure)
    w_np, b_np = w.detach().numpy(), b.detach().numpy()

    def predict(X_test):
        return (np.asarray(X_test) @ w_np + b_np > 0).astype(float)

    return predict


def few_label_accuracy(X_pool, y_pool, X_test, y_test, k, reps, seed):
    """
    "只有 k 个标签"时的平均测试准确率：重复 reps 次，每次从 pool 里随机抽 k 个样本训练，在 test 上评估。

    【参数】X_pool, y_pool：可供抽样的标注数据；X_test, y_test：测试集；k：标签数；reps：重复次数；
            seed：随机种子（每个 k 用不同的种子，但不同特征用同一组抽样——这样比较才公平）
    【返回】平均准确率
    """
    rng = np.random.default_rng(seed)
    accs = []
    for _ in range(reps):
        idx = rng.permutation(len(X_pool))[:k]
        predict = fit_logreg(X_pool[idx], y_pool[idx])
        accs.append(float((predict(X_test) == y_test).mean()))
    return float(np.mean(accs))


# =============================================================================
# 演示 1：预训练表示里带着"标签给不了的知识"
# =============================================================================
def name_char_features(name):
    """从零开始的基线：名字里每个字母、每对相邻字母出现几次（26 + 26×26 维）。"""
    v = np.zeros(26 + 26 * 26)
    for c in name:
        v[ord(c) - 97] += 1
    for a, b in zip(name, name[1:]):
        v[26 + (ord(a) - 97) * 26 + (ord(b) - 97)] += 1
    return v


def demo_knowledge_probe(model, tok, world):
    print_header("演示 1：给出名字，判断住在北方还是南方（region 从没在语料里出现过）")
    people = world.people
    y = np.array([REGION[p.city] == "north" for p in people], dtype=float)
    feats = {
        "预训练表示：读 \"kari lives in \" 末尾": np.stack(
            [hidden_states(model, tok, f"\n{p.name} lives in ")[-1].numpy() for p in people]).astype(np.float64),
        "预训练表示：读名字的最后一个字符": np.stack(
            [hidden_states(model, tok, f"\n{p.name}")[-1].numpy() for p in people]).astype(np.float64),
        "从零开始：名字的字母 / 字母对计数": np.stack([name_char_features(p.name) for p in people]),
        "上限参照：直接告诉它真实城市（one-hot）": np.stack(
            [np.eye(len(CITIES))[CITIES.index(p.city)] for p in people]),
    }
    ks = [4, 8, 16, 32]
    print(f"{len(people)} 个人，north 占 {y.mean():.0%}。每个 k：随机抽 k 个人当标注数据，在其余 {len(people)} − k 人上测，重复 20 次取平均。\n")
    print(pad("特征", 42) + "".join(pad(f"k = {k}", 10, ">") for k in ks))
    print(pad("多数类基线：永远猜 north", 42) + "".join(pad(f"{y.mean():.3f}", 10, ">") for _ in ks))
    for name, X in feats.items():
        accs = []
        for k in ks:
            rng = np.random.default_rng(1000 + k)
            a = []
            for _ in range(20):
                idx = rng.permutation(len(people))
                tr, te = idx[:k], idx[k:]
                predict = fit_logreg(X[tr], y[tr])
                a.append(float((predict(X[te]) == y[te]).mean()))
            accs.append(np.mean(a))
        print(pad(name, 42) + "".join(pad(f"{a:.3f}", 10, ">") for a in accs))
    print("""
观察 Observations：
  · 先看两条参照线。"永远猜 north"有 0.60（60 人里 36 人住北方）。"直接告诉它真实城市"是这个任务的上限：
    k = 4 时它也只有 0.6 左右——4 个标签最多覆盖 4 个城市，没见过的城市属于北方还是南方，标签没告诉它；
    k = 32 时接近 1.0。所以标签少时准确率低，一部分原因是"标签本身信息不够"，不全是模型的问题。
  · 只看名字的"从零开始"模型停在 0.5 左右：名字是随机音节，里面**根本没有**住在哪里的信息，
    再多标签也学不会（它甚至不如"永远猜 north"，因为它在努力拟合噪声）。
  · 预训练表示，读在"kari lives in "的末尾（模型正要说出城市名的位置）：k = 16 起超过多数类基线，
    k = 32 时 0.74。标签只教会了探针"哪些城市算北方"，"这个人住在哪个城市"是预训练时从文本里学到的。
    这就是基础模型最核心的价值：**把大量无标签数据里的知识，带到只有少量标签的下游任务里**。
    离上限还有距离：这个 11 万参数的模型对每个人的城市并不是百分之百确定（代码 02：事实补全 0.93），
    而且城市信息在 64 维的表示里不一定是"线性可读"的。
  · 同一个模型，读在名字的最后一个字符处，就和瞎猜一样：在那个位置，模型只需要预测下一个词是
    "lives"、"works" 还是 "is"，还不需要"想起"城市。表示里有什么，取决于模型在那个位置要做什么预测。
    从哪里读、怎样读（以及要不要微调整个模型），是第 6 讲（适配）的主题。""")


# =============================================================================
# 演示 2：标签很少时预训练有多大帮助
# =============================================================================
def make_reviews(n, rng, food_adjs=None):
    """生成 n 条评论（不含评分）及"食物好不好"的标签。"""
    out = []
    for _ in range(n):
        text, _, food_pos, _ = make_review(rng, food_adjs=food_adjs)
        out.append((text, float(food_pos)))
    return out


def review_features(model, tok, reviews, vocab):
    """
    为一组评论算三种特征。

    【返回】dict：特征名 -> (n, d) numpy 数组
        - prompt_state：在评论后面接上 " rating: "，取最后一个位置的表示（模型正要说出评分的位置）
        - mean_pool   ：评论里每个位置的表示取平均（"句子向量"的常见做法）
        - bow         ：词袋——每个词出现几次（从零开始的基线，不用预训练模型）
    """
    prompt_state, mean_pool, bow = [], [], []
    for text, _ in reviews:
        prompt_state.append(hidden_states(model, tok, f"\n{text} rating: ")[-1].numpy())
        mean_pool.append(hidden_states(model, tok, f"\n{text}")[1:].mean(0).numpy())
        v = np.zeros(len(vocab))
        for wd in text.replace(".", " ").split():
            v[vocab[wd]] += 1
        bow.append(v)
    return {"prompt_state": np.array(prompt_state, dtype=np.float64),
            "mean_pool": np.array(mean_pool, dtype=np.float64), "bow": np.array(bow)}


def demo_few_labels(model, tok):
    print_header("演示 2：评论里的食物好不好？—— 预训练表示 vs 从零训练的词袋模型，标签数从 4 到 512")
    rng = random.Random(7)
    seen_split = (FOOD_POS[:4], FOOD_NEG[:4])      # 标注数据只用前一半形容词
    unseen_split = (FOOD_POS[4:], FOOD_NEG[4:])    # "没见过的形容词"版本的测试集用后一半
    settings = {
        "A. 测试评论的形容词在标注数据里都可能出现": (make_reviews(600, rng), make_reviews(400, rng)),
        "B. 测试评论的食物形容词在标注数据里从没出现过": (make_reviews(600, rng, seen_split),
                                                           make_reviews(400, rng, unseen_split)),
    }
    all_words = sorted({wd for pool, test in settings.values() for text, _ in pool + test
                        for wd in text.replace(".", " ").split()})
    vocab = {wd: i for i, wd in enumerate(all_words)}
    names = {"prompt_state": "预训练表示：\"rating: \" 末尾", "mean_pool": "预训练表示：各位置平均",
             "bow": "从零开始：词袋逻辑回归"}
    ks = [4, 8, 16, 32, 64, 128, 512]
    for title, (pool, test) in settings.items():
        print(f"\n{title}（标注池 {len(pool)} 条，测试 {len(test)} 条，每个 k 重复 10 次）")
        fp, ft = review_features(model, tok, pool, vocab), review_features(model, tok, test, vocab)
        yp = np.array([l for _, l in pool])
        yt = np.array([l for _, l in test])
        print(pad("特征", 32) + "".join(pad(f"k={k}", 8, ">") for k in ks))
        for key, label in names.items():
            accs = [few_label_accuracy(fp[key], yp, ft[key], yt, k, reps=10, seed=k) for k in ks]
            print(pad(label, 32) + "".join(pad(f"{a:.3f}", 8, ">") for a in accs))
    print("""
观察 Observations：
  · 设定 A（普通情况）：标签很少时，读在 "rating: " 末尾的预训练表示遥遥领先（k = 8：0.77 vs 0.58；
    k = 32：0.95 vs 0.77）。标签多了以后，词袋模型追上来，k = 512 时反超（1.000 vs 0.989）。
    原因是这个任务"表面上"很简单——食物形容词本身就决定了答案，只要每个形容词都在标注数据里见过几次，
    最简单的词袋模型就够了。**预训练的优势主要在标签稀缺的时候**；有充足标签时，永远要和一个简单基线比一比。
  · 设定 B（测试用的形容词在标注数据里一个也没见过）：词袋模型完全无能为力（≈ 0.5，标签再多也没用）——
    它对没见过的词没有任何知识。预训练表示却能做到 0.9 以上：预训练时，模型在大量评论里
    见过 "lovely"、"greasy" 和评分的关系，已经知道它们的"正负"，标注数据只需要教会探针"读哪个方向"。
    这就是"预训练表示能泛化到标注数据之外"的含义。
  · 但并不是"随便拿一个表示就行"：把各位置的表示**平均**起来（常见的句子向量做法），在设定 A 里明显落后，
    在设定 B 里只比瞎猜好一点。对这个小模型来说，"食物好不好"的信息集中在它准备给出评分的那个位置；
    平均会把它和几十个无关位置混在一起。适配方法的选择本身就是一个研究问题（第 6 讲）。""")


# =============================================================================
# 演示 3：缺陷也会被继承
# =============================================================================
def pronoun_prefixes(job):
    """4 种句式里，代词之前的部分，例如 "\\nthe pilot finished the work. then "。"""
    return ["\n" + t.split("{pron}")[0].format(job=job) for t in STORY_TEMPLATES]


def p_she(model, tok, prefix):
    """在 prefix 之后，模型认为接 "she" 的概率（只在 she / he 两者之间归一化）。"""
    a = completion_logprob(model, tok, prefix, "she ")
    b = completion_logprob(model, tok, prefix, "he ")
    return 1.0 / (1.0 + math.exp(b - a))


def sampled_she_rate(model, tok, prefix, temperature, n, seed):
    """按温度采样 n 次，统计接下来生成的代词是 she 的比例（不是 she / he 的样本忽略不计）。"""
    g = torch.Generator().manual_seed(seed)
    she = he = 0
    for _ in range(n):
        out = generate(model, tok, prefix, max_new_chars=4, temperature=temperature, stop=" ", generator=g)
        she += out == "she "
        he += out == "he "
    return she / max(1, she + he)


def demo_inherited_bias(model, tok, world):
    print_header("演示 3：缺陷也会被继承 —— 职业后面的代词（比例是随意设定的，见 fm_world.py 开头第 4 点）")
    stories = [l for l in world.train_lines if world.line_genres[l] == "story"]
    print("4 种句式 = 4 个\"应用\"：")
    for t in STORY_TEMPLATES:
        print("  · " + t)
    print("\n" + pad("职业", 8) + pad("设定比例", 10, ">") + pad("语料里实际", 12, ">")
          + "".join(pad(f"应用{i + 1}", 8, ">") for i in range(4)) + pad("T=1 采样", 10, ">")
          + pad("T=0.5", 8, ">") + pad("贪心", 8, ">"))
    summary = {"data": [], "model": [], "t1": [], "t05": [], "greedy": []}
    for j_idx, job in enumerate(JOBS):
        lines = [l for l in stories if l.startswith(f"the {job} ") or f" the {job} " in l]
        corpus_rate = sum(" she " in l for l in lines) / len(lines)
        probs = [p_she(model, tok, pre) for pre in pronoun_prefixes(job)]
        pre = pronoun_prefixes(job)[0]
        t1 = sampled_she_rate(model, tok, pre, 1.0, 200, seed=10 + j_idx)
        t05 = sampled_she_rate(model, tok, pre, 0.5, 200, seed=20 + j_idx)
        greedy = 1.0 if generate(model, tok, pre, max_new_chars=4, temperature=0, stop=" ") == "she " else 0.0
        for key, v in zip(summary, [corpus_rate, np.mean(probs), t1, t05, greedy]):
            summary[key].append(v)
        print(pad(job, 8) + pad(f"{P_SHE[job]:.2f}", 10, ">") + pad(f"{corpus_rate:.2f}", 12, ">")
              + "".join(pad(f"{p:.2f}", 8, ">") for p in probs) + pad(f"{t1:.2f}", 10, ">")
              + pad(f"{t05:.2f}", 8, ">") + pad(f"{greedy:.0f}", 8, ">"))
    print("\n（\"应用 i\" 一列是模型在第 i 种句式里给 she 的概率；采样和贪心都用应用 1 的句式，各采样 200 次）")
    skew = lambda xs: np.mean([abs(x - 0.5) for x in xs])
    print("\n偏斜程度 = |she 的比例 − 0.5| 在 8 个职业上的平均（0 = 完全平衡，0.5 = 全部同一个代词）：")
    print(f"  语料 {skew(summary['data']):.3f}    模型概率（4 个应用平均）{skew(summary['model']):.3f}    "
          f"T=1 采样 {skew(summary['t1']):.3f}    T=0.5 采样 {skew(summary['t05']):.3f}    贪心 {skew(summary['greedy']):.3f}")
    print("""
观察 Observations：
  · 模型给出的 she 概率大体跟着语料的比例走：语料偏斜的 4 个职业（pilot、potter 偏 she，tailor、judge 偏 he），
    在全部 4 种句式里偏斜的方向都和语料一致；语料 50 : 50 的职业，模型大多在 0.4–0.6 之间。
    模型没有"故意"偏向谁，它只是学了数据的统计——而这正是问题所在：数据里有什么偏差，模型就有什么偏差。
  · 不同句式的数字并不完全一样（例如应用 2 的句式对所有职业都更偏 she）：一个只有 11 万参数、
    每种"职业 × 句式"只见过几十次的模型，估计出的比例带着噪声。但偏斜的**方向**在 4 个应用里是一致的。
    如果很多产品都建立在同一个基础模型上，它们会**共享同一组缺陷**。这是同质化的另一面
    （Bommasani 等人称之为 "single point of failure"；第 18 讲还会讨论"结果同质化"）。
  · 解码方式会**放大**偏差：T = 0.5 采样比语料更偏（0.219 vs 0.173），贪心解码则把每个职业都变成
    100 : 0（0.500）——包括语料里 50 : 50 的职业：模型的概率只要稍稍偏离 0.5（比如 0.53），贪心就总选同一个。
    （T = 1 采样的偏斜反而比语料略小，因为应用 1 的句式里模型的估计本身偏向 0.5。）
    同一个模型，部署时选的解码方式不同，表现出的偏差程度也不同。
  · 迷你世界里的比例是随意设定的；真实语料里的比例来自真实社会，偏差的后果也真实得多（第 18 讲：伦理与危害）。""")


if __name__ == "__main__":
    world = build_world()
    tok = CharTokenizer(ALPHABET)
    model, info = load_or_train_base_model(world, tok)
    demo_knowledge_probe(model, tok, world)
    demo_few_labels(model, tok)
    demo_inherited_bias(model, tok, world)
