"""
=============================================================================
第 1 讲 · 代码 02：词向量 —— "看一个词和谁在一起"
=============================================================================

对应讲义：lectures/lecture01_history_of_lms.md §3（词的分布式表示）

【这个脚本要回答的问题】
    1. 为什么要把词变成稠密向量？one-hot 有什么问题？共现计数、PPMI 在做什么？（演示 1）
    2. 两种词向量（PPMI-SVD 和 word2vec 的 SGNS）学到的"相似"是什么意思？（演示 2）
    3. 著名的类比 king − man + woman ≈ queen 在这里成立吗？什么情况下注定做不到？（演示 3）
    4. 一个词只有一个向量，那多义词（bank：银行 / 河岸）怎么办？（演示 4）
    5. 反义词（cold / warm）在向量空间里离得远吗？（演示 5）

【怎么读输出】
    余弦相似度（cosine similarity）在 −1 到 1 之间，1 表示方向完全相同。
    这份玩具语料只有 290 个词，所以相似度普遍偏高；看**相对大小**比看绝对值更有意义。

【运行】python3 02_word_vectors.py     （约 15 秒）
【依赖】numpy；import 同目录的 pretrain_world.py、embeddings.py、console.py。
"""

import numpy as np

from console import pct, print_header, print_observations, print_table
from embeddings import (analogy, cooccurrence, cosine, nearest, normalize, ppmi, svd_vectors,
                        train_sgns)
from pretrain_world import (FAMILY, GENDER_PAIRS, NEGATIVE, POSITIVE, ROYAL, YOUNG, WordTokenizer,
                            build_corpus, build_world, corpus_text)


def fmt_nn(pairs):
    """把 [(词, 相似度), ...] 写成 "king .99, lord .98" 这样紧凑的字符串。"""
    return ", ".join(f"{w} {s:.2f}" for w, s in pairs)


# ---------------------------------------------------------------------------
def demo1_counts(tok, ids, skip):
    """演示 1：one-hot、原始共现计数、PPMI。"""
    print_header("演示 1：从 one-hot 到共现计数再到 PPMI")
    V = len(tok)
    print(f"one-hot：每个词是一个 {V} 维的向量，只有自己那一维是 1。任何两个不同的词，点积都是 0——"
          f"queen 和 king 的相似度，与 queen 和 coffee 的相似度一样，都是 0。")
    C = cooccurrence(ids, V, window=2, skip=skip)
    P = ppmi(C)
    w = tok.stoi["queen"]
    top_raw = sorted(range(V), key=lambda j: (-C[w, j], j))[:8]
    top_pmi = sorted(range(V), key=lambda j: (-P[w, j], j))[:8]
    print()
    print_table(["排名", "queen 的 ±2 窗口里最常见的词（次数）", "PPMI 最高的词（PPMI）"],
                [[i + 1, f"{tok.itos[a]}（{int(C[w, a])}）", f"{tok.itos[b]}（{P[w, b]:.2f}）"]
                 for i, (a, b) in enumerate(zip(top_raw, top_pmi))])
    print_observations([
        "one-hot 把每个词当成互不相关的符号，'相似'无从谈起；n-gram 本质上也是这样看词的。",
        "原始计数的前几名是 'the'、'.' 这类几乎和谁都一起出现的词——它们什么都说明不了。",
        f"PPMI 问的是'比随机搭配多出多少'：queen 的高分邻居变成了 {', '.join(tok.itos[j] for j in top_pmi[:3])} "
        "这些描述'它做什么'的动词（王室人物才 rules、sits on the throne）。",
        "窗口只有 ±2：crown、palace 离 queen 3 个词以上，she 更远（4–6 个词），都不在 queen 的共现统计里。"
        "窗口大小决定了向量能'看到'什么——演示 3、5 都会回到这一点。",
    ])
    return C


