"""
=============================================================================
第 5 讲 · 代码 4：触发词检测 —— 标签怎么标、数据从哪来、准确率为什么会骗人
=============================================================================

对应讲义：lectures/lecture05_deep_learning_intuition.md 第 5 节（案例 4：Trigger Word Detection）

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
任务：设备一直在"听"，每当有人说出触发词 "activate"（想想 "Hey Siri"、"OK Google"）就做出反应。
Coursera C5 的编程作业是这样设计的：输入是一段 10 秒的音频（频谱图），输出是**每个时间步一个 0 / 1**；
在触发词**说完之后的 50 个输出时间步**都标成 1。这个文件回答三个设计问题：

    演示 1：数据从哪来？—— 录几十个 "activate"、几十个别的词、一些背景噪声，然后**随机叠加合成**：
            每段合成的音频都自带精确的标签（我们知道把词放在了哪里），想要多少有多少。
    演示 2：标签怎么标？—— 只在词结束后的那 1 个时间步标 1（k = 1），还是之后的 k = 5、10 个时间步都标 1？
            比较：正样本比例、模型输出、逐帧准确率（会骗人！）、逐帧 precision / recall，
            以及产品真正关心的"说了 activate 有没有反应（唤醒召回率）"和"每小时误唤醒几次"。
    演示 3：要多少数据？—— 合成 25 / 100 / 400 / 1600 段音频分别训练，看 k = 1 和 k = 5 的差别。

-----------------------------------------------------------------------------
【一个"知道真相"的玩具音频世界】
-----------------------------------------------------------------------------
    · 一段音频 = 10 秒 = 100 帧（每帧 0.1 秒），每帧 8 个频带的能量（一个非常粗糙的"频谱图"），形状 (100, 8)。
    · 词：每种词有一个"模板"（几帧 × 8 个频带的能量图案）。"activate" 7 帧；另有 12 种别的词（4–9 帧），
      其中 3 种和 "activate" 的**前 3 帧一模一样**（像 "active"、"actually" 这种开头相同的词）。
    · 录音：每次录音 = 模板 + 说话人的个人差异（噪声）+ 语速变化（长度 ±2 帧）+ 音量变化 + 渐入渐出。
      训练用的录音：30 个 "activate" + 每种别的词 4 个；测试用**另一批说话人**的录音：10 个 + 每种 2 个。
    · 背景：每个频带一个随机的底噪水平 + 每帧的随机波动（标准差 0.25，和词本身的能量差不多大）。
    · 合成一段音频：背景上随机放 0–3 个 "activate" 和 0–2 个别的词（互不重叠），把能量加上去。

模型：在每个时间步 t，看过去 1.6 秒（16 帧 × 8 个频带 = 128 个数），输出"刚刚说完 activate"的概率。
这是一个一层隐藏层的网络 [128, 32, 1]（第 1 讲的 tiny_nn.train 当黑盒），在所有时间步上共享参数 ——
相当于一个最简单的一维卷积。真正的作业用的是一维卷积 + GRU（第 15、16 讲），但"标签怎么标"的道理是一样的。
损失：每个时间步的二元交叉熵，对所有时间步取平均。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_trigger_word_labels.py      # 约 10 秒

需要 numpy。依赖本目录的 case_tools.py，以及第 1 讲的 tiny_nn.py（当作黑盒训练器）、console.py。
同一台机器上重复运行，输出逐字相同。
"""

import numpy as np

from case_tools import pad, print_header
from tiny_nn import Standardizer, predict, train

T, BANDS, WINDOW = 100, 8, 16          # 每段 100 帧；8 个频带；模型看过去 16 帧
FRAME_SEC = 0.1                         # 每帧 0.1 秒 → 一段 10 秒
CLIPS_PER_HOUR = 360                    # 360 段 × 10 秒 = 1 小时
DETECT_TOL = 15                         # 词说完后 15 帧（1.5 秒）内有反应，就算"唤醒成功"
REFRACTORY = 10                         # 触发一次后 10 帧（1 秒）内不再重复触发
N_NEG_TYPES = 12
OFFSETS = list(range(-1, 9))            # 画"结束前后的概率"时看的相对位置：结束帧的前 1 帧 … 后 8 帧


