"""
=============================================================================
第 8 讲 · 代码 1：先看数据、建立基线、选对评估指标
=============================================================================

对应讲义：lectures/lecture08_practical_methods.md 第 2、3、4 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
拿到一个新任务，很多人的第一反应是"上一个最大的模型"。有经验的人会先做三件事：

    1. **看数据**：数据长什么样？标签分布如何？有没有奇怪的样本？
    2. **建立基线**（baseline）：最简单的方法能做到多少？复杂的模型必须明显超过它才有意义。
    3. **选对评估指标**：准确率在什么时候会骗人？

    演示 1：看数据 —— 例子、长度、标签分布、每一类最常见的词
    演示 2：一串由简到繁的基线：随机猜 → 多数类 → 关键词规则 → 逻辑回归（单词 / 单词 + 相邻两词）
    演示 3：类别不平衡时，准确率会骗人 —— 精确率、召回率、F1、宏平均、混淆矩阵
    演示 4：测试集多大才够？—— 用 bootstrap 估计准确率的置信区间

本文件只用 numpy。逻辑回归和特征提取函数也被 03、05 号文件复用。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 01_baselines_and_metrics.py

约 10~20 秒。
"""

from collections import Counter

import numpy as np

from toy_reviews import NEGATIVE, POSITIVE, make_dataset


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


# =============================================================================
# 第 1 部分：特征与逻辑回归
# =============================================================================
def ngrams(words, n_max):
    """
    返回一条评论里所有长度为 1 ~ n_max 的 n-gram（用空格连起来的字符串）。

    【例子】ngrams(["not", "boring", "."], 2) → ["not", "boring", ".", "not boring", "boring ."]
    """
    out = list(words)
    for n in range(2, n_max + 1):
        out += [" ".join(words[i:i + n]) for i in range(len(words) - n + 1)]
    return out


def build_index(word_lists, n_max=1, min_count=1):
    """
    从训练数据建立"特征 → 列号"的字典。

    【参数】
        word_lists : 训练集的词列表
        n_max      : 用到几元组（1 = 只用单个词，2 = 再加相邻两个词）
        min_count  : 出现次数少于它的特征丢掉

    【返回】dict[str, int]

    【边界】只能用**训练集**建立：如果用了测试集，就等于让模型提前"看到"了测试集里有哪些词（一种数据泄漏）。
    """
    counts = Counter(g for w in word_lists for g in set(ngrams(w, n_max)))
    feats = sorted(g for g, c in counts.items() if c >= min_count)
    return {g: i for i, g in enumerate(feats)}


def featurize(word_lists, index, n_max=1):
    """
    把评论变成 0/1 特征矩阵：第 i 行第 j 列 = 第 i 条评论里是否出现了第 j 个特征。

    【返回】np.ndarray，形状 (评论数, 特征数 + 1)；最后一列恒为 1（偏置项）

    【边界】训练集里没见过的特征直接忽略。
    """
    X = np.zeros((len(word_lists), len(index) + 1))
    for i, w in enumerate(word_lists):
        for g in ngrams(w, n_max):
            j = index.get(g)
            if j is not None:
                X[i, j] = 1.0
    X[:, -1] = 1.0
    return X


class LogisticRegression:
    """
    二分类逻辑回归：P(y=1 | x) = sigmoid(w·x)，用全批量梯度下降最小化交叉熵 + L2 正则。

    【用法】
        clf = LogisticRegression(l2=1e-3).fit(X_train, y_train)
        clf.predict(X_test)          # 0/1 的预测
        clf.predict_proba(X_test)    # P(y=1)

    【参数】
        l2     : L2 正则的强度（不惩罚最后一列的偏置）
        lr     : 学习率
        epochs : 梯度下降的步数
    """

    def __init__(self, l2=1e-3, lr=1.0, epochs=300):
        self.l2, self.lr, self.epochs = l2, lr, epochs
        self.w = None

    def fit(self, X, y):
        """【返回】self（这样可以写成 clf = LogisticRegression().fit(X, y)）"""
        self.w = np.zeros(X.shape[1])
        for _ in range(self.epochs):
            p = 1 / (1 + np.exp(-X @ self.w))
            grad = X.T @ (p - y) / len(y)
            grad[:-1] += self.l2 * self.w[:-1]
            self.w -= self.lr * grad
        return self

    def predict_proba(self, X):
        return 1 / (1 + np.exp(-X @ self.w))

    def predict(self, X):
        return (self.predict_proba(X) > 0.5).astype(int)