def demo2_neighbors(world, tok, X_svd, X_sg):
    """演示 2：两种词向量的最近邻。"""
    print_header("演示 2：最近邻——两种方法学到的'相似'")
    c0 = world.countries[0]
    words = ["queen", "copper", c0.capital, c0.name, "cold"]
    rows = []
    for w in words:
        label = w + (f"（{c0.name} 的首都）" if w == c0.capital else "（国家）" if w == c0.name else "")
        rows.append([label, "PPMI-SVD", fmt_nn(nearest(X_svd, tok, w, 4))])
        rows.append(["", "SGNS", fmt_nn(nearest(X_sg, tok, w, 4))])
    print_table(["词", "方法", "最近的 4 个词（余弦相似度）"], rows, aligns=["<", "<", "<"])
    caps = {c.capital for c in world.countries} - {c0.capital}
    stat = {}
    for name, X in [("PPMI-SVD", X_svd), ("SGNS", X_sg)]:
        nn10 = [w for w, _ in nearest(X, tok, c0.capital, 10)]
        stat[name] = (sum(w in caps for w in nn10), nn10.index(c0.name) + 1 if c0.name in nn10 else None)
    for name, (k, r) in stat.items():
        print(f"  {name}：{c0.capital} 的 10 个最近邻里有 {k} 个是别的首都；它自己的国家 {c0.name} "
              + (f"排第 {r}" if r else "不在前 10"))
    cold_nn = [w for w, _ in nearest(X_sg, tok, "cold", 4)]
    print_observations([
        "queen 在两种方法里的邻居都是 king、lady、lord——不是'意思相同'，而是'可以出现在同样的位置上'。"
        "分布式相似首先是**可替换性**。（Levy & Goldberg 2014 证明 SGNS 在隐式地分解一个平移过的 PMI 矩阵，两种方法本是一家。）",
        f"{c0.capital} 的前 10 个邻居里，别的首都占了 {stat['PPMI-SVD'][0]} 个（PPMI-SVD）和 {stat['SGNS'][0]} 个（SGNS）"
        "——这是'同一类别'的相似（可替换）；两种方法的第 1 名都是它自己国家的语言，PPMI-SVD 还把国家本身排在"
        + (f"第 {stat['PPMI-SVD'][1]}" if stat['PPMI-SVD'][1] else "前 10 之外") +
        "——这是'同一话题'的相似（relatedness）。做检索时这两种相似都会出现（第 7 讲）：和查询最像的，常常是同一类的**别的**东西。",
        f"PPMI-SVD 里 cold 的邻居褒贬都有；SGNS 里却是 {', '.join(cold_nn)}，全是贬义词。演示 5 解释为什么。",
    ])


# ---------------------------------------------------------------------------
def signature(word):
    """
    人物词在这个世界里的"分布签名"：(王室?, 年轻?, 家庭?, 性别)。

    【为什么需要它】pretrain_world.py 生成故事时，一个人物词出现在什么语境里**只由这四个特征决定**。
        所以签名相同的两个词（例如 king 和 lord）在语料里的分布完全一样——
        任何只看共现的方法都不可能把它们分开。我们把它们叫作"分布孪生词"。
    """
    female = {f for _, f in GENDER_PAIRS}
    return (word in ROYAL, word in YOUNG, word in FAMILY, word in female)