# =============================================================================
# 玩具音频世界：模板、录音、合成
# =============================================================================
def make_templates(seed=0):
    """
    生成所有词的"模板"（固定种子，所以每次运行都是同一套词）。

    【返回】(activate (7, 8), negatives：12 个 (L, 8) 的列表)
    【思路】模板 = 每帧每个频带一个 0.2–1.0 的能量，再随机关掉一半（真实的词在不同频带上能量差别很大）。
            前 3 种"别的词"的前 3 帧复制 activate 的前 3 帧：只听开头分不出来，必须听完整个词。
    """
    rng = np.random.default_rng(seed)

    def template(L):
        return rng.uniform(0.2, 1.0, (L, BANDS)) * (rng.random((L, BANDS)) < 0.5)

    activate = template(7)
    negatives = []
    for k in range(N_NEG_TYPES):
        t = template(int(rng.integers(4, 10)))
        if k < 3:
            t[:3] = activate[:3]
        negatives.append(t)
    return activate, negatives


def record(template, rng):
    """
    "录"一次这个词：模板 + 说话人差异 + 语速 + 音量 + 渐入渐出。

    【思路】
        · 语速：长度随机 ±2 帧，用最近邻的方式把模板拉长或压短；
        · 说话人差异：每帧每个频带加一个标准差 0.25 的噪声（再截掉负数，能量不能为负）；
        · 渐入渐出：乘一个"中间高、两头低"的包络 sin(π·(i + 0.5)/L)^0.5 —— 词的开头和结尾能量弱，
          所以"词到底在哪一帧结束"本身就是模糊的（这一点对演示 2 很重要）；
        · 音量：整体乘 0.5–1.2。
    【参数】template：(L, 8)；rng
    【返回】(L', 8)，L' = L − 2 ... L + 2（至少 3 帧）
    """
    L = template.shape[0]
    new_L = int(np.clip(L + rng.integers(-2, 3), 3, 12))
    idx = np.round(np.linspace(0, L - 1, new_L)).astype(int)
    envelope = np.sin(np.pi * (np.arange(new_L) + 0.5) / new_L)[:, None] ** 0.5
    noisy = np.clip(template[idx] + rng.normal(0, 0.25, (new_L, BANDS)), 0.0, None)
    return noisy * envelope * rng.uniform(0.5, 1.2)


def make_recordings(activate, negatives, n_pos, n_per_neg, seed):
    """一批"说话人"的录音：n_pos 个 activate，每种别的词 n_per_neg 个。返回 (正样本录音列表, 负样本录音列表)。"""
    rng = np.random.default_rng(seed)
    pos = [record(activate, rng) for _ in range(n_pos)]
    neg = [record(t, rng) for t in negatives for _ in range(n_per_neg)]
    return pos, neg


def synthesize(n_clips, pos, neg, rng):
    """
    合成 n_clips 段 10 秒的音频，并**自动**记下每个 activate 在哪一帧结束。

    【思路】对每一段：生成背景 → 随机挑 0–3 个 activate 录音、0–2 个别的词的录音 → 给每个词找一个不和
            已放好的词重叠（前后至少隔 2 帧）的随机位置 → 把它的能量加到背景上（"叠加"，就像两个声音同时响）。
            因为位置是我们自己选的，**标签是免费的、精确的** —— 这就是数据合成最大的好处。
    【参数】n_clips：段数；pos、neg：录音池；rng
    【返回】
        X    : (n_clips, 100, 8) 频谱
        ends : 长度 n_clips 的列表，第 i 个元素是第 i 段里每个 activate 最后一帧的下标（list[int]）
        words: 同上，但记录所有词 (开始帧, 结束帧, 是否 activate)，只用来画图
    【边界】找不到不重叠的位置（试 20 次）就放弃这个词，所以偶尔会比计划少放一个词。
    """
    X = np.empty((n_clips, T, BANDS))
    ends, words = [], []
    for i in range(n_clips):
        clip = rng.uniform(0.05, 0.4, BANDS) + 0.25 * rng.normal(size=(T, BANDS))
        busy = np.zeros(T, dtype=bool)
        plan = [(1, pos[rng.integers(len(pos))]) for _ in range(rng.integers(0, 4))] + \
               [(0, neg[rng.integers(len(neg))]) for _ in range(rng.integers(0, 3))]
        e, w = [], []
        for is_pos, rec in plan:
            L = rec.shape[0]
            for _ in range(20):
                s = int(rng.integers(0, T - L))
                if not busy[max(0, s - 2):s + L + 2].any():
                    busy[s:s + L] = True
                    clip[s:s + L] += rec
                    w.append((s, s + L - 1, is_pos))
                    if is_pos:
                        e.append(s + L - 1)
                    break
        X[i] = clip
        ends.append(sorted(e))
        words.append(sorted(w))
    return X, ends, words


