"""
=============================================================================
第 2 讲 · 代码 4：怎么评估词向量？—— 内在评估、外在评估、超参数的影响
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
到目前为止，我们用"类别纯度""区分度""类比准确率"来衡量词向量。它们都属于**内在评估**
（intrinsic evaluation）：直接在词向量上做一个小测试，快、容易理解，但不一定反映真实用处。
**外在评估**（extrinsic evaluation）则是把词向量用到一个真正的下游任务里，看任务效果有没有变好。

本文件：
    演示 1：词相似度评估 —— 模型的余弦相似度和"人工评分"的 Spearman 相关系数
            （我们按语料的设计规则模拟了一份"人工评分"，相当于玩具版的 WordSim-353）
    演示 2：一个外在评估 —— 小样本词分类：每类只给 2 个带标签的词，能不能认出同类的其他词？
            对比 one-hot（完全无法泛化）和几种词向量
    演示 3：超参数的影响 —— 维度 d、窗口大小、语料规模
    演示 4：内在指标和外在指标一致吗？

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

需要 numpy。运行时间约 1~2 分钟（演示 3 要训练 30 多个模型）。
"""

import importlib
import itertools

import numpy as np

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

sgns = importlib.import_module("02_sgns_training")
glove = importlib.import_module("03_glove")
l1_count = importlib.import_module("03_cooccurrence_svd")

GEO_GROUP = {}          # 词 → 它属于哪一组（国家, 首都, 语言），用于模拟人工评分
for _c, _t, _l in importlib.import_module("shared").GEOGRAPHY:
    GEO_GROUP[_c] = GEO_GROUP[_t] = GEO_GROUP[_l] = _c


# =============================================================================
# 第 1 部分：词相似度评估（内在评估）
# =============================================================================
def simulated_human_rating(w1, w2):
    """
    按语料的设计规则，给一对词打一个 0~10 分的"模拟人工相似度评分"。

    【思路】
        真实的词相似度数据集（WordSim-353、SimLex-999 等）是请很多人给词对打分再取平均。
        我们没有人工评分，就按"人看了模板后会怎么想"写一套规则：
            - 同类的动物 / 水果 / 交通工具：7 分；
            - 人物词：3 分起，每多共享一个特征（性别 / 身份 / 年龄）加 2 分（king–queen 共享 2 个 → 7 分）；
            - 地理词：同一组（france–paris）国家—首都 7 分、国家—语言 6 分、首都—语言 5 分；
                      不同组但同类（paris–tokyo）5 分；不同组也不同类（paris–japan）2 分；
            - 跨大类（cat–car、king–paris）：1 分。

    【参数】
        w1, w2 : str，都必须是有类别的内容词（corpus.word_categories() 里的词）

    【返回】
        float，0~10

    【边界】
        - 这只是**模拟**。真实的人工评分有分歧、有噪声，也会受词频、熟悉程度影响。
        - 它主要衡量"相似"（similarity）。真实数据集会区分"相似"和"相关"（relatedness）：
          SimLex-999 只要相似（car 和 bike 高分，car 和 road 低分），WordSim-353 两者混在一起。
    """
    cats = word_categories()
    c1, c2 = cats[w1], cats[w2]
    if c1 == c2 == "person":
        shared = sum(a == b for a, b in zip(PERSONS[w1], PERSONS[w2]))
        return 3.0 + 2.0 * shared
    if c1 == c2 and c1 in ("animal", "fruit", "vehicle"):
        return 7.0
    geo = ("country", "city", "language")
    if c1 in geo and c2 in geo:
        if GEO_GROUP[w1] == GEO_GROUP[w2]:
            return {frozenset(["country", "city"]): 7.0, frozenset(["country", "language"]): 6.0,
                    frozenset(["city", "language"]): 5.0}[frozenset([c1, c2])]
        return 5.0 if c1 == c2 else 2.0
    return 1.0


