"""
=============================================================================
第 2 讲 · 代码 5：一词多义与子词 —— 伪词实验、义项归纳、fastText
=============================================================================

对应讲义：lectures/lecture02_word_vectors_advanced.md 第 8、9 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
word2vec / GloVe 有两个"一个词一个向量"带来的问题：

问题 1：一词多义。bank 既是银行又是河岸，却只有一个向量。这个向量是什么样的？能不能把义项分开？
    我们用经典的**伪词**（pseudo-word）技巧做实验：把语料里所有的 cat 和 train 都替换成同一个
    "词" cattrain。这样就人为制造了一个有两个义项（动物 / 交通工具）的词，而且我们知道每次出现
    真正是哪个义项 —— 可以定量检验各种方法。
        演示 1：cattrain 的向量 ≈ 两个义项向量的加权和（Arora 等人 2018 的"线性叠加"）
        演示 2：把 cattrain 每次出现的上下文聚成两类，能不能自动找回两个义项？（义项归纳，multi-prototype）

问题 2：没见过的词（未登录词，OOV）。词表外的词没有向量；英语的复数、时态，
    德语、芬兰语这类形态丰富的语言里的大量词形，都很难在语料里见够次数。
        演示 3：fastText 把一个词表示成它的字符 n-gram 向量的平均，
               没见过的 "peaches"、拼错的 "bananna" 也能得到合理的向量。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 05_senses_and_subwords.py

需要 numpy。运行时间约 10 秒。
"""

import importlib

import numpy as np

from shared import (NegativeSampler, build_vocab, evaluate_vectors, generate_corpus, nearest_neighbors,
                    noise_distribution, normalize_rows, print_header, sentences_to_ids, sigmoid,
                    word_categories)

sgns = importlib.import_module("02_sgns_training")

PSEUDO = "cattrain"
SENSE_A, SENSE_B = "cat", "train"          # 动物义项、交通工具义项


# =============================================================================
# 第 1 部分：伪词
# =============================================================================
def make_pseudo_word_corpus(sentences, a=SENSE_A, b=SENSE_B, pseudo=PSEUDO):
    """
    把语料中所有的 a 和 b 都替换成 pseudo，并记录每次出现的真实义项。

    【参数】
        sentences : list[list[str]]
        a, b      : 被合并的两个词
        pseudo    : 合并后的新词

    【返回】
        (new_sentences, occurrences)
            new_sentences : list[list[str]]，替换后的语料
            occurrences   : list[(句子编号, 位置, 真实义项 a 或 b)]

    【边界】
        - 伪词实验是研究词义消歧的经典技巧（Gale、Church、Yarowsky 和 Schütze 等人在 1990 年代使用）：
          它能凭空造出"标准答案"，代价是这种人造的多义词比真实多义词更"干净"
          （真实多义词的义项往往是相关的，比如 "paper" 的"纸"和"论文"）。
    """
    new_sents, occ = [], []
    for si, sent in enumerate(sentences):
        new = []
        for pi, w in enumerate(sent):
            if w in (a, b):
                occ.append((si, pi, w))
                new.append(pseudo)
            else:
                new.append(w)
        new_sents.append(new)
    return new_sents, occ


def superposition_fit(vectors, word2id, members_a, members_b, pseudo=PSEUDO):
    """
    用最小二乘把伪词的向量拟合成两个义项"类中心"的线性组合：v_pseudo ≈ α·μ_A + β·μ_B。

    【思路】（讲义第 8.2 节）
        我们没有"cat 义项"的独立向量（cat 已经从语料里消失了），但同类词的平均向量 μ_A
        （dog、horse……的平均）可以作为"动物义项"的代表，μ_B 同理。
        Arora 等人（2018）的理论预测：多义词的向量 ≈ 各义项向量按出现频率加权的和。
        所以 α / (α + β) 应该接近"动物义项出现的比例"。

    【参数】
        vectors   : np.ndarray (V, d)
        word2id   : dict
        members_a : list[str]，代表义项 A 的同类词（不含被合并的词）
        members_b : list[str]，代表义项 B 的同类词

    【返回】
        (alpha, beta, cos_a, cos_b)：两个系数，以及伪词与两个类中心的余弦相似度

    【边界】
        - 类中心只是义项向量的近似；向量也都先归一化，所以 α、β 只能看相对大小，不能看绝对值。

    【Python 小知识】
        np.linalg.lstsq(A, y)：求解最小二乘问题 min ||A x - y||²，返回 (x, 残差, 秩, 奇异值)。
    """
    unit = normalize_rows(vectors)
    mu_a = unit[[word2id[w] for w in members_a]].mean(axis=0)
    mu_b = unit[[word2id[w] for w in members_b]].mean(axis=0)
    v = unit[word2id[pseudo]]
    (alpha, beta), *_ = np.linalg.lstsq(np.stack([mu_a, mu_b], axis=1), v, rcond=None)
    cos = lambda x, y: float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y)))
    return float(alpha), float(beta), cos(v, mu_a), cos(v, mu_b)