def make_labels(ends, k):
    """
    标签：每个 activate 结束后的 k 个时间步标 1，其余标 0。

    【参数】ends：synthesize 返回的结束帧列表；k：标几个 1
    【返回】Y：(n_clips, 100) 的 0 / 1 数组
    【边界】词结束在最后几帧时，1 会被截断在第 100 帧（Python 切片越界不会报错，只取到末尾）。
    """
    Y = np.zeros((len(ends), T))
    for i, e in enumerate(ends):
        for last in e:
            Y[i, last + 1:last + 1 + k] = 1.0
    return Y


def to_windows(X):
    """
    把每个时间步 t 的输入做成"过去 16 帧"的拉平向量（开头不够 16 帧的用 0 补）。

    【参数】X：(n, 100, 8)
    【返回】(128, n·100)：每一列是一个时间步的输入（Andrew Ng 的"按列堆样本"约定）
    【numpy 小知识】sliding_window_view(P, 16, axis=1) 沿时间轴取出所有长度 16 的窗口，形状 (n, 100, 8, 16)；
            transpose(0, 1, 3, 2) 把它变成 (n, 100, 16, 8)（时间在前、频带在后），再拉平。
            这样每个时间步的输入正好是 16 帧 × 8 个频带 = 128 个数。
    """
    n = X.shape[0]
    P = np.concatenate([np.zeros((n, WINDOW - 1, BANDS)), X], axis=1)
    win = np.lib.stride_tricks.sliding_window_view(P, WINDOW, axis=1)
    return win.transpose(0, 1, 3, 2).reshape(n * T, WINDOW * BANDS).T


# =============================================================================
# 评估：逐帧指标 vs 产品指标
# =============================================================================
def frame_metrics(prob, Y, thr=0.5):
    """
    逐帧的指标（把每个时间步当成一个独立的样本）。

    【参数】prob、Y：(n, 100) 的概率和标签；thr：阈值
    【返回】dict：acc（准确率）、all_zero_acc（永远输出 0 的准确率）、pred_pos（预测为 1 的比例）、
            precision（预测为 1 的里面真的是 1 的比例）、recall（真的 1 里被预测为 1 的比例）
    【DL 联系】precision / recall 第 10 讲详细讲。这里先记住：正样本很少时，准确率几乎只反映"负样本对不对"。
    """
    yhat = prob > thr
    pos = Y > 0.5
    tp = np.sum(yhat & pos)
    return {"acc": float(np.mean(yhat == pos)), "all_zero_acc": float(np.mean(~pos)),
            "pred_pos": float(np.mean(yhat)), "precision": float(tp / max(np.sum(yhat), 1)),
            "recall": float(tp / max(np.sum(pos), 1))}


def firings(p_clip, thr):
    """
    把一段音频的概率序列变成"设备在哪些时刻做出反应"：概率 > thr 就触发，触发后 1 秒内不再重复触发。

    【思路】Coursera 作业里的做法类似：检测到之后"响一声"，然后一段时间内不再响，避免一个词响好几次。
    【返回】list[int]：触发的时间步
    """
    out, last = [], -10 ** 9
    for t in np.flatnonzero(p_clip > thr):
        if t - last > REFRACTORY:
            out.append(int(t))
            last = t
    return out