def rankdata(x):
    """
    把数组变成"名次"（从 1 开始），相同的值取平均名次。

    【例子】
        rankdata([10, 30, 20, 20]) → [1.0, 4.0, 2.5, 2.5]
        （两个 20 并列第 2、3 名，各记 2.5）

    【参数】x : 一维数组
    【返回】np.ndarray，与 x 等长

    【Python 小知识】
        np.argsort(x, kind="stable")：返回把 x 从小到大排序的下标顺序；stable 保证相同值保持原来的先后。
    """
    x = np.asarray(x, dtype=float)
    order = np.argsort(x, kind="stable")
    ranks = np.empty(len(x))
    i = 0
    while i < len(x):
        j = i
        while j + 1 < len(x) and x[order[j + 1]] == x[order[i]]:
            j += 1
        ranks[order[i:j + 1]] = (i + j) / 2.0 + 1.0       # 并列的一组取平均名次
        i = j + 1
    return ranks


def spearman(x, y):
    """
    Spearman 等级相关系数：先把两组数各自换成名次，再算名次之间的 Pearson 相关系数。

    【思路】（讲义第 7.2 节）
        我们只关心"排序是否一致"：模型认为最像的词对，人是不是也认为最像？
        至于模型的余弦是 0.9 还是 0.6、人给的是 9 分还是 7 分，单位不同，不必一致。
        换成名次后再算相关系数，就只比较排序了。

    【参数】x, y : 等长的一维数组
    【返回】float，[-1, 1]。1 = 排序完全一致，0 = 无关，-1 = 完全相反
    """
    return float(np.corrcoef(rankdata(x), rankdata(y))[0, 1])


def word_similarity_score(vectors, word2id):
    """
    在所有内容词对上，计算模型余弦相似度与模拟人工评分的 Spearman 相关系数。

    【返回】float
    """
    words = [w for w in word_categories() if w in word2id]
    unit = normalize_rows(vectors[[word2id[w] for w in words]])
    model, human = [], []
    for i, j in itertools.combinations(range(len(words)), 2):
        model.append(unit[i] @ unit[j])
        human.append(simulated_human_rating(words[i], words[j]))
    return spearman(model, human)


# =============================================================================
# 第 2 部分：一个外在评估 —— 小样本词分类
# =============================================================================
def train_softmax_classifier(X, y, n_classes, lr=0.5, steps=300, l2=1e-3):
    """
    用梯度下降训练一个多分类逻辑回归（softmax 回归）。

    【思路】
        logits = X W + b，概率 = softmax(logits)，损失 = 平均交叉熵 + L2 正则。
        梯度（第 1 讲第 10.5 节的老朋友）：∂J/∂logits = (ŷ - y) / N。
        这是最简单的"下游模型"：如果特征（词向量）本身好，简单的分类器就够了。

    【参数】
        X         : np.ndarray (N, d)，特征
        y         : np.ndarray[int] (N,)，类别 0..n_classes-1
        n_classes : int
        lr, steps, l2 : 学习率、迭代次数、L2 正则系数

    【返回】
        (W, b)：W 形状 (d, n_classes)，b 形状 (n_classes,)

    【边界】
        - 训练样本极少（每类 2 个）时，正则化很重要，否则权重会无限增大去"背"住训练集。
    """
    N, d = X.shape
    W, b = np.zeros((d, n_classes)), np.zeros(n_classes)
    Y = np.eye(n_classes)[y]                         # one-hot 标签
    for _ in range(steps):
        logits = X @ W + b
        logits -= logits.max(axis=1, keepdims=True)
        P = np.exp(logits)
        P /= P.sum(axis=1, keepdims=True)
        G = (P - Y) / N
        W -= lr * (X.T @ G + l2 * W)
        b -= lr * G.sum(axis=0)
    return W, b


