"""
=============================================================================
第 2 讲 · 代码 3：从零实现 GloVe —— 共现概率的比值、加权最小二乘、AdaGrad
=============================================================================

对应讲义：lectures/lecture02_word_vectors_advanced.md 第 6 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
第 1 讲有两条路线：
    计数（共现矩阵 + SVD）：用上了全局统计，但 SVD 的目标（平方误差）不适合计数数据；
    预测（word2vec）       ：效果好，但一个窗口一个窗口地学，没有直接利用"全局"的共现统计。
GloVe（Pennington, Socher & Manning, 2014）想要两者兼得：
    **直接拟合共现次数的对数**，但用一个精心设计的加权函数，并用 SGD 一类的方法训练。

    J = Σ_{i,j: X_ij > 0}  f(X_ij) · ( w_i · w̃_j + b_i + b̃_j - log X_ij )²

    w_i、w̃_j ：词 i 作为"中心词"、词 j 作为"上下文词"的向量（类似 word2vec 的 v 和 u）
    b_i、b̃_j ：两个偏置（标量）
    f(x)     ：加权函数，次数少的词对权重小（不可靠），次数多的词对权重封顶（防止 the 之类主导）

本文件：
    演示 1：GloVe 的出发点 —— 共现概率的**比值**才是有意义的（复刻论文表 1 的思路）
    演示 2：加权函数 f(x)
    演示 3：按论文原样训练 GloVe（AdaGrad）—— 对 log X 拟合得很好，词向量却很差？！
    演示 4：诊断：GloVe 只看非零元素，丢掉了"从不一起出现"这一信息；加一个很小的平滑计数就好了
    演示 5：与 PPMI + SVD、SGNS 对比；加权函数的消融实验（多个随机种子）

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 03_glove.py

需要 numpy。运行时间约 20~40 秒（对比实验要训练多个模型）。
"""

import importlib
import time

import numpy as np

from shared import (build_vocab, evaluate_vectors, generate_corpus, nearest_neighbors, print_header,
                    sentences_to_ids, word_categories)

sgns = importlib.import_module("02_sgns_training")
l1_count = importlib.import_module("03_cooccurrence_svd")      # 第 1 讲的 PPMI + SVD


# =============================================================================
# 第 1 部分：带距离加权的共现矩阵
# =============================================================================
def weighted_cooccurrence(id_sentences, V, window=2):
    """
    计算共现矩阵 X，但每次共现按 1/距离 加权：相邻的词加 1，隔一个词的加 1/2，……

    【思路】
        GloVe 论文的做法：离得越远的两个词关系越弱，所以贡献越小。
        （word2vec 的动态窗口达到了类似的效果，见代码 02。）

    【参数】
        id_sentences : list[list[int]]
        V            : int，词表大小
        window       : int，窗口半径（论文用 10；我们用 2，和其他方法保持一致）

    【返回】
        np.ndarray (V, V)，对称、非负的浮点矩阵

    【边界】
        - 加权后 X_ij 不再是整数；只要 > 0 就会进入 GloVe 的损失。X_ij = 0 的格子完全不参与训练
          （log 0 无定义）—— 这也让 GloVe 只需处理非零元素，非常高效。
    """
    X = np.zeros((V, V))
    for sent in id_sentences:
        for t, center in enumerate(sent):
            for dist in range(1, window + 1):
                if t + dist < len(sent):
                    X[center, sent[t + dist]] += 1.0 / dist
                    X[sent[t + dist], center] += 1.0 / dist
    return X