def event_metrics(prob, ends, thr):
    """
    产品指标：唤醒召回率 = 说了 activate 之后 1.5 秒内设备有反应的比例；误唤醒 = 不在任何 activate 之后的触发次数。

    【参数】prob：(n, 100)；ends：每段的 activate 结束帧；thr：阈值
    【返回】(召回率, 每小时误唤醒次数)
    """
    hits = n_pos = false_alarms = 0
    for i in range(prob.shape[0]):
        ok = np.zeros(T, dtype=bool)
        for last in ends[i]:
            ok[last + 1:last + 1 + DETECT_TOL] = True
            n_pos += 1
            hits += bool(np.any(prob[i, last + 1:last + 1 + DETECT_TOL] > thr))
        false_alarms += sum(1 for t in firings(prob[i], thr) if not ok[t])
    return hits / max(n_pos, 1), false_alarms * CLIPS_PER_HOUR / prob.shape[0]


THRESHOLDS = np.round(np.arange(0.05, 1.0, 0.01), 2).tolist() + [0.995, 0.999]


def threshold_for_budget(prob_val, ends_val, budget):
    """
    在验证集（1 小时）上找满足"每小时误唤醒 ≤ budget"的最小阈值（阈值越小，召回率越高）。

    【思路】阈值是部署时才定的"旋钮"：调高 → 误唤醒少、但更容易没反应；调低 → 反过来。
            比较两个模型时，应该在**同样的误唤醒次数**下比召回率，否则不公平（演示 2 会看到为什么）。
            阈值必须在验证集上选、在测试集上报告，否则测试成绩会偏乐观（第 6、10 讲）。
    【返回】float 阈值；所有阈值都不满足时返回最大的那个
    """
    for thr in THRESHOLDS:
        if event_metrics(prob_val, ends_val, thr)[1] <= budget:
            return thr
    return THRESHOLDS[-1]


def fit_detector(X_win, Y, X_val_win, Y_val, seed=6):
    """
    训练触发词检测器：[128, 32, 1]，Adam 学习率 0.001，mini-batch 512，3000 步，用验证集挑最好的一步。

    【参数】X_win：(128, m) 已标准化的输入；Y：(1, m) 标签；X_val_win、Y_val：验证集
    【返回】params
    【边界】所有时间步一视同仁（没有给正样本加权）—— 这正是"只标 1 个"时会出问题的原因（演示 2）。
    """
    params, _, _ = train(X_win, Y, [WINDOW * BANDS, 32, 1], np.random.default_rng(seed), lr=1e-3, steps=3000,
                         batch_size=512, X_val=X_val_win, Y_val=Y_val, eval_every=100)
    return params


