"""
=============================================================================
CS230 · 第 16 讲共享模块：词向量工具箱（numpy 从零实现）
=============================================================================

对应讲义：lectures/lecture16_word_embeddings_seq2seq_attention.md §1–§4、§6

-----------------------------------------------------------------------------
【这个模块提供什么】
-----------------------------------------------------------------------------
    词表         build_vocab、encode_corpus
    相似度       cosine_similarity、normalize_rows、nearest_neighbors
    类比         solve_analogy、analogy_accuracy           讲义 §3：e_king − e_man + e_woman ≈ e_queen
    skip-gram    make_skipgram_pairs、negative_table、sgns_loss_and_grads、rows_to_dense、train_sgns
                 （word2vec 的 skip-gram + 负采样，讲义 §4.3–§4.4；mini-batch + AdaGrad / SGD，numpy 写出每一个梯度）
    对照方法     cooccurrence_matrix、ppmi_svd（共现矩阵 + SVD）、train_glove（GloVe，讲义 §4.5）
    默认词向量   train_default_embeddings：用固定的语料和超参数训练一份词向量，02、03 两个脚本直接用它

-----------------------------------------------------------------------------
【记号】和 Andrew Ng 的视频一致
-----------------------------------------------------------------------------
    V      词表大小；d 词向量的维数
    E      嵌入矩阵，形状 (V, d)：第 w 行是词 w 的词向量 e_w。
           （Ng 的视频里 E 是 (d, V)、e_w = E · o_w 是一列；numpy / PyTorch 习惯"一行一个词"，
           所以这里的 E 是视频里那个矩阵的转置。nn.Embedding 的 weight 也是 (V, d)。）
    Θ      skip-gram 的"输出"向量，形状 (V, d)：第 t 行是 θ_t
    c, t   Ng 的叫法：c = context（输入的那个词，比如 orange），t = target（要预测的附近的词，比如 juice）。
           注意 Mikolov 的论文里叫法正好相反（"center word" 是输入，"context words" 是被预测的），读论文时别搞混。

【注释标签】【思路】【参数】【返回】【边界】【DL 联系】【numpy 小知识】【Python 小知识】
"""

import numpy as np

from word_world import generate_corpus


# =============================================================================
# 0. 小工具：数值稳定的 sigmoid 与 log-sigmoid
# =============================================================================
def sigmoid(x):
    """
    σ(x) = 1 / (1 + e^(−x))，用 tanh 写成 0.5 · (1 + tanh(x / 2))：数学上完全相等，但 x 很大或很小时不会溢出。

    【参数】x：任意形状的数组；【返回】同形状
    """
    return 0.5 * (1.0 + np.tanh(0.5 * x))


def log_sigmoid(x):
    """
    log σ(x) = −log(1 + e^(−x)) = −logaddexp(0, −x)。

    【思路】直接算 np.log(sigmoid(x)) 在 x = −40 时会变成 log(0) = −inf。
            np.logaddexp(a, b) = log(e^a + e^b)，numpy 内部用了"先减最大值"的技巧（第 9 讲 §3.7 的 log-sum-exp），不会溢出。
    """
    return -np.logaddexp(0.0, -x)


# =============================================================================
# 1. 词表
# =============================================================================
def build_vocab(corpus):
    """
    统计词频、给每个词编号。

    【思路】按词频从高到低排序（同频的按字母序，保证每次结果一样），第 0 号是最常见的词。
    【参数】corpus：list[list[str]]
    【返回】(words, word2id, counts)
        words   : list[str]，长度 V，words[i] 是第 i 号词
        word2id : dict，词 → 编号
        counts  : (V,) 整数数组，每个词出现的次数
    【Python 小知识】sorted(..., key=lambda kv: (-kv[1], kv[0]))：先按次数从大到小（取负号），再按词本身。
            lambda 是一个"一行的匿名函数"，kv 是 (词, 次数) 这样的二元组。
    """
    freq = {}
    for sent in corpus:
        for w in sent:
            freq[w] = freq.get(w, 0) + 1
    items = sorted(freq.items(), key=lambda kv: (-kv[1], kv[0]))
    words = [w for w, _ in items]
    counts = np.array([c for _, c in items], dtype=np.int64)
    return words, {w: i for i, w in enumerate(words)}, counts


