"""
=============================================================================
第 3 讲 · 代码 5：文本上的 Integrated Gradients —— 词向量、基线，以及"模型真的读懂了吗？"
=============================================================================

对应讲义：lectures/lecture03_deep_attribution.md 第 8 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
图像的像素是连续的数，可以直接对它求梯度；文本的 token 是离散的编号，"把 good 调大 0.01"没有意义。
Spring 2022 Lab 3 的答案：对**词向量**（embedding）求梯度，IG 从"基线词向量"积分到"输入词向量"，
最后把每个 token 的各个维度加起来，得到这个 token 的归因。

这里造一个"知道真相"的小影评世界：每条影评有一个情感词（good / bad……），10% 的情感词前面有 not（情感反转）；
训练标签有 20% 是噪声；另外，来自某个网站的影评末尾常带一个来源标记 "imdb" —— 在训练数据里，
正面影评有 50% 带它，负面只有 5%。模型是一个 Kim（2014）风格的小 CNN（IG 论文的问题分类实验用的也是这种结构）。

    演示 1：Token-level IG —— 准确率很好；单条影评的 token 归因；completeness 检查。
    演示 2：Baselines for text —— 全零词向量（= [PAD]）、"the the the …"、词表平均词向量：结论变不变？
    演示 3：Global view —— 把几百条影评的归因按词汇汇总：模型最依赖的词里有没有不该有的？
    演示 4：Did the model understand the review? —— 仿照 Mudrakarta et al.（2018）的 VQA 实验：
            只保留归因最大的词，预测还一样吗？把 "imdb" 塞进负面影评，会发生什么？去掉 not 呢？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 05_text_ig.py          # 约 3 秒

需要 numpy 和 torch（CPU）。依赖本目录的 attribution_tools.py。
"""

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

from attribution_tools import integrated_gradients, print_header, spearman_rows

torch.set_num_threads(1)

FILLERS = ["the", "movie", "film", "plot", "was", "is", "really", "quite", "acting", "story",
           "and", "this", "a", "it", "very"]
POSITIVE = ["good", "great", "fun", "excellent", "loved"]
NEGATIVE = ["bad", "boring", "awful", "poor", "hated"]
VOCAB = ["[PAD]"] + FILLERS + POSITIVE + NEGATIVE + ["not", "imdb"]
IDX = {w: i for i, w in enumerate(VOCAB)}         # 【Python 小知识】dict 推导式：词 → 编号
MAXLEN = 10
P_NEG = 0.10        # 情感词前面加 not 的概率
P_SRC = 0.50        # 训练数据里正面影评带 "imdb" 的概率（负面：0.05）
ETA = 0.20          # 训练标签的噪声率


def make_reviews(n, rng, src="correlated", p_neg=P_NEG, eta=0.0):
    """
    生成 n 条影评（token 编号矩阵）。

    【思路】长度 6–10 个词，先全部填"中性词"，再在第 1…L−2 个位置之一放一个情感词；以 p_neg 的概率在它前面放 not
        （情感反转）。真实标签 y_true 由情感词和 not 决定；记录下来的标签 y 以概率 eta 被翻转（标注噪声）。
        最后按 src 决定是否把一个中性词换成 "imdb"：
            "correlated"  y = 1 时 50%、y = 0 时 5%（按**记录的**标签，像一个碰巧偏向好评的数据来源）
            "none"        都不带
    【参数】n：条数；rng：np.random.Generator；src：见上；p_neg：not 的概率；eta：标签噪声率
    【返回】dict：tokens（n × MAXLEN 的 long 张量，右侧用 [PAD] = 0 补齐）、y（记录的标签，float）、
        y_true（真实情感，float）、words（Python 列表的列表，方便打印）
    【边界】not 永远紧挨在情感词前面；"imdb" 只替换中性词，不会覆盖情感词和 not。
    """
    tokens = np.zeros((n, MAXLEN), dtype=np.int64)
    y_obs, y_true, all_words = np.zeros(n), np.zeros(n), []
    for i in range(n):
        L = int(rng.integers(6, MAXLEN + 1))
        words = [str(w) for w in rng.choice(FILLERS, size=L)]
        s = int(rng.integers(0, 2))
        pos = int(rng.integers(1, L - 1))
        words[pos] = str(rng.choice(POSITIVE if s == 1 else NEGATIVE))
        negated = rng.random() < p_neg
        if negated:
            words[pos - 1] = "not"
        yt = 1 - s if negated else s
        yo = 1 - yt if rng.random() < eta else yt
        has_src = src == "correlated" and rng.random() < (P_SRC if yo == 1 else 0.05)
        if has_src:
            free = [j for j in range(L) if words[j] in FILLERS]
            words[int(rng.choice(free))] = "imdb"
        tokens[i, :L] = [IDX[w] for w in words]
        y_obs[i], y_true[i] = yo, yt
        all_words.append(words)
    return {"tokens": torch.from_numpy(tokens), "y": torch.tensor(y_obs, dtype=torch.float32),
            "y_true": torch.tensor(y_true, dtype=torch.float32), "words": all_words}