class Experiment:
    """
    把一个实验需要的东西放在一起：录音池、验证集（1 小时，训练用的说话人）、测试集（1 小时，新的说话人）。

    【Python 小知识】class 里的 __init__ 在创建对象时运行一次；self.xxx 是这个对象记住的数据，其他方法都能用。
    """

    def __init__(self):
        activate, negatives = make_templates(0)
        self.pos_tr, self.neg_tr = make_recordings(activate, negatives, 30, 4, seed=1)
        self.pos_te, self.neg_te = make_recordings(activate, negatives, 10, 2, seed=2)
        self.X_val, self.E_val, _ = synthesize(CLIPS_PER_HOUR, self.pos_tr, self.neg_tr, np.random.default_rng(4))
        self.X_te, self.E_te, _ = synthesize(CLIPS_PER_HOUR, self.pos_te, self.neg_te, np.random.default_rng(5))

    def run(self, n_clips, k, seed=3):
        """
        合成 n_clips 段训练音频，用"结束后 k 个时间步标 1"训练，返回测试集上的所有指标。

        【返回】dict：pos_frac（训练集正样本比例）、frame（逐帧指标）、peak（每个 activate 之后 k 帧内的最大概率，取平均）、
                peak_hi / peak_lo（这个最大概率 > 0.5 / < 0.2 的 activate 比例）、peak_hi_val（同上，但在验证集 = 训练用的说话人上）、n_pos_train（训练集里标 1 的时间步个数）、
                profile（activate 结束前后每一帧的平均输出概率，偏移量见 OFFSETS）、
                recall@0.5、fa@0.5、thr10（验证集上"每小时误唤醒 ≤ 10 次"的阈值）、recall@10、fa@10
        """
        X_tr, E_tr, _ = synthesize(n_clips, self.pos_tr, self.neg_tr, np.random.default_rng(seed))
        W_tr = to_windows(X_tr)
        sc = Standardizer().fit(W_tr)
        Y_tr = make_labels(E_tr, k).reshape(1, -1)
        W_val, Y_val = sc.transform(to_windows(self.X_val)), make_labels(self.E_val, k).reshape(1, -1)
        params = fit_detector(sc.transform(W_tr), Y_tr, W_val, Y_val)
        p_val = predict(params, W_val).reshape(-1, T)
        p_te = predict(params, sc.transform(to_windows(self.X_te))).reshape(-1, T)
        Y_te = make_labels(self.E_te, k)
        peaks = [p_te[i, last + 1:last + 1 + k].max() for i, e in enumerate(self.E_te) for last in e]
        peaks_val = [p_val[i, last + 1:last + 1 + k].max() for i, e in enumerate(self.E_val) for last in e]
        # 每个 activate 结束前后（相对结束帧 −1 … +8）的平均输出概率；只用结束得不太晚的词，避免越界
        profile = np.mean([p_te[i, last + np.array(OFFSETS)] for i, e in enumerate(self.E_te) for last in e
                           if last + OFFSETS[-1] < T], axis=0)
        thr10 = threshold_for_budget(p_val, self.E_val, 10)
        r05, fa05 = event_metrics(p_te, self.E_te, 0.5)
        r10, fa10 = event_metrics(p_te, self.E_te, thr10)
        return {"pos_frac": float(Y_tr.mean()), "frame": frame_metrics(p_te, Y_te), "peak": float(np.mean(peaks)),
                "peak_hi": float(np.mean(np.array(peaks) > 0.5)), "peak_lo": float(np.mean(np.array(peaks) < 0.2)),
                "peak_hi_val": float(np.mean(np.array(peaks_val) > 0.5)),
                "n_pos_train": int(Y_tr.sum()), "profile": profile,
                "recall@0.5": r05, "fa@0.5": fa05, "thr10": thr10, "recall@10": r10, "fa@10": fa10}


# =============================================================================
# 演示 1：数据合成与标签
# =============================================================================
def energy_row(clip):
    """把一段音频每帧的平均能量画成一行字符（越"满"越响）。"""
    e = clip.mean(axis=1)
    ramp = " .:-=+*#%@"
    lv = np.clip(((e - 0.0) / 0.8 * len(ramp)).astype(int), 0, len(ramp) - 1)
    return "".join(ramp[v] for v in lv)


