"""
=============================================================================
第 4 讲 · 代码 3：传统的依存分析器 —— 人工设计的稀疏特征 + 平均感知机
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
代码 02 把"分析句子"变成了"一连串分类"：在每个状态下，从 SHIFT / LEFT-ARC / RIGHT-ARC 中选一个。
2014 年之前，这个分类器通常是这样做的（MaltParser 等）：
    1. 人工设计大量**指示特征**（indicator features），每个特征是一个字符串，例如
           "s1.w=pizza"                     栈顶的词是 pizza
           "s1.t=NOUN|b1.t=ADP"             栈顶是名词、缓冲区第一个是介词
           "s1.w=pizza|b3.w=cheese"         栈顶是 pizza、缓冲区第三个是 cheese
       一个状态通常会"点亮"几十个特征，而特征的总数可以达到几十万甚至上百万 —— 非常**稀疏**。
    2. 用线性分类器（感知机、SVM、逻辑回归）给每个动作打分：得分 = 所有被点亮特征的权重之和。

本文件实现一个这样的分析器，用**平均感知机**（averaged perceptron）训练，并观察它的问题：
    演示 1：一个状态会点亮哪些特征？
    演示 2：训练，看开发集 / 测试集上的 UAS、LAS、介词短语附着准确率
    演示 3：学到的权重 —— 它记住了大量具体的词对（"pizza 后面跟 cheese"）
    演示 4：特征工程的三个问题：稀疏、不完整、计算慢

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 03_feature_based_parser.py           # 完整运行（训练 5 轮）
    python 03_feature_based_parser.py --quick   # 冒烟测试（只用 300 句训练 1 轮）

需要 numpy。完整运行约 10~20 秒。
"""

import sys
import time

import numpy as np

from parsing import (Vocab, attachment_scores, feature_positions, gold_transitions, legal_mask, oracle,
                     parse_batch, pp_accuracy_by_visibility, pp_attachment_accuracy)
from treebank import LEXICON, make_treebank

NONE = "_"


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


# =============================================================================
# 第 1 部分：特征模板
# =============================================================================
def sparse_features(c, sent):
    """
    为一个状态生成指示特征（字符串列表）。

    【思路】
        先取出若干位置上的词（w）、词性（t）、已预测的依存标签（l），
        再按"特征模板"组合：单个位置的特征、两三个位置的组合特征。
        **组合特征**是关键：线性分类器本身不会"组合"信息，"栈顶是 pizza 并且 b3 是 cheese"
        这种交互必须由人显式地写成一个特征。

    【参数】
        c    : Configuration
        sent : Sentence（只用 words、tags）

    【返回】list[str]，大约 35 个特征

    【边界】
        - 这些模板是我为这份树库**专门设计**的（比如 s1.w|b3.w 是为了处理介词短语附着而加的）。
          真实的分析器（如 MaltParser、ZPar）有几十到上百个模板，是多年经验积累的结果。
    """
    pos, kids = feature_positions(c)
    s1, s2, s3, b1, b2, b3 = pos[:6]
    W = lambda p: sent.words[p] if p >= 0 else NONE
    T = lambda p: sent.tags[p] if p >= 0 else NONE
    L = lambda p: c.labels.get(p, NONE) if p >= 0 else NONE
    s1lc, s1rc, s2lc, s2rc = kids[0], kids[1], kids[6], kids[7]
    return [
        # 单个位置
        f"s1.w={W(s1)}", f"s1.t={T(s1)}", f"s1.wt={W(s1)}/{T(s1)}",
        f"s2.w={W(s2)}", f"s2.t={T(s2)}", f"s2.wt={W(s2)}/{T(s2)}",
        f"s3.t={T(s3)}",
        f"b1.w={W(b1)}", f"b1.t={T(b1)}", f"b1.wt={W(b1)}/{T(b1)}",
        f"b2.w={W(b2)}", f"b2.t={T(b2)}", f"b3.w={W(b3)}", f"b3.t={T(b3)}",
        # 两个位置的组合
        f"s1.w|s2.w={W(s1)}|{W(s2)}", f"s1.t|s2.t={T(s1)}|{T(s2)}",
        f"s1.w|s2.t={W(s1)}|{T(s2)}", f"s1.t|s2.w={T(s1)}|{W(s2)}",
        f"s1.t|b1.t={T(s1)}|{T(b1)}", f"s1.w|b1.w={W(s1)}|{W(b1)}",
        f"s2.t|s1.t|b1.t={T(s2)}|{T(s1)}|{T(b1)}", f"s1.t|b1.t|b2.t={T(s1)}|{T(b1)}|{T(b2)}",
        # 往前看：处理介词短语附着
        f"s1.w|b2.w={W(s1)}|{W(b2)}", f"s1.w|b3.w={W(s1)}|{W(b3)}",
        f"s2.w|b2.w={W(s2)}|{W(b2)}", f"s2.w|b3.w={W(s2)}|{W(b3)}",
        f"s1.t|b1.w|b3.t={T(s1)}|{W(b1)}|{T(b3)}",
        # 已经连上的依存成分
        f"s1.lc.l={L(s1lc)}", f"s1.rc.l={L(s1rc)}", f"s2.lc.l={L(s2lc)}", f"s2.rc.l={L(s2rc)}",
        f"s1.t|s1.rc.l={T(s1)}|{L(s1rc)}", f"s2.t|s2.rc.l={T(s2)}|{L(s2rc)}",
        f"s1.t|s1.lc.l={T(s1)}|{L(s1lc)}", "bias",
    ]