class TextCNN(nn.Module):
    """
    Kim（2014）风格的极简文本 CNN：词向量（8 维）→ 宽度为 2 的一维卷积（16 个过滤器，能看到相邻两个词，比如 "not good"）
    → ReLU → 沿句子取最大值（max-over-time pooling）→ 线性层 → 1 个 logit（> 0 = 正面）。

    【思路】from_emb 让我们可以**直接从词向量**开始前向传播 —— IG 需要在词向量空间里沿直线插值，
        而 token 编号是离散的，没法插值。
    【PyTorch 小知识】nn.Embedding(V, 8, padding_idx=0)：一张 V × 8 的查找表；编号 0（[PAD]）那一行固定为 0 向量、
        训练时不更新。所以"全零词向量"基线和"整句都是 [PAD]"是同一个东西。
    """

    def __init__(self):
        super().__init__()
        self.emb = nn.Embedding(len(VOCAB), 8, padding_idx=0)
        self.conv = nn.Conv1d(8, 16, kernel_size=2)
        self.fc = nn.Linear(16, 1)

    def from_emb(self, E):
        """E：n × MAXLEN × 8 的词向量 → n 个 logit。Conv1d 要求"通道"在第 1 维，所以先 transpose。"""
        h = F.relu(self.conv(E.transpose(1, 2)))
        return self.fc(h.max(2).values).squeeze(1)

    def forward(self, tokens):
        return self.from_emb(self.emb(tokens))


def train_text(data, seed=0, epochs=10):
    """
    用 Adam + binary cross-entropy 训练 TextCNN（和 synthetic_images.train_cnn 同样的写法）。

    【参数】data：make_reviews 返回的 dict（用其中**带噪声的**标签 y 训练）；seed；epochs
    【返回】eval 模式的模型
    【边界】4000 条影评、10 个 epoch，单线程约 1 秒。
    """
    torch.manual_seed(seed)
    model = TextCNN()
    opt = torch.optim.Adam(model.parameters(), lr=1e-2)
    rng = np.random.default_rng(seed)
    T, y = data["tokens"], data["y"]
    for _ in range(epochs):
        perm = torch.from_numpy(rng.permutation(len(y)))
        for s in range(0, len(y), 64):
            idx = perm[s:s + 64]
            loss = F.binary_cross_entropy_with_logits(model(T[idx]), y[idx])
            opt.zero_grad()
            loss.backward()
            opt.step()
    return model.eval()


def acc_true(model, data):
    """
    对**真实情感**的准确率（不是对带噪声的记录标签）。

    【可信联系】测试集如果也带着和训练集一样的标签噪声 / 来源偏差，准确率就会"继承"它们 —— 第 1 讲 §1 的老问题。
    """
    with torch.no_grad():
        return ((model(data["tokens"]) > 0).float() == data["y_true"]).float().mean().item()