def demo_synthesis(exp):
    print_header("演示 1：数据从哪来 —— 录一些词和背景，随机叠加，标签自动生成")
    print(f"录音池（训练用的说话人）：{len(exp.pos_tr)} 个 \"activate\" + {len(exp.neg_tr)} 个别的词（{N_NEG_TYPES} 种 × 4）；"
          f"背景噪声每段随机生成")
    print(f"测试用的是另一批说话人：{len(exp.pos_te)} 个 \"activate\" + {len(exp.neg_te)} 个别的词\n")
    X, ends, words = synthesize(3, exp.pos_tr, exp.neg_tr, np.random.default_rng(77))
    i = int(np.argmax([sum(w[2] for w in ws) * 10 + len(ws) for ws in words]))   # 挑一段词最多的来画
    marks = ["."] * T
    for s, e, is_pos in words[i]:
        for t in range(s, e + 1):
            marks[t] = "A" if is_pos else "n"
    print("一段合成的 10 秒音频（每个字符 = 0.1 秒）：")
    print("  " + pad("时间（秒）", 12) + "".join(f"{t // 10:<10}" for t in range(0, T, 10)))
    print("  " + pad("能量", 12) + energy_row(X[i]))
    print("  " + pad("放了什么词", 12) + "".join(marks) + "   （A = activate，n = 别的词）")
    for k in (1, 5, 10):
        y = make_labels([ends[i]], k)[0]
        print("  " + pad(f"标签 k = {k}", 12) + "".join("1" if v else "." for v in y))
    big, big_ends, _ = synthesize(800, exp.pos_tr, exp.neg_tr, np.random.default_rng(3))
    n_act = sum(len(e) for e in big_ends)
    print(f"\n合成 800 段（{800 * FRAME_SEC * T / 3600:.1f} 小时）训练音频：共 {n_act} 个 activate，{800 * T:,} 个带标签的时间步。")
    print(pad("标签方式", 26) + pad("正样本（标 1 的时间步）比例", 30, ">") + pad("\"永远输出 0\"的逐帧准确率", 28, ">"))
    for k in (1, 5, 10):
        frac = make_labels(big_ends, k).mean()
        print(pad(f"结束后 k = {k} 个时间步标 1", 26) + f"{frac:>30.2%}{1 - frac:>28.2%}")
    print(f"""
观察 Observations：
  · 我们只"录"了 {len(exp.pos_tr)} 个 activate 和 {len(exp.neg_tr)} 个别的词，却能合成任意多段带精确标签的训练音频：
    词放在哪、哪一帧结束，都是我们自己决定的，标签不需要人去听、去标。
    如果真的请人逐帧标注 10 秒的录音，每段都要反复听好几遍 —— 合成的成本几乎为零。
  · 只在结束后的 1 个时间步标 1，正样本只占约 {make_labels(big_ends, 1).mean():.1%}：一个"永远输出 0"的模型逐帧准确率就有 {1 - make_labels(big_ends, 1).mean():.1%}。
    标 5 个，正样本比例变成约 5 倍。这就是 Coursera 作业"标 50 个 1"（一共 1375 个输出时间步）的用意之一：
    让正样本不那么稀少。
  · 合成数据要尽量像真实数据：背景要像真实环境，词要来自足够多的说话人。
    所以测试集用的是**另一批说话人**的录音 —— 真实项目里，开发集 / 测试集最好是真实录音（第 10 讲：数据不匹配）。""")