# =============================================================================
# 第 2 部分：义项归纳 —— 把上下文聚类
# =============================================================================
def context_vectors(sentences, occurrences, vectors, word2id, window=2, word_weights=None):
    """
    为伪词的每一次出现，计算一个"上下文向量" = 窗口内上下文词向量的（加权）平均。

    【思路】
        不加权时，the、a、is 这些虚词和内容词的权重一样，而虚词对两个义项毫无区分作用，
        会把上下文向量"冲淡"。一个简单的办法是按 1/sqrt(词频) 加权：
        越常见的词权重越小。这和代码 02 的高频词下采样、信息检索里的 IDF 是同一个想法。

    【参数】
        sentences    : 替换后的语料
        occurrences  : make_pseudo_word_corpus 返回的出现位置列表
        vectors      : np.ndarray (V, d)，训练好的词向量
        word2id      : dict
        window       : int
        word_weights : None（等权平均）或 np.ndarray (V,)，每个词的权重

    【返回】
        np.ndarray (N, d)，N = 出现次数

    【边界】
        - 窗口里只有两个义项共用的词（"i saw the cattrain"）时，上下文本身就不含义项信息，
          这些出现无论怎么加权都很难分对。
    """
    unit = normalize_rows(vectors)
    out = []
    for si, pi, _ in occurrences:
        sent = sentences[si]
        ctx = [word2id[sent[j]] for j in range(max(0, pi - window), min(len(sent), pi + window + 1)) if j != pi]
        w = np.ones(len(ctx)) if word_weights is None else word_weights[ctx]
        out.append((unit[ctx] * w[:, None]).sum(axis=0) / w.sum())
    return np.array(out)