def token_ig(model, tokens, baseline_emb, steps=64):
    """
    文本的 IG：在词向量空间里从基线积分到输入，再把每个 token 的 8 个维度加起来。

    【思路】E = emb(tokens)（n × L × 8），E′ = 基线词向量（同形状）。IG 给出 n × L × 8 个数；
        对最后一维求和 → 每个 token 一个数。completeness 仍然成立：所有 token 的归因之和 = F(E) − F(E′)。
    【参数】baseline_emb：L × 8 或 n × L × 8 的基线词向量
    【返回】(attr：n × L，gap：长度 n)
    【可信联系】completeness 让 token 归因可以加总成"这句话为什么是正面"的完整账目；第 12 讲的 groundedness
        问的是同一类问题："回答的哪一部分由哪一段文字支持"。
    【边界】梯度只能传到词向量，传不到"选哪个词"这一步；所以 IG 解释的是"这个位置上的这个词向量"，
        如果词向量层之前还有别的网络（比如 tokenizer 之前的处理），IG 看不到（Lab 3 的讨论）。
    """
    with torch.no_grad():
        E = model.emb(tokens)
    base = baseline_emb.expand_as(E)
    ig, gap = integrated_gradients(model.from_emb, E, base, steps=steps, return_gap=True)
    return ig.sum(2), gap


def show(words, attr):
    """把一句话打印成 "词(+0.12)" 的形式。"""
    return " ".join(f"{w}({a:+.2f})" for w, a in zip(words, attr.tolist()))


# =============================================================================
# 演示 1：token 级 IG
# =============================================================================
def demo_token_ig(model, sets):
    print_header("演示 1：Token-level IG —— 对词向量积分，再按 token 求和")
    print(f"{'test set':<44}{'acc vs true sentiment':>22}")
    for name, key in [("same pipeline (imdb correlated)", "test"), ("no imdb tokens", "clean"),
                      ("every review negated (not + word)", "negated")]:
        print(f"{name:<44}{acc_true(model, sets[key]):>22.3f}")
    d = sets["test"]
    zero = torch.zeros(MAXLEN, 8)
    attr, gap = token_ig(model, d["tokens"], zero)
    with torch.no_grad():
        logit = model(d["tokens"])
    print(f"\n基线 = 全零词向量（= 整句 [PAD]，logit = {model(torch.zeros(1, MAXLEN, dtype=torch.long)).item():.2f}）；"
          f"completeness：|Σ_token IG − (F(x) − F(x′))| 的中位数 = {gap.abs().median().item():.4f}")
    picks = []
    for cond in [lambda w: "imdb" in w and "not" not in w, lambda w: "not" in w, lambda w: "imdb" not in w and "not" not in w]:
        picks.append(next(i for i, w in enumerate(d["words"]) if cond(w)))
    for i in picks:
        L = len(d["words"][i])
        print(f"  logit {logit[i].item():+6.2f} | " + show(d["words"][i], attr[i, :L]))
    print("""
观察 Observations：
  · 同管线测试集 0.961、没有 imdb 的测试集 0.972：只看准确率，看不出任何问题 —— 包括 "imdb" 在不在。
    全是否定句的测试集只有 0.770：训练数据里只有 10% 的句子带 not，标签又有 20% 的噪声，否定没学好。
  · 每个 token 的归因 = 它的 8 个词向量维度上的 IG 之和；中性词的归因大多接近 0。
    第 1 句是正面影评：几乎全部证据来自 "imdb"（+3.98），情感词 "excellent" 反而是 −0.84 —— 第一个危险信号。
    第 2 句 "not loved"：not 拿到 −1.78，模型把它判成负面（对）；第 3 句 "boring" 拿到 −2.16。
  · completeness 在文本上同样成立：所有 token 的归因之和等于 logit 相对基线的变化（误差中位数 0.0057）。
    全零基线（= 整句 [PAD]）的 logit 是 0.02，接近"中性"，IG 论文推荐的正是这个基线。""")
    return attr


