"""
=============================================================================
第 1 讲 · 代码 5：训练 skip-gram word2vec，并检验学到的词向量
=============================================================================

对应讲义：lectures/lecture01_nlp_history_word_vectors.md 第 11 ~ 13 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
代码 04 推导并验证了 skip-gram（朴素 softmax）的损失和梯度。现在把它们用起来：
在玩具语料上用**小批量随机梯度下降**（mini-batch SGD）训练词向量，然后检查：
    1) 损失是不是在下降？降到多少才算"学好了"？（和"理论最低损失"对比）
    2) 最近邻：cat 的邻居是不是动物？king 的邻居是不是人物？
    3) 类比：king - man + woman 是不是最接近 queen？
    4) 和代码 03 的"计数 + SVD"方法比一比。
    5) 中心词向量 V 和上下文向量 U 有什么区别？最后该用哪个？

-----------------------------------------------------------------------------
【整体流程】
-----------------------------------------------------------------------------
    语料（corpus.py） → 抽取 (中心词, 上下文词) 样本对（窗口 2）
    → 随机初始化 V、U（形状都是 词表大小 × d）
    → 重复若干轮（epoch）：
          打乱样本顺序 → 每次取一小批（batch）→ naive_softmax_batch 算损失和梯度
          → V ← V - α ∂J/∂V，U ← U - α ∂J/∂U
    → 评估：最近邻、类别纯度、区分度、类比、二维投影

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

需要 numpy；装了 matplotlib 会额外把二维图保存到 figures/ 文件夹。
运行时间：约 5~10 秒（取决于电脑）。
"""

import importlib
import time
from pathlib import Path

import numpy as np

from corpus import (analogy_questions, build_vocab, generate_corpus, sentences_to_ids, skipgram_pairs,
                    word_categories)
from vector_tools import (analogy, ascii_scatter, category_purity, evaluate_analogies, nearest_neighbors,
                          normalize_rows, pca_2d, save_scatter_png, similarity_gap)

# 【Python 小知识】
#   "04_word2vec_gradients" 以数字开头，不是合法的 Python 名字，不能写 import 04_word2vec_gradients。
#   importlib.import_module("04_word2vec_gradients") 用字符串指定模块名，就能绕过这个限制。
w2v = importlib.import_module("04_word2vec_gradients")
count_based = importlib.import_module("03_cooccurrence_svd")

FIGURE_DIR = Path(__file__).parent / "figures"


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


