"""
=============================================================================
第 4 讲 · 代码 4：神经依存分析器（Chen & Manning 2014）
=============================================================================

对应讲义：lectures/lecture04_dependency_parsing.md 第 8 ~ 10 节
对应作业：CS224N 作业 2 的编程部分（ParserModel）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
代码 03 的传统分析器有三个问题：特征稀疏、不完整、计算慢。Chen & Manning（2014）的想法是：
    把"词""词性""依存标签"都表示成**稠密向量**（嵌入），
    把状态中 48 个位置的嵌入**拼接**起来，送进一个只有一个隐藏层的神经网络，输出每个动作的得分。
这和第 3 讲的窗口分类器几乎一模一样！区别只是"窗口"换成了"栈和缓冲区里的若干个位置"。

    输入：18 个词 + 18 个词性 + 12 个依存标签 的编号
      → 查嵌入表，每个 50 维，拼接成 48 × 50 = 2400 维的向量 x
      → 隐藏层 h = (W x + b)³        （论文用了立方激活函数；作业 2 用 ReLU）
      → 输出层 logits = U h → softmax（只在合法动作中选）

    演示 1：模型结构与参数量
    演示 2：训练，并和代码 03 的感知机比较
    演示 3：激活函数：立方 vs ReLU vs tanh
    演示 4：特征窗口的限制 —— 多看两个词（b4、b5）会怎样？
    演示 5：分析器学到的词向量 —— fork 和 knife 靠在一起了吗？
    演示 6：错误分析

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 04_neural_parser.py           # 完整运行
    python 04_neural_parser.py --quick   # 冒烟测试（300 句、训练 1 轮）

需要 numpy 和 PyTorch。完整运行约 2~4 分钟（CPU，会训练 5 个模型）。
"""

import importlib
import sys
import time

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

from parsing import (NULL, UNK, Vocab, attachment_scores, extract_features, gold_transitions, legal_mask,
                     parse_batch, pp_accuracy_by_visibility, pp_attachment_accuracy)
from treebank import LEXICON, make_treebank

trees = importlib.import_module("01_dependency_trees")


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


# =============================================================================
# 第 1 部分：特征
# =============================================================================
def features(c, sent, vocab, extra_lookahead=False):
    """
    Chen & Manning 的 48 个特征；extra_lookahead=True 时再加上缓冲区第 4、5 个词和词性（共 52 个）。

    【返回】list[int]：前面是词编号，然后是词性编号，最后是标签编号（顺序见 split_features）
    """
    f = extract_features(c, sent, vocab)
    if not extra_lookahead:
        return f
    extra = [c.buffer[i] if len(c.buffer) > i else -1 for i in (3, 4)]
    w = [vocab.word2id[NULL] if p < 0 else vocab.word2id.get(sent.words[p], vocab.word2id[UNK]) for p in extra]
    t = [vocab.tag2id[NULL] if p < 0 else vocab.tag2id.get(sent.tags[p], vocab.tag2id[UNK]) for p in extra]
    return f[:18] + w + f[18:36] + t + f[36:]


def make_training_data(sents, vocab, extra_lookahead=False):
    """
    用 oracle 把每个训练句子走一遍，收集 (特征, 正确动作) 样本。

    【返回】(X, y)：X 是 torch.LongTensor (N, 48 或 52)，y 是 torch.LongTensor (N,)
    """
    X, y = [], []
    for s in sents:
        for c, t in gold_transitions(s):
            X.append(features(c, s, vocab, extra_lookahead))
            y.append(vocab.t2id[t])
    return torch.tensor(X, dtype=torch.long), torch.tensor(y, dtype=torch.long)


# =============================================================================
# 第 2 部分：模型
# =============================================================================
class Cube(nn.Module):
    """立方激活函数 f(z) = z³（Chen & Manning 2014）。"""

    def forward(self, z):
        return z ** 3