# =============================================================================
# 演示 2：文本的基线
# =============================================================================
def demo_baselines(model, sets, attr_zero):
    print_header("演示 2：Baselines for text —— [PAD]（全零）、“the the …”、词表平均词向量")
    d = sets["test"]
    T = d["tokens"]
    with torch.no_grad():
        W = model.emb.weight
        the = W[IDX["the"]].expand(MAXLEN, 8)
        mean = W[1:].mean(0).expand(MAXLEN, 8)        # 不含 [PAD] 的平均词向量
    bases = {"zero / [PAD]": torch.zeros(MAXLEN, 8), "'the' × 10": the, "mean embedding": mean}
    mask = (T > 0).double()                          # 只比较真实 token（不含补齐的 [PAD]）
    i = next(k for k, w in enumerate(d["words"]) if "imdb" in w and "not" not in w)
    L = len(d["words"][i])
    print(f"  {'baseline':<18}{'F(x′)':>8}{'median |gap|':>14}{'rank corr w/ zero':>19}{'mean IG of the':>16}")
    for name, b in bases.items():
        a, gap = token_ig(model, T, b)
        with torch.no_grad():
            fb = model.from_emb(b.unsqueeze(0)).item()
        corr = spearman_rows((attr_zero.abs() * mask).double(), (a.abs() * mask).double())
        the_mask = T == IDX["the"]
        print(f"  {name:<18}{fb:>8.2f}{gap.abs().median().item():>14.4f}{corr:>19.3f}{a[the_mask].mean().item():>16.3f}")
        print(f"      例句：" + show(d["words"][i], a[i, :L]))
    print("""
观察 Observations：
  · IG 论文建议文本用全零词向量作基线：训练会让不重要的词的词向量范数变小，所以"全零"近似"没有任何词"。
    它不是一个真实存在的句子 —— 和图像里的全黑图不同 —— 但在数学上可用。这里三个基线的 F(x′) 都接近 0（−0.15 到 0.03）。
  · 换成 "the the the …"：基线本身是一个真实的（无意义的）句子；"the" 的归因恰好全部变成 0（盲点：和基线相同的
    token 永远得 0），token 排名和全零基线的秩相关只有 0.692。例句里 −0.84 从 "excellent" 挪到了相邻的 "very" 上：
    宽度为 2 的卷积让相邻两个词发生交互，交互的功劳怎样分给两个词，取决于从哪里出发（基线）。
  · 换成词表平均词向量：和全零基线几乎一样（秩相关 0.960）。
  · 稳健的结论（"imdb" 约 +4、是全句最大的证据）在三个基线下都成立；细节（相邻词之间怎么分）不稳定。
    对文本，"去掉一个词"有很多种说法（删掉、换成 [PAD]、换成 [MASK]、换成中性词），每种都是一个不同的问题，
    报告 token 归因时要写明基线。""")



# =============================================================================
# 演示 3：按词汇汇总
# =============================================================================
def demo_global(model, sets, attr):
    print_header("演示 3：Global view —— 把归因按词汇汇总：模型最依赖哪些词？")
    d = sets["test"]
    T = d["tokens"]
    rows = []
    for w, k in IDX.items():
        if w == "[PAD]":
            continue
        sel = T == k
        if int(sel.sum().item()) == 0:
            continue
        rows.append((attr[sel].abs().mean().item(), attr[sel].mean().item(), int(sel.sum().item()), w))
    rows.sort(reverse=True)
    print(f"  {len(T)} 条同管线测试影评；按平均 |IG| 排序（基线 = 全零词向量）")
    print(f"  {'token':<12}{'mean |IG|':>11}{'mean IG':>10}{'count':>8}")
    for a_abs, a_mean, cnt, w in rows[:12]:
        print(f"  {w:<12}{a_abs:>11.3f}{a_mean:>+10.3f}{cnt:>8}")
    print("  …")
    for a_abs, a_mean, cnt, w in rows[-3:]:
        print(f"  {w:<12}{a_abs:>11.3f}{a_mean:>+10.3f}{cnt:>8}")
    print("""
观察 Observations：
  · 排在第一的不是任何情感词，而是 "imdb"（平均 +3.77，每次都是正的）：模型把"来自这个网站"当成了好评最强的证据。
    IG 论文在问题分类模型上发现过一模一样的东西：人名 "charles" 被当成了 yes/no 问题的触发词。
  · 第二个发现更隐蔽：负面词的平均 |IG| 在 2.06–2.47，正面词只有 0.59–1.08，"excellent" 的平均归因甚至是 −0.016。
    模型几乎不"读"正面词 —— 它的"好评"更像是"没有负面词（再加上 imdb）"。演示 4 (d) 验证这一点。
  · 单条影评的归因容易被忽略；把几百条的归因按词汇汇总成一张"词典"，不该出现的模式就会冒出来。
    这和代码 02 里"几百张图的平均 |IG|"是同一个技巧。中性词的归因都很小（表的末尾，约 0.1）。""")