# =============================================================================
# 第 2 部分：平均感知机
# =============================================================================
class AveragedPerceptron:
    """
    多类平均感知机：每个特征对每个动作有一个权重，得分 = 被点亮特征的权重之和。

    【思路】（讲义第 7.2 节）
        感知机的更新规则极其简单：预测错了，就把正确动作的相关权重 +1，把预测的错误动作的权重 -1。
        "平均"：最终使用训练过程中所有时刻权重的**平均值**，而不是最后一刻的权重。
        这能大幅减少震荡、提高泛化（相当于一种正则化）。
        高效实现平均的小技巧：除了权重 W，再维护一个 U，每次更新时 U += c · Δ（c 是到目前为止的样本数），
        训练结束后平均权重 = W - U / c。

    【参数】（构造函数）
        n_classes : 动作的种类数

    【边界】
        - 特征是在训练过程中遇到时才分配编号的；测试时遇到没见过的特征直接忽略。
        - 权重矩阵按需扩容（容量不够时翻倍）。
    """

    def __init__(self, n_classes):
        self.n_classes = n_classes
        self.feat2id = {}
        self.W = np.zeros((1024, n_classes))
        self.U = np.zeros((1024, n_classes))
        self.c = 1
        self.averaged = None

    def ids(self, feats, add=False):
        """把特征字符串变成编号；add=True 时为新特征分配编号（训练时），否则跳过没见过的特征。"""
        out = []
        for f in feats:
            i = self.feat2id.get(f)
            if i is None and add:
                i = len(self.feat2id)
                self.feat2id[f] = i
                if i >= len(self.W):                       # 容量不够，翻倍
                    self.W = np.vstack([self.W, np.zeros_like(self.W)])
                    self.U = np.vstack([self.U, np.zeros_like(self.U)])
            if i is not None:
                out.append(i)
        return out

    def scores(self, ids, use_average=False):
        """给每个动作打分：被点亮特征的权重之和。返回 np.ndarray (n_classes,)。"""
        W = self.averaged if use_average else self.W
        return W[ids].sum(axis=0) if ids else np.zeros(self.n_classes)

    def update(self, ids, gold, pred):
        """预测错误时的更新：正确动作 +1，错误动作 -1。"""
        self.W[ids, gold] += 1.0
        self.W[ids, pred] -= 1.0
        self.U[ids, gold] += self.c
        self.U[ids, pred] -= self.c

    def finalize(self):
        """计算平均权重（训练结束、评估前调用）。"""
        n = len(self.feat2id)
        self.averaged = self.W[:n] - self.U[:n] / self.c