def kmeans(X, k=2, n_restarts=10, iters=50, seed=0):
    """
    k-means 聚类（基于余弦：先归一化，再用欧氏距离）。多次随机重启，取总距离最小的结果。

    【思路】
        重复两步直到稳定：(1) 每个点分给最近的中心；(2) 每个中心移到它所有点的平均位置。
        结果依赖初始中心，所以随机重启几次，保留"各点到所属中心的距离平方和"最小的那次。

    【参数】
        X : np.ndarray (N, d)；k : 类别数；n_restarts、iters : 重启次数和每次的迭代次数；seed : 随机种子

    【返回】
        np.ndarray[int] (N,)，每个点的类别 0..k-1

    【边界】
        - 某个类在迭代中变空时，保持它原来的中心不变（避免除以 0）。
    """
    X = normalize_rows(X)
    rng = np.random.default_rng(seed)
    best, best_cost = None, np.inf
    for _ in range(n_restarts):
        centers = X[rng.choice(len(X), size=k, replace=False)]
        for _ in range(iters):
            d2 = ((X[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)    # (N, k)
            labels = d2.argmin(axis=1)
            for j in range(k):
                if np.any(labels == j):
                    centers[j] = X[labels == j].mean(axis=0)
        cost = d2[np.arange(len(X)), labels].sum()
        if cost < best_cost:
            best, best_cost = labels.copy(), cost
    return best


# =============================================================================
# 第 3 部分：fastText —— 用字符 n-gram 表示词
# =============================================================================
def char_ngrams(word, nmin=3, nmax=5):
    """
    把一个词拆成字符 n-gram（fastText 的做法），再加上整个词本身。

    【思路】（讲义第 9.2 节）
        先在词的两端加上边界符号 < 和 >，这样前缀、后缀和词中间的片段就能区分开：
            where → <where> → 3-gram：<wh, whe, her, ere, re>
        注意 "her" 这个 3-gram 和独立的单词 "<her>" 是两个不同的东西。

    【参数】
        word : str；nmin、nmax : n-gram 的最小 / 最大长度（fastText 默认 3 ~ 6，这里用 3 ~ 5）

    【返回】
        list[str]，所有 n-gram（去重、保持顺序），最后一个元素是整个词 "<word>"

    【边界】
        - 很短的词（比如 "a" → "<a>"）可能没有任何长度 >= 3 的 n-gram，只剩整词本身。
    """
    w = f"<{word}>"
    grams = [w[i:i + n] for n in range(nmin, nmax + 1) for i in range(len(w) - n + 1)]
    grams.append(w)
    return list(dict.fromkeys(grams))


class FastTextSGNS:
    """
    fastText 风格的 skip-gram 负采样：中心词向量 = 它的所有字符 n-gram 向量的平均。

    【思路】
        普通 SGNS：每个词一个独立的中心词向量 v_w。
        fastText：每个 n-gram g 有一个向量 z_g，词 w 的中心词向量是 h_w = 平均(z_g, g ∈ ngrams(w))。
        训练时，损失对 h_w 的梯度平均分给它的每个 n-gram。上下文向量 U 仍然是每个词一个。
        于是拼写相近的词（prince / princess、peach / peaches）共享很多 n-gram 参数。

    【边界】
        - 真正的 fastText 用"哈希"把所有 n-gram 映射到固定数量（默认 200 万个）的桶里，
          这样任何 n-gram（包括训练时没见过的）都有向量，代价是偶尔有冲突。
          我们只为训练词表里出现过的 n-gram 建向量，查询新词时跳过没见过的 n-gram。
        - fastText 原文把 n-gram 向量**相加**，官方实现中对它们取平均；这里取平均。
    """

    def __init__(self, id2word, dim=10, nmin=3, nmax=5, seed=0):
        """
        【参数】
            id2word : list[str]，训练词表
            dim     : 向量维度
            nmin, nmax : n-gram 长度范围
            seed    : 随机种子
        """
        self.nmin, self.nmax, self.dim = nmin, nmax, dim
        self.word_grams = [char_ngrams(w, nmin, nmax) for w in id2word]
        self.gram2id = {}
        for grams in self.word_grams:
            for g in grams:
                self.gram2id.setdefault(g, len(self.gram2id))
        # 把每个词的 n-gram 编号排成一个用 -1 填充的矩阵，方便批量计算
        L = max(len(g) for g in self.word_grams)
        self.ngram_ids = np.full((len(id2word), L), -1, dtype=int)
        for i, grams in enumerate(self.word_grams):
            self.ngram_ids[i, :len(grams)] = [self.gram2id[g] for g in grams]
        rng = np.random.default_rng(seed)
        self.Z = (rng.random((len(self.gram2id), dim)) - 0.5) / dim     # n-gram 向量
        self.U = np.zeros((len(id2word), dim))                            # 上下文向量（每个词一个）

    def center_vectors(self, word_ids):
        """批量计算中心词向量 h_w（n-gram 向量的平均）。返回 (h, mask, n)。"""
        ng = self.ngram_ids[word_ids]                     # (B, L)
        mask = ng >= 0
        n = mask.sum(axis=1)
        h = (self.Z[np.where(mask, ng, 0)] * mask[:, :, None]).sum(axis=1) / n[:, None]
        return h, ng, mask, n

    def vector_for(self, word):
        """
        任意字符串（包括词表外的词）的向量：它的已知 n-gram 向量的平均。

        【返回】np.ndarray (d,)；如果一个已知 n-gram 都没有，返回零向量。
        """
        ids = [self.gram2id[g] for g in char_ngrams(word, self.nmin, self.nmax) if g in self.gram2id]
        return self.Z[ids].mean(axis=0) if ids else np.zeros(self.dim)

    def train(self, id_sentences, counts, window=2, K=5, epochs=20, lr=0.025, batch_size=128,
              subsample_t=1e-2, seed=0):
        """
        训练（和 02 号文件的 train_sgns 相同的流程，只是中心词向量换成了 n-gram 平均）。

        【参数】同 02 号文件的 train_sgns。
        【返回】每轮的平均损失列表。
        """
        rng = np.random.default_rng(seed)
        sampler = NegativeSampler(noise_distribution(counts), seed=seed + 1)
        keep = sgns.keep_probabilities(counts, subsample_t) if subsample_t else None
        first = sgns.make_skipgram_pairs(id_sentences, window, rng, True, keep)
        total_steps = epochs * (len(first) // batch_size + 1)
        step, history = 0, []
        for epoch in range(epochs):
            pairs = first if epoch == 0 else sgns.make_skipgram_pairs(id_sentences, window, rng, True, keep)
            pairs = pairs[rng.permutation(len(pairs))]
            loss_sum = 0.0
            for start in range(0, len(pairs), batch_size):
                batch = pairs[start:start + batch_size]
                alpha = lr * max(1e-4, 1.0 - step / total_steps)
                h, ng, mask, n = self.center_vectors(batch[:, 0])
                rows = np.concatenate([batch[:, 1:2], sampler.sample((len(batch), K))], axis=1)
                u = self.U[rows]
                signs = np.ones(rows.shape)
                signs[:, 1:] = -1.0
                probs = sigmoid(signs * np.einsum("bkd,bd->bk", u, h))
                loss_sum += -np.sum(np.log(np.maximum(probs, 1e-12)))
                g = -(1.0 - probs) * signs
                grad_h = np.einsum("bk,bkd->bd", g, u)
                np.add.at(self.U, rows.ravel(), -alpha * (g[:, :, None] * h[:, None, :]).reshape(-1, self.dim))
                per_gram = np.repeat((grad_h / n[:, None])[:, None, :], ng.shape[1], axis=1)
                np.add.at(self.Z, ng[mask], -alpha * per_gram[mask])
                step += 1
            history.append(loss_sum / len(pairs))
        return history


# =============================================================================
# 演示
# =============================================================================
def main():
    cats = word_categories()
    sents = generate_corpus()
    merged, occ = make_pseudo_word_corpus(sents)
    word2id, id2word, cnt = build_vocab(merged)
    counts = np.array([cnt[w] for w in id2word], dtype=float)
    ids = sentences_to_ids(merged, word2id)
    n_a = sum(1 for *_, s in occ if s == SENSE_A)
    n_b = len(occ) - n_a
    content = [w for w in cats if w in word2id] + [PSEUDO]
    animals = [w for w in cats if cats[w] == "animal" and w != SENSE_A]
    vehicles = [w for w in cats if cats[w] == "vehicle" and w != SENSE_B]

    # ---------------- 演示 1：线性叠加 ----------------
    print_header(f"演示 1：伪词 {PSEUDO} = {SENSE_A}（动物）+ {SENSE_B}（交通工具）")
    print(f"\n  {PSEUDO} 一共出现 {len(occ)} 次：其中 {n_a} 次原本是 {SENSE_A}（{n_a / len(occ):.0%}），"
          f"{n_b} 次原本是 {SENSE_B}（{n_b / len(occ):.0%}）")
    fits = []
    for seed in [0, 1, 2]:
        C, U, _ = sgns.train_sgns(ids, counts, seed=seed, verbose=False)
        vec = C + U
        if seed == 0:
            vec0 = vec
            nb = nearest_neighbors(PSEUDO, vec, word2id, id2word, k=8, restrict_to=content)
            print(f"  （种子 0）{PSEUDO} 的最近邻：" + ", ".join(f"{w}({s:.2f})" for w, s in nb))
        fits.append(superposition_fit(vec, word2id, animals, vehicles))
    fits = np.array(fits)
    print(f"\n  拟合 v_{PSEUDO} ≈ α·μ_动物 + β·μ_交通工具（3 个随机种子）：")
    for seed, (a, b, ca, cb) in zip([0, 1, 2], fits):
        print(f"    种子 {seed}：α = {a:.3f}，β = {b:.3f}，α/(α+β) = {a / (a + b):.2f}；"
              f"与动物类中心的余弦 {ca:.2f}，与交通工具类中心的余弦 {cb:.2f}")
    share = fits[:, 0] / fits[:, :2].sum(axis=1)
    print(f"  α/(α+β) 的平均值 {share.mean():.2f}，动物义项实际所占比例 {n_a / len(occ):.2f}")
    print("""
  观察：伪词的向量既不像纯粹的动物，也不像纯粹的交通工具，而是**夹在两者之间**，
    并且偏向出现更多的那个义项。这就是"线性叠加"：一个多义词的向量 ≈ 各义项向量按频率加权的和。""")

    # ---------------- 演示 2：义项归纳 ----------------
    print_header("演示 2：把每次出现的上下文聚成两类，能找回两个义项吗？")
    truth = np.array([s == SENSE_A for *_, s in occ])
    freq = counts / counts.sum()
    print(f"\n  基线：全部猜成出现更多的义项（{SENSE_A}），一致率 {max(truth.mean(), 1 - truth.mean()):.1%}")
    cands = [w for w in cats if w in word2id]
    unit = normalize_rows(vec0)
    for name, weights in [("等权平均", None), ("按 1/sqrt(词频) 加权", 1.0 / np.sqrt(freq))]:
        X = context_vectors(merged, occ, vec0, word2id, word_weights=weights)
        labels = kmeans(X, k=2)
        acc = max(np.mean(labels == truth), np.mean(labels != truth))    # 两种"类别 ↔ 义项"对应方式取较好的
        print(f"\n  上下文向量 = 上下文词向量的{name}：k-means 与真实义项的一致率 {acc:.1%}")
        for j in range(2):
            proto = X[labels == j].mean(axis=0)
            sims = unit[[word2id[w] for w in cands]] @ (proto / np.linalg.norm(proto))
            top = [cands[i] for i in np.argsort(-sims)[:4]]
            print(f"    类 {j}：{np.sum(labels == j):>3d} 次出现，其中 {np.mean(truth[labels == j]):.0%} 原本是 {SENSE_A}；"
                  f"类中心最接近的词：{top}")
    print("""
  观察：
    - 等权平均时，聚类结果和"全猜一个义项"差不多：the、a、is 这些虚词在每次出现的上下文里都有，
      把真正有区分作用的词（fed、sleeping、drove、station）冲淡了。
    - 按 1/sqrt(词频) 加权后，大部分出现被正确地分成了"动物"和"交通工具"两组，每组的中心靠近对应的类别。
    - 分错的大多是两类共用模板的句子（"i saw the cattrain""the cattrain is fast"）——连人也判断不了义项。
    Huang 等人（2012）的"多原型"词向量就是这个思路：先聚类上下文，再给每个类单独学一个向量。""")

    # ---------------- 演示 3：fastText ----------------
    print_header("演示 3：fastText —— 用字符 n-gram 处理没见过的词")
    print(f"\n  char_ngrams('where') = {char_ngrams('where')}")
    sents_orig = sents
    w2i, i2w, ct = build_vocab(sents_orig)
    cts = np.array([ct[w] for w in i2w], dtype=float)
    idl = sentences_to_ids(sents_orig, w2i)
    ft = FastTextSGNS(i2w, dim=10)
    ft.train(idl, cts)
    print(f"  词表 {len(i2w)} 个词，共拆出 {len(ft.gram2id)} 个不同的 n-gram（每个 n-gram 一个向量）")
    H = np.array([ft.vector_for(w) for w in i2w])
    evaluate_vectors("fastText 的中心词向量（n-gram 平均）", H, w2i, i2w)
    cands = list(cats)
    print("\n  词表外的词（训练语料里从没出现过）的最近邻：")
    unit_h = normalize_rows(H[[w2i[w] for w in cands]])
    for oov in ["peaches", "bikes", "taxis", "horses", "princes", "queens", "bananna", "germanic", "spain's"]:
        assert oov not in w2i
        v = ft.vector_for(oov)
        known = [g for g in char_ngrams(oov) if g in ft.gram2id]
        sims = unit_h @ (v / np.linalg.norm(v))
        top = [(cands[i], sims[i]) for i in np.argsort(-sims)[:3]]
        print(f"    {oov:<9s}（{len(known):>2d} 个已知 n-gram）→ " + ", ".join(f"{w}({s:.2f})" for w, s in top))
    print("""
  观察：
    - 普通 word2vec 对这些词只能说"不认识"；fastText 靠共享的字符片段（"peach" 之于 "peaches"）给出了合理的向量：
      大多数词的第一近邻就是它的原形，taxis、拼写错误的 bananna 的正确答案也排在前两名。
    - 代价：拼写相近但意思无关的词也会被拉近（比如 "car" 和 "carpet" 共享 <ca、car）；
      很多词还共享 "es>""<th" 之类的常见片段，所以 fastText 向量整体更相似（上面的异类余弦约 0.5，区分度下降）。
      n-gram 数量远多于词数，参数也更多。fastText 在形态丰富的语言（芬兰语、土耳其语、德语）上收益最大。
    - 今天的大模型用 BPE 等子词分词（CS336 第 1 讲、本课第 16 讲），从根本上避免了"词表外"的问题。""")


if __name__ == "__main__":
    main()