# =============================================================================
# 演示 4：模型真的读懂了吗？
# =============================================================================
def replace_first_filler(tokens, new_id):
    """把每条影评里的第一个中性词换成 new_id（模拟"往句子里插一个看起来无害的词"）。返回新张量。"""
    T = tokens.clone()
    filler_ids = torch.tensor([IDX[w] for w in FILLERS])
    for i in range(len(T)):
        pos = torch.where(torch.isin(T[i], filler_ids))[0]     # 【PyTorch 小知识】isin：逐元素判断是否属于某个集合
        T[i, pos[0]] = new_id
    return T


def demo_understanding(model, sets, attr):
    print_header("演示 4：Did the model understand the review? —— 用归因指导扰动")
    d = sets["test"]
    T = d["tokens"]
    with torch.no_grad():
        pred = model(T) > 0
    gen = torch.Generator().manual_seed(54)
    real = T > 0
    # (a) 只保留 |IG| 最大的 2 个 token，其余换成 [PAD]；对照：随机保留 2 个真实 token
    a = attr.abs().masked_fill(~real, -1.0)                   # 【PyTorch 小知识】masked_fill：把 mask 为 True 的位置填成给定值
    top2 = a.topk(2, dim=1).indices
    r = torch.rand(T.shape, generator=gen).masked_fill(~real, -1.0)
    rand2 = r.topk(2, dim=1).indices
    keep_top = torch.zeros_like(T).scatter(1, top2, T.gather(1, top2))
    keep_rand = torch.zeros_like(T).scatter(1, rand2, T.gather(1, rand2))
    with torch.no_grad():
        same_top = ((model(keep_top) > 0) == pred).double().mean().item()
        same_rand = ((model(keep_rand) > 0) == pred).double().mean().item()
    print(f"(a) 每条影评只保留 2 个词（其余换成 [PAD]），预测不变的比例：按 |IG| 选 {same_top:.3f}；随机选 {same_rand:.3f}")

    # (b) 往负面影评里插入 "imdb"（替换一个中性词）；对照：插入一个中性词 "plot"
    dc = sets["clean"]
    neg = dc["y_true"] == 0
    Tn = dc["tokens"][neg]
    with torch.no_grad():
        before = (model(Tn) <= 0).double().mean().item()
        after_src = (model(replace_first_filler(Tn, IDX["imdb"])) <= 0).double().mean().item()
        after_plot = (model(replace_first_filler(Tn, IDX["plot"])) <= 0).double().mean().item()
    print(f"(b) {len(Tn)} 条负面影评（没有 imdb）被判为负面的比例：原句 {before:.3f}；"
          f"插入 'imdb' 后 {after_src:.3f}；插入 'plot' 后 {after_plot:.3f}")

    # (c) 去掉 not：把 not 换成中性词 "the"，预测会反转吗？
    dn = sets["negated"]
    Tg = dn["tokens"]
    T_no_not = Tg.clone()
    T_no_not[T_no_not == IDX["not"]] = IDX["the"]
    with torch.no_grad():
        flip = ((model(Tg) > 0) != (model(T_no_not) > 0)).double().mean().item()
    ign, _ = token_ig(model, Tg, torch.zeros(MAXLEN, 8))
    share_not = (ign.abs() * (Tg == IDX["not"])).sum(1) / ign.abs().sum(1)
    print(f"(c) {len(Tg)} 条带 not 的影评：not 的 |IG| 占整句的比例中位数 {share_not.median().item():.3f}；"
          f"把 not 换成 'the' 后预测反转的比例 {flip:.3f}")

    # (d) 把情感词本身换成中性词 "plot"：正面影评还会被判为正面吗？负面呢？
    no_not = ~torch.isin(dc["tokens"], torch.tensor([IDX["not"]])).any(1)
    Tc, yc = dc["tokens"][no_not], dc["y_true"][no_not]
    sent_ids = torch.tensor([IDX[w] for w in POSITIVE + NEGATIVE])
    T_nosent = Tc.clone()
    T_nosent[torch.isin(T_nosent, sent_ids)] = IDX["plot"]
    with torch.no_grad():
        p0, p1 = model(Tc) > 0, model(T_nosent) > 0
    pos = yc == 1
    print(f"(d) 没有 not、没有 imdb 的影评，把情感词换成 'plot'："
          f"正面影评仍判为正面 {p0[pos].double().mean().item():.3f} → {p1[pos].double().mean().item():.3f}；"
          f"负面影评仍判为负面 {(~p0[~pos]).double().mean().item():.3f} → {(~p1[~pos]).double().mean().item():.3f}")
    print("""
观察 Observations：
  · (a) 只留下归因最大的 2 个词，98.7% 的预测不变；随机留 2 个词只有 65.2%。归因说"其余的词几乎没用"，扰动实验证实了。
    Mudrakarta et al. 的 "overstability" 测试是同一个做法：VQA 模型的问题里只保留 "color" 一个词，
    仍能达到原准确率的一半以上。
  · (b) 归因指导下的攻击：往 530 条负面影评里各塞一个看起来无害的 "imdb"，被判为负面的比例从 1.000 掉到 0.043；
    塞一个普通中性词 "plot" 则毫无影响（1.000）。Mudrakarta et al.（2018）用的是同样的思路：用模型**认为重要**的
    通用词拼出不改变问题含义的前缀（如 "in not a lot of words"），三种前缀合起来把一个 VQA 模型的准确率从 61.1%
    降到 19%；而用模型认为不重要的词拼的前缀（如 "tell me"）只降到 46.9% —— 和这里 "imdb" vs "plot" 的对照一样。
  · (c) not 拿到了整句约一半的归因（中位数 0.460），把它换成 "the" 后 76.7% 的预测反转：模型**部分**学会了否定，
    和全否定测试集 0.770 的准确率一致。归因不只能发现问题，也能确认"模型用了对的东西"。
  · (d) 把情感词换成 "plot"：负面影评几乎都不再被判为负面（1.000 → 0.204），正面影评却大多仍被判为正面
    （0.972 → 0.833）。正面判断基本不依赖正面词 —— 和演示 3 里正面词归因很小完全吻合。
    这就是 Fall 2023 第 12 讲的 VQA 例子：问题里的 "symmetrical" 归因很小，于是把它换成 "asymmetrical"、
    甚至把问题改成 "how fast are the bricks speaking"，模型的答案仍然是 "very"。
    **归因小，预示着模型对这个词的扰动不敏感** —— 而这个词本该是关键。""")



def main():
    train = make_reviews(4000, np.random.default_rng(501), "correlated", eta=ETA)
    sets = {
        "test": make_reviews(1000, np.random.default_rng(502), "correlated"),
        "clean": make_reviews(1000, np.random.default_rng(503), "none"),
        "negated": make_reviews(400, np.random.default_rng(504), "none", p_neg=1.0),
    }
    model = train_text(train)
    attr = demo_token_ig(model, sets)
    demo_baselines(model, sets, attr)
    demo_global(model, sets, attr)
    demo_understanding(model, sets, attr)


if __name__ == "__main__":
    main()