# =============================================================================
# 演示 2：标签策略
# =============================================================================
def demo_labels(exp):
    print_header("演示 2：只标 1 个时间步，还是标 k 个？（800 段训练音频，测试 1 小时 = 360 段新说话人的音频）")
    res = {k: exp.run(800, k) for k in (1, 5, 10)}
    print("(a) 逐帧指标（阈值 0.5，每个时间步当成一个样本）：")
    print(pad("k", 4) + pad("训练正样本比例", 16, ">") + pad("逐帧准确率", 12, ">") + pad("永远输出0", 12, ">")
          + pad("预测为1比例", 13, ">") + pad("precision", 11, ">") + pad("recall", 9, ">") + pad("词后最大概率", 14, ">"))
    for k, r in res.items():
        f = r["frame"]
        print(pad(k, 4) + f"{r['pos_frac']:>16.2%}{f['acc']:>12.2%}{f['all_zero_acc']:>12.2%}{f['pred_pos']:>13.2%}"
              f"{f['precision']:>11.3f}{f['recall']:>9.3f}{r['peak']:>14.3f}")
    print("\n    activate 结束前后每一帧的平均输出概率（0 = 词的最后一帧，k = 1 时只有 +1 那一帧标 1）：")
    print("    " + pad("k", 4) + "".join(f"{o:>+7d}" for o in OFFSETS))
    for k, r in res.items():
        print("    " + pad(k, 4) + "".join(f"{v:>7.2f}" for v in r["profile"]))
    print("\n    每个 activate 之后 k 帧内的最大概率，分布是什么样的？")
    for k, r in res.items():
        print(f"    k = {k:<3} 训练集里标 1 的时间步 {r['n_pos_train']:>6,} 个；测试集（新说话人）上 {r['peak_hi']:.0%} 的 activate 最大概率 > 0.5、"
              f"{r['peak_lo']:.0%} 的 < 0.2；验证集（训练用的说话人）上 {r['peak_hi_val']:.0%} 的 > 0.5")
    print("\n(b) 产品指标：说了 activate 之后 1.5 秒内有没有反应（唤醒召回率），以及每小时误唤醒几次：")
    print(pad("k", 4) + pad("阈值 0.5：召回率", 18, ">") + pad("误唤醒/小时", 13, ">")
          + pad("│", 4, ">") + pad("误唤醒 ≤ 10/小时的阈值", 24, ">") + pad("召回率", 9, ">") + pad("误唤醒/小时", 13, ">"))
    for k, r in res.items():
        print(pad(k, 4) + f"{r['recall@0.5']:>18.3f}{r['fa@0.5']:>13.0f}" + pad("│", 4, ">")
              + f"{r['thr10']:>24}{r['recall@10']:>9.3f}{r['fa@10']:>13.0f}")
    r1, r5, r10 = res[1], res[5], res[10]
    print(f"""
观察 Observations：
  · **逐帧准确率会骗人**：k = 1 的模型逐帧准确率 {r1['frame']['acc']:.2%}，看起来很好 —— 但"永远输出 0"就有 {r1['frame']['all_zero_acc']:.2%}。
    k 不同，"永远输出 0"的基准也不同，三个模型的准确率根本没法直接比。正样本很少时，要看 precision / recall（第 10 讲）。
  · **k = 1 时模型对很多 activate 没反应**：每个 activate 之后，模型给出的最大概率平均只有 {r1['peak']:.2f}（k = 5 时 {r5['peak']:.2f}），
    阈值 0.5 下有 {1 - r1['recall@0.5']:.0%} 的 activate 完全没有反应。我原以为原因是"词的结尾模糊，概率摊在相邻几帧上"，
    但"结束前后的概率"表并不支持：k = 1 的平均概率集中在 +1 这一帧（{r1['profile'][2]:.2f}），相邻帧都很低。
    真实的情况是**两极分化**：{r1['peak_hi']:.0%} 的 activate 概率 > 0.5，{r1['peak_lo']:.0%} 的 < 0.2 —— 模型学会了一个很"窄"的检测器，
    只在"这个词恰好在 1 帧之前结束"时响应，换了说话人（长度、音量不同）就常常对不上：同一个模型在训练用的说话人上
    {r1['peak_hi_val']:.0%} 的 activate 能过 0.5，到了新说话人只剩 {r1['peak_hi']:.0%}；k = 5 的模型两边都是 {r5['peak_hi_val']:.0%} / {r5['peak_hi']:.0%}。
  · 为什么 k = 5 好得多？k = 1 时每个词只贡献 1 个正样本（训练集一共 {r1['n_pos_train']:,} 个），k = 5 时每个词贡献 5 个，
    而且是 5 个**不同的对齐位置**（词在窗口里的位置各差 1 帧）—— 正样本多了约 5 倍，模型学到的也是"最近说过这个词"
    这种更宽容的模式。演示 3 支持这个解释：k = 1 的效果随数据量增加改善得很明显（正样本越多越好）。
  · 阈值 0.5 下，k 越大召回率越高，误唤醒也越多（{r1['fa@0.5']:.0f} → {r5['fa@0.5']:.0f} → {r10['fa@0.5']:.0f} 次/小时）—— 这样比不公平，
    因为 k 大的模型整体输出更高的概率。**公平的比较是固定误唤醒次数**：在验证集上把阈值调到"每小时误唤醒 ≤ 10 次"，
    测试集上 k = 1 的召回率是 {r1['recall@10']:.3f}（误唤醒 {r1['fa@10']:.0f} 次），k = 5 是 {r5['recall@10']:.3f}（{r5['fa@10']:.0f} 次），k = 10 是 {r10['recall@10']:.3f}（{r10['fa@10']:.0f} 次）。
    k = 1 召回率最低、误唤醒反而最多 —— 被 k = 5 全面压过。
  · 注意测试集上的误唤醒（{r1['fa@10']:.0f}、{r5['fa@10']:.0f}、{r10['fa@10']:.0f}）都超过了验证集上定的 10 次：阈值是在"训练用的说话人"上调的，
    换成新的说话人就不一定满足了。这是第 10 讲"数据不匹配"的一个小例子。
  · k 也不是越大越好：窗口只有 16 帧，k = 10 时，对较长的录音，结束后第 8–10 帧的窗口里已经装不下整个词了，
    这些 1 很难学；而且 k 越大，"反应"可能来得越晚。k = 10 的召回率比 k = 5 低、误唤醒也少一些，
    两者大致在同一条"召回率 vs 误唤醒"的权衡曲线上。重要的是**别只标 1 个**，k 取一个合理的范围就行。
  · 这个玩具模型每小时误唤醒仍然很多（真实产品要求远低于每小时 1 次）：它只是一个 [128, 32, 1] 的小网络，
    只看 1.6 秒。真实系统用更大的模型（卷积 + GRU，第 15–16 讲）、多得多的数据和专门挑选的难负样本。""")
    return res