# =============================================================================
# 第 1 部分：训练
# =============================================================================
def train_word2vec(pairs, vocab_size, dim=10, epochs=15, batch_size=64, lr=0.5, seed=0, verbose=True):
    """
    用小批量 SGD 训练 skip-gram（朴素 softmax）词向量。

    【思路】（讲义第 11 节）
        1. 初始化：V、U 都用很小的随机数（均匀分布在 ±0.5/d 之间）。
           为什么不全设成 0？如果所有向量都相同，所有词收到的梯度也相同，它们会永远一模一样
           （"对称性"无法打破）。随机初始化让不同的词从不同的起点出发。
           为什么要很小？向量很小时所有分数接近 0，softmax 接近均匀分布 —— 一个"不偏不倚"的起点。
        2. 每一轮（epoch）把所有样本对打乱顺序，切成大小为 batch_size 的小批，
           每批算一次平均梯度并更新参数。打乱顺序是为了让每批样本更有"代表性"，
           避免连续很多步都在学同一类句子。
        3. 学习率在训练过程中线性衰减到初始值的 10%（原始 word2vec 也使用线性衰减）：
           早期步子大、学得快；后期步子小、收敛得稳。

    【参数】
        pairs      : list[(center_id, outside_id)]，训练样本对（来自 corpus.skipgram_pairs）
        vocab_size : int，词表大小 V
        dim        : int，词向量维度 d
        epochs     : int，把全部样本过几遍
        batch_size : int，每批样本数 B
        lr         : float，初始学习率 α
        seed       : int，随机种子（控制初始化和打乱顺序，保证可复现）
        verbose    : bool，是否打印每轮的平均损失

    【返回】
        (center_vectors, outside_vectors, history)
            center_vectors  : np.ndarray (V, d)，中心词向量 V
            outside_vectors : np.ndarray (V, d)，上下文向量 U
            history         : list[float]，每轮的平均训练损失

    【边界】
        - 学习率太大（比如 5.0）会让损失震荡甚至变成 nan；太小（比如 0.01）在 15 轮内几乎学不到东西。
          README 的"动手改一改"里有这个实验。
        - 朴素 softmax 每个样本都要对整个词表算概率，所以只适合我们这种 V ≈ 170 的小词表。

    【NLP 联系】
        "随机初始化 → 打乱数据 → 小批量梯度下降 → 学习率衰减"是训练几乎所有神经网络的标准流程，
        大语言模型的预训练也是如此（只是优化器换成了 AdamW、学习率先预热再衰减）。

    【Python 小知识】
        rng.permutation(n)：返回 0 ~ n-1 的一个随机排列，用来打乱样本顺序。
        pairs_arr[idx, 0]：二维数组的花式索引，取出这批样本的第 0 列（中心词编号）。
    """
    rng = np.random.default_rng(seed)
    center_vectors = (rng.random((vocab_size, dim)) - 0.5) / dim
    outside_vectors = (rng.random((vocab_size, dim)) - 0.5) / dim
    pairs_arr = np.array(pairs)
    n = len(pairs_arr)
    total_steps = epochs * ((n + batch_size - 1) // batch_size)
    step = 0
    history = []
    for epoch in range(epochs):
        order = rng.permutation(n)
        epoch_loss, n_batches = 0.0, 0
        for start in range(0, n, batch_size):
            idx = order[start:start + batch_size]
            alpha = lr * (1.0 - 0.9 * step / total_steps)          # 线性衰减到 0.1 × lr
            loss, g_center, g_outside = w2v.naive_softmax_batch(
                pairs_arr[idx, 0], pairs_arr[idx, 1], center_vectors, outside_vectors)
            center_vectors -= alpha * g_center
            outside_vectors -= alpha * g_outside
            epoch_loss += loss
            n_batches += 1
            step += 1
        history.append(epoch_loss / n_batches)
        if verbose:
            print(f"    第 {epoch + 1:>2d} 轮：平均损失 {history[-1]:.4f}")
    return center_vectors, outside_vectors, history


def optimal_loss(pairs, vocab_size):
    """
    理论最低损失：如果模型能**完美**地预测每个中心词的上下文分布，平均损失是多少？

    【思路】
        对中心词 c，语料中它的上下文词的真实分布是 q(o | c) = count(c, o) / count(c)。
        任何模型的平均损失都 >= 这个分布的熵（交叉熵 >= 熵）：
            min loss = Σ_c [count(c) / N] × H(q(· | c))，其中 H(q) = -Σ_o q(o) log q(o)。
        这给了我们一个"天花板"：训练损失降到这附近，说明模型已经把语料里的规律学得差不多了。
        （上下文本身是有随机性的 —— "the ___ is sweet" 里填 apple 还是 pear 本来就不确定，
          所以损失不可能降到 0。）

    【参数】
        pairs      : list[(center_id, outside_id)]
        vocab_size : int

    【返回】
        float，理论最低的平均损失（单位：nat，自然对数）。

    【边界】
        - 这是**训练集**上的下界。d 很小时模型容量不够，可能达不到它。
    """
    counts = np.zeros((vocab_size, vocab_size))
    np.add.at(counts, (np.array(pairs)[:, 0], np.array(pairs)[:, 1]), 1)
    row = counts.sum(axis=1, keepdims=True)
    q = np.divide(counts, row, out=np.zeros_like(counts), where=row > 0)
    with np.errstate(divide="ignore", invalid="ignore"):
        ent = -np.where(q > 0, q * np.log(q), 0.0).sum(axis=1)
    return float((row[:, 0] / row.sum()) @ ent)


# =============================================================================
# 第 2 部分：评估
# =============================================================================
def report(name, vectors, word2id, id2word, cats, questions, show_neighbors=True):
    """
    打印一组词向量的评估结果：最近邻、类别纯度、区分度、类比准确率。

    【参数】
        name      : str，这组向量的名字（打印用）
        vectors   : np.ndarray (V, d)
        word2id, id2word : 词表
        cats      : dict[str, str]，词 → 类别
        questions : 类比题列表
        show_neighbors : 是否打印最近邻

    【返回】
        dict，包含 purity、within、between、gap、analogy 五个数，方便汇总比较。
    """
    content = list(cats)
    purity, chance = category_purity(vectors, word2id, cats, k=5)
    within, between, gap = similarity_gap(vectors, word2id, cats)
    acc, per = evaluate_analogies(questions, vectors, word2id, id2word, restrict_to=content)
    print(f"\n  [{name}]")
    if show_neighbors:
        for q in ["cat", "apple", "car", "king", "girl", "paris", "french"]:
            nbrs = nearest_neighbors(q, vectors, word2id, id2word, k=4, restrict_to=content)
            print(f"    {q:<7s}→ " + ", ".join(f"{w}({s:.2f})" for w, s in nbrs))
    print(f"    类别纯度 {purity:.3f}（随机 ≈ {chance:.3f}）；同类平均余弦 {within:.3f}，"
          f"异类平均余弦 {between:.3f}，区分度 {gap:.3f}")
    print(f"    类比准确率 {acc:.1%}：" + "，".join(f"{k} {r}/{t}" for k, (r, t) in per.items()))
    return {"purity": purity, "within": within, "between": between, "gap": gap, "analogy": acc}


# =============================================================================
# 演示
# =============================================================================
def main():
    sents = generate_corpus()
    word2id, id2word, _ = build_vocab(sents)
    pairs = skipgram_pairs(sentences_to_ids(sents, word2id), window=2)
    cats = word_categories()
    questions = analogy_questions()
    V = len(word2id)

    # ---------------- 演示 1：训练 ----------------
    print_header("演示 1：训练 skip-gram（朴素 softmax，小批量 SGD）")
    dim = 10
    print(f"\n  词表 V = {V}，样本对 {len(pairs)} 个，向量维度 d = {dim}，"
          f"参数总数 2·V·d = {2 * V * dim}")
    print(f"  训练前（均匀分布）的损失 = log V = {np.log(V):.4f}")
    print(f"  理论最低损失（完美预测每个中心词的上下文分布）= {optimal_loss(pairs, V):.4f}\n")
    t0 = time.perf_counter()
    C, U, history = train_word2vec(pairs, V, dim=dim)
    print(f"\n  训练用时 {time.perf_counter() - t0:.1f} 秒（取决于你的电脑）")

    # ---------------- 演示 2：评估 word2vec ----------------
    print_header("演示 2：word2vec 学到了什么？（使用 V + U 作为最终词向量）")
    w2v_vec = C + U
    r_w2v = report("word2vec, d=10, V+U", w2v_vec, word2id, id2word, cats, questions)
    content = list(cats)
    print("\n  几道类比题的前 3 个候选：")
    for a, b, c in [("man", "woman", "king"), ("king", "queen", "boy"), ("boy", "man", "princess"),
                    ("france", "paris", "japan"), ("france", "french", "italy")]:
        top = analogy(a, b, c, w2v_vec, word2id, id2word, k=3, restrict_to=content)
        print(f"    {a} : {b} :: {c} : ?   → " + ", ".join(f"{w}({s:.2f})" for w, s in top))

    # ---------------- 演示 3：V、U、V+U ----------------
    print_header("演示 3：中心词向量 V、上下文向量 U，用哪个？")
    rows = {}
    for name, vec in [("只用 V（中心词向量）", C), ("只用 U（上下文向量）", U), ("V + U", C + U)]:
        rows[name] = report(name, vec, word2id, id2word, cats, questions, show_neighbors=False)
    the, cat = word2id["the"], word2id["cat"]
    print(f"\n  一个细节：v_cat · u_the = {C[cat] @ U[the]:+.2f}（cat 旁边常出现 the → 点积大），"
          f"而 v_cat · v_the = {C[cat] @ C[the]:+.2f}")
    print("  → 同一个空间里，'常一起出现'体现在 v·u 上，'上下文相似'体现在 v·v（或 u·u）上。")

    # ---------------- 演示 4：与计数 + SVD 方法对比 ----------------
    print_header("演示 4：和代码 03 的'计数 + SVD'对比（同样 10 维）")
    svd_vec, _, _ = count_based.svd_word_vectors(sents, window_size=2, k=dim, weighting="ppmi")
    r_svd = report("PPMI + SVD, k=10", svd_vec, word2id, id2word, cats, questions, show_neighbors=False)
    print("\n  汇总（同样的语料、同样的维度）：")
    print(f"    {'方法':<22s}{'纯度':>8s}{'同类余弦':>10s}{'异类余弦':>10s}{'区分度':>9s}{'类比':>8s}")
    for name, r in [("PPMI + SVD (k=10)", r_svd), ("word2vec (d=10, V+U)", r_w2v)]:
        print(f"    {name:<22s}{r['purity']:>8.3f}{r['within']:>10.3f}{r['between']:>10.3f}"
              f"{r['gap']:>9.3f}{r['analogy']:>8.1%}")
    print("""
  提醒：这是在**我们自己设计的玩具语料**上的结果，不能据此断言哪种方法在真实语料上更好。
    第 2 讲会看到，Levy & Goldberg（2014）证明了 skip-gram 负采样在某种意义上
    等价于对（平移后的）PMI 矩阵做分解 —— 两条路线其实殊途同归。""")

    # ---------------- 演示 5：维度的影响 ----------------
    print_header("演示 5：词向量维度 d 的影响")
    for d in [2, 5, 20]:
        Cd, Ud, hist = train_word2vec(pairs, V, dim=d, verbose=False)
        purity, _ = category_purity(Cd + Ud, word2id, cats, k=5)
        acc, _ = evaluate_analogies(questions, Cd + Ud, word2id, id2word, restrict_to=content)
        print(f"    d = {d:>2d}：最终训练损失 {hist[-1]:.4f}，类别纯度 {purity:.3f}，类比准确率 {acc:.1%}")
    print(f"    （d = 10 的结果见演示 2：损失 {history[-1]:.4f}，纯度 {r_w2v['purity']:.3f}，类比 {r_w2v['analogy']:.1%}）")

    # ---------------- 演示 6：二维投影 ----------------
    print_header("演示 6：把 word2vec 词向量投影到二维")
    show = ["cat", "dog", "horse", "apple", "banana", "pear", "car", "bus", "train",
            "king", "queen", "man", "woman", "boy", "girl", "paris", "france", "tokyo", "japan"]
    pts = pca_2d(normalize_rows(w2v_vec[[word2id[w] for w in show]]))
    print(ascii_scatter(pts, show))
    FIGURE_DIR.mkdir(exist_ok=True)
    if save_scatter_png(pts, show, FIGURE_DIR / "lecture01_word2vec_2d.png", "word2vec (d=10), PCA to 2D"):
        print("  （已保存图片：figures/lecture01_word2vec_2d.png）")

    # 只看人物词：性别、身份、年龄三个方向
    persons = ["king", "queen", "prince", "princess", "man", "woman", "boy", "girl"]
    pts = pca_2d(normalize_rows(w2v_vec[[word2id[w] for w in persons]]))
    print("\n  只投影 8 个人物词（PCA 的两个主方向往往对应我们设计的特征之一）：")
    print(ascii_scatter(pts, persons, width=60, height=14))
    if save_scatter_png(pts, persons, FIGURE_DIR / "lecture01_word2vec_persons.png", "persons, PCA to 2D"):
        print("  （已保存图片：figures/lecture01_word2vec_persons.png）")

    # 自我检查：训练确实让损失下降，并学到了类别结构
    assert history[-1] < history[0] < np.log(V)
    assert r_w2v["purity"] > 0.9


if __name__ == "__main__":
    main()
