"""
=============================================================================
第 5 讲 · 代码 5：从语言模型生成文本 —— 贪心、温度、top-k、top-p
=============================================================================

对应讲义：lectures/lecture05_language_models_rnn.md 第 7 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
语言模型每一步给出"下一个词"在整个词表上的概率分布。生成文本时，每一步要从这个分布里**选一个词**，
接到后面，再预测下一个……选词的方法叫**解码策略**（decoding strategy），对生成质量影响很大：

    贪心（greedy）    ：每一步都选概率最高的词。确定、但单调，容易重复。
    纯采样            ：按模型的概率分布随机抽。多样，但偶尔会抽到很不靠谱的低概率词。
    温度（temperature）：采样前把 logits 除以温度 τ。τ < 1 让分布更"尖"（更保守），τ > 1 更"平"（更随机）。
    top-k             ：只在概率最高的 k 个词里采样。
    top-p（核采样）   ：只在累积概率达到 p 的最小词集合里采样（集合大小随分布自适应）。

本文件用代码 04 训练好的 RNN 语言模型比较这些策略：
    演示 1：温度怎样改变一个具体的概率分布
    演示 2：各种策略生成的句子
    演示 3：定量比较 —— 多样性（distinct-n、重复率）和流畅度（用 4-gram 参考模型打分）

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 05_sampling.py           # 需要 checkpoints/rnn_lm.pt（先运行 04 号文件；没有的话会先训练）
    python 05_sampling.py --quick   # 冒烟测试（临时训练一个很小的模型）

需要 PyTorch。有检查点时运行约 20 秒。
"""

import importlib
import math
import sys
from collections import Counter

import numpy as np
import torch

from toy_language import Vocab, generate_corpus

rnnlm = importlib.import_module("04_rnn_lm")
ngram = importlib.import_module("01_ngram_lm")


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


# =============================================================================
# 第 1 部分：解码策略
# =============================================================================
def apply_strategy(logits, strategy, rng):
    """
    按照给定的策略，从一组 logits 中选出下一个词的编号。

    【参数】
        logits   : np.ndarray (V,)，模型输出的未归一化分数（特殊符号已经设为 -inf）
        strategy : dict，例如 {"type": "greedy"}、{"type": "sample", "temperature": 0.7}、
                   {"type": "top_k", "k": 5}、{"type": "top_p", "p": 0.9}
        rng      : np.random.Generator

    【返回】int，选中的词编号

    【思路】（讲义第 7.2 节）
        - temperature：softmax(logits / τ)。τ → 0 时退化成贪心，τ → ∞ 时趋于均匀分布。
        - top-k：把排名第 k 之后的词的概率设为 0，再重新归一化。
        - top-p：按概率从大到小排序，保留累积概率刚达到 p 的那些词，其余设为 0，再重新归一化。

    【边界】
        - top-p 至少保留概率最高的那一个词（即使它的概率已经超过 p）。
    """
    t = strategy["type"]
    if t == "greedy":
        return int(np.argmax(logits))
    tau = strategy.get("temperature", 1.0)
    z = logits / tau
    z = z - z[np.isfinite(z)].max()
    p = np.exp(z)
    p[~np.isfinite(z)] = 0.0
    p /= p.sum()
    if t == "top_k":
        cutoff = np.sort(p)[-strategy["k"]]
        p = np.where(p >= cutoff, p, 0.0)
    elif t == "top_p":
        order = np.argsort(-p)
        cum = np.cumsum(p[order])
        keep = order[:int(np.searchsorted(cum, strategy["p"]) + 1)]
        mask = np.zeros_like(p)
        mask[keep] = 1.0
        p = p * mask
    p /= p.sum()
    return int(rng.choice(len(p), p=p))


@torch.no_grad()
def generate(model, vocab, strategy, rng, max_len=25):
    """
    用 RNN 语言模型和给定的解码策略生成一个句子。

    【返回】list[str]
    """
    model.eval()
    banned = [vocab.stoi[Vocab.PAD], vocab.stoi[Vocab.BOS], vocab.stoi[Vocab.UNK]]
    x = torch.tensor([[vocab.stoi[Vocab.BOS]]])
    state, out = None, []
    for _ in range(max_len):
        logits, _, state = model(x, state)
        z = logits[0, -1].numpy().astype(float)
        z[banned] = -np.inf
        w = apply_strategy(z, strategy, rng)
        if vocab.itos[w] == Vocab.EOS:
            break
        out.append(vocab.itos[w])
        x = torch.tensor([[w]])
    return out


def distinct_n(sentences, n):
    """distinct-n：所有生成句子中，不同的 n-gram 个数 / n-gram 总数。越高越多样。"""
    grams = [tuple(s[i:i + n]) for s in sentences for i in range(len(s) - n + 1)]
    return len(set(grams)) / max(len(grams), 1)