def encode_corpus(corpus, word2id):
    """
    把语料变成两个一维整数数组：ids（每个 token 的编号）和 sent_id（它属于第几句）。

    【返回】(ids, sent_id)，形状都是 (N,)，N 是 token 总数
    【边界】词表里没有的词会报 KeyError（我们的词表就是从同一个语料建的，不会发生）。
    """
    ids = np.array([word2id[w] for s in corpus for w in s], dtype=np.int64)
    sent_id = np.repeat(np.arange(len(corpus)), [len(s) for s in corpus])
    return ids, sent_id


# =============================================================================
# 2. 相似度、最近邻、类比
# =============================================================================
def cosine_similarity(u, v):
    """
    余弦相似度 sim(u, v) = uᵀv / (‖u‖ ‖v‖)，取值在 [−1, 1]：方向相同为 1，垂直为 0，相反为 −1。

    【参数】u, v：(d,) 向量
    【返回】float
    【边界】有一个是零向量时分母为 0，这里返回 0.0。
    【DL 联系】Coursera 作业 "Operations on Word Vectors" 的第一个函数就是它。只看方向、不看长度：
            一个词出现得多，它的向量往往更长，但这和"意思像不像"无关。
    """
    nu, nv = np.linalg.norm(u), np.linalg.norm(v)
    if nu == 0 or nv == 0:
        return 0.0
    return float(u @ v / (nu * nv))


def normalize_rows(M):
    """
    把矩阵的每一行除以它的长度（L2 范数），变成单位向量。之后两行的点积就是余弦相似度。

    【参数】M：(V, d)；【返回】(V, d)
    【numpy 小知识】np.linalg.norm(M, axis=1, keepdims=True) 得到 (V, 1)：keepdims 保留被求和的那一维，
            这样 M / norms 能按行广播（每一行除以自己的长度）。
    """
    norms = np.linalg.norm(M, axis=1, keepdims=True)
    return M / np.maximum(norms, 1e-12)


def nearest_neighbors(E, word2id, words, query, k=5, exclude=()):
    """
    按余弦相似度找和 query 最近的 k 个词。

    【参数】E：(V, d)；word2id / words：词表；query：词（str）或向量 (d,)；exclude：不参加排名的词
    【返回】list[(词, 相似度)]，按相似度从高到低
    【numpy 小知识】np.argsort(-sims) 返回"把 sims 从大到小排"的下标顺序。
    """
    En = normalize_rows(E)
    q = En[word2id[query]] if isinstance(query, str) else query / max(np.linalg.norm(query), 1e-12)
    sims = En @ q                                    # (V,)：每个词和 query 的余弦相似度
    skip = set(exclude) | ({query} if isinstance(query, str) else set())
    out = []
    for i in np.argsort(-sims):
        if words[i] not in skip:
            out.append((words[i], float(sims[i])))
        if len(out) == k:
            break
    return out


def solve_analogy(En, word2id, words, a, b, c):
    """
    回答类比题 "a : b :: c : ?"（a 之于 b，如同 c 之于 ?）。

    【思路】如果 e_b − e_a 表示"从 a 到 b 的那种关系"，那么答案 d 应该满足 e_d − e_c ≈ e_b − e_a，
            即 e_d ≈ e_c + (e_b − e_a)。在所有词里找和这个目标向量余弦相似度最大的词，**排除 a、b、c 三个题面词**
            （不排除的话，答案往往就是 c 自己：e_c + 一个小的差 仍然离 e_c 最近）。
    【参数】En：(V, d) 已经按行归一化的嵌入矩阵；a, b, c：词
    【返回】预测的词 d（str）
    【边界】Mikolov et al. (2013) 的评测就是这样做的（3CosAdd）；"排除题面词"这个细节常被忽略，
            但它对结果影响很大（练习 3）。
    """
    target = En[word2id[b]] - En[word2id[a]] + En[word2id[c]]
    sims = En @ target
    for w in (a, b, c):
        sims[word2id[w]] = -np.inf                  # 题面词不能当答案
    return words[int(np.argmax(sims))]