class ParserModel(nn.Module):
    """
    Chen & Manning 的前馈神经网络分析器。

    【参数】（构造函数）
        vocab           : Vocab
        n_word, n_tag, n_label : 三种特征各有几个位置（默认 18、18、12；多看两个词时是 20、20、12）
        embed_dim       : 嵌入维度（论文 50）
        hidden          : 隐藏单元数（论文 200）
        activation      : "cube"、"relu" 或 "tanh"
        dropout         : 隐藏层之后的 dropout 概率（论文 0.5）

    【思路】
        词、词性、标签各有一张嵌入表（三张表互相独立）。
        **同一张表被所有位置共享**：s1 的词和 b2 的词查的是同一张词嵌入表。
        但拼接之后，隐藏层的权重 W 对每个位置是不同的 —— 模型知道"这个词出现在 s1 还是 b2"。

    【NLP 联系】
        为什么用立方函数？论文的解释是：z³ = (Σ_i w_i x_i + b)³ 展开后包含 x_i x_j x_k 这样的乘积项，
        相当于自动地组合了**三个特征**——这正是代码 03 里人工设计的"组合特征"所做的事。
        后来的工作（以及作业 2）发现 ReLU 也同样好用，而且更稳定。
    """

    def __init__(self, vocab, n_word=18, n_tag=18, n_label=12, embed_dim=50, hidden=200,
                 activation="cube", dropout=0.5):
        super().__init__()
        self.splits = (n_word, n_tag, n_label)
        self.word_emb = nn.Embedding(len(vocab.word2id), embed_dim)
        self.tag_emb = nn.Embedding(len(vocab.tag2id), embed_dim)
        self.label_emb = nn.Embedding(len(vocab.label2id), embed_dim)
        for emb in (self.word_emb, self.tag_emb, self.label_emb):
            nn.init.uniform_(emb.weight, -0.01, 0.01)
        self.hidden = nn.Linear((n_word + n_tag + n_label) * embed_dim, hidden)
        nn.init.xavier_uniform_(self.hidden.weight)
        self.act = {"cube": Cube(), "relu": nn.ReLU(), "tanh": nn.Tanh()}[activation]
        self.dropout = nn.Dropout(dropout)
        self.out = nn.Linear(hidden, len(vocab.transitions))

    def forward(self, X):
        """X: (B, n_word + n_tag + n_label) → (B, 动作数) 的 logits。"""
        nw, nt, _ = self.splits
        e = torch.cat([self.word_emb(X[:, :nw]), self.tag_emb(X[:, nw:nw + nt]), self.label_emb(X[:, nw + nt:])], dim=1)
        h = self.dropout(self.act(self.hidden(e.view(X.shape[0], -1))))
        return self.out(h)


def train_parser(train, dev, vocab, activation="cube", extra_lookahead=False, epochs=8, lr=1e-3,
                 batch_size=512, seed=0, verbose=True):
    """
    训练神经分析器（Adam，交叉熵损失），每轮结束在开发集上评估。

    【参数】
        train, dev      : list[Sentence]
        vocab           : Vocab
        activation      : 激活函数
        extra_lookahead : 是否多看两个词
        epochs, lr, batch_size, seed : 训练超参数
        verbose         : 是否打印每轮的损失和开发集结果

    【返回】ParserModel（已切换到评估模式）

    【边界】
        - 训练时用交叉熵在**所有**动作上做 softmax（包括当前不合法的动作）；分析时只在合法动作中选。
          这是作业 2 的做法，简单有效。
    """
    torch.manual_seed(seed)
    X, y = make_training_data(train, vocab, extra_lookahead)
    n_word = 20 if extra_lookahead else 18
    model = ParserModel(vocab, n_word=n_word, n_tag=n_word, activation=activation)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    for epoch in range(epochs):
        model.train()
        perm = torch.randperm(len(X))
        total, t0 = 0.0, time.perf_counter()
        for start in range(0, len(X), batch_size):
            idx = perm[start:start + batch_size]
            opt.zero_grad()
            loss = F.cross_entropy(model(X[idx]), y[idx])
            loss.backward()
            opt.step()
            total += loss.item() * len(idx)
        if verbose:
            uas, las = attachment_scores(dev, parse_with(model, dev, vocab, extra_lookahead))
            print(f"    第 {epoch + 1} 轮：训练损失 {total / len(X):.4f}，开发集 UAS {uas:.3f} LAS {las:.3f}"
                  f"（本轮训练 {time.perf_counter() - t0:.1f} 秒）")
    model.eval()
    return model


