"""
=============================================================================
第 2 讲 · 代码 1：负采样（Negative Sampling）的数学 —— 损失、梯度与代价
=============================================================================

对应讲义：lectures/lecture02_word_vectors_advanced.md 第 3 节
对应作业：CS224N 历年作业中的 negSamplingLossAndGradient 与相应的梯度推导题

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
第 1 讲的朴素 softmax 有一个致命问题：每个样本都要和词表里**所有**词算点积，
代价和词表大小 V 成正比；而且上下文向量矩阵 U 的每一行都有梯度，每一步都要更新整个 U。

负采样（skip-gram with negative sampling, SGNS）换了一个问题来问：
    朴素 softmax ："在 V 个词里，哪一个是 c 的上下文？"（V 选 1 的多分类）
    负采样       ："(c, o) 这一对是真的从语料里来的，还是随机拼凑的噪声？"（二分类）
对每个真实的 (c, o)，再随机抽 K 个"负例"词 w_1..w_K，构成 K 个假的 (c, w_k) 对。
模型要做的只是：让真实对的得分高、假对的得分低。

一个样本的损失（讲义第 3.3 节）：
    J = -log σ(u_o · v_c) - Σ_{k=1..K} log σ(-u_{w_k} · v_c)
梯度（讲义第 3.5 节推导）：
    ∂J/∂v_c   = (σ(u_o·v_c) - 1) u_o + Σ_k σ(u_{w_k}·v_c) u_{w_k}
    ∂J/∂u_o   = (σ(u_o·v_c) - 1) v_c
    ∂J/∂u_{w_k} = σ(u_{w_k}·v_c) v_c
只有 o 和 K 个负例这 K+1 行 U 有梯度，代价从 O(V·d) 降到 O(K·d)。

本文件：
    演示 1：sigmoid 及其性质
    演示 2：噪声分布 —— 为什么是词频的 3/4 次方？
    演示 3：一个样本的损失和梯度，逐项打印
    演示 4：梯度检验（包括"负例重复""负例恰好是正例"等边界情况）
    演示 5：批量版本与逐个样本的结果一致
    演示 6：朴素 softmax vs 负采样，代价随词表大小如何变化

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 01_negative_sampling.py

需要 numpy。运行时间约 2 秒（演示 6 要计时）。会导入第 1 讲的 04 号文件做对比。
"""

import importlib
import time

import numpy as np

from shared import NegativeSampler, build_vocab, generate_corpus, noise_distribution, print_header, sigmoid

# 第 1 讲的 04 号文件（朴素 softmax 与梯度检验工具）。shared.py 已经把 lecture01 加进了搜索路径。
# 【Python 小知识】文件名以数字开头，只能用 importlib.import_module("...") 按字符串导入。
l1_grad = importlib.import_module("04_word2vec_gradients")
numerical_gradient = l1_grad.numerical_gradient
relative_error = l1_grad.relative_error