def probability_ratio_table(X, word2id, a, b, probes):
    """
    打印"共现概率比值"表：P(k | a)、P(k | b) 和 P(k | a) / P(k | b)。

    【思路】（讲义第 6.1 节）
        P(k | i) = X_ik / Σ_j X_ij：在词 i 的上下文里看到 k 的概率。
        单独看 P(k | a) 说明不了多少（the 和谁都常一起出现）；
        但比值 P(k | a) / P(k | b) 很有信息量：
            k 只和 a 相关 → 比值很大；k 只和 b 相关 → 比值很小；
            k 和两者都相关或都无关 → 比值接近 1。

    【参数】
        X       : np.ndarray (V, V)
        word2id : dict
        a, b    : str，要比较的两个词
        probes  : list[str]，探针词 k

    【边界】
        - 分母为 0（k 从没和 b 一起出现）时比值为无穷大，打印为 inf；分子分母都为 0 时打印 0/0。
        - 只有窗口内的词才算共现：例如 "the king wears a crown" 中 crown 离 king 有 3 个位置，
          窗口为 2 时不计入，所以探针词要选窗口内会出现的词。
    """
    Pa = X[word2id[a]] / X[word2id[a]].sum()
    Pb = X[word2id[b]] / X[word2id[b]].sum()
    print(f"\n    {'k':<10s}{'P(k|' + a + ')':>14s}{'P(k|' + b + ')':>14s}{'比值':>10s}")
    for k in probes:
        pa, pb = Pa[word2id[k]], Pb[word2id[k]]
        if pb > 0:
            ratio = f"{pa / pb:.2f}"
        else:
            ratio = "inf" if pa > 0 else "0/0"      # 两者都从没出现：比值无定义
        print(f"    {k:<10s}{pa:>14.4f}{pb:>14.4f}{ratio:>10s}")


# =============================================================================
# 第 2 部分：GloVe 的加权函数与训练
# =============================================================================
def glove_weight(x, x_max=100.0, alpha=0.75):
    """
    GloVe 的加权函数：f(x) = (x / x_max)^alpha（x < x_max 时），否则 f(x) = 1。

    【思路】（讲义第 6.3 节）
        对它的三个要求：
          (1) f(0) = 0：没共现过的词对不参与（否则 log 0 = -∞）；
          (2) 单调不减：共现越多越可靠，权重越大；
          (3) 有上限：the 和 cat 共现几百次，不能让这一项把其他项都淹没。
        alpha = 3/4 是论文的经验选择（巧合的是，和 word2vec 噪声分布的 3/4 次方一样）。

    【参数】
        x     : float 或 np.ndarray，共现次数（>= 0）
        x_max : float，封顶的阈值（论文用 100）
        alpha : float，指数

    【返回】
        与 x 同形状，取值 [0, 1]。
    """
    x = np.asarray(x, dtype=float)
    return np.where(x < x_max, (x / x_max) ** alpha, 1.0)