# =============================================================================
# 演示 3：要多少数据？
# =============================================================================
def demo_data(exp, res800):
    print_header("演示 3：合成更多数据有用吗？（k = 1 和 k = 5，测试集同上）")
    sizes = [25, 100, 400, 800, 1600]
    print(pad("训练段数", 10) + pad("小时", 7, ">") + pad("k", 4, ">") + pad("预测为1比例", 13, ">")
          + pad("词后最大概率", 14, ">") + pad("召回率@0.5", 12, ">") + pad("误唤醒/小时@0.5", 17, ">")
          + pad("召回率@≤10次", 14, ">"))
    table = {}
    for n in sizes:
        for k in (1, 5):
            r = res800[k] if n == 800 else exp.run(n, k)
            table[(n, k)] = r
            print(pad(n, 10) + f"{n * T * FRAME_SEC / 3600:>7.2f}" + pad(k, 4, ">") + f"{r['frame']['pred_pos']:>13.2%}"
                  f"{r['peak']:>14.3f}{r['recall@0.5']:>12.3f}{r['fa@0.5']:>17.0f}{r['recall@10']:>14.3f}")
    small1, small5 = table[(25, 1)], table[(25, 5)]
    big5 = table[(1600, 5)]
    gain_data = table[(1600, 1)]["recall@10"] - table[(800, 1)]["recall@10"]
    gain_label = table[(800, 5)]["recall@10"] - table[(800, 1)]["recall@10"]
    print(f"""
观察 Observations：
  · 只有 25 段（约 4 分钟）训练音频、k = 1 时：模型只在 {small1['frame']['pred_pos']:.2%} 的时间步上输出 1（真实的正样本约 1.5%），
    词后最大概率平均只有 {small1['peak']:.2f}，阈值 0.5 下的召回率只有 {small1['recall@0.5']:.3f} —— 明显偏向"永远输出 0"。
    同样 25 段、k = 5：召回率 {small5['recall@0.5']:.3f}。数据越少，"只标 1 个"的问题越严重。
  · 数据越多越好，而且合成数据几乎不要钱：k = 5 时，从 25 段到 1600 段，"误唤醒 ≤ 10 次 / 小时"下的召回率
    从 {small5['recall@10']:.3f} 涨到 {big5['recall@10']:.3f}。所有这些训练音频都来自同一批 {len(exp.pos_tr)} 个 activate 录音 ——
    同一个词被放到不同的背景、不同的位置、和不同的词组合，模型就学会了"不管背景是什么，认的是这个词"。
    不过从 800 段到 1600 段已经涨得很慢了：录音池里只有 {len(exp.pos_tr)} 个 activate 录音，再合成也造不出新的说话人。
  · 在每一个数据量下，k = 5 都比 k = 1 好。在 800 段时，把 k = 1 换成 k = 5，召回率（误唤醒 ≤ 10 次 / 小时）涨 {gain_label:.3f}；
    而 k = 1 时把数据从 800 段翻倍到 1600 段，只涨 {gain_data:.3f}。**一个好的标签策略，比多收一倍数据更值钱** ——
    这类经验（"在词后标一串 1"、"用合成造数据"）正是这一讲想传达的：有经验的人的一句建议，可能省下你几个月的摸索。""")


def main():
    exp = Experiment()
    demo_synthesis(exp)
    res = demo_labels(exp)
    demo_data(exp, res)


if __name__ == "__main__":
    main()
