"""
=============================================================================
第 1 讲 · 代码 4：word2vec 的数学 —— softmax、损失函数、手推梯度与梯度检验
=============================================================================

对应讲义：lectures/lecture01_nlp_history_word_vectors.md 第 9 ~ 11 节
对应作业：CS224N 历年作业中 "Understanding word2vec" 的推导题与 naiveSoftmaxLossAndGradient

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
代码 03 的思路是"先数共现次数，再降维"。word2vec 换了一个角度：
    **不数了，直接学一个能"预测上下文"的模型，词向量就是这个模型的参数。**

skip-gram 模型（讲义第 9 节）：
    每个词 w 有两个向量：作为中心词时的 v_w，作为上下文词（outside word）时的 u_w。
    给定中心词 c，上下文词是 o 的概率用 softmax 定义：

                       exp(u_o · v_c)
        P(o | c) = ─────────────────────────
                    Σ_{w ∈ 词表} exp(u_w · v_c)

    对一个 (c, o) 样本，损失是负对数概率：J = -log P(o | c)。
    训练 = 调整所有 u、v，让语料中真实出现的 (c, o) 对概率尽量大（损失尽量小）。

要用梯度下降训练，就得知道损失对每个参数的导数（梯度）。讲义第 10 节一步步推出了结果：

        ∂J/∂v_c = -u_o + Σ_w P(w | c) · u_w      = Uᵀ (ŷ - y)        "期望 - 观测"
        ∂J/∂u_w = (P(w | c) - 1[w = o]) · v_c    → 写成矩阵：(ŷ - y) v_cᵀ

    其中 ŷ 是 softmax 给出的概率向量，y 是真实上下文词 o 的 one-hot 向量。

本文件：
    第 1 部分：softmax 及其数值稳定性（为什么要先减去最大值）
    第 2 部分：单个样本的损失与梯度（naive_softmax_loss_and_gradient），并解释"期望 - 观测"
    第 3 部分：梯度检验 —— 用数值方法验证手推的公式没写错（深度学习里极其重要的调试手段）
    第 4 部分：一次梯度下降之后，P(o | c) 真的变大了吗？
    第 5 部分：批量（mini-batch）版本，供 05 号文件训练使用，并验证它与逐个样本的结果一致
    第 6 部分：朴素 softmax 有多慢？—— 引出第 2 讲的负采样

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 04_word2vec_gradients.py

需要 numpy。运行时间约 2 秒（第 6 部分要计时）。
"""

import time

import numpy as np


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


# =============================================================================
# 第 1 部分：softmax
# =============================================================================
def softmax(x):
    """
    数值稳定的 softmax：把任意实数向量变成概率分布。

    【思路】（讲义第 9.4 节）
        softmax(x)_i = exp(x_i) / Σ_j exp(x_j)
        - exp 保证每一项都是正数；除以总和保证加起来等于 1 → 得到一个概率分布。
        - "soft max"：最大的 x_i 得到最大的概率，但其他项也保留一点概率（不像 max 那样"赢者通吃"）。
        数值问题：x_i 很大时 exp(x_i) 会溢出成 inf（float64 下 exp(710) 就溢出了），inf / inf = nan。
        技巧：分子分母同时乘以 exp(-max(x))，结果不变（"平移不变性"），
              但这样最大的指数是 exp(0) = 1，永远不会溢出。

    【参数】
        x : np.ndarray，一维 (n,) 或二维 (batch, n)。二维时对**每一行**分别做 softmax。

    【返回】
        np.ndarray，与 x 同形状，每行元素都在 (0, 1] 之间、且每行和为 1。

    【边界】
        - x 中某些值极小（比如 -1000）时，对应的概率会下溢成 0.0，这在数值上是可以接受的；
          但随后若对它取 log 会得到 -inf，所以计算损失时只取真实类别那一项的 log（它不会是 0）。

    【NLP 联系】
        几乎所有语言模型的最后一层都是 softmax：把词表里每个 token 的分数变成"下一个 token"的概率。
        分数除以一个"温度"再做 softmax，就是生成文本时调节随机性的温度参数（演示 1 的放大 / 缩小）。

    【Python 小知识】
        x.max(axis=-1, keepdims=True)：沿最后一个维度取最大值并保留维度，
            一维时形状 (1,)，二维时形状 (batch, 1)，两种情况都能正确广播。
    """
    shifted = x - x.max(axis=-1, keepdims=True)
    e = np.exp(shifted)
    return e / e.sum(axis=-1, keepdims=True)