def train_perceptron(train, vocab, epochs=5, seed=0, dev=None, verbose=True):
    """
    沿着 oracle 的动作序列训练平均感知机（"静态 oracle"训练）。

    【思路】
        对每个训练句子，从初始状态出发；每一步用当前权重预测一个动作，和 oracle 的正确动作比较，
        错了就更新；然后**总是执行正确的动作**，走到下一个状态。

    【参数】
        train  : list[Sentence]；vocab : Vocab；epochs : 训练轮数；seed : 打乱顺序的随机种子
        dev    : None 或 list[Sentence]，每轮结束后在上面评估
        verbose: 是否打印

    【返回】AveragedPerceptron

    【边界】
        - 训练时只见过"正确路径"上的状态；测试时一旦走错一步，就会进入训练时从没见过的状态，
          错误可能越滚越大（"暴露偏差"，讲义第 7.3 节提到的动态 oracle 就是为了解决它）。
    """
    model = AveragedPerceptron(len(vocab.transitions))
    rng = np.random.default_rng(seed)
    for epoch in range(epochs):
        t0, errors, total = time.perf_counter(), 0, 0
        for si in rng.permutation(len(train)):
            sent = train[si]
            for c, gold_t in gold_transitions(sent):
                ids = model.ids(sparse_features(c, sent), add=True)
                s = np.where(legal_mask(c, vocab), model.scores(ids), -np.inf)
                pred, gold = int(np.argmax(s)), vocab.t2id[gold_t]
                if pred != gold:
                    model.update(ids, gold, pred)
                    errors += 1
                total += 1
                model.c += 1
        model.finalize()
        if verbose:
            msg = f"    第 {epoch + 1} 轮：训练时的动作错误率 {errors / total:.2%}，特征数 {len(model.feat2id):,}，" \
                  f"用时 {time.perf_counter() - t0:.1f} 秒"
            if dev is not None:
                uas, las = attachment_scores(dev, parse_with(model, dev, vocab))
                msg += f"；开发集 UAS {uas:.3f} LAS {las:.3f}"
            print(msg)
    return model