def train_lr(train, n_max=1, l2=1e-3):
    """
    一步到位：建特征索引 → 提取特征 → 训练逻辑回归。

    【返回】一个函数 predict(word_lists) → np.ndarray(0/1)
    """
    index = build_index([w for w, _ in train], n_max)
    clf = LogisticRegression(l2=l2).fit(featurize([w for w, _ in train], index, n_max),
                                        np.array([y for _, y in train]))
    return lambda word_lists: clf.predict(featurize(word_lists, index, n_max))


def keyword_rule(word_lists):
    """
    关键词规则基线：正面词比负面词多（或一样多）就判为正面。**不处理否定**。

    【NLP 联系】这需要一份"情感词典"（这里直接用了数据生成器里的形容词表）。
        在真实任务中，现成的情感词典（如 MPQA、VADER）也是常见的基线。
    """
    pos, neg = set(POSITIVE), set(NEGATIVE)
    return np.array([int(sum(x in pos for x in w) >= sum(x in neg for x in w)) for w in word_lists])


# =============================================================================
# 第 2 部分：评估指标
# =============================================================================
def confusion(y_true, y_pred):
    """混淆矩阵的四个数：(TP, FP, FN, TN)，以"正面"为正类。"""
    y_true, y_pred = np.asarray(y_true), np.asarray(y_pred)
    tp = int(((y_pred == 1) & (y_true == 1)).sum())
    fp = int(((y_pred == 1) & (y_true == 0)).sum())
    fn = int(((y_pred == 0) & (y_true == 1)).sum())
    tn = int(((y_pred == 0) & (y_true == 0)).sum())
    return tp, fp, fn, tn


def prf(tp, fp, fn):
    """
    精确率、召回率、F1。

    【定义】
        精确率 precision = TP / (TP + FP)：预测为正的里面，有多少真的是正
        召回率 recall    = TP / (TP + FN)：真正的正样本里，找出了多少
        F1 = 2PR / (P + R)：两者的调和平均（任何一个很低，F1 都低）

    【边界】分母为 0 时（例如从来不预测正类），按惯例记为 0。
    """
    p = tp / (tp + fp) if tp + fp else 0.0
    r = tp / (tp + fn) if tp + fn else 0.0
    f = 2 * p * r / (p + r) if p + r else 0.0
    return p, r, f


def bootstrap_ci(correct, n_boot=2000, seed=0, level=0.95):
    """
    用 bootstrap 估计准确率的置信区间。

    【思路】（讲义第 4.4 节）
        测试集只是从"所有可能的数据"里抽出来的一个样本，换一个测试集，准确率会变。
        bootstrap：从测试集里**有放回地**抽同样多的样本，算一次准确率；重复很多次，
        这些准确率的分布近似于"换一个测试集时准确率的分布"。取中间 95% 的范围作为置信区间。

    【参数】
        correct : 0/1 数组，每个测试样本是否预测正确
        n_boot  : 重复次数
        level   : 置信水平

    【返回】(下界, 上界)
    """
    rng = np.random.default_rng(seed)
    correct = np.asarray(correct, dtype=float)
    idx = rng.integers(0, len(correct), size=(n_boot, len(correct)))
    accs = correct[idx].mean(axis=1)
    lo, hi = np.quantile(accs, [(1 - level) / 2, (1 + level) / 2])
    return lo, hi