def demo3_analogies(world, tok, X_by_name):
    """演示 3：类比。"""
    print_header("演示 3：类比 a : a* :: b : ?   （答案 = 离 a* − a + b 最近的词）")
    persons = [w for pair in GENDER_PAIRS for w in pair]
    rows, err_stats = [], {}
    for name, X in X_by_name.items():
        exact = twin = n = 0
        for a, a_star in GENDER_PAIRS:
            for b, b_star in GENDER_PAIRS:
                if a == b:
                    continue
                pred = analogy(X, tok, a, a_star, b, persons)
                n += 1
                exact += pred == b_star
                twin += signature(pred) == signature(b_star)
        rows.append([name, f"{exact}/{n} = {pct(exact, n)}", f"{twin}/{n} = {pct(twin, n)}"])
        err_stats[name] = (n - exact, twin - exact)
    print("(a) 性别类比：10 对 (男, 女) 人物词两两组合，共 90 题（候选只限人物词）")
    print_table(["词向量", "完全答对", "答到了'分布孪生词'也算对"], rows)
    print(f"    分布孪生词的例子：cos(king, lord) = {cosine(X_by_name['SGNS'], tok, 'king', 'lord'):.3f}，"
          f"cos(queen, lady) = {cosine(X_by_name['SGNS'], tok, 'queen', 'lady'):.3f}（SGNS）")
    print("    几个例子（SGNS）：", "；".join(
        f"{a}:{a2} :: {b}:{analogy(X_by_name['SGNS'], tok, a, a2, b, persons)}"
        for a, a2, b in [("king", "queen", "man"), ("man", "woman", "king"), ("man", "woman", "father")]))

    # 国家 → 首都 的类比：只在国家名和首都名里找答案
    pairs = [(c.name, c.capital) for c in world.countries]
    cands = [c.capital for c in world.countries] + [c.name for c in world.countries]
    print("\n(b) 国家 → 首都：X : 首都(X) :: Y : ?   （24 个国家两两组合，552 题；候选是全部国名和首都名）")
    rows = []
    for name, X in X_by_name.items():
        ok = sum(analogy(X, tok, a, a2, b, cands) == b2 for a, a2 in pairs for b, b2 in pairs if a != b)
        rows.append([name, f"{ok}/552 = {pct(ok, 552)}"])
    print_table(["词向量", "答对"], rows)
    print_observations([
        f"性别类比的'完全答对'只有六成左右，但大部分错误是答成了分布孪生词（SGNS：{err_stats['SGNS'][0]} 个错误里有 "
        f"{err_stats['SGNS'][1]} 个）：man:woman :: king:? 答成 lady 而不是 queen。"
        "在这个世界里 king 和 lord 出现的语境一模一样，没有任何共现统计能把 queen 和 lady 分开。"
        "（如果孪生词之间随机选一个，'完全答对'的期望值正好是 54/90——和表里的数字几乎一样。）",
        "这是分布式假设最纯粹的形式：**词的意思 = 它的上下文分布**。分布相同，向量就相同；"
        "真实语言里很少有完全的孪生词，但'几乎孪生'很常见（演示 5 的反义词就是）。",
        "国家 → 首都的类比在 50 维时几乎全对，10 维时大多答错：一个国家和它的首都共享'同一篇文章里的词'"
        "（语言、领导人……），向量里要有足够多的维度，才能给 24 个国家各留一个'话题方向'。",
    ])


# ---------------------------------------------------------------------------
def demo4_polysemy(tok, X_by_name):
    """演示 4：多义词 bank。"""
    print_header("演示 4：一个词一个向量——多义词 bank（银行 / 河岸）")
    rows = []
    for name, X in X_by_name.items():
        Xn = normalize(X)
        v = {w: Xn[tok.stoi[w]] for w in ["bank", "vault", "shore"]}
        coef, *_ = np.linalg.lstsq(np.stack([v["vault"], v["shore"]], axis=1), v["bank"], rcond=None)
        rows.append([name, f"{cosine(X, tok, 'bank', 'vault'):.2f}", f"{cosine(X, tok, 'bank', 'shore'):.2f}",
                     f"{cosine(X, tok, 'vault', 'shore'):.2f}", f"{coef[0]:.2f} · vault + {coef[1]:.2f} · shore"])
    print("bank 一半出现在金融语境（money、loan、cash），一半出现在河岸语境（river、fish、boats）；")
    print("vault 只出现在金融语境，shore 只出现在河岸语境。")
    print_table(["词向量", "cos(bank, vault)", "cos(bank, shore)", "cos(vault, shore)", "bank ≈ 最小二乘拟合"], rows)
    print_observations([
        "bank 和 vault、shore 都很像，而 vault 和 shore 彼此不太像：bank 的向量落在两种意思**中间**。",
        "用 vault 和 shore 去拟合 bank，两个系数差不多大：一个静态词向量就是它各种意思的加权平均"
        "（Arora et al. 2018 证明了词向量近似是各个词义向量的线性叠加）。",
        "这正是后来 ELMo、BERT 这类**上下文相关**的表示要解决的问题：'river bank' 里的 bank 和 'bank loan' 里的 bank 应该是两个不同的向量（§7）。",
    ])