def analogy_accuracy(E, word2id, words, questions):
    """
    在一组类比题上算 top-1 准确率（必须**恰好**猜中那个词才算对）。

    【参数】E：(V, d) 嵌入矩阵（不必归一化）；questions：dict 关系名 → list[(a, b, c, d)]
    【返回】dict：关系名 → 准确率；另有 "全部" → 所有题的平均
    """
    En = normalize_rows(E)
    result, n_all, n_ok_all = {}, 0, 0
    for name, qs in questions.items():
        ok = sum(solve_analogy(En, word2id, words, a, b, c) == d for a, b, c, d in qs)
        result[name] = ok / len(qs)
        n_all += len(qs)
        n_ok_all += ok
    result["全部"] = n_ok_all / n_all
    return result


# =============================================================================
# 3. skip-gram + 负采样（word2vec）
# =============================================================================
def rows_to_dense(rows, vals, V):
    """
    把"每对样本对某一行的梯度"加成一个 (V, d) 的稠密梯度矩阵：G[rows[i]] += vals[i]，**重复的行号会累加**。

    【思路】同一个 batch 里一个词可能出现很多次。普通写法 G[rows] += vals 遇到重复的下标只会加一次
            （numpy 的一个经典的坑）；np.add.at(G, rows, vals) 是正确的写法，但比较慢。
            这里把"第几行第几列"压成一个整数下标 rows · d + 列号，用 np.bincount 一次性求和，结果和 np.add.at 一样，快几倍。
    【参数】rows：(n,) 整数；vals：(n, d)；V：行数
    【返回】(V, d)
    【numpy 小知识】np.bincount(x, weights=w, minlength=L)：对每个整数 i，把所有 x == i 的位置上的 w 加起来，
            返回长度 L 的数组。rows[:, None] * d + np.arange(d) 用广播造出 (n, d) 的"压平后的下标"。
    """
    d = vals.shape[1]
    flat = (rows[:, None] * d + np.arange(d)).ravel()
    return np.bincount(flat, weights=vals.ravel(), minlength=V * d).reshape(V, d)


def make_skipgram_pairs(ids, sent_id, window):
    """
    造 skip-gram 的训练样本：每个词 c 和它左右各 window 个词以内的每个词 t 组成一对 (c, t)。

    【思路】例句 "i want a glass of orange juice"，window = 2 时，以 orange 为 c 的样本是
            (orange, glass)、(orange, of)、(orange, juice)（右边没有第二个词了）。
            向量化的写法：对每个偏移量 off = 1..window，把 ids 错开 off 位对齐，只保留"在同一句话里"的那些位置。
    【参数】ids, sent_id：encode_corpus 的输出，(N,)；window：窗口半径
    【返回】(c, t)：两个 (P,) 整数数组，P 是样本对的个数
    【边界】这里用的是固定窗口；word2vec 的原始实现会在 1..window 之间随机取实际窗口（近的词被采到的概率更大），
            还会对 the、a 这样的高频词做下采样（subsampling）。为了简单，这里都没做（讲义 §4.3 提到）。
    【numpy 小知识】ids[:-off] 和 ids[off:] 长度相同，第 i 个位置分别是第 i 个和第 i + off 个词。
    """
    cs, ts = [], []
    for off in range(1, window + 1):
        same = sent_id[:-off] == sent_id[off:]          # 只在同一句里配对
        left, right = ids[:-off][same], ids[off:][same]
        cs += [left, right]                              # 两个方向：右边的词是左边的词的上下文，反之亦然
        ts += [right, left]
    return np.concatenate(cs), np.concatenate(ts)