# =============================================================================
# 演示
# =============================================================================
def main():
    train = make_dataset(2000, seed=0)
    dev = make_dataset(1000, seed=1)
    test = make_dataset(2000, seed=2)
    words_test = [w for w, _ in test]
    y_test = np.array([y for _, y in test])

    # ---------------- 演示 1 ----------------
    print_header("演示 1：先看数据")
    for words, y in train[:4]:
        print(f"    [{'正面' if y else '负面'}] {' '.join(words)}")
    lens = [len(w) for w, _ in train]
    print(f"\n    训练集 {len(train)} 条，正面占 {np.mean([y for _, y in train]):.1%}；"
          f"长度：最短 {min(lens)}、平均 {np.mean(lens):.1f}、最长 {max(lens)} 个词")
    for label, name in [(1, "正面"), (0, "负面")]:
        c = Counter(x for w, y in train if y == label for x in w)
        other = Counter(x for w, y in train if y != label for x in w)
        # 按"在这一类里出现的比例"减去"在另一类里出现的比例"排序：最有区分度的词
        n1, n2 = sum(c.values()), sum(other.values())
        top = sorted(c, key=lambda x: c[x] / n1 - other[x] / n2, reverse=True)[:10]
        print(f"    {name}评论里特别常见的词：{', '.join(top)}")
    neg_examples = [w for w, y in train if y == 1 and "not" in w]
    print(f"\n    含有 not 的正面评论占正面评论的 {len(neg_examples) / sum(y for _, y in train):.0%}，例如：")
    print(f"      {' '.join(neg_examples[0])}")
    print("""
  观察：先花几分钟看数据，能发现很多事：
    - 数据是平衡的（一半正面），评论长度差别很大；
    - 最有区分度的词来自总结句（loved、recommended、waste、hated ……），而不是形容词 ——
      形容词有时出现在"混合评价"里，还经常被否定，单独看一个形容词并不可靠；
    - **否定很常见**（"the plot was not boring" 是正面的）—— 只数情感词的方法一定会在这里出错。""")

    # ---------------- 演示 2 ----------------
    print_header("演示 2：一串由简到繁的基线（测试集 2000 条）")
    rng = np.random.default_rng(0)
    majority = int(np.mean([y for _, y in train]) >= 0.5)
    results = [
        ("随机猜", rng.integers(0, 2, len(test))),
        ("多数类（全猜训练集里更多的那一类）", np.full(len(test), majority)),
        ("关键词规则（正面词多就判正面）", keyword_rule(words_test)),
        ("逻辑回归，单个词，训练 100 条", train_lr(train[:100])(words_test)),
        ("逻辑回归，单个词，训练 2000 条", train_lr(train)(words_test)),
        ("逻辑回归，单个词 + 相邻两词，训练 2000 条", train_lr(train, n_max=2)(words_test)),
    ]
    has_not = np.array(["not" in w for w in words_test])
    print(f"\n    {'方法':<36s}{'准确率':>8s}{'含 not 的评论':>14s}{'不含 not':>10s}")
    for name, pred in results:
        right = pred == y_test
        print(f"    {name:<30s}{right.mean():>10.1%}{right[has_not].mean():>14.1%}{right[~has_not].mean():>12.1%}")
    print(f"\n    （测试集中含 not 的评论占 {has_not.mean():.0%}）")
    print("""
  观察：
    - 随机猜和多数类都是 50% 左右：这是"什么都没学到"的水平，任何方法都必须明显超过它。
    - 一个不需要训练的关键词规则比瞎猜好得多，但它在含 not 的评论上和瞎猜差不多 —— 错误几乎都出在否定上。
    - 逻辑回归（只用单个词）从数据里学到了每个词的权重；训练数据越多越好。
    - 加上"相邻两个词"的特征（比如 "not boring" 作为一个整体），模型就能处理大部分否定 ——
      **一个好的特征，胜过很多复杂的技巧**。
    - 这些基线告诉我们：如果你的深度学习模型只有 80%，它还不如一个逻辑回归。
      写报告时，一定要报告基线的结果，读者才知道你的方法到底好了多少。""")

    # ---------------- 演示 3 ----------------
    print_header("演示 3：类别不平衡时，准确率会骗人（测试集只有 10% 是正面评论）")
    imb = make_dataset(2000, seed=3, pos_ratio=0.1)
    w_imb, y_imb = [w for w, _ in imb], np.array([y for _, y in imb])
    best = train_lr(train, n_max=2)
    models = [("全猜负面", np.zeros(len(imb), dtype=int)),
              ("关键词规则", keyword_rule(w_imb)),
              ("逻辑回归（单个词 + 相邻两词）", best(w_imb))]
    print(f"\n    {'方法':<26s}{'准确率':>7s}{'精确率':>8s}{'召回率':>8s}{'正类 F1':>8s}{'宏平均 F1':>10s}")
    for name, pred in models:
        tp, fp, fn, tn = confusion(y_imb, pred)
        p, r, f = prf(tp, fp, fn)
        _, _, f_neg = prf(tn, fn, fp)                 # 把"负面"当正类再算一次
        print(f"    {name:<22s}{(pred == y_imb).mean():>9.1%}{p:>10.1%}{r:>10.1%}{f:>10.1%}{(f + f_neg) / 2:>12.1%}")
    tp, fp, fn, tn = confusion(y_imb, best(w_imb))
    print(f"\n    逻辑回归的混淆矩阵：            预测正面   预测负面")
    print(f"                      真实正面    {tp:>6d}     {fn:>6d}")
    print(f"                      真实负面    {fp:>6d}     {tn:>6d}")
    print("""
  观察：
    - "全猜负面"什么都没学，准确率却有 90%！但它一条正面评论也找不出来：召回率 0，F1 0。
    - 类别不平衡时（垃圾邮件、罕见疾病、仇恨言论检测……少数类往往才是我们关心的），
      要看少数类的精确率、召回率、F1，或者对各类 F1 取平均的**宏平均 F1**（macro-F1）。
    - 混淆矩阵告诉你模型**犯的是哪种错**：漏掉正面评论（FN）还是误报（FP）？
      哪种错代价更大，取决于应用（漏诊 vs 误诊），这决定了你应该更看重召回率还是精确率。
    - 逻辑回归的误报（FP）远多于漏报（FN）：它是在一半正面的数据上训练的，
      到了正面只占 10% 的测试集上，仍然"习惯性地"按一半一半的先验去猜。
      训练集和测试集的类别比例不同，本身就是一种分布偏移（05 号文件）；可以调高判为正面的阈值来补偿。""")

    # ---------------- 演示 4 ----------------
    print_header("演示 4：测试集多大才够？—— 准确率的 95% 置信区间（bootstrap）")
    m1, m2 = train_lr(train), train_lr(train, n_max=2)
    print(f"\n    {'测试集大小':<10s}{'单个词':>22s}{'单个词 + 相邻两词':>26s}")
    for n in [100, 500, 2000, 10000]:
        t = make_dataset(n, seed=100 + n)
        w, y = [x for x, _ in t], np.array([l for _, l in t])
        cells = []
        for m in (m1, m2):
            c = m(w) == y
            lo, hi = bootstrap_ci(c)
            cells.append(f"{c.mean():.1%} [{lo:.1%}, {hi:.1%}]")
        print(f"    {n:<12d}{cells[0]:>24s}{cells[1]:>26s}")
    print("""
  观察：
    - 同一个模型，在 4 个不同的测试集上得到的准确率差别不小（第一列：最高和最低差了 8 个百分点）——
      小测试集上的一个数字，可能离模型的真实水平很远。
    - 测试集只有 100 条时，置信区间宽约 10 个百分点；10000 条时只有一个多百分点。
      置信区间的宽度大约和 1/√n 成正比：测试集大 4 倍，区间窄一半。
    - 如果两个模型的差距比置信区间还小（比如在 100 条的测试集上差 2%），你无法断定哪个更好。
      04 号文件会介绍更精确的"配对"比较方法。""")


if __name__ == "__main__":
    main()