def parse_with(model, sents, vocab, extra_lookahead=False):
    """用神经分析器批量分析句子。"""
    def score_fn(configs, ss):
        X = torch.tensor([features(c, s, vocab, extra_lookahead) for c, s in zip(configs, ss)], dtype=torch.long)
        with torch.no_grad():
            return model(X).numpy()
    model.eval()
    return parse_batch(sents, score_fn, vocab)


def report(name, sents, preds):
    """打印 UAS、LAS 和介词短语附着准确率（按名词是否在窗口内分组）。返回 (uas, las, 看得见, 看不见)。"""
    uas, las = attachment_scores(sents, preds)
    vis = pp_accuracy_by_visibility(sents, preds)
    print(f"    {name:<30s}UAS {uas:.3f}  LAS {las:.3f}  介词短语：名词看得见 {vis['看得见'][0]:.3f}，"
          f"看不见 {vis['看不见'][0]:.3f}")
    return uas, las, vis["看得见"][0], vis["看不见"][0]


# =============================================================================
# 演示
# =============================================================================
def main(quick=False):
    train, dev, test = make_treebank()
    epochs = 8
    if quick:
        train, dev, test, epochs = train[:300], dev[:100], test[:100], 1
    vocab = Vocab(train)

    # ---------------- 演示 1：模型结构 ----------------
    print_header("演示 1：模型结构")
    model = ParserModel(vocab)
    print(f"\n  词表：{len(vocab.word2id)} 个词、{len(vocab.tag2id)} 个词性、{len(vocab.label2id)} 个标签，{len(vocab.transitions)} 种动作")
    print(f"  输入：18 个词 + 18 个词性 + 12 个标签 = 48 个特征，每个嵌入 50 维，拼接后 {48 * 50} 维")
    for name, p in model.named_parameters():
        print(f"    {name:<20s}形状 {str(tuple(p.shape)):<14s}{p.numel():>8,d} 个参数")
    print(f"  合计 {sum(p.numel() for p in model.parameters()):,} 个参数（和代码 03 中 {len(vocab.transitions)} × 特征数 的稀疏权重对比）")

    # ---------------- 演示 2：训练并与感知机比较 ----------------
    print_header(f"演示 2：训练神经分析器（立方激活，{len(train)} 句，{epochs} 轮），并与感知机比较")
    t0 = time.perf_counter()
    model = train_parser(train, dev, vocab, epochs=epochs)
    print(f"\n  训练总用时 {time.perf_counter() - t0:.1f} 秒（取决于你的电脑）\n  测试集：")
    perceptron = importlib.import_module("03_feature_based_parser")
    pmodel = perceptron.train_perceptron(train, vocab, epochs=1 if quick else 5, verbose=False)
    report("平均感知机（代码 03）", test, perceptron.parse_with(pmodel, test, vocab))
    report("神经分析器（立方激活）", test, parse_with(model, test, vocab))
    t0 = time.perf_counter()
    parse_with(model, test, vocab)
    t_neural = time.perf_counter() - t0
    print(f"\n  神经分析器分析 {len(test)} 个测试句子用时 {t_neural:.2f} 秒（约 {len(test) / t_neural:.0f} 句/秒；"
          f"大部分时间花在 Python 的特征抽取上）")

    # ---------------- 演示 3：激活函数 ----------------
    print_header("演示 3：激活函数（测试集）")
    print()
    for act in ["cube", "relu", "tanh"]:
        m = model if act == "cube" else train_parser(train, dev, vocab, activation=act, epochs=epochs, verbose=False)
        report(f"激活函数 {act}", test, parse_with(m, test, vocab))
    print("""
  说明：每种激活函数只训练了 1 次（随机种子 0）。差别如果只有零点几个百分点，很可能在随机波动范围内。""")

    # ---------------- 演示 4：多看两个词 ----------------
    print_header("演示 4：特征窗口的限制 —— 把缓冲区的第 4、5 个词也加进特征")
    m_ext = train_parser(train, dev, vocab, extra_lookahead=True, epochs=epochs, verbose=False)
    print()
    report("标准特征（b1~b3）", test, parse_with(model, test, vocab))
    report("加上 b4、b5", test, parse_with(m_ext, test, vocab, extra_lookahead=True))
    print("""
  观察：多看两个词之后，"名词看不见"的那一组大幅改善 —— 原来的错误主要不是模型不够聪明，
    而是**特征窗口里根本没有做决策所需的信息**。但窗口总是有限的：句子里可以有任意多个形容词。
    后来的分析器（Kiperwasser & Goldberg 2016；Dozat & Manning 2017）用双向 LSTM 先读完整个句子，
    让每个位置的表示都包含整句话的信息，就不再受固定窗口的限制了（第 5、6 讲的 RNN、LSTM）。""")

    # ---------------- 演示 5：学到的词向量 ----------------
    print_header("演示 5：分析器学到的词向量")
    E = model.word_emb.weight.detach().numpy()
    E = E / np.linalg.norm(E, axis=1, keepdims=True)
    for q in ["fork", "cheese", "hat", "kitchen", "pizza"]:
        if q not in vocab.word2id:
            continue
        sims = E @ E[vocab.word2id[q]]
        cands = [(w, sims[i]) for w, i in vocab.word2id.items() if w != q and w.isalpha()]
        top = sorted(cands, key=lambda x: -x[1])[:5]
        print(f"    {q:<8s}→ " + ", ".join(f"{w}({s:.2f})" for w, s in top))
    groups = {k: [w for w in LEXICON[k] if w in vocab.word2id] for k in ["tool", "topping", "accessory", "place"]}
    within, between = [], []
    for k1, ws1 in groups.items():
        for k2, ws2 in groups.items():
            for a in ws1:
                for b in ws2:
                    if a < b or k1 != k2:
                        s = float(E[vocab.word2id[a]] @ E[vocab.word2id[b]])
                        (within if k1 == k2 else between).append(s)
    print(f"\n  工具 / 配料 / 衣饰 / 地点 四类词：同类词平均余弦 {np.mean(within):.3f}，异类词平均余弦 {np.mean(between):.3f}")
    print("""
  观察：没有任何人告诉分析器"fork 和 knife 是一类"，它只是为了把介词短语挂对，
    就自己学出了这样的词向量。同一类词向量相近，所以学到的知识能在同类词之间共享 ——
    这正是稠密特征相对于稀疏指示特征（每个词对都要单独学）的优势。""")

    # ---------------- 演示 6：错误分析 ----------------
    print_header("演示 6：错误分析（标准特征的神经分析器）")
    preds = parse_with(model, test, vocab)
    shown = 0
    for s, (heads, labels) in zip(test, preds):
        wrong = [i for i in range(1, len(s.words)) if heads[i] != s.heads[i]]
        if wrong and shown < 2:
            shown += 1
            print(f"\n  {s.text()}")
            for i in wrong:
                print(f"    '{s.words[i]}' 的中心词：应为 '{s.words[s.heads[i]] if s.heads[i] else 'ROOT'}'（{s.labels[i]}），"
                      f"预测为 '{s.words[heads[i]] if heads[i] else 'ROOT'}'（{labels[i]}）")
            print("    标准答案：")
            print(trees.draw_arcs(s))


if __name__ == "__main__":
    main(quick="--quick" in sys.argv)