def few_shot_word_classification(features, word2id, n_train=2, n_trials=20, seed=0):
    """
    小样本词分类：每个类别随机选 n_train 个词作为训练集，其余同类词作为测试集，报告测试准确率。

    【思路】（讲义第 7.3 节）
        这模拟了一个常见的真实场景：标注数据很少（比如每类只有几个例子），
        而测试时会遇到训练时**从没见过**的词。好的词向量应该让"同类的新词"自动落在正确的区域里。

    【参数】
        features : np.ndarray (V, d)，每个词的特征（词向量，或者 one-hot）
        word2id  : dict
        n_train  : 每类几个训练词
        n_trials : 随机划分多少次取平均
        seed     : 随机种子

    【返回】
        float，平均测试准确率（随机猜测约为 1/7 ≈ 14%）

    【边界】
        - 特征是 one-hot 时，测试词对应的维度在训练时从来没有非零值，对应的权重永远是 0，
          分类器对所有测试词给出同样的预测 —— 这就是"孤立符号无法泛化"（第 1 讲代码 01）的又一个例子。
    """
    cats = word_categories()
    labels = sorted(set(cats.values()))
    by_label = {lab: [w for w in cats if cats[w] == lab] for lab in labels}
    rng = np.random.default_rng(seed)
    X_all = features / np.maximum(np.linalg.norm(features, axis=1, keepdims=True), 1e-12)
    accs = []
    for _ in range(n_trials):
        train, test = [], []
        for k, lab in enumerate(labels):
            ws = list(rng.permutation(by_label[lab]))
            train += [(w, k) for w in ws[:n_train]]
            test += [(w, k) for w in ws[n_train:]]
        Xtr = X_all[[word2id[w] for w, _ in train]]
        ytr = np.array([k for _, k in train])
        W, b = train_softmax_classifier(Xtr, ytr, len(labels))
        Xte = X_all[[word2id[w] for w, _ in test]]
        pred = np.argmax(Xte @ W + b, axis=1)
        accs.append(np.mean(pred == np.array([k for _, k in test])))
    return float(np.mean(accs))