def demo_softmax():
    """演示 1：softmax 的性质与数值稳定性。"""
    print_header("演示 1：softmax")
    scores = np.array([2.0, 1.0, 0.1])
    p = softmax(scores)
    print(f"\n  分数 {scores} → 概率 {np.round(p, 3)}，和 = {p.sum():.6f}")
    print(f"  平移不变：分数统一加 100 → {np.round(softmax(scores + 100), 3)}（完全一样）")
    print(f"  分数放大 5 倍 → {np.round(softmax(scores * 5), 3)}（更'尖'，接近 max）")
    print(f"  分数缩小到 1/5 → {np.round(softmax(scores / 5), 3)}（更'平'，接近均匀分布）")

    big = np.array([1000.0, 999.0, 998.0])
    with np.errstate(over="ignore", invalid="ignore"):          # 暂时屏蔽溢出警告，好让你看到 nan
        naive = np.exp(big) / np.exp(big).sum()
    print(f"\n  分数很大时：{big}")
    print(f"    朴素写法 exp(x)/Σexp(x)    → {naive}   （exp(1000) 溢出成 inf，inf/inf = nan）")
    print(f"    先减最大值的稳定写法      → {np.round(softmax(big), 3)}")


# =============================================================================
# 第 2 部分：单个样本的损失与梯度
# =============================================================================
def naive_softmax_loss_and_gradient(center_vec, outside_idx, outside_vectors):
    """
    skip-gram 模型中，一个 (中心词 c, 上下文词 o) 样本的损失和梯度（朴素 softmax 版）。

    【思路】
        1. 分数：scores = U · v_c，第 w 个分数是 u_w · v_c（"上下文词 w 和中心词 c 有多搭"）。
        2. 概率：ŷ = softmax(scores)，ŷ_w = P(w | c)。
        3. 损失：J = -log ŷ_o（真实上下文词 o 的概率越大，损失越小）。
        4. 梯度（讲义第 10 节推导）：
             记 δ = ŷ - y（y 是 o 的 one-hot 向量），即"预测分布 - 真实分布"，
             ∂J/∂v_c = Uᵀ δ        = Σ_w ŷ_w u_w - u_o     （期望的上下文向量 - 真实的上下文向量）
             ∂J/∂U   = δ v_cᵀ      （第 w 行 = (ŷ_w - 1[w=o]) · v_c）

    【参数】
        center_vec      : np.ndarray，形状 (d,)，中心词的向量 v_c
        outside_idx     : int，真实上下文词 o 的编号，0 <= outside_idx < V
        outside_vectors : np.ndarray，形状 (V, d)，所有词的"上下文向量"矩阵 U（第 w 行是 u_w）

    【返回】
        (loss, grad_center_vec, grad_outside_vecs)
            loss              : float，J = -log P(o | c)，>= 0
            grad_center_vec   : np.ndarray (d,)，∂J/∂v_c
            grad_outside_vecs : np.ndarray (V, d)，∂J/∂U（**每一行**都可能非零）

    【边界】
        - 计算量 ∝ V × d：要和词表里**每个词**算一次点积。V 很大时非常慢（见演示 6）。
        - 参数会被读取但不会被修改（函数没有"副作用"），更新参数是调用者的事。

    【NLP 联系】
        "softmax + 交叉熵，梯度是 ŷ - y"这个结论会在整门课里反复出现：
        第 3 讲的分类器、第 5 讲的 RNN 语言模型、第 7 讲的 Transformer，输出层的梯度都是这个形式。

    【Python 小知识】
        np.outer(a, b)：外积，结果矩阵的 (i, j) 元素是 a[i] * b[j]，形状 (len(a), len(b))。
        y_hat.copy()：复制一份数组。直接写 delta = y_hat 的话，两个名字指向同一块内存，
                      修改 delta 会把 y_hat 也改掉。
    """
    scores = outside_vectors @ center_vec              # (V,)
    y_hat = softmax(scores)                            # (V,)
    loss = -np.log(y_hat[outside_idx])

    delta = y_hat.copy()
    delta[outside_idx] -= 1.0                          # δ = ŷ - y
    grad_center_vec = outside_vectors.T @ delta        # (d,)
    grad_outside_vecs = np.outer(delta, center_vec)    # (V, d)
    return float(loss), grad_center_vec, grad_outside_vecs


