"""
=============================================================================
第 1 讲 · 代码 2：怎么在计算机里表示"词的意义"？——从人工词典到 one-hot 再到稠密向量
=============================================================================

对应讲义：lectures/lecture01_nlp_history_word_vectors.md 第 4 ~ 7 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
代码 01 的结尾发现：如果每个词只是一个孤立的符号，模型就无法知道 "sad" 和 "miserable" 意思相近。
那么，计算机里到底该怎么表示一个词的"意义"？本文件依次尝试讲义里的几种方案：

    第 1 部分：人工词典（WordNet 风格）
              语言学家手工整理同义词和"上位词"（猫 → 猫科 → 食肉动物 → 哺乳动物 → ……）。
              能算出 "cat 和 dog 比 cat 和 car 更像"，但缺新词、缺细微差别、缺"相关性"、成本高。
    第 2 部分：one-hot 向量（离散符号）
              每个词一个维度。任意两个不同的词都**正交**（点积为 0）→ 没有任何相似度信息。
    第 3 部分：稠密向量 + 余弦相似度（顺便入门 numpy）
              如果每个词是一个"含义坐标"，相似度就能自然地算出来。但坐标轴谁来设计？
    第 4 部分：分布假说 —— 让上下文告诉我们词的意思
              经典的 "tezgüino" 例子：没人告诉你它是什么，只看它出现的句子，就能猜出它是一种酒。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 02_word_meaning_discrete.py

需要 numpy。如果你另外装了 nltk 并下载了 WordNet 数据，第 1 部分会额外展示真正的 WordNet：
    pip install nltk
    python -c "import nltk; nltk.download('wordnet')"
"""

from collections import Counter

# 【Python 小知识】
#   numpy 是 Python 里做数值计算的标准库。它的核心是 ndarray（多维数组）：
#     np.array([1, 2, 3])           一维数组（向量），形状 (3,)
#     np.array([[1, 2], [3, 4]])    二维数组（矩阵），形状 (2, 2)
#   numpy 的运算是"整体进行"的：a + b、a * b 都是逐元素运算，a @ b 是矩阵乘法 / 点积。
#   这比用 Python 的 for 循环逐个算快几十到几百倍。习惯上写 import numpy as np。
import numpy as np


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


# =============================================================================
# 第 1 部分：人工词典（WordNet 风格）
# =============================================================================
# WordNet（普林斯顿大学，1985 年起）是最著名的人工英语词汇库。它把词组织成"同义词集"（synset），
# 再用"上位词"（hypernym，"是一种"关系）把同义词集连成一棵大树。
# 下面是**仿照 WordNet 结构手工写的迷你版**，只包含本演示用到的几个概念，并做了简化。

# 上位词关系：概念 → 它的上一级概念。顺着往上走，最终都到达根节点 "entity"（实体）。
HYPERNYMS = {
    # 动物
    "cat": "feline", "feline": "carnivore",
    "dog": "canine", "canine": "carnivore",
    "panda": "procyonid", "procyonid": "carnivore",
    "carnivore": "placental", "horse": "equine", "equine": "ungulate", "ungulate": "placental",
    "placental": "mammal", "mammal": "vertebrate", "vertebrate": "animal",
    "animal": "organism", "organism": "living_thing", "living_thing": "physical_entity",
    # 交通工具
    "car": "motor_vehicle", "truck": "motor_vehicle", "motor_vehicle": "wheeled_vehicle",
    "bike": "wheeled_vehicle", "wheeled_vehicle": "vehicle", "vehicle": "artifact",
    "artifact": "object",
    # 食物
    "apple": "edible_fruit", "banana": "edible_fruit", "edible_fruit": "produce",
    "produce": "food", "food": "matter", "matter": "physical_entity",
    "object": "physical_entity", "physical_entity": "entity",
}

# 同义词集：一个词往往有多个义项（sense），每个义项是一组"可互换"的词 + 一句释义。
# 下面仿照 WordNet 中 "good" 的部分义项（真实 WordNet 里 good 有二十多个义项）。
SYNSETS = {
    "good": [
        ("noun", ["good", "goodness"], "道德上的善"),
        ("noun", ["commodity", "trade_good", "good"], "商品、货物"),
        ("adj", ["good"], "好的、令人满意的"),
        ("adj", ["full", "good"], "足足的（a good mile 足有一英里）"),
        ("adj", ["adept", "expert", "good", "practiced", "proficient", "skillful"], "擅长的（good at）"),
        ("adj", ["estimable", "good", "honorable", "respectable"], "可敬的"),
        ("adj", ["beneficial", "good"], "有益的（good for you）"),
    ],
}