# =============================================================================
# 演示
# =============================================================================
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)

    # 准备几组词向量
    M, _ = l1_count.compute_co_occurrence_matrix(sents, 2, word2id)
    X = glove.weighted_cooccurrence(ids, V, 2)
    C, U, _ = sgns.train_sgns(ids, counts, verbose=False)
    G1, G2, *_ = glove.train_glove(X + 0.1, verbose=False)
    vectors = {
        "one-hot（V 维）": np.eye(V),
        "原始共现计数（V 维）": M,
        "PPMI + SVD（10 维）": l1_count.reduce_to_k_dim(l1_count.ppmi(M), 10),
        "SGNS（10 维）": C + U,
        "GloVe，平滑 ε=0.1（10 维）": G1 + G2,
    }

    # ---------------- 演示 1：词相似度 ----------------
    print_header("演示 1：词相似度评估（模拟人工评分，Spearman 相关系数）")
    examples = [("king", "queen"), ("king", "girl"), ("cat", "dog"), ("france", "paris"),
                ("paris", "tokyo"), ("paris", "japan"), ("cat", "car")]
    print("\n  模拟人工评分举例：" + "，".join(f"{a}–{b} {simulated_human_rating(a, b):.0f}" for a, b in examples))
    print(f"  名次函数的例子：rankdata([10, 30, 20, 20]) = {rankdata([10, 30, 20, 20]).tolist()}\n")
    for name, v in vectors.items():
        if name.startswith("one-hot"):
            print(f"    {name:<26s}Spearman = （所有词对的余弦都是 0，无法排序）")
            continue
        print(f"    {name:<26s}Spearman = {word_similarity_score(v, word2id):.3f}")

    # ---------------- 演示 2：小样本词分类 ----------------
    print_header("演示 2：外在评估 —— 小样本词分类（每类 2 个训练词，20 次随机划分取平均）")
    print()
    for name, v in vectors.items():
        print(f"    {name:<26s}测试准确率 {few_shot_word_classification(v, word2id):.1%}")
    print("""
  观察：
    - one-hot 的准确率只有随机水平：测试词的那一维在训练时从没出现过，分类器对它们一无所知。
    - 词向量把"同类的新词"放在了训练词附近，只看 2 个例子就能认出同类的其他词。
      这正是第 1 讲开头想要的：从一个词学到的知识，自动迁移到意思相近的词上。""")

    # ---------------- 演示 3：超参数 ----------------
    print_header("演示 3：超参数的影响（SGNS，每个设置 3 个随机种子取平均）")

    def run(sentences, seeds=(0, 1, 2), **kw):
        w2i, i2w, ct = build_vocab(sentences)
        cts = np.array([ct[w] for w in i2w], dtype=float)
        idl = sentences_to_ids(sentences, w2i)
        rows = []
        for s in seeds:
            Cs, Us, _ = sgns.train_sgns(idl, cts, seed=s, verbose=False, **kw)
            vec = Cs + Us
            r = evaluate_vectors("", vec, w2i, i2w, verbose=False)
            rows.append((r["analogy"], word_similarity_score(vec, w2i), few_shot_word_classification(vec, w2i)))
        return np.mean(rows, axis=0)

    all_rows = []
    header = f"    {'设置':<22s}{'类比':>8s}{'相似度 Spearman':>16s}{'小样本分类':>12s}"
    print("\n  (a) 向量维度 d（窗口 2，6000 句）\n" + header)
    for d in [2, 5, 10, 25, 50]:
        r = run(sents, dim=d)
        all_rows.append(r)
        print(f"    {'d = ' + str(d):<22s}{r[0]:>8.1%}{r[1]:>16.3f}{r[2]:>12.1%}")
    print("\n  (b) 窗口半径（d = 10，6000 句）\n" + header)
    for w in [1, 2, 4]:
        r = run(sents, window=w)
        all_rows.append(r)
        print(f"    {'window = ' + str(w):<22s}{r[0]:>8.1%}{r[1]:>16.3f}{r[2]:>12.1%}")
    print("\n  (c) 语料规模（d = 10，窗口 2）\n" + header)
    for n in [1000, 3000, 6000, 12000]:
        r = run(generate_corpus(n))
        all_rows.append(r)
        print(f"    {str(n) + ' 句':<22s}{r[0]:>8.1%}{r[1]:>16.3f}{r[2]:>12.1%}")

    # ---------------- 演示 4：内在 vs 外在 ----------------
    print_header("演示 4：内在指标和外在指标一致吗？")
    A = np.array(all_rows)
    print(f"\n  在演示 3 的 {len(A)} 组设置上：")
    print(f"    类比准确率 与 小样本分类准确率 的 Spearman 相关：{spearman(A[:, 0], A[:, 2]):.3f}")
    print(f"    相似度评估 与 小样本分类准确率 的 Spearman 相关：{spearman(A[:, 1], A[:, 2]):.3f}")
    print("""
  说明：
    - 相似度评估和小样本分类高度相关：两者主要都由"大类分得清不清楚"决定。
    - 类比和小样本分类几乎不相关：类比考的是**类别内部**的细微关系（男/女、国家/首都），
      而小样本分类只需要分清大类。d 从 10 增加到 50，类比大幅提高，小样本分类却没有变化。
    - 另外，很多设置的小样本分类都是 100%（"天花板效应"）：任务太容易，就分不出好坏，
      这也让上面的相关系数不太可靠。第 13 讲会看到，NLP 基准也会因为被"刷满"而失去区分能力。
    - 结论：不同的评估测的是不同的性质。选评估方法时要问"我关心的是哪种性质"，
      真实研究最终以下游任务的表现为准。""")


if __name__ == "__main__":
    main()