# =============================================================================
# 第 1 部分：单个样本的损失与梯度
# =============================================================================
def neg_sampling_loss_and_gradient(center_vec, outside_idx, outside_vectors, neg_indices):
    """
    一个 (中心词 c, 上下文词 o) 样本、配上 K 个负例时的负采样损失与梯度。

    【思路】
        1. 正例得分 s_o = u_o · v_c，负例得分 s_k = u_{w_k} · v_c。
        2. 损失 J = -log σ(s_o) - Σ_k log σ(-s_k)：
           正例：希望 σ(s_o) → 1（"这对是真的"），即 s_o 越大越好；
           负例：希望 σ(-s_k) → 1（"这对是假的"），即 s_k 越小（越负）越好。
        3. 利用 d/dx[-log σ(x)] = σ(x) - 1 = -(1 - σ(x))：
             对 s_o 的导数 = σ(s_o) - 1   （负数：要把 s_o 往上推）
             对 s_k 的导数 = σ(s_k)       （正数：要把 s_k 往下压）
           再用链式法则：s = u · v，所以 ∂s/∂v = u、∂s/∂u = v。

    【参数】
        center_vec      : np.ndarray (d,)，中心词向量 v_c
        outside_idx     : int，真实上下文词 o 的编号
        outside_vectors : np.ndarray (V, d)，上下文向量矩阵 U
        neg_indices     : list[int] 或 np.ndarray，长度 K，负例词的编号（可以有重复）

    【返回】
        (loss, grad_center_vec, rows, grad_rows)
            loss            : float
            grad_center_vec : np.ndarray (d,)，∂J/∂v_c
            rows            : np.ndarray[int] (K+1,)，[o, w_1, ..., w_K]，即 U 中有梯度的那些行
            grad_rows       : np.ndarray (K+1, d)，对应每一行的梯度

    【边界】
        - 为什么不像第 1 讲那样返回一个 (V, d) 的完整梯度矩阵？因为它除了这 K+1 行全是 0。
          只返回"有梯度的行"，正是负采样快的关键（稀疏更新）。调用者用 np.add.at 把它们加回去，
          这样同一个词出现多次（比如负例重复）时梯度会正确累加。
        - 负例恰好等于 o 时，它既被当正例拉近、又被当负例推开，梯度部分抵消。数学上没有错，
          只是这个负例"浪费"了。原始 word2vec 会跳过这种负例。

    【Python 小知识】
        np.concatenate([[a], b])：把单个元素 a 和数组 b 拼成一个新数组。
        g[:, None] * u：g 形状 (K+1,)，加 None 变成 (K+1, 1)，广播后每行 u 乘以对应的 g。

    【NLP 联系】
        负采样是"噪声对比估计"（NCE）的简化版。同样的思想后来被用在对比学习里：
        第 12 讲的稠密检索、第 18 讲的 CLIP，都是"让正例对得分高、负例对得分低"。
    """
    rows = np.concatenate([[outside_idx], np.asarray(neg_indices, dtype=int)])
    u = outside_vectors[rows]                          # (K+1, d)
    scores = u @ center_vec                            # (K+1,)
    signs = np.ones(len(rows))
    signs[1:] = -1.0                                   # 正例用 +s，负例用 -s
    probs = sigmoid(signs * scores)                    # σ(s_o)、σ(-s_k)
    loss = -np.sum(np.log(np.maximum(probs, 1e-12)))

    # ∂J/∂s：正例 σ(s_o) - 1；负例 σ(s_k) = 1 - σ(-s_k)。统一写成 -(1 - probs) * signs
    g = -(1.0 - probs) * signs                         # (K+1,)
    grad_center_vec = g @ u                            # (d,) = Σ g_i u_i
    grad_rows = g[:, None] * center_vec[None, :]       # (K+1, d)
    return float(loss), grad_center_vec, rows, grad_rows


def scatter_rows(V, d, rows, grad_rows):
    """
    把"稀疏的行梯度"还原成一个完整的 (V, d) 矩阵（只用于检查和演示，训练时不需要）。

    【参数】
        V, d      : 矩阵形状
        rows      : np.ndarray[int]，行号，可以重复
        grad_rows : np.ndarray (len(rows), d)

    【返回】
        np.ndarray (V, d)

    【Python 小知识】
        np.add.at(A, rows, vals)：对每个 k 执行 A[rows[k]] += vals[k]，重复的行号会正确累加。
    """
    full = np.zeros((V, d))
    np.add.at(full, rows, grad_rows)
    return full