def hypernym_path(word):
    """
    从一个词出发，沿"上位词"关系一直走到根节点，返回经过的所有概念。

    【参数】
        word : str，必须在 HYPERNYMS 中出现过（作为键）。

    【返回】
        list[str]，例如 hypernym_path("cat") =
            ["cat", "feline", "carnivore", "placental", "mammal", ..., "entity"]

    【边界】
        - word 不在词典里 → 抛出 KeyError。这正是人工词典的痛点：**没收录的词，就什么都算不了**。
        - 真实 WordNet 中一个概念可以有多个上位词（形成"有向无环图"而不是树），这里简化成树。

    【Python 小知识】
        while 条件: 循环直到条件为假。这里一直往上走，直到当前概念没有上位词（到达根）。
    """
    if word not in HYPERNYMS:
        raise KeyError(f"'{word}' 不在我们的迷你词典里 —— 人工词典无法处理没收录的词")
    path = [word]
    while path[-1] in HYPERNYMS:
        path.append(HYPERNYMS[path[-1]])
    return path


def path_similarity(w1, w2):
    """
    WordNet 风格的"路径相似度"：1 / (1 + 两个概念在树上的最短距离)。

    【思路】
        两个词在上位词树上离得越近，越相似。
        最短距离 = w1 走到"最近公共祖先"的步数 + w2 走到它的步数。
        例：cat → feline → carnivore ← canine ← dog，距离 4，相似度 1/5 = 0.2。
        自己和自己距离 0，相似度 1。（这和 NLTK 中 WordNet 的 path_similarity 定义一致。）

    【参数】
        w1, w2 : str，都必须在词典里。

    【返回】
        float，取值 (0, 1]。

    【边界】
        - 任一词不在词典中 → KeyError（来自 hypernym_path）。
        - 只能衡量"是一种"意义上的相似（similarity），衡量不了"相关"（relatedness）：
          car 和 road 很相关，但它们在上位词树上离得很远。

    【Python 小知识】
        path1.index(x)：返回 x 在列表 path1 中第一次出现的位置。
        next(生成器)：取出第一个满足条件的元素。
    """
    path1, path2 = hypernym_path(w1), hypernym_path(w2)
    common = next(c for c in path1 if c in path2)            # 最近公共祖先
    distance = path1.index(common) + path2.index(common)
    return 1.0 / (1.0 + distance)


def demo_wordnet():
    """演示 1：人工词典能做什么，不能做什么。"""
    print_header("演示 1：人工词典（WordNet 风格）")

    print("\n  (a) 'good' 的同义词集（一个词有很多义项）：")
    for pos, synonyms, gloss in SYNSETS["good"]:
        print(f"      {pos:<5s}{', '.join(synonyms):<52s}{gloss}")

    print("\n  (b) 上位词链（'是一种'关系）：")
    print("      cat   : " + " → ".join(hypernym_path("cat")))
    print("      panda : " + " → ".join(hypernym_path("panda")))

    print("\n  (c) 路径相似度：")
    words = ["cat", "dog", "panda", "horse", "car", "bike", "apple"]
    print(" " * 12 + "".join(f"{w:>7s}" for w in words))
    for w1 in words:
        print(f"      {w1:<6s}" + "".join(f"{path_similarity(w1, w2):>7.3f}" for w2 in words))

    print("\n  (d) 但是 ……")
    for new_word in ["chatgpt", "selfie"]:
        try:
            path_similarity(new_word, "cat")
        except KeyError as e:
            print(f"      - {e.args[0]}")
    print("""      - 同义词集说 proficient 是 good 的同义词，但只在 "good at" 这个义项下成立：
        "a good person" 不能换成 "a proficient person"。词典很难记录这种细微差别。
      - car 和 road、coffee 和 cup 非常"相关"，但在上位词树上相距很远（甚至不在一棵子树里）。
      - 所有内容都要语言学家手工维护：昂贵、主观、永远跟不上新词和新用法。""")

    # 可选：真正的 WordNet
    try:
        from nltk.corpus import wordnet as wn
        synsets = wn.synsets("good")
        print(f"\n  (e) 检测到 nltk + WordNet 数据：真实 WordNet 里 'good' 有 {len(synsets)} 个义项，前 5 个：")
        for s in synsets[:5]:
            print(f"      {s.name():<22s}{', '.join(l.name() for l in s.lemmas())}")
        panda = wn.synset("panda.n.01")
        chain = [panda]
        while chain[-1].hypernyms():
            chain.append(chain[-1].hypernyms()[0])
        print("      panda 的上位词链：" + " → ".join(s.name().split(".")[0] for s in chain))
    except (ImportError, LookupError):
        # ImportError：没装 nltk；LookupError：装了 nltk 但没下载 WordNet 数据
        print("\n  (e) 没有检测到 nltk + WordNet 数据，跳过真实 WordNet 演示（不影响其他部分）。")