def skipgram(center_idx, outside_indices, center_vectors, outside_vectors):
    """
    一个窗口内的 skip-gram 损失与梯度：中心词 c 与它窗口里的所有上下文词 o_1, o_2, ...

    【思路】
        J_window = Σ_j -log P(o_j | c)，把每个上下文词的损失和梯度加起来。
        中心词向量只有 v_c 那一行有梯度；上下文向量矩阵 U 的每一行都可能有梯度。

    【参数】
        center_idx      : int，中心词编号
        outside_indices : list[int]，窗口内的上下文词编号（可以有重复）
        center_vectors  : np.ndarray (V, d)，中心词向量矩阵（第 w 行是 v_w）
        outside_vectors : np.ndarray (V, d)，上下文向量矩阵（第 w 行是 u_w）

    【返回】
        (loss, grad_center_vectors, grad_outside_vectors)
            loss                 : float
            grad_center_vectors  : np.ndarray (V, d)，只有第 center_idx 行非零
            grad_outside_vectors : np.ndarray (V, d)

    【边界】
        - outside_indices 为空时，损失为 0、梯度全为 0。
    """
    loss = 0.0
    grad_center = np.zeros_like(center_vectors)
    grad_outside = np.zeros_like(outside_vectors)
    for o in outside_indices:
        l, gc, go = naive_softmax_loss_and_gradient(center_vectors[center_idx], o, outside_vectors)
        loss += l
        grad_center[center_idx] += gc
        grad_outside += go
    return loss, grad_center, grad_outside


def demo_single_example():
    """演示 2：在一个 5 个词的小词表上，把损失和梯度的每一步都打印出来。"""
    print_header("演示 2：一个样本的损失与梯度（词表 5 个词，向量 3 维）")
    vocab = ["the", "cat", "sat", "on", "mat"]
    rng = np.random.default_rng(0)
    center_vectors = rng.normal(scale=0.5, size=(5, 3))
    outside_vectors = rng.normal(scale=0.5, size=(5, 3))
    c, o = vocab.index("cat"), vocab.index("sat")

    v_c = center_vectors[c]
    scores = outside_vectors @ v_c
    y_hat = softmax(scores)
    print(f"\n  中心词 c = cat，真实上下文词 o = sat")
    print(f"  v_cat = {np.round(v_c, 3)}")
    print("\n  每个候选上下文词 w 的分数 u_w·v_c 和概率 P(w | cat)：")
    for w, s, p in zip(vocab, scores, y_hat):
        mark = "  ← 真实的上下文词" if w == "sat" else ""
        print(f"    {w:<4s} 分数 {s:+.3f}   概率 {p:.3f}{mark}")

    loss, g_vc, g_U = naive_softmax_loss_and_gradient(v_c, o, outside_vectors)
    print(f"\n  损失 J = -log P(sat | cat) = -log {y_hat[o]:.3f} = {loss:.3f}")
    print(f"  （如果模型完全不懂，5 个词等概率，损失是 log 5 = {np.log(5):.3f}）")

    expected = y_hat @ outside_vectors                 # Σ_w P(w|c) u_w
    print("\n  ∂J/∂v_c 的'期望 - 观测'解读：")
    print(f"    期望的上下文向量 Σ P(w|c) u_w = {np.round(expected, 3)}")
    print(f"    观测到的上下文向量 u_sat       = {np.round(outside_vectors[o], 3)}")
    print(f"    两者之差（= 梯度）             = {np.round(expected - outside_vectors[o], 3)}")
    print(f"    函数算出的 ∂J/∂v_c             = {np.round(g_vc, 3)}")
    print("""
  解读：梯度下降沿 -∂J/∂v_c 方向更新 v_c，也就是把 v_c 往"观测到的 u_sat"方向拉，
        往"模型目前期望的平均上下文"方向推。当模型的预测和观测一致时，梯度为 0，不再更新。""")
    print("  ∂J/∂U 的每一行（第 w 行 = (P(w|c) - 1[w=o]) · v_c）：")
    for w, row in zip(vocab, g_U):
        print(f"    {w:<4s}{np.round(row, 3)}")
    print("  → 真实词 sat 那一行的系数是负的（P - 1 < 0），更新时 u_sat 会被拉向 v_cat；其他行被推开。")