def negative_table(counts, power=0.75):
    """
    负采样的分布 P(w) = f(w)^{3/4} / Σ_j f(w_j)^{3/4}，返回它的累积分布（用来快速抽样）。

    【思路】Mikolov et al. (2013b) 试过几种分布：按词频 f(w) 抽，the、of 这样的词会被抽得太多；
            均匀地抽，又和真实的词分布差太远。3/4 次方介于两者之间（power = 1 是按词频，power = 0 是均匀），
            论文说它在他们的任务上明显更好，这是一个经验选择。
    【参数】counts：(V,) 词频；power：指数
    【返回】(cdf, probs)：(V,) 的累积分布和分布本身
    【numpy 小知识】np.cumsum 求累积和；抽样时 np.searchsorted(cdf, u) 找到 u ∈ [0, 1) 落在哪一格。
    """
    p = counts.astype(np.float64) ** power
    p /= p.sum()
    cdf = np.cumsum(p)
    cdf[-1] = 1.0                                      # 防止浮点误差让最后一格略小于 1
    return cdf, p


def sgns_loss_and_grads(E, Theta, c, t, neg):
    """
    一个 mini-batch 上负采样的损失和梯度（只对用到的那几行）。

    【思路】对每一对 (c, t) 和 k 个随机抽的"负样本"词 n_1..n_k，把"预测 t"这个 V 类的 softmax 换成 k + 1 个二分类：
                (c, t)   标签 1："t 真的出现在 c 附近"     P(y = 1 | c, t) = σ(θ_tᵀ e_c)
                (c, n_i) 标签 0："n_i 是随便抽的"           P(y = 0 | c, n_i) = σ(−θ_{n_i}ᵀ e_c)
            损失 = −log σ(θ_tᵀ e_c) − Σ_i log σ(−θ_{n_i}ᵀ e_c)。
            对 s = θᵀe 求导：∂/∂s_pos = σ(s_pos) − 1，∂/∂s_neg = σ(s_neg)（和逻辑回归的 "a − y" 一样，第 2 讲）。
            再用链式法则：∂s/∂e_c = θ，∂s/∂θ = e_c。
    【参数】
        E, Theta : (V, d) 输入向量（嵌入矩阵）和输出向量
        c, t     : (B,) 一个 batch 的输入词和真实的附近词
        neg      : (B, k) 负样本
    【返回】(loss, dE_rows, dT_pos, dT_neg)
        loss     : 这个 batch 的平均损失（float）
        dE_rows  : (B, d)，对 e_c 的梯度（每对一行）
        dT_pos   : (B, d)，对 θ_t 的梯度
        dT_neg   : (B, k, d)，对 θ_{n_i} 的梯度
        梯度是"每一对的损失"的梯度（没有除以 B），train_sgns 直接把它们加到对应的行上。
    【边界】负样本偶尔会抽到 t 本身（或者别的真实附近词），word2vec 也不管它：这只是一点噪声。
    【numpy 小知识】np.einsum("bd,bkd->bk", v, U) 对每个 b、k 算 Σ_d v[b,d]·U[b,k,d]：
            "下标字符串"写清了哪些维相乘、哪一维被求和，比一串 reshape / transpose 好读。
    """
    v = E[c]                                       # (B, d)  e_c
    u_pos = Theta[t]                               # (B, d)  θ_t
    u_neg = Theta[neg]                             # (B, k, d)  θ_{n_i}
    s_pos = np.sum(v * u_pos, axis=1)              # (B,)
    s_neg = np.einsum("bd,bkd->bk", v, u_neg)      # (B, k)
    loss = -(log_sigmoid(s_pos) + np.sum(log_sigmoid(-s_neg), axis=1))
    g_pos = sigmoid(s_pos) - 1.0                   # (B,)    ∂loss/∂s_pos
    g_neg = sigmoid(s_neg)                         # (B, k)  ∂loss/∂s_neg
    dE_rows = g_pos[:, None] * u_pos + np.einsum("bk,bkd->bd", g_neg, u_neg)
    dT_pos = g_pos[:, None] * v
    dT_neg = g_neg[:, :, None] * v[:, None, :]
    return float(np.mean(loss)), dE_rows, dT_pos, dT_neg