# =============================================================================
# 第 2 部分：one-hot 向量 —— 把词当作离散符号
# =============================================================================
def one_hot(word, word2id):
    """
    返回一个词的 one-hot 向量：长度 = 词表大小，只有这个词对应的位置是 1，其余全是 0。

    【参数】
        word    : str，必须在 word2id 中
        word2id : dict[str, int]，词 → 编号

    【返回】
        np.ndarray，形状 (V,)，V = len(word2id)，dtype 为 float。

    【边界】
        - 不在词表中的词 → KeyError。one-hot 无法表示词表外的词（out-of-vocabulary, OOV）。

    【Python 小知识】
        np.zeros(V)：创建长度为 V、全是 0 的数组。
        vec[i] = 1.0：把第 i 个位置改成 1（numpy 数组和列表一样用方括号下标访问）。

    【NLP 联系】
        one-hot 向量乘以一个 V × d 的矩阵 E，结果正好是 E 的第 i 行：one_hot(w) @ E == E[i]。
        所以神经网络里的"嵌入层"（embedding layer）本质上就是"one-hot × 矩阵"，
        只是实现时直接按下标取行，省掉了和一大堆 0 相乘。
    """
    vec = np.zeros(len(word2id))
    vec[word2id[word]] = 1.0
    return vec


def demo_one_hot():
    """演示 2：one-hot 向量的致命问题 —— 所有词两两正交。"""
    print_header("演示 2：one-hot 向量")
    vocab = ["hotel", "motel", "inn", "cat", "seattle"]
    word2id = {w: i for i, w in enumerate(vocab)}
    print("\n  词表：", vocab)
    for w in vocab:
        print(f"    {w:<8s}→ {one_hot(w, word2id)}")

    print("\n  两两点积（one_hot(a) · one_hot(b)）：")
    print(" " * 12 + "".join(f"{w:>8s}" for w in vocab))
    for a in vocab:
        row = "".join(f"{one_hot(a, word2id) @ one_hot(b, word2id):>8.0f}" for b in vocab)
        print(f"    {a:<8s}{row}")
    print("""
  观察：除了对角线（自己和自己），所有点积都是 0。
    在 one-hot 表示下，"hotel 和 motel" 与 "hotel 和 cat" 一样"毫不相关"。
    搜索引擎的例子：用户搜 "seattle motel"，网页写的是 "seattle hotel"，
    按 one-hot 逐词比较，两者只有 seattle 重合，motel 和 hotel 的相似性完全丢失。""")

    # 维度问题
    for V in [5, 50_000, 500_000]:
        mb = V * 4 / 1e6              # float32 每个数 4 字节
        print(f"    词表大小 V = {V:>7,d} → 每个 one-hot 向量 {V:>7,d} 维（按 float32 存约 {mb:,.3f} MB）")
    print("  （【Python 小知识】f-string 里的 {V:,d} 会加千分位逗号，{x:.3f} 保留 3 位小数。）")