# =============================================================================
# 第 3 部分：梯度检验
# =============================================================================
def numerical_gradient(f, x, eps=1e-5):
    """
    用"中心差分"数值地估计梯度：∂f/∂x_i ≈ [f(x + eps·e_i) - f(x - eps·e_i)] / (2·eps)。

    【思路】
        导数的定义就是"自变量变一点点，函数值变多少"。逐个元素地把 x_i 加减一个很小的 eps，
        看 f 的变化，就能近似出每个偏导数。它很慢（每个元素都要算 2 次 f），但几乎不会写错，
        所以专门用来**检查**手推的解析梯度。

    【参数】
        f   : 函数，输入与 x 同形状的数组，返回一个 float（标量）
        x   : np.ndarray，任意形状（会被临时修改，但函数返回前一定恢复原值）
        eps : float，扰动大小。太大 → 近似误差大；太小 → 浮点舍入误差大。1e-5 左右通常最好。

    【返回】
        np.ndarray，与 x 同形状，数值梯度。

    【边界】
        - 计算量 = 2 × x.size 次调用 f。只能用于小规模检查，不能用来训练。
        - f 中如果有随机性（比如随机采样），两次调用结果不同，数值梯度就没有意义 —— 检查时要固定随机性。

    【Python 小知识】
        np.nditer(x, flags=["multi_index"])：逐个遍历多维数组的每个元素，it.multi_index 是当前元素的下标元组。
        这样同一段代码能处理一维、二维……任意形状的数组。
    """
    grad = np.zeros_like(x)
    it = np.nditer(x, flags=["multi_index"])
    while not it.finished:
        idx = it.multi_index
        old = x[idx]
        x[idx] = old + eps
        f_plus = f(x)
        x[idx] = old - eps
        f_minus = f(x)
        x[idx] = old                                    # 一定要恢复原值！
        grad[idx] = (f_plus - f_minus) / (2 * eps)
        it.iternext()
    return grad


def relative_error(a, b):
    """
    两个数组的相对误差：|a - b| 的最大值 / max(|a|, |b|) 的最大值。

    【返回】
        float。经验上：< 1e-7 很好；1e-7 ~ 1e-4 要警惕；> 1e-4 几乎肯定有 bug。
    """
    return float(np.max(np.abs(a - b)) / max(np.max(np.abs(a)), np.max(np.abs(b)), 1e-12))


def demo_gradient_check():
    """演示 3：用数值梯度验证手推的解析梯度。"""
    print_header("演示 3：梯度检验 —— 手推的公式对不对？")
    rng = np.random.default_rng(1)
    V, d = 7, 4
    center_vectors = rng.normal(size=(V, d))
    outside_vectors = rng.normal(size=(V, d))
    center_idx, window = 2, [0, 3, 3, 5]               # 一个窗口：中心词 2，上下文词 0、3、3、5

    loss, g_center, g_outside = skipgram(center_idx, window, center_vectors, outside_vectors)
    num_center = numerical_gradient(lambda C: skipgram(center_idx, window, C, outside_vectors)[0],
                                    center_vectors.copy())
    num_outside = numerical_gradient(lambda U: skipgram(center_idx, window, center_vectors, U)[0],
                                     outside_vectors.copy())
    e1, e2 = relative_error(g_center, num_center), relative_error(g_outside, num_outside)
    print(f"\n  窗口损失 = {loss:.4f}")
    print(f"  中心词向量的梯度：解析 vs 数值，相对误差 = {e1:.2e}")
    print(f"  上下文向量的梯度：解析 vs 数值，相对误差 = {e2:.2e}")
    assert e1 < 1e-7 and e2 < 1e-7, "梯度检验失败：解析梯度的公式或代码有错！"

    # 故意写一个错误的梯度，看看梯度检验能不能抓住
    def buggy(center_vec, outside_idx, U):
        y_hat = softmax(U @ center_vec)
        delta = y_hat.copy()                           # 忘了减去 one-hot！
        return float(-np.log(y_hat[outside_idx])), U.T @ delta, np.outer(delta, center_vec)
    _, g_bug, _ = buggy(center_vectors[center_idx], 3, outside_vectors)
    num = numerical_gradient(lambda v: naive_softmax_loss_and_gradient(v, 3, outside_vectors)[0],
                             center_vectors[center_idx].copy())
    print(f"  一个'忘了减 one-hot'的错误版本：相对误差 = {relative_error(g_bug, num):.2e}  ← 梯度检验立刻发现了问题")