# =============================================================================
# 第 2 部分：批量版本（供 02 号文件训练使用）
# =============================================================================
def sgns_batch(centers, outsides, negatives, center_vectors, outside_vectors):
    """
    一批样本的负采样损失与梯度，全部用矩阵运算完成。

    【思路】
        与 neg_sampling_loss_and_gradient 完全相同的公式，只是多了一个"批"的维度 B：
            vc     = V[centers]                        (B, d)
            rows   = [outsides, negatives] 拼在一起      (B, K+1)
            u      = U[rows]                           (B, K+1, d)
            scores = 每个样本里 u 的每一行与 vc 的点积    (B, K+1)
        之后的 sigmoid、梯度系数 g 都是逐元素运算。

    【参数】
        centers         : np.ndarray[int] (B,)
        outsides        : np.ndarray[int] (B,)
        negatives       : np.ndarray[int] (B, K)
        center_vectors  : np.ndarray (V, d)
        outside_vectors : np.ndarray (V, d)

    【返回】
        (loss_sum, grad_vc, rows, grad_u)
            loss_sum : float，这批样本的损失**之和**
            grad_vc  : np.ndarray (B, d)，每个样本对它的中心词向量的梯度
            rows     : np.ndarray[int] (B, K+1)
            grad_u   : np.ndarray (B, K+1, d)，每个样本对 rows 中每一行的梯度

    【边界】
        - 这里**不**除以 B（不取平均），每个样本的梯度保持原样。原始 word2vec 是一个样本一个样本地
          用很小的学习率（0.025）更新的；我们一批一批地累加，等价于"同时"做 B 次单样本更新，
          所以学习率可以沿用 word2vec 的取值。（和第 1 讲取平均的做法不同，学习率也就不同。）
        - 返回的仍是"稀疏行梯度"，调用者用 np.add.at 更新参数。

    【Python 小知识】
        np.einsum("bkd,bd->bk", u, vc)：爱因斯坦求和。字母表示维度：
            对每个 b、k，把 u[b, k, :] 和 vc[b, :] 沿 d 维相乘再求和 → 结果形状 (B, K+1)。
            它等价于一个 for 循环里做很多次点积，但快得多、写起来也清楚。
    """
    vc = center_vectors[centers]                                   # (B, d)
    rows = np.concatenate([outsides[:, None], negatives], axis=1)  # (B, K+1)
    u = outside_vectors[rows]                                      # (B, K+1, d)
    scores = np.einsum("bkd,bd->bk", u, vc)                        # (B, K+1)
    signs = np.ones_like(scores)
    signs[:, 1:] = -1.0
    probs = sigmoid(signs * scores)
    loss_sum = -np.sum(np.log(np.maximum(probs, 1e-12)))
    g = -(1.0 - probs) * signs                                     # (B, K+1)
    grad_vc = np.einsum("bk,bkd->bd", g, u)                        # (B, d)
    grad_u = g[:, :, None] * vc[:, None, :]                        # (B, K+1, d)
    return float(loss_sum), grad_vc, rows, grad_u


# =============================================================================
# 演示
# =============================================================================
def demo_sigmoid():
    """演示 1：sigmoid 的形状与性质。"""
    print_header("演示 1：sigmoid —— 把任意实数变成 (0, 1) 之间的'概率'")
    xs = np.array([-1000.0, -5.0, -1.0, 0.0, 1.0, 5.0, 1000.0])
    print("\n      x      σ(x)      σ(-x)    σ(x)+σ(-x)")
    for x in xs:
        print(f"    {x:7.0f}  {sigmoid(x):8.5f}  {sigmoid(-x):8.5f}  {sigmoid(x) + sigmoid(-x):8.5f}")
    print("""
  观察：
    - x = 0 时 σ = 0.5；x 越大越接近 1，越小越接近 0。x = ±1000 也不会溢出（稳定写法）。
    - σ(x) + σ(-x) = 1：所以"这对是假的"的概率 1 - σ(s) 可以写成 σ(-s)，负采样的损失里正是这样写的。""")