# =============================================================================
# 第 3 部分：稠密向量与余弦相似度（numpy 入门）
# =============================================================================
# 设想我们**手工**给每个词设计几个"含义维度"并打分（0~1）：
#            是动物  是交通工具  体型大  家养  速度快
HAND_FEATURES = ["animal", "vehicle", "big", "domestic", "fast"]
HAND_VECTORS = {
    "cat":   [1.0, 0.0, 0.1, 0.9, 0.5],
    "dog":   [1.0, 0.0, 0.3, 1.0, 0.6],
    "horse": [1.0, 0.1, 0.8, 0.7, 0.9],
    "car":   [0.0, 1.0, 0.6, 0.0, 0.9],
    "bike":  [0.0, 1.0, 0.2, 0.0, 0.4],
}


def cosine_similarity(a, b):
    """
    余弦相似度：cos θ = (a · b) / (|a| × |b|)。

    【思路】（讲义第 6.3 节）
        点积 a · b = Σ a_i b_i 衡量"两个向量在多大程度上指向同一方向"，
        但它也会随向量长度变大而变大。除以两个长度，就只剩下"方向"的信息 —— 即夹角的余弦。

    【参数】
        a, b : np.ndarray，一维，长度相同。

    【返回】
        float，范围 [-1, 1]。

    【边界】
        - 零向量没有方向，数学上无定义；这里返回 0.0。

    【Python 小知识】
        a @ b                ：两个一维数组的点积（等价于 np.dot(a, b)）。
        np.linalg.norm(a)    ：向量的长度（L2 范数）= sqrt(Σ a_i²)。
    """
    na, nb = np.linalg.norm(a), np.linalg.norm(b)
    if na == 0 or nb == 0:
        return 0.0
    return float(a @ b / (na * nb))


def demo_dense_vectors():
    """演示 3：稠密向量让相似度"自然出现"，但手工设计维度不可行。"""
    print_header("演示 3：稠密向量 + 余弦相似度")
    words = list(HAND_VECTORS)
    M = np.array([HAND_VECTORS[w] for w in words])     # 形状 (5, 5)：5 个词 × 5 个维度
    print(f"\n  手工设计的维度：{HAND_FEATURES}")
    print(f"  词向量矩阵 M 的形状：{M.shape}（{M.shape[0]} 个词，每个 {M.shape[1]} 维）")

    print("\n  两两余弦相似度：")
    print(" " * 10 + "".join(f"{w:>7s}" for w in words))
    for i, a in enumerate(words):
        print(f"    {a:<6s}" + "".join(f"{cosine_similarity(M[i], M[j]):>7.2f}" for j in range(len(words))))

    # 点积 vs 余弦：长度的影响
    cat, dog = M[0], M[1]
    print("\n  点积 vs 余弦：把 dog 的向量整体放大 10 倍（方向不变，只变长）")
    print(f"    cat·dog     = {cat @ dog:6.2f}   →  cat·(10·dog)     = {cat @ (10 * dog):6.2f}   （点积跟着变大 10 倍）")
    print(f"    cos(cat,dog)= {cosine_similarity(cat, dog):6.2f}   →  cos(cat, 10·dog) = {cosine_similarity(cat, 10 * dog):6.2f}   （余弦不变）")
    print("""
  观察：
    - 有了"含义坐标"，cat 和 dog 的余弦相似度很高，cat 和 car 很低 —— 相似度自然就有了。
    - 余弦只看方向不看长度。后面我们总是用余弦来比较词向量。
    - 问题：这些维度和分数是**我手工编的**。英语有几十万个词，意义的维度成百上千，
      而且很多维度人类根本说不清（"正式程度""带不带贬义""常用于体育新闻"……）。
      → 需要一种**自动**从数据中学出这些向量的方法。""")


# =============================================================================
# 第 4 部分：分布假说 —— "看一个词的朋友，就知道它是谁"
# =============================================================================
# 经典例子（来自语言学家 Eugene Nida，Dekang Lin 等人的论文中也使用过）：
# 你从没见过 "tezguino" 这个词（原文是 tezgüino，这里去掉变音符号），只看到它出现在这些句子里……
TEZGUINO_CORPUS = [
    "a bottle of tezguino is on the table",
    "everybody likes tezguino",
    "tezguino makes you drunk",
    "we make tezguino out of corn",
    # 下面是其他词的句子，用来比较
    "a bottle of wine is on the table",
    "everybody likes wine",
    "wine makes you drunk",
    "we make wine out of grapes",
    "a bottle of beer is on the table",
    "beer makes you drunk",
    "we make beer out of barley",
    "a loaf of bread is on the table",
    "everybody likes bread",
    "we make bread out of wheat",
    "i eat bread with butter",
    "a glass of water is on the table",
    "water makes you healthy",
    "the car is on the road",
    "the car makes noise",
    "we drive the car to work",
]