# =============================================================================
# 演示
# =============================================================================
def main(quick=False):
    train = generate_corpus(20000, seed=0)
    vocab = Vocab(train)
    if quick:
        model = rnnlm.train_rnnlm(train[:2000], vocab, epochs=1, verbose=False)
    else:
        model = rnnlm.load_or_train(vocab, train, cell="rnn", path=rnnlm.CKPT_DIR / "rnn_lm.pt", epochs=8, verbose=False)
    train_set = set(" ".join(s) for s in train)
    dev = generate_corpus(2000, seed=1)
    ref = ngram.NGramLM(n=4).fit(train)
    lambdas, _ = ngram.tune_lambdas(ref, dev)

    # ---------------- 演示 1：温度 ----------------
    print_header("演示 1：温度怎样改变概率分布（前文：'the dog'）")
    with torch.no_grad():
        logits, _, _ = model(torch.tensor([vocab.encode(["the", "dog"], add_eos=False)]))
    z = logits[0, -1].numpy().astype(float)
    top = np.argsort(-z)[:6]
    print(f"\n    {'下一个词':<10s}" + "".join(f"{'τ = ' + str(t):>10s}" for t in [0.5, 1.0, 2.0]))
    probs = {}
    for t in [0.5, 1.0, 2.0]:
        e = np.exp((z - z.max()) / t)
        probs[t] = e / e.sum()
    for i in top:
        print(f"    {vocab.itos[i]:<10s}" + "".join(f"{probs[t][i]:>10.3f}" for t in [0.5, 1.0, 2.0]))
    for t in [0.5, 1.0, 2.0]:
        H = -np.sum(probs[t] * np.log(probs[t] + 1e-30))
        print(f"    τ = {t}：分布的熵 {H:.2f} nat（相当于在约 {math.exp(H):.1f} 个词之间均匀选择）")
    print("""
  观察：τ < 1 时高概率的词更高、低概率的词更低（更保守）；τ > 1 时分布被"压平"（更随机）。""")

    # ---------------- 演示 2：生成的句子 ----------------
    strategies = [
        ("贪心", {"type": "greedy"}),
        ("纯采样（τ = 1）", {"type": "sample", "temperature": 1.0}),
        ("温度 τ = 0.5", {"type": "sample", "temperature": 0.5}),
        ("温度 τ = 1.5", {"type": "sample", "temperature": 1.5}),
        ("top-k（k = 5）", {"type": "top_k", "k": 5}),
        ("top-p（p = 0.9）", {"type": "top_p", "p": 0.9}),
    ]
    print_header("演示 2：不同策略生成的句子")
    for name, st in strategies:
        rng = np.random.default_rng(0)
        print(f"\n  {name}：")
        for _ in range(3):
            print("    " + " ".join(generate(model, vocab, st, rng)))

    # ---------------- 演示 3：定量比较 ----------------
    n_gen = 50 if quick else 300
    print_header(f"演示 3：每种策略生成 {n_gen} 个句子，比较多样性和流畅度")
    print(f"\n    {'策略':<18s}{'distinct-1':>11s}{'distinct-2':>11s}{'不同句子':>9s}{'照抄训练集':>11s}{'参考困惑度':>11s}")
    for name, st in strategies:
        rng = np.random.default_rng(1)
        sents = [generate(model, vocab, st, rng) for _ in range(n_gen)]
        unique = len(set(" ".join(s) for s in sents)) / n_gen
        copied = np.mean([" ".join(s) in train_set for s in sents])
        ref_ppl = ref.perplexity([s for s in sents if s], "interp", lambdas)
        print(f"    {name:<18s}{distinct_n(sents, 1):>11.3f}{distinct_n(sents, 2):>11.3f}{unique:>9.1%}{copied:>11.1%}{ref_ppl:>11.2f}")
    print("""
  说明：
    - distinct-n：不同 n-gram 的比例，越高越多样；"不同句子"：生成的句子中互不相同的比例。
    - "参考困惑度"：用代码 01 的 4-gram 模型（在真实语料上训练）给生成的句子打分。越低说明越像真实语料
      （越"流畅"）。但它偏爱短而常见的句子，所以不能单独用来评价生成质量。
  观察：
    - 贪心每次都生成同一个句子，毫无多样性。
    - 降低温度（τ = 0.5）：distinct-n 下降、参考困惑度降低 —— 更保守、更"安全"。
      有意思的是"不同句子"反而更多了：在这门语言里，最可能的下一个词往往是 that、near（继续加修饰语），
      压低温度会让句子变长，而长句子自然不容易重复。温度的效果取决于模型把概率放在了哪里。
    - 升高温度（τ = 1.5）：参考困惑度大幅上升，低概率的、不通顺的组合明显变多。
    - top-k、top-p 砍掉了概率分布的"长尾"：参考困惑度比纯采样低，多样性只略有下降，取得了较好的平衡。
      这也是今天的大语言模型最常用的采样方式（通常再配合一个温度）。
    - "照抄训练集"的比例主要和句子长短有关：短句（"the doctor is happy ."）在训练集里几乎都出现过。
    （具体数字以你运行的结果为准。）""")


if __name__ == "__main__":
    main(quick="--quick" in sys.argv)