def demo_noise_distribution():
    """演示 2：在玩具语料上比较三种噪声分布。"""
    print_header("演示 2：噪声分布 —— 词频的几次方？")
    sents = generate_corpus()
    word2id, id2word, counts = build_vocab(sents)
    cnt = np.array([counts[w] for w in id2word], dtype=float)
    dists = {p: noise_distribution(cnt, p) for p in [1.0, 0.75, 0.0]}
    print(f"\n  玩具语料 V = {len(id2word)}。几个词在不同噪声分布下被采为负例的概率：")
    print(f"    {'词':<10s}{'出现次数':>8s}{'power=1':>12s}{'power=0.75':>13s}{'power=0':>11s}")
    for w in ["the", "a", "is", "cat", "queen", "paris", "spanish", "loud"]:
        i = word2id[w]
        print(f"    {w:<10s}{int(cnt[i]):>8d}{dists[1.0][i]:>12.4f}{dists[0.75][i]:>13.4f}{dists[0.0][i]:>11.4f}")
    top5 = np.argsort(-cnt)[:5]
    print(f"\n  最常见的 5 个词占全部负例的比例：power=1 时 {dists[1.0][top5].sum():.1%}，"
          f"power=0.75 时 {dists[0.75][top5].sum():.1%}，power=0 时 {dists[0.0][top5].sum():.1%}")
    print("""
  观察：
    - power = 1（按词频）："the" 一个词就占了 17% 的负例，模型会花大量力气学"cat 旁边的词不是 the"
      —— 可 the 恰恰经常出现在 cat 旁边，这些负例既浪费又有误导性。
    - power = 0（均匀）：罕见词和高频词被采到的机会一样，负例太"容易"（大多是和 c 毫不相干的罕见词）。
    - power = 0.75：折中。高频词的比例被压低、罕见词被抬高。这是 word2vec 论文的经验选择，
      没有严格的理论依据，但在很多任务上效果最好。""")


def demo_single_example():
    """演示 3：一个样本的损失与梯度，逐项打印。"""
    print_header("演示 3：一个样本的负采样损失与梯度（V = 6，d = 3，K = 3）")
    vocab = ["the", "cat", "sat", "on", "mat", "car"]
    rng = np.random.default_rng(0)
    C = rng.normal(scale=0.5, size=(6, 3))             # 中心词向量 V
    U = rng.normal(scale=0.5, size=(6, 3))             # 上下文向量 U
    c, o, negs = vocab.index("cat"), vocab.index("sat"), [vocab.index(w) for w in ["car", "the", "mat"]]
    loss, g_vc, rows, g_rows = neg_sampling_loss_and_gradient(C[c], o, U, negs)

    print("\n  中心词 cat，真实上下文词 sat，负例 car、the、mat")
    for r, label in zip(rows, ["正例", "负例", "负例", "负例"]):
        s = U[r] @ C[c]
        p = sigmoid(s) if label == "正例" else sigmoid(-s)
        print(f"    {label} {vocab[r]:<4s} 得分 s = u·v = {s:+.3f}   "
              f"{'σ(s)' if label == '正例' else 'σ(-s)'} = {p:.3f}   这一项的损失 -log = {-np.log(p):.3f}")
    print(f"  总损失 J = {loss:.3f}")
    print(f"\n  ∂J/∂v_cat = {np.round(g_vc, 3)}")
    for r, g in zip(rows, g_rows):
        print(f"  ∂J/∂u_{vocab[r]:<4s}= {np.round(g, 3)}")
    print("""
  观察：
    - 只有 sat、car、the、mat 这 4 行 U 有梯度；on 那一行完全没被碰到。
      词表有 300 万个词时也一样：每个样本只更新 K + 1 行，而不是 300 万行。
    - 正例的梯度系数是 σ(s_o) - 1 < 0：更新时 u_sat 被拉向 v_cat；
      负例的系数是 σ(s_k) > 0：u_car 等被推离 v_cat，模型越"误以为"它是真的（s_k 越大），推得越狠。""")


def demo_gradient_check():
    """演示 4：梯度检验，包括负例重复、负例等于正例的情况。"""
    print_header("演示 4：梯度检验")
    rng = np.random.default_rng(1)
    V, d = 8, 4
    C = rng.normal(size=(V, d))
    U = rng.normal(size=(V, d))
    cases = [("普通情况", 2, 5, [0, 3, 7]), ("负例有重复", 2, 5, [3, 3, 1, 3]), ("负例恰好是正例", 2, 5, [5, 6])]
    for name, c, o, negs in cases:
        loss, g_vc, rows, g_rows = neg_sampling_loss_and_gradient(C[c], o, U, negs)
        num_vc = numerical_gradient(lambda v: neg_sampling_loss_and_gradient(v, o, U, negs)[0], C[c].copy())
        num_U = numerical_gradient(lambda M: neg_sampling_loss_and_gradient(C[c], o, M, negs)[0], U.copy())
        e1 = relative_error(g_vc, num_vc)
        e2 = relative_error(scatter_rows(V, d, rows, g_rows), num_U)
        print(f"    {name:<10s}：∂J/∂v_c 相对误差 {e1:.1e}，∂J/∂U 相对误差 {e2:.1e}")
        assert e1 < 1e-7 and e2 < 1e-7, "梯度检验失败！"
    print("  → 三种情况都通过：np.add.at 正确处理了重复的行。")