def train_glove(X, dim=10, epochs=100, lr=0.05, x_max=100.0, alpha=0.75, batch_size=64, seed=0,
                use_weighting=True, verbose=True):
    """
    用 AdaGrad 训练 GloVe 词向量。

    【思路】
        只遍历 X 的**非零**元素 (i, j, X_ij)。对每个元素：
            diff = w_i · w̃_j + b_i + b̃_j - log X_ij      （模型的预测减去目标）
            损失 = ½ f(X_ij) diff²                        （乘 ½ 只是让梯度少一个常数 2，不影响最优解）
            ∂/∂w_i = f·diff·w̃_j，∂/∂w̃_j = f·diff·w_i，∂/∂b_i = ∂/∂b̃_j = f·diff
        AdaGrad：每个参数有自己的学习率 lr / sqrt(累计梯度平方)。
            经常被更新的参数（高频词）学习率很快变小，很少被更新的（罕见词）保持较大的学习率。
            GloVe 原始实现就用 AdaGrad，累计量初始化为 1。

    【参数】
        X          : np.ndarray (V, V)，共现矩阵（可以带距离加权）
        dim        : int，向量维度
        epochs     : int，遍历非零元素的轮数
        lr         : float，AdaGrad 的初始学习率（论文用 0.05）
        x_max, alpha : 加权函数的参数
        batch_size : int，每批多少个非零元素
        seed       : int，随机种子
        use_weighting : bool，False 时所有词对的权重都是 1（用于消融实验）
        verbose    : bool，每 20 轮打印一次损失

    【返回】
        (W, W_tilde, b, b_tilde, history)
            W、W_tilde : np.ndarray (V, d)；b、b_tilde : np.ndarray (V,)
            history    : list[float]，每轮的平均损失（按非零元素平均）

    【边界】
        - 不加权时，大量只共现过一两次的词对（log X ≈ 0 附近、噪声大）和 the 之类的高频词对
          权重一样，向量质量会变差（演示 5）。
        - 损失下降到某个值就不再下降：d 维向量只能做"低秩近似"，拟合不了所有细节。

    【Python 小知识】
        np.nonzero(X)：返回所有非零元素的 (行下标数组, 列下标数组)。

    【NLP 联系】
        AdaGrad 是第一个被广泛使用的"自适应学习率"优化器；第 3 讲的 Adam 可以看作它的改进版
        （用指数移动平均代替累加，避免学习率一直变小）。
    """
    V = X.shape[0]
    rng = np.random.default_rng(seed)
    W = (rng.random((V, dim)) - 0.5) / dim
    Wt = (rng.random((V, dim)) - 0.5) / dim
    b, bt = np.zeros(V), np.zeros(V)
    # AdaGrad 的梯度平方累计量，初始化为 1（和 GloVe 官方实现一致）
    GW, GWt, Gb, Gbt = np.ones((V, dim)), np.ones((V, dim)), np.ones(V), np.ones(V)

    rows, cols = np.nonzero(X)
    logx = np.log(X[rows, cols])
    fx = glove_weight(X[rows, cols], x_max, alpha) if use_weighting else np.ones(len(rows))
    history = []
    for epoch in range(epochs):
        order = rng.permutation(len(rows))
        total = 0.0
        for start in range(0, len(order), batch_size):
            idx = order[start:start + batch_size]
            i, j = rows[idx], cols[idx]
            diff = np.sum(W[i] * Wt[j], axis=1) + b[i] + bt[j] - logx[idx]
            fd = fx[idx] * diff
            total += 0.5 * np.sum(fx[idx] * diff ** 2)
            # 先把这一批里同一行的梯度累加起来，再做 AdaGrad 更新
            gW, gWt = np.zeros_like(W), np.zeros_like(Wt)
            gb, gbt = np.zeros(V), np.zeros(V)
            np.add.at(gW, i, fd[:, None] * Wt[j])
            np.add.at(gWt, j, fd[:, None] * W[i])
            np.add.at(gb, i, fd)
            np.add.at(gbt, j, fd)
            for P, G, g in [(W, GW, gW), (Wt, GWt, gWt), (b, Gb, gb), (bt, Gbt, gbt)]:
                P -= lr * g / np.sqrt(G)
                G += g ** 2
        history.append(total / len(rows))
        if verbose and (epoch + 1) % 20 == 0:
            print(f"    第 {epoch + 1:>3d} 轮：平均加权平方误差 {history[-1]:.4f}")
    return W, Wt, b, bt, history