def parse_with(model, sents, vocab):
    """用训练好的感知机（平均权重）批量分析句子。"""
    def score_fn(configs, ss):
        return np.stack([model.scores(model.ids(sparse_features(c, s)), use_average=True) for c, s in zip(configs, ss)])
    return parse_batch(sents, score_fn, vocab)


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

    # ---------------- 演示 1：一个状态的特征 ----------------
    print_header("演示 1：一个状态会点亮哪些特征？")
    sent = next(s for s in train if "with" in s.words and "cheese" in s.words and s.labels[s.words.index("cheese")] == "nmod"
                and s.heads[s.words.index("cheese")] == next(i for i in range(1, len(s.words)) if s.labels[i] == "obj"))
    steps = gold_transitions(sent)
    # 找介词短语附着的决策点：栈顶是宾语名词、下面是动词、缓冲区第一个词是 with
    c, t = next((c, t) for c, t in steps if len(c.stack) >= 2 and sent.tags[c.stack[-1]] == "NOUN"
                and sent.tags[c.stack[-2]] == "VERB" and c.buffer and sent.words[c.buffer[0]] == "with")
    print(f"\n  句子：{sent.text()}")
    print(f"  状态：栈顶两个词 = {sent.words[c.stack[-2]]}、{sent.words[c.stack[-1]]}；"
          f"缓冲区前三个 = {[sent.words[b] for b in c.buffer[:3]]}；oracle 的正确动作 = {t}")
    feats = sparse_features(c, sent)
    print(f"  点亮的 {len(feats)} 个特征：")
    for i in range(0, len(feats), 4):
        print("    " + "   ".join(f"{f:<28s}" for f in feats[i:i + 4]))

    # ---------------- 演示 2：训练 ----------------
    print_header(f"演示 2：训练平均感知机（{len(train)} 句，{1 if quick else 5} 轮）")
    t0 = time.perf_counter()
    model = train_perceptron(train, vocab, epochs=1 if quick else 5, dev=dev)
    print(f"\n  总训练用时 {time.perf_counter() - t0:.1f} 秒（取决于你的电脑）")
    for name, data in [("开发集", dev), ("测试集", test)]:
        preds = parse_with(model, data, vocab)
        uas, las = attachment_scores(data, preds)
        pp, n = pp_attachment_accuracy(data, preds)
        vis = pp_accuracy_by_visibility(data, preds)
        print(f"  {name}：UAS {uas:.3f}  LAS {las:.3f}  介词短语附着准确率 {pp:.3f}（{n} 个）；其中 "
              + "，".join(f"名词{k} {a:.3f}（{m} 个）" for k, (a, m) in vis.items()))
    print("""
  观察：整体 UAS 很高（这份树库的大部分结构都很规则），几乎所有错误都出在介词短语附着上。
    而且错误集中在"名词看不见"的那一组："with the fresh cheese" 中，做决策时 cheese 在 b4，
    超出了特征窗口（b1~b3），分析器只能靠猜。""")

    # ---------------- 演示 3：学到的权重 ----------------
    print_header("演示 3：学到的权重 —— '先 SHIFT（等着连修饰语）'还是'立刻 RIGHT-ARC(obj)'？")
    sh, ro = vocab.t2id["S"], vocab.t2id["R-obj"]
    pp_nouns = set(LEXICON["tool"] + LEXICON["topping"] + LEXICON["accessory"] + LEXICON["place"])
    lex = [(f, i) for f, i in model.feat2id.items() if f.startswith("s1.w|b3.w=") and f.split("|")[-1] in pp_nouns]
    diff = sorted(lex, key=lambda fi: model.averaged[fi[1], sh] - model.averaged[fi[1], ro])
    print("\n  只看 's1.w|b3.w' 且 b3 是介词短语里的名词的特征。最支持'立刻 RIGHT-ARC(obj)'的：")
    for f, i in diff[:5]:
        print(f"    {f:<34s}SHIFT {model.averaged[i, sh]:+8.1f}   R-obj {model.averaged[i, ro]:+8.1f}")
    print("  最支持'先 SHIFT'的词对特征：")
    for f, i in diff[-5:][::-1]:
        print(f"    {f:<34s}SHIFT {model.averaged[i, sh]:+8.1f}   R-obj {model.averaged[i, ro]:+8.1f}")
    print("""
  观察：模型记住的是一个个**具体的词对**（栈顶是某种食物、b3 是某种配料 → 先 SHIFT；b3 是某种工具 → 立刻 RIGHT-ARC）。
    它并不知道 cheese、sauce、cream 是"同一类东西"：每个词对都必须在训练数据里单独见过，权重才会被学好。
    （如果把筛选条件去掉，你还会看到 "pizza|happy" 之类的特征权重很大：b3 是形容词时真正的名词在 b4，
     模型就去记形容词和动作之间纯属巧合的相关性 —— 这是过拟合。）""")

    # ---------------- 演示 4：特征工程的问题 ----------------
    print_header("演示 4：特征工程的三个问题")
    used = np.zeros(len(model.feat2id))
    n_states = 0
    t0 = time.perf_counter()
    for s in test[:100]:
        for c, _ in gold_transitions(s):
            sparse_features(c, s)
            n_states += 1
    t_feat = time.perf_counter() - t0
    counts = {}
    for s in train:
        for c, _ in gold_transitions(s):
            for f in sparse_features(c, s):
                counts[f] = counts.get(f, 0) + 1
    rare = sum(1 for v in counts.values() if v <= 2)
    unseen = total = 0
    for s in test:
        for c, _ in gold_transitions(s):
            for f in sparse_features(c, s):
                total += 1
                unseen += f not in model.feat2id
    print(f"""
  (1) 稀疏：一共 {len(counts):,} 种特征，其中 {rare:,} 种（{rare / len(counts):.0%}）在训练集中只出现过 1~2 次，权重几乎学不到东西。
  (2) 不完整：测试集的状态中，{unseen / total:.1%} 的特征在训练时从没出现过，只能忽略。
      而且模板是人想出来的，难免漏掉有用的组合；每加一个组合模板，特征数就成倍增长。
  (3) 慢：给 {n_states} 个状态生成特征用了 {t_feat * 1e3:.0f} 毫秒（取决于你的电脑）。
      Chen & Manning（2014）报告：在传统分析器中，95% 以上的分析时间花在**计算特征**上（拼字符串、查哈希表）。
  → 下一个文件：用稠密的词向量 + 神经网络代替这一切（Chen & Manning 2014）。""")


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