def context_counts(target, sentences, window=2):
    """
    统计 target 在语料中的"上下文词"：它左右 window 个位置以内出现过哪些词、各几次。

    【参数】
        target    : str，目标词
        sentences : list[str]，每个元素是一个用空格分隔的句子
        window    : int，窗口半径（左右各看几个词）

    【返回】
        Counter，上下文词 → 次数。target 从没出现过时返回空 Counter。

    【边界】
        - 窗口不跨句子；句首句尾的词上下文会少一些。
    """
    ctx = Counter()
    for sent in sentences:
        words = sent.split()
        for t, w in enumerate(words):
            if w == target:
                ctx.update(words[max(0, t - window):t] + words[t + 1:t + 1 + window])
    return ctx


def demo_distributional_hypothesis():
    """演示 4：只靠上下文，猜出没见过的词是什么意思。"""
    print_header("演示 4：分布假说 —— tezguino 是什么？")
    print("\n  语料中含 tezguino 的句子：")
    for s in TEZGUINO_CORPUS[:4]:
        print(f"    {s}")

    targets = ["tezguino", "wine", "beer", "bread", "water", "car"]
    # 所有目标词的上下文词合在一起，作为向量的"维度"
    # 窗口取 3：这样 "tezguino makes you drunk" 里的 drunk 也算作上下文
    ctxs = {t: context_counts(t, TEZGUINO_CORPUS, window=3) for t in targets}
    dims = sorted(set(w for c in ctxs.values() for w in c))
    vecs = {t: np.array([ctxs[t][d] for d in dims], dtype=float) for t in targets}

    print(f"\n  用'上下文词的出现次数'做向量（窗口 3，共 {len(dims)} 个维度）：")
    print(f"    tezguino 的上下文：{dict(ctxs['tezguino'])}")
    print("\n  tezguino 与其他词的余弦相似度：")
    for t in targets[1:]:
        shared = sorted(set(ctxs["tezguino"]) & set(ctxs[t]))
        print(f"    {t:<6s}{cosine_similarity(vecs['tezguino'], vecs[t]):>6.2f}    共享的上下文词：{shared}")
    print("""
  观察：tezguino 和 wine、beer 共享 "bottle of""makes you drunk""we make ... out of" 这样的上下文，
    所以和它们最像 —— 不用任何人告诉你，你也能猜出 tezguino 是一种（用玉米酿的）酒。
    这就是**分布假说**：一个词的意义由它经常出现的上下文决定。
    注意 bread 的相似度也不低：它和 tezguino 共享了 of、is、on 这些**高频虚词**。
    直接用原始次数时，高频虚词的影响过大 —— 代码 03 会用 PPMI 加权来解决这个问题。
    代码 03 把这个想法推广到整个词表（共现矩阵 + SVD），
    代码 04、05 用"预测上下文"的方式来学习（word2vec）。""")


def self_check():
    """自我检查：讲义中引用的结论必须成立。"""
    assert abs(path_similarity("cat", "dog") - 0.2) < 1e-12       # cat→feline→carnivore←canine←dog
    assert path_similarity("cat", "dog") > path_similarity("cat", "car")
    vocab = {"hotel": 0, "motel": 1}
    assert one_hot("hotel", vocab) @ one_hot("motel", vocab) == 0
    ctx = {t: context_counts(t, TEZGUINO_CORPUS, window=3) for t in ["tezguino", "wine", "bread", "car"]}
    dims = sorted(set(w for c in ctx.values() for w in c))
    v = {t: np.array([ctx[t][d] for d in dims], float) for t in ctx}
    assert cosine_similarity(v["tezguino"], v["wine"]) > cosine_similarity(v["tezguino"], v["bread"])
    assert cosine_similarity(v["tezguino"], v["bread"]) > cosine_similarity(v["tezguino"], v["car"])


if __name__ == "__main__":
    self_check()
    demo_wordnet()
    demo_one_hot()
    demo_dense_vectors()
    demo_distributional_hypothesis()