def demo_batch_matches_loop():
    """演示 5：批量版本与逐个样本的结果一致。"""
    print_header("演示 5：批量版本 = 逐个样本之和")
    rng = np.random.default_rng(2)
    V, d, B, K = 10, 5, 16, 4
    C, U = rng.normal(size=(V, d)), rng.normal(size=(V, d))
    centers, outsides = rng.integers(0, V, B), rng.integers(0, V, B)
    negatives = rng.integers(0, V, (B, K))

    loss_b, g_vc, rows, g_u = sgns_batch(centers, outsides, negatives, C, U)
    gC_b, gU_b = scatter_rows(V, d, centers, g_vc), scatter_rows(V, d, rows.ravel(), g_u.reshape(-1, d))
    loss_l, gC_l, gU_l = 0.0, np.zeros((V, d)), np.zeros((V, d))
    for b in range(B):
        l, gv, r, gr = neg_sampling_loss_and_gradient(C[centers[b]], outsides[b], U, negatives[b])
        loss_l += l
        gC_l[centers[b]] += gv
        np.add.at(gU_l, r, gr)
    print(f"\n  损失之和：批量 {loss_b:.6f}，循环 {loss_l:.6f}")
    print(f"  中心词梯度相对误差 {relative_error(gC_b, gC_l):.1e}，上下文梯度相对误差 {relative_error(gU_b, gU_l):.1e}")
    assert abs(loss_b - loss_l) < 1e-9 and relative_error(gC_b, gC_l) < 1e-10 and relative_error(gU_b, gU_l) < 1e-10


def demo_cost():
    """演示 6：朴素 softmax 与负采样的单样本耗时随词表大小的变化。"""
    print_header("演示 6：朴素 softmax vs 负采样 —— 代价随词表大小的变化")
    rng = np.random.default_rng(3)
    d, K = 100, 10
    print(f"\n  d = {d}，K = {K}，单个样本求损失和梯度的平均耗时（毫秒）：")
    print(f"    {'V':>9s}{'朴素 softmax':>14s}{'负采样':>10s}{'倍数':>8s}")
    sampler_cache = {}
    for V in [1_000, 10_000, 100_000]:
        U = rng.normal(size=(V, d)).astype(np.float32)
        v = rng.normal(size=d).astype(np.float32)
        n_naive = 20 if V < 100_000 else 5
        t0 = time.perf_counter()
        for _ in range(n_naive):
            l1_grad.naive_softmax_loss_and_gradient(v, 0, U)
        t_naive = (time.perf_counter() - t0) / n_naive
        sampler_cache[V] = NegativeSampler(np.full(V, 1.0 / V), seed=0)
        n_ns = 500
        t0 = time.perf_counter()
        for _ in range(n_ns):
            neg_sampling_loss_and_gradient(v, 0, U, sampler_cache[V].sample(K))
        t_ns = (time.perf_counter() - t0) / n_ns
        print(f"    {V:>9,d}{t_naive * 1e3:>14.3f}{t_ns * 1e3:>10.3f}{t_naive / t_ns:>8.0f}×")
    print("""
  观察：朴素 softmax 的耗时随 V 线性增长；负采样的耗时几乎不变（只和 K、d 有关）。
    （具体毫秒数取决于你的电脑；负采样在 V 小时甚至可能更慢，因为 Python 函数调用本身有固定开销。
     真正的 word2vec 用 C 语言实现，这部分开销可以忽略。）""")


if __name__ == "__main__":
    demo_sigmoid()
    demo_noise_distribution()
    demo_single_example()
    demo_gradient_check()
    demo_batch_matches_loop()
    demo_cost()