# =============================================================================
# 第 4 部分：梯度下降一步，概率真的变大了吗？
# =============================================================================
def demo_one_sgd_step():
    """演示 4：对同一个样本反复做梯度下降，看 P(o|c) 和损失的变化。"""
    print_header("演示 4：沿负梯度方向走几步")
    rng = np.random.default_rng(0)
    center_vectors = rng.normal(scale=0.5, size=(5, 3))
    outside_vectors = rng.normal(scale=0.5, size=(5, 3))
    c, o, lr = 1, 2, 0.5
    print(f"\n  学习率 α = {lr}；每一步：v_c ← v_c - α ∂J/∂v_c，U ← U - α ∂J/∂U")
    for step in range(6):
        loss, g_vc, g_U = naive_softmax_loss_and_gradient(center_vectors[c], o, outside_vectors)
        print(f"    第 {step} 步：P(o|c) = {np.exp(-loss):.3f}，损失 = {loss:.3f}")
        center_vectors[c] -= lr * g_vc
        outside_vectors -= lr * g_U
    print("""
  观察：每走一步，真实上下文词的概率都在上升。
    但注意：只对这一个样本优化，最终会让 P(o|c) → 1，也就是"c 的上下文永远是 o"——显然不对。
    真实训练时，同一个中心词会和很多不同的上下文词配对，梯度互相"拉扯"，
    最终的平衡点就是让 P(·|c) 尽量接近语料中 c 的真实上下文分布。""")


# =============================================================================
# 第 5 部分：批量版本（供 05 号文件训练使用）
# =============================================================================
def naive_softmax_batch(centers, outsides, center_vectors, outside_vectors):
    """
    一批 (中心词, 上下文词) 样本的**平均**损失与梯度 —— 用矩阵运算一次算完。

    【思路】
        和 naive_softmax_loss_and_gradient 完全相同的公式，只是把 B 个样本叠成矩阵：
            Vc     = center_vectors[centers]     形状 (B, d)：这批样本的中心词向量
            scores = Vc @ Uᵀ                     形状 (B, V)：每个样本对每个候选词的分数
            Ŷ      = softmax(scores)             每行是一个概率分布
            Δ      = Ŷ - Y                       Y 是 one-hot 矩阵（每行在真实上下文词处为 1）
            ∂J/∂U  = Δᵀ @ Vc / B                 形状 (V, d)
            ∂J/∂Vc = Δ @ U / B                   形状 (B, d)，再按中心词编号"累加"回 (V, d) 的矩阵
        取平均（除以 B）而不是求和，这样学习率的大小不依赖于批量大小。

    【参数】
        centers         : np.ndarray[int]，形状 (B,)，中心词编号
        outsides        : np.ndarray[int]，形状 (B,)，对应的上下文词编号
        center_vectors  : np.ndarray (V, d)
        outside_vectors : np.ndarray (V, d)

    【返回】
        (loss, grad_center_vectors, grad_outside_vectors)
            loss : float，这批样本的平均损失
            两个梯度都是 (V, d) 的矩阵

    【边界】
        - 同一个中心词在一批中出现多次时，它的梯度要**累加**。普通的 grad[centers] += g 做不到这一点
          （重复下标只会生效一次），必须用 np.add.at。这是新手很容易踩的坑，演示 5 专门验证了这一点。
        - 内存占用 ∝ B × V（scores 矩阵）。V 很大时要减小 B。

    【Python 小知识】
        np.arange(B)：生成 [0, 1, ..., B-1]。
        delta[np.arange(B), outsides] -= 1：对每一行 i，把第 outsides[i] 列减 1（"花式索引"）。
        np.add.at(A, idx, vals)：对 idx 中的每个下标执行 A[idx[k]] += vals[k]，重复下标会正确累加。
    """
    B = len(centers)
    Vc = center_vectors[centers]                       # (B, d)
    y_hat = softmax(Vc @ outside_vectors.T)            # (B, V)
    loss = -np.mean(np.log(y_hat[np.arange(B), outsides]))
    delta = y_hat
    delta[np.arange(B), outsides] -= 1.0               # Δ = Ŷ - Y（直接在 y_hat 上改，省一次复制）
    delta /= B
    grad_outside = delta.T @ Vc                        # (V, d)
    grad_center = np.zeros_like(center_vectors)
    np.add.at(grad_center, centers, delta @ outside_vectors)
    return float(loss), grad_center, grad_outside