# ---------------------------------------------------------------------------
def polarity_stats(X, tok):
    """褒义词之间、贬义词之间的平均相似度 vs 褒贬之间的平均相似度；以及最近的形容词同极性的比例。"""
    Xn = normalize(X)
    vec = lambda w: Xn[tok.stoi[w]]
    same = [vec(a) @ vec(b) for L in (POSITIVE, NEGATIVE) for a in L for b in L if a < b]
    opp = [vec(a) @ vec(b) for a in POSITIVE for b in NEGATIVE]
    adj = POSITIVE + NEGATIVE
    nn_same = 0
    for a in adj:
        nn = max((b for b in adj if b != a), key=lambda b: (vec(a) @ vec(b), b))
        nn_same += (a in POSITIVE) == (nn in POSITIVE)
    return float(np.mean(same)), float(np.mean(opp)), nn_same


def demo5_antonyms(tok, ids, skip, X_by_name):
    """演示 5：反义词。"""
    print_header("演示 5：反义词——cold 和 warm 离得远吗？")
    X_nosub, _ = train_sgns(ids, len(tok), window=2, epochs=3, lr=0.005, subsample=1.0, skip=skip)
    variants = dict(X_by_name)
    variants["SGNS（关闭下采样）"] = X_nosub
    rows = []
    stats = {}
    for name, X in variants.items():
        s, o, nn = polarity_stats(X, tok)
        stats[name] = nn
        rows.append([name, f"{s:.3f}", f"{o:.3f}", f"{nn}/20"])
    print_table(["词向量", "同极性形容词的平均相似度", "反义（褒 vs 贬）的平均相似度", "最近的形容词与自己同极性"], rows)
    print_observations([
        "PPMI-SVD 和关闭下采样的 SGNS 里，褒义词和贬义词的相似度几乎一样高，'最近的形容词'是褒是贬接近随机（10/20 左右）："
        "cold 和 warm 出现在完全相同的位置（'the soup was ___'），所以分布式方法分不开反义词。",
        f"打开下采样的 SGNS 却把褒贬分开了（{stats['SGNS']}/20）。原因不是它'更懂语义'，而是下采样先删掉了 '.'、':' 这些高频词，"
        "窗口被'撑大'，'... cold . rating : negative' 里的 negative 进了 cold 的窗口——极性信息来自**评分标签**。",
        "教训：词向量里有什么，取决于**上下文窗口里有什么**。想要区分褒贬，上下文里必须有能区分褒贬的东西。",
    ])


def main():
    world = build_world()
    text = corpus_text(build_corpus(world))
    tok = WordTokenizer(text)
    ids = tok.encode(text)
    skip = (tok.stoi["<doc>"],)
    demo1_counts(tok, ids, skip)
    X_svd = svd_vectors(ppmi(cooccurrence(ids, len(tok), 2, skip)), 50)
    X_svd10 = svd_vectors(ppmi(cooccurrence(ids, len(tok), 2, skip)), 10)
    print("\n  训练 SGNS（32 维，窗口 ±2，5 个负样本，3 遍）……")
    X_sg, losses = train_sgns(ids, len(tok), dim=32, window=2, epochs=3, skip=skip)
    print(f"  每遍平均损失：{', '.join(f'{x:.3f}' for x in losses)}")
    demo2_neighbors(world, tok, X_svd, X_sg)
    demo3_analogies(world, tok, {"PPMI-SVD（10 维）": X_svd10, "PPMI-SVD（50 维）": X_svd, "SGNS": X_sg})
    demo4_polysemy(tok, {"PPMI-SVD（50 维）": X_svd, "SGNS": X_sg})
    demo5_antonyms(tok, ids, skip, {"PPMI-SVD（50 维）": X_svd, "SGNS": X_sg})


if __name__ == "__main__":
    main()