# =============================================================================
# 演示
# =============================================================================
def main():
    sents = generate_corpus()
    word2id, id2word, cnt = build_vocab(sents)
    counts = np.array([cnt[w] for w in id2word], dtype=float)
    ids = sentences_to_ids(sents, word2id)
    V = len(id2word)
    X = weighted_cooccurrence(ids, V, window=2)
    content = list(word_categories())

    # ---------------- 演示 1：概率比值 ----------------
    print_header("演示 1：共现概率的比值（仿照 GloVe 论文表 1 的 ice / steam 例子）")
    print("\n  (a) king vs queen：")
    probability_ratio_table(X, word2id, "king", "queen", ["he", "his", "she", "her", "royal", "rules", "works", "the"])
    print("\n  (b) paris vs tokyo：")
    probability_ratio_table(X, word2id, "paris", "tokyo", ["french", "japanese", "speak", "flew", "of", "cat"])
    print("""
  观察：
    - he、his 只和 king 相关 → 比值为无穷大（这里从没和 queen 一起出现）；she、her 只和 queen 相关 → 比值为 0。
    - royal、rules 和两者都相关，the 和谁都相关 → 比值接近 1；works（平民的上下文）和两者都无关 → 0/0。
    - 所以"区分 king 和 queen"的信息，恰好体现在比值偏离 1 的那些探针词上：比值就是"意义的差别"。
      GloVe 的目标是让 (w_king - w_queen) · w̃_k ≈ log P(k|king) - log P(k|queen) = log 比值。""")

    # ---------------- 演示 2：加权函数 ----------------
    print_header("演示 2：加权函数 f(x) = min(1, (x / 100)^0.75)")
    for x in [0.5, 1, 5, 10, 50, 100, 500]:
        print(f"    x = {x:>5}：f(x) = {float(glove_weight(x)):.3f}")
    nz = X[X > 0]
    print(f"\n  我们的共现矩阵有 {len(nz)} 个非零元素（占 {len(nz) / X.size:.1%}），"
          f"其中 X >= 100 的只有 {np.sum(nz >= 100)} 个（权重封顶为 1）。")

    # ---------------- 演示 3：按论文原样训练 ----------------
    print_header("演示 3：按论文原样训练 GloVe（只用非零元素，d = 10，AdaGrad，100 轮）")
    t0 = time.perf_counter()
    W, Wt, b, bt, hist = train_glove(X, dim=10)
    print(f"\n  训练用时 {time.perf_counter() - t0:.1f} 秒（取决于你的电脑）")
    r, c = np.nonzero(X)
    pred = np.sum(W[r] * Wt[c], axis=1) + b[r] + bt[c]
    print(f"  在非零元素上，模型预测 w·w̃ + b + b̃ 与 log X 的相关系数：{np.corrcoef(pred, np.log(X[r, c]))[0, 1]:.3f}")
    print("\n  最近邻（W + W̃，只在内容词中找）：")
    for q in ["cat", "apple", "car", "king", "paris"]:
        nb = nearest_neighbors(q, W + Wt, word2id, id2word, k=4, restrict_to=content)
        print(f"    {q:<7s}→ " + ", ".join(f"{w}({s:.2f})" for w, s in nb))
    evaluate_vectors("论文原样的 GloVe（W + W̃）", W + Wt, word2id, id2word)
    print("""
  观察：模型对 log X 拟合得很好（相关系数 0.95），但 cat 的邻居里混进了 apple、banana，
    异类平均余弦高达 0.8 —— 几乎所有词都挤在同一个方向上。"拟合得好"不等于"词向量好"。""")

    # ---------------- 演示 4：诊断与修复 ----------------
    print_header("演示 4：诊断 —— GloVe 看不到'从不一起出现'")
    ctx = {w: set(np.nonzero(X[word2id[w]])[0]) for w in ["cat", "dog", "apple"]}
    shared_ca = sorted(id2word[k] for k in ctx["cat"] & ctx["apple"])
    print(f"\n  cat 有 {len(ctx['cat'])} 个不同的上下文词，apple 有 {len(ctx['apple'])} 个，"
          f"两者共有 {len(shared_ca)} 个：{shared_ca}")
    print(f"  cat 和 dog 共有 {len(ctx['cat'] & ctx['dog'])} 个。")
    print("""
  GloVe 的损失只对 X_ij > 0 的格子求和。对 cat 来说，它只被要求"和 the、fed、sleeping……的点积要对"；
  至于 cat 和 sweet、drove 的点积是多少，**损失函数根本不管**。
  而 cat 和 apple 共有的上下文（the、a、is、saw……）恰恰是次数最多、权重最大的那些格子，
  于是"让 cat 和 apple 很像"完全不会受到惩罚。
  word2vec 的负例、PPMI 矩阵里的 0，提供的正是这种"它们不该一起出现"的**负信息**。

  修复办法（我们的实验做法，不是论文的做法）：给所有格子加一个很小的平滑计数 ε = 0.1。
  原本为 0 的格子变成 0.1，log 0.1 ≈ -2.3 是一个很低的目标值，模型必须让这些词对的点积很小。""")
    Xs = X + 0.1
    t0 = time.perf_counter()
    W2, Wt2, _, _, _ = train_glove(Xs, dim=10, verbose=False)
    print(f"  加平滑后训练（{Xs.size} 个格子都参与）用时 {time.perf_counter() - t0:.1f} 秒\n")
    for q in ["cat", "apple", "car", "king", "paris"]:
        nb = nearest_neighbors(q, W2 + Wt2, word2id, id2word, k=4, restrict_to=content)
        print(f"    {q:<7s}→ " + ", ".join(f"{w}({s:.2f})" for w, s in nb))
    print()
    for name, v in [("平滑后：只用 W", W2), ("平滑后：只用 W̃", Wt2), ("平滑后：W + W̃", W2 + Wt2)]:
        evaluate_vectors(name, v, word2id, id2word)
    print("""
  说明：
    - 共现矩阵是对称的，W 和 W̃ 的地位完全相同，只是随机初始化不同；两者相加相当于两个模型取平均。
    - 为什么论文不需要平滑？在几十亿词的真实语料里，常见词之间几乎都共现过，
      "从没共现"的大多是罕见词对，这些 0 本身就很不可靠，忽略它们是合理的。
      我们的玩具语料由模板生成，不同类别的上下文几乎不重叠，0 反而携带了关键信息。
      后来也有工作（例如 Swivel，2016）专门研究如何利用"未观测到的共现"。""")

    # ---------------- 演示 5：对比与消融 ----------------
    print_header("演示 5：GloVe vs PPMI + SVD vs SGNS；加权函数的消融（d = 10，3 个随机种子）")
    M, _ = l1_count.compute_co_occurrence_matrix(sents, 2, word2id)
    svd_vec = l1_count.reduce_to_k_dim(l1_count.ppmi(M), 10)
    print()
    evaluate_vectors("PPMI + SVD（没有随机性，只跑 1 次）", svd_vec, word2id, id2word)
    configs = [
        ("SGNS（代码 02 的默认设置）", lambda s: sgns.train_sgns(ids, counts, seed=s, verbose=False)[:2]),
        ("GloVe（论文原样，只用非零元素）", lambda s: train_glove(X, seed=s, verbose=False)[:2]),
        ("GloVe（平滑 ε = 0.1）", lambda s: train_glove(Xs, seed=s, verbose=False)[:2]),
        ("GloVe（平滑 ε = 0.1，不加权 f = 1）", lambda s: train_glove(Xs, seed=s, use_weighting=False, verbose=False)[:2]),
        ("GloVe（平滑 ε = 0.1，x_max = 10）", lambda s: train_glove(Xs, seed=s, x_max=10.0, verbose=False)[:2]),
    ]
    for name, fn in configs:
        rs = [evaluate_vectors(name, sum(fn(s)), word2id, id2word, verbose=False) for s in [0, 1, 2]]
        accs = [x["analogy"] for x in rs]
        print(f"    {name:<34s}纯度 {np.mean([x['purity'] for x in rs]):.3f}  区分度 {np.mean([x['gap'] for x in rs]):.3f}  "
              f"类比 {np.mean(accs):.1%}（{min(accs):.1%} ~ {max(accs):.1%}）")
    print("""
  提醒：这是玩具语料上的结果，而且平滑是我们为玩具语料做的改动。GloVe 论文在大规模语料上报告它在
    类比任务上优于 word2vec；Levy、Goldberg 和 Dagan（2015）则发现，把超参数和技巧调到同等程度后，
    几种方法的差距很小。这里真正想说明的是：**词向量需要"负信息"**——
    word2vec 用负例提供，PPMI + SVD 用矩阵里的 0 提供，GloVe 在大语料上靠海量的非零共现间接获得。""")


if __name__ == "__main__":
    main()