def demo_batch_matches_loop():
    """演示 5：批量版本与逐个样本循环的结果完全一致（包括重复的中心词）。"""
    print_header("演示 5：批量版本 = 逐个样本的平均")
    rng = np.random.default_rng(2)
    V, d, B = 10, 6, 32
    C = rng.normal(size=(V, d))
    U = rng.normal(size=(V, d))
    centers = rng.integers(0, V, size=B)               # 32 个样本，中心词必然有重复
    outsides = rng.integers(0, V, size=B)

    loss_b, gc_b, go_b = naive_softmax_batch(centers, outsides, C, U)
    loss_l, gc_l, go_l = 0.0, np.zeros_like(C), np.zeros_like(U)
    for c, o in zip(centers, outsides):
        l, gc, go = naive_softmax_loss_and_gradient(C[c], o, U)
        loss_l += l / B
        gc_l[c] += gc / B
        go_l += go / B
    print(f"\n  平均损失：批量 {loss_b:.6f}，循环 {loss_l:.6f}")
    print(f"  中心词梯度相对误差：{relative_error(gc_b, gc_l):.1e}，上下文梯度相对误差：{relative_error(go_b, go_l):.1e}")
    assert abs(loss_b - loss_l) < 1e-10 and relative_error(gc_b, gc_l) < 1e-10 and relative_error(go_b, go_l) < 1e-10

    wrong = np.zeros_like(C)
    wrong[centers] += (softmax(C[centers] @ U.T) - np.eye(V)[outsides]) / B @ U    # 错误：重复下标不累加
    print(f"  如果用 grad[centers] += ... 代替 np.add.at：相对误差 {relative_error(wrong, gc_l):.2f}  ← 错了！")


# =============================================================================
# 第 6 部分：朴素 softmax 有多慢？
# =============================================================================
def demo_cost():
    """演示 6：每个样本的计算量随词表大小线性增长 —— 引出第 2 讲的负采样。"""
    print_header("演示 6：朴素 softmax 的代价")
    rng = np.random.default_rng(3)
    d = 100
    print(f"\n  向量维度 d = {d}，对单个样本调用 naive_softmax_loss_and_gradient 的平均耗时：")
    timings = {}
    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 = 20 if V < 100_000 else 5
        t0 = time.perf_counter()
        for _ in range(n):
            naive_softmax_loss_and_gradient(v, 0, U)
        timings[V] = (time.perf_counter() - t0) / n
        print(f"    V = {V:>7,d}：{timings[V] * 1e3:8.3f} 毫秒 / 样本")
    per_sample = timings[100_000] * 30                 # 线性外推到 V = 300 万
    pairs = 1e9                                        # 假设语料产生 10 亿个 (c, o) 样本对
    print(f"""
  按线性外推：V = 300 万（Google News 词向量的词表大小）时约 {per_sample * 1e3:.0f} 毫秒 / 样本；
  如果语料产生 10 亿个样本对，过一遍就要约 {per_sample * pairs / 86400 / 365:.0f} 年（单线程、按本机速度粗略估计）。
  （具体毫秒数取决于你的电脑；关键是**耗时与 V 成正比**。）

  瓶颈在分母 Σ_w exp(u_w · v_c)：每个样本都要和词表里的**所有词**算一遍。
  另外 ∂J/∂U 的每一行都非零，意味着每一步都要更新整个 U 矩阵。
  → 第 2 讲的**负采样**（negative sampling）只和几个随机"负例"比较，把代价从 O(V) 降到 O(k)。""")


if __name__ == "__main__":
    demo_softmax()
    demo_single_example()
    demo_gradient_check()
    demo_one_sgd_step()
    demo_batch_matches_loop()
    demo_cost()