def train_sgns(ids, sent_id, counts, d=50, window=4, k=5, epochs=2, batch_size=1024, lr=0.1,
               optimizer="adagrad", rng=None, max_steps=None):
    """
    用 mini-batch 训练 skip-gram + 负采样（SGNS）。

    【思路】每个 epoch：打乱全部 (c, t) 对 → 切成 batch → 每个 batch 抽 B × k 个负样本 → 算梯度 → 只有用到的行有梯度。
            · 初始化和 word2vec 一样：E 是 [−0.5/d, 0.5/d] 上的均匀分布，Θ 全是 0。
            · 同一个 batch 里一个词可能出现很多次，它们的梯度要**累加**到同一行（rows_to_dense）。
            · 更新规则有两种：
                "sgd"     ：θ ← θ − lr · g（word2vec 的原始实现就是这样，但它是**一对一对**地更新的，学习率 0.025 并线性衰减）；
                "adagrad" ：每个参数累积自己的梯度平方 G ← G + g²，θ ← θ − lr · g / sqrt(G)（GloVe 论文用的就是 AdaGrad）。
              为什么默认用 AdaGrad？我们为了在 numpy 里跑得快，一次处理 1024 对。the 这样的高频词在一个 batch 里
              会作为负样本被抽中几百次，几百个梯度**加在一起**，用 SGD 的步长就太大了，训练会发散（01 演示 2 亲眼看到）。
              AdaGrad 让每一行有自己的步长：被更新得越多、梯度越大，步子越小。
    【参数】
        ids, sent_id, counts : encode_corpus / build_vocab 的输出
        d, window, k         : 维数、窗口半径、每个正样本配几个负样本
        epochs, batch_size, lr : 训练设置
        optimizer            : "adagrad" 或 "sgd"（sgd 时学习率从 lr 线性衰减到 lr × 0.01）
        rng                  : np.random.Generator（打乱和负采样都用它）
        max_steps            : 只训练这么多个 batch 就停（演示用）；None 表示训练完 epochs 个 epoch
    【返回】(E, Theta, info)
        E, Theta : 两个 (V, d) 矩阵。词向量用 E（Ng 的 e_c）
        info     : dict，"epoch_loss"（每个 epoch 的平均损失）、"step_loss"（每个 batch 的损失）、
                   "diverged"（是否发散）、"steps"（实际训练的 batch 数）
    【边界】每一步的计算量只和 B·(k + 1)·d 有关，和词表大小 V **无关** —— 这正是负采样的意义（讲义 §4.4）。
            发散检测：参数里出现 inf / nan 或绝对值超过 1e6 就立刻停止（第 1 讲 §6.3 的教训：
            BLAS 矩阵乘法里产生的 inf / nan 不会触发 numpy 的浮点警告，必须自己检查）。
    【DL 联系】E 就是讲义里的嵌入矩阵；训练完以后 Θ 一般扔掉（GloVe 则把两者平均，§4.5）。
    """
    if rng is None:
        rng = np.random.default_rng(0)
    V = len(counts)
    E = (rng.random((V, d)) - 0.5) / d
    Theta = np.zeros((V, d))
    GE, GT = np.full((V, d), 1e-8), np.full((V, d), 1e-8)     # AdaGrad 的累积梯度平方
    c_all, t_all = make_skipgram_pairs(ids, sent_id, window)
    cdf, _ = negative_table(counts)
    n_pairs = len(c_all)
    total_steps = epochs * ((n_pairs + batch_size - 1) // batch_size)
    info = {"epoch_loss": [], "step_loss": [], "diverged": False, "steps": 0}
    for _ in range(epochs):
        order = rng.permutation(n_pairs)
        losses = []
        for start in range(0, n_pairs, batch_size):
            idx = order[start:start + batch_size]
            c, t = c_all[idx], t_all[idx]
            neg = np.searchsorted(cdf, rng.random((len(idx), k)))     # (B, k) 按 f^{3/4} 抽负样本
            loss, dE, dTp, dTn = sgns_loss_and_grads(E, Theta, c, t, neg)
            gE = rows_to_dense(c, dE, V)                               # (V, d)，没用到的行是 0
            gT = rows_to_dense(t, dTp, V) + rows_to_dense(neg.ravel(), dTn.reshape(-1, d), V)
            if optimizer == "sgd":
                alpha = lr * max(0.01, 1.0 - info["steps"] / total_steps)   # 线性衰减的学习率
                E -= alpha * gE
                Theta -= alpha * gT
            else:
                GE += gE ** 2
                GT += gT ** 2
                E -= lr * gE / np.sqrt(GE)
                Theta -= lr * gT / np.sqrt(GT)
            info["steps"] += 1
            if not (np.isfinite(loss) and np.max(np.abs(E)) < 1e6 and np.max(np.abs(Theta)) < 1e6):
                info["diverged"] = True
                return E, Theta, info
            losses.append(loss)
            info["step_loss"].append(loss)
            if max_steps is not None and info["steps"] >= max_steps:
                info["epoch_loss"].append(float(np.mean(losses)))
                return E, Theta, info
        info["epoch_loss"].append(float(np.mean(losses)))
    return E, Theta, info


# =============================================================================
# 4. 对照方法：共现矩阵 + SVD，GloVe
# =============================================================================
def cooccurrence_matrix(ids, sent_id, V, window):
    """
    共现矩阵 X：X[i, j] = 词 j 出现在词 i 左右 window 个词以内的次数（对称）。

    【参数】ids, sent_id：(N,)；V：词表大小；window：窗口半径
    【返回】(V, V) 的 float64 矩阵
    【边界】GloVe 原论文还按距离加权（隔 k 个词的那一对只算 1/k 次），Ng 的视频里没有提，这里也不做。
    """
    c, t = make_skipgram_pairs(ids, sent_id, window)
    X = np.zeros((V, V))
    np.add.at(X, (c, t), 1.0)
    return X


def ppmi_svd(X, d):
    """
    共现矩阵 → PPMI（正的逐点互信息）→ 截断 SVD，得到 d 维词向量。这是"数数 + 矩阵分解"的老办法。

    【思路】PMI(i, j) = log [ P(i, j) / (P(i) P(j)) ]：i、j 一起出现的次数比"互相独立时"多多少倍。
            负值（以及没共现过的 −∞）一律截成 0，得到 PPMI。再做 SVD：PPMI ≈ U S Vᵀ，取前 d 个奇异值，
            词向量 = U_d · sqrt(S_d)。Levy & Goldberg (2014) 证明了 SGNS 在隐式地分解一个"平移过的 PMI 矩阵"，
            所以这两种方法是近亲。
    【参数】X：(V, V) 共现矩阵；d：维数
    【返回】(V, d)
    【numpy 小知识】np.linalg.svd(A, full_matrices=False) 返回 U (V, r)、S (r,)、Vt (r, V)，奇异值从大到小。
    """
    total = X.sum()
    pi = X.sum(axis=1, keepdims=True) / total            # (V, 1)
    pj = X.sum(axis=0, keepdims=True) / total            # (1, V)
    with np.errstate(divide="ignore"):                   # log(0) = −inf 是预料之中的，下一行截掉
        pmi = np.log(np.maximum(X / total, 1e-300) / (pi * pj))
    ppmi = np.where(X > 0, np.maximum(pmi, 0.0), 0.0)
    U, S, _ = np.linalg.svd(ppmi, full_matrices=False)
    return U[:, :d] * np.sqrt(S[:d])


def glove_weight(x, x_max=100.0, alpha=0.75):
    """
    GloVe 的加权函数 f(x) = (x / x_max)^α（x < x_max 时），否则 1。f(0) = 0。

    【思路】(1) X_ij = 0 的项不参加训练（log 0 没有定义；f(0) = 0 让它们的权重为 0）；
            (2) 很常见的词对（"of the"）不要权重太大：f 最多是 1；
            (3) 稀有的词对也不要权重太小：α = 3/4 让 f 增长得比线性慢。x_max = 100、α = 3/4 是论文的取值。
    """
    return np.where(x < x_max, (x / x_max) ** alpha, 1.0)


def train_glove(X, d=50, iters=600, lr=0.05, x_max=100.0, rng=None, log=None):
    """
    GloVe：最小化 Σ_{X_ij > 0} f(X_ij) · (θ_iᵀ e_j + b_i + b'_j − log X_ij)²。

    【思路】直接拟合"共现次数的对数"。只在非零的项上算（我们的 V = 300 多，非零项约几万个），每次用全部非零项
            做一步 AdaGrad（论文用的就是 AdaGrad，学习率 0.05）。最后的词向量用 (e_w + θ_w) / 2：
            在这个损失里 e 和 θ 的地位是对称的（Ng 在视频里特别提到）。
    【参数】X：(V, V) 共现矩阵；d：维数；iters：迭代次数；lr：AdaGrad 学习率；rng：初始化用
    【返回】(V, d) 词向量
    【边界】AdaGrad：每个参数自己累积梯度平方 G，步长是 lr / sqrt(G)。经常被更新、梯度大的参数步子会变小。
    """
    if rng is None:
        rng = np.random.default_rng(0)
    V = X.shape[0]
    i_idx, j_idx = np.nonzero(X)
    x = X[i_idx, j_idx]
    logx, fx = np.log(x), glove_weight(x, x_max)
    W = (rng.random((V, d)) - 0.5) / d          # e
    C = (rng.random((V, d)) - 0.5) / d          # θ
    b, bc = np.zeros(V), np.zeros(V)
    params = [W, C, b, bc]
    G = [np.full_like(p, 1e-8) for p in params]   # AdaGrad 的累积梯度平方（从一个很小的数开始，避免除以 0）
    for _ in range(iters):
        diff = np.sum(W[i_idx] * C[j_idx], axis=1) + b[i_idx] + bc[j_idx] - logx    # (nnz,)
        g = fx * diff                                    # 损失对 diff 的导数的一半（常数 2 并进学习率）
        gW = rows_to_dense(i_idx, g[:, None] * C[j_idx], V)     # 对 e_i 的梯度：Σ_j f·diff·θ_j
        gC = rows_to_dense(j_idx, g[:, None] * W[i_idx], V)     # 对 θ_j 的梯度
        gb = np.bincount(i_idx, weights=g, minlength=V)
        gbc = np.bincount(j_idx, weights=g, minlength=V)
        for p, gp, Gp in zip(params, (gW, gC, gb, gbc), G):
            Gp += gp ** 2
            p -= lr * gp / np.sqrt(Gp)
        if log is not None:
            log.append(float(np.sum(fx * diff ** 2) / len(x)))
    if not np.all(np.isfinite(W)):
        raise FloatingPointError("GloVe 训练发散了")
    return (W + C) / 2


# =============================================================================
# 5. 默认词向量：02、03 两个脚本共用
# =============================================================================
DEFAULT_CORPUS_SENTENCES = 60000
DEFAULT_CORPUS_SEED = 0
DEFAULT_SGNS = dict(d=50, window=4, k=5, epochs=2, batch_size=1024, lr=0.1, optimizer="adagrad")
DEFAULT_SGNS_SEED = 1


def load_default_corpus():
    """用固定的种子造默认语料（60,000 句），返回 (corpus, words, word2id, counts, ids, sent_id)。"""
    corpus = generate_corpus(DEFAULT_CORPUS_SENTENCES, np.random.default_rng(DEFAULT_CORPUS_SEED))
    words, word2id, counts = build_vocab(corpus)
    ids, sent_id = encode_corpus(corpus, word2id)
    return corpus, words, word2id, counts, ids, sent_id


def train_default_embeddings():
    """
    用默认语料和默认超参数训练一份 SGNS 词向量。01 演示 2 训练的就是这一份；02、03 直接调用它（几秒钟）。

    【返回】(E, words, word2id, counts)：E 是 (V, 50) 的嵌入矩阵
    【边界】固定种子，同一台机器上每次逐位相同。
    """
    _, words, word2id, counts, ids, sent_id = load_default_corpus()
    E, _, _ = train_sgns(ids, sent_id, counts, rng=np.random.default_rng(DEFAULT_SGNS_SEED), **DEFAULT_SGNS)
    return E, words, word2id, counts
