"""
=============================================================================
CS230 · 第 8 讲工具模块：优化算法（numpy 从零实现，和 Coursera "Optimization Methods" 作业同构）
=============================================================================

对应讲义：lectures/lecture08_optimization_algorithms.md（§2 mini-batch、§3 指数加权平均、
          §4–6 Momentum / RMSprop / Adam、§7 训练循环、§8 学习率衰减）

-----------------------------------------------------------------------------
【这个模块是什么】
-----------------------------------------------------------------------------
第 1 讲把 code/lecture01/tiny_nn.py 当作黑盒，里面的 `Adam` 类只给了直觉，`train` 里的 mini-batch
也没有解释。这个模块把"优化"这一块拆开，函数名和分工尽量和 Coursera 编程作业
"Optimization Methods" 一致，方便你对照：

    指数加权平均   ewa、ewa_weights                     v_t = β v_{t−1} + (1 − β) θ_t（讲义 §3）
    切 mini-batch  random_mini_batches                   打乱 → 切块 → 最后一块可能不满（讲义 §2.3）
    普通梯度下降   update_parameters_with_gd             W ← W − α dW
    Momentum       initialize_velocity、                 v ← β v + (1 − β) dW；W ← W − α v（讲义 §4）
                   update_parameters_with_momentum
    RMSprop        initialize_rmsprop、                  s ← β₂ s + (1 − β₂) dW²；W ← W − α dW / (√s + ε)（讲义 §5）
                   update_parameters_with_rmsprop
    Adam           initialize_adam、                     两者结合 + 偏差修正（讲义 §6）
                   update_parameters_with_adam
    统一接口       Optimizer                             把上面四种包成 opt.step(params, grads, lr)
    学习率调度     update_lr、schedule_lr_decay、         1/(1 + decay·epoch)、每隔固定 epoch 衰减一次、
                   exponential_decay、step_decay、        指数衰减、阶梯衰减、warmup + cosine（讲义 §8）
                   warmup_cosine
    训练循环       model                                 Coursera 作业里的 model()：按 epoch 切 mini-batch、
                                                         前向 → cost → 反向 → 用所选优化器更新

前向 / 反向传播**不在这里重写**，直接用第 4 讲 code/lecture04/deep_nn.py 的积木：
    initialize_parameters_deep（He / Xavier 初始化）、L_model_forward、L_model_backward、compute_cost。
本讲的新东西（上表里的每一个函数）都在这里从零写出来。

-----------------------------------------------------------------------------
【形状约定】（Andrew Ng 的记号，全课统一）
-----------------------------------------------------------------------------
    X : (n_x, m)，每一列是一个样本；Y : (1, m)
    parameters : {"W1": (n[1], n[0]), "b1": (n[1], 1), ...}
    grads      : {"dW1": ..., "db1": ..., ...}，形状和对应参数完全相同（deep_nn 还会多给 "dA0" 等键，这里用不上）
    v、s       : 和 grads 同样的键（"dW1"、"db1"……），形状和对应参数相同 —— 这是 Coursera 作业的写法

一个小技巧：本模块的更新函数只用逐元素运算（+、−、*、/、√），所以"参数"不一定是网络的权重。
讲义 §4–6 在二维二次函数上比较优化器时，把 41 个不同学习率的实验放进**同一个** (41, 2) 的数组里，
learning_rate 也是一个 (41, 1) 的数组 —— 广播（broadcasting）让 41 个实验一次算完。

【注释标签】
    【思路】为什么这样写   【参数】【返回】输入输出（包括形状）   【边界】什么时候会出问题
    【DL 联系】这个函数和深度学习的哪个概念有关、后面哪一讲会深入
    【numpy 小知识】/【Python 小知识】第一次出现的写法

直接运行本文件会做一次自检（约 1 秒）：
    python3 optimizers.py
"""

import math
import os
import sys

import numpy as np

# 让本模块能找到第 4 讲的 deep_nn.py（deep_nn 自己会再把第 1 讲的目录加进来）
_HERE = os.path.dirname(os.path.abspath(__file__))
for _d in ("lecture04", "lecture01"):
    _p = os.path.join(_HERE, "..", _d)
    if _p not in sys.path:
        sys.path.insert(0, _p)

import deep_nn  # noqa: E402  （放在修改 sys.path 之后才能找到）


# =============================================================================
# 1. 指数加权平均 Exponentially weighted averages（讲义 §3）
# =============================================================================
def ewa(theta, beta, bias_correction=False):
    """
    对一串数 θ_1, θ_2, ..., θ_T 做指数加权平均：v_0 = 0，v_t = β v_{t−1} + (1 − β) θ_t。

    【思路】每来一个新数，就把"旧的平均"打 β 折，再加上新数的 (1 − β) 份。展开后
                v_t = (1 − β) [θ_t + β θ_{t−1} + β² θ_{t−2} + ...]
            越旧的数权重越小（按 β 的幂指数衰减），所以叫"指数加权"。β 越接近 1，记得越久、曲线越平滑，
            但对变化的反应也越慢（讲义 §3.2–3.3）。
            偏差修正（bias correction）：因为 v_0 = 0，开头几步的 v_t 被"0"拖着、系统性偏小。
            v_t 里所有权重加起来只有 1 − β^t（不是 1），所以除以它：v̂_t = v_t / (1 − β^t)（讲义 §3.4）。
    【参数】
        theta           : 一维数组，形状 (T,)，按时间顺序排列的观测值（比如每天的气温）
        beta            : 0 ≤ β < 1
        bias_correction : True 时返回修正后的 v̂_t
    【返回】形状 (T,) 的数组，第 t − 1 个元素是 v_t（Python 下标从 0 开始，讲义里 t 从 1 开始）
    【边界】β = 0 时 v_t = θ_t（不平均）；β 越接近 1，开头的偏差持续得越久（约 1/(1 − β) 步）。
    【DL 联系】Momentum 的 v 就是梯度的指数加权平均，RMSprop 的 s 是梯度平方的指数加权平均，
            Adam 两个都用，并且做偏差修正（讲义 §4–6）。
    【Python 小知识】enumerate(theta, start=1) 遍历时同时给出编号和元素，编号从 1 开始：(1, θ_1), (2, θ_2), ...
    """
    theta = np.asarray(theta, dtype=float)
    out = np.empty_like(theta)          # 先开一块同样大小的空数组，再逐个填进去
    v = 0.0
    for t, th in enumerate(theta, start=1):
        v = beta * v + (1.0 - beta) * th          # 讲义公式 (3.1)：只需要记住一个数 v，O(1) 内存
        out[t - 1] = v / (1.0 - beta ** t) if bias_correction else v
    return out


def ewa_weights(beta, n):
    """
    指数加权平均里，"k 天前"的数据拿到的权重：w_k = (1 − β) β^k，k = 0, 1, ..., n − 1。

    【思路】把 v_t 展开：v_t = Σ_k (1 − β) β^k θ_{t−k}（再加上被初始值 0 吃掉的部分）。
            权重随 k 指数衰减；当 k = 1/(1 − β) 时，β^k ≈ 1/e ≈ 0.37 —— 这就是"约等于最近 1/(1 − β) 天
            的平均"这句话的来历（讲义 §3.3）。
    【参数】beta：0 ≤ β < 1；n：要多少个权重
    【返回】形状 (n,) 的数组
    【numpy 小知识】np.arange(n) 生成 0, 1, ..., n − 1；beta ** np.arange(n) 对每个元素逐个求幂。
    """
    return (1.0 - beta) * beta ** np.arange(n)


# =============================================================================
# 2. 切 mini-batch（讲义 §2.3）
# =============================================================================
def random_mini_batches(X, Y, mini_batch_size=64, rng=None, drop_last=False):
    """
    把训练集打乱，再切成一块块大小为 mini_batch_size 的 mini-batch（Coursera 作业的同名函数）。

    【思路】两步（讲义 §2.3 有图）：
            1. 打乱（shuffle）：用同一个随机排列同时打乱 X 的列和 Y 的列，保证第 i 个输入仍然配第 i 个标签。
               为什么要打乱？如果数据按类别排好（前一半全是 0、后一半全是 1），不打乱的话
               前几个 mini-batch 全是 0，梯度会把网络往"永远猜 0"的方向推，然后再被后面的 batch 推回来。
               每个 epoch 重新打乱一次，每个 mini-batch 都更像整个训练集的一个"随机小样本"。
            2. 切块（partition）：按顺序每 mini_batch_size 列切一块。m 不一定能被整除，
               最后一块只有 m − mini_batch_size · ⌊m / mini_batch_size⌋ 个样本。
               · Coursera 作业的做法（drop_last=False，默认）：保留这个"不满的"最后一块。
               · 第 1 讲 tiny_nn.train 的做法相当于 drop_last=True：剩下的样本不够一个 batch 就不用了，
                 直接重新打乱开始下一个 epoch。被丢掉的样本每次不同，所以长期看每个样本都会被用到。
               两种都常见（PyTorch 的 DataLoader 也有 drop_last 选项）。保留的坏处是最后一块的梯度噪声更大；
               丢掉的坏处是每个 epoch 有一小部分样本没被看到。m 远大于 batch 大小时两者几乎没有差别。
    【参数】
        X               : (n_x, m)
        Y               : (1, m)
        mini_batch_size : 每块的样本数，常用 64、128、256、512（讲义 §2.6）
        rng             : np.random.Generator；None 时用种子 0
        drop_last       : True 时丢掉最后那个不满的块
    【返回】list，每个元素是一个元组 (mini_batch_X, mini_batch_Y)，形状 (n_x, b)、(1, b)，
            除最后一块外 b = mini_batch_size
    【边界】mini_batch_size ≥ m 时只返回一块（就是打乱后的整个训练集 = 全批量梯度下降）；
            mini_batch_size = 1 时每块一个样本（= 随机梯度下降，SGD）。
    【numpy 小知识】rng.permutation(m) 返回 0, 1, ..., m − 1 的一个随机排列；X[:, perm] 按这个顺序重排列。
            math.floor(m / b) 向下取整；也可以写成整除 m // b。
    【Python 小知识】列表的 .append(x) 在末尾加一个元素；(a, b) 是元组（tuple），可以一次返回两个东西。
    """
    if rng is None:
        rng = np.random.default_rng(0)
    m = X.shape[1]
    perm = rng.permutation(m)
    shuffled_X = X[:, perm]
    shuffled_Y = Y[:, perm].reshape((1, m))          # reshape 是保险：确保 Y 仍是 (1, m) 而不是 (m,)

    mini_batches = []
    num_complete = math.floor(m / mini_batch_size)    # 能切出多少个"满的"块
    for k in range(num_complete):
        lo, hi = k * mini_batch_size, (k + 1) * mini_batch_size
        mini_batches.append((shuffled_X[:, lo:hi], shuffled_Y[:, lo:hi]))
    if m % mini_batch_size != 0 and not drop_last:   # % 是取余数：不为 0 说明还剩一些样本
        lo = num_complete * mini_batch_size
        mini_batches.append((shuffled_X[:, lo:], shuffled_Y[:, lo:]))
    return mini_batches


# =============================================================================
# 3. 四种参数更新规则（讲义 §4–6）
# =============================================================================
def update_parameters_with_gd(parameters, grads, learning_rate):
    """
    普通（mini-batch）梯度下降的一步：对每个参数 W ← W − α dW。

    【参数】parameters、grads：dict（见文件开头的形状约定）；learning_rate：α，一个数（或可广播的数组）
    【返回】parameters（**同一个** dict，里面的数组被换成了更新后的新数组）
    【边界】和 Coursera 作业一样，会修改传进来的 dict；如果你还需要更新前的参数，先自己复制一份。
            （第 4 讲的 deep_nn.update_parameters 返回一个新 dict —— 两种风格都常见，这里为了快选了前者。）
    """
    for key in parameters:
        parameters[key] = parameters[key] - learning_rate * grads["d" + key]
    return parameters


def initialize_velocity(parameters):
    """
    Momentum 的"速度" v 初始化为 0：v["dW1"] 和 W1 同形状的全 0，以此类推。

    【返回】dict，键是 "dW1"、"db1"……（和 grads 的键一样）
    【numpy 小知识】np.zeros_like(A) 生成一个和 A 同形状、同类型的全 0 数组。
    【Python 小知识】{"d" + k: ... for k in d} 是字典推导式：对 d 的每个键 k 造一个新键 "d" + k。
    """
    return {"d" + key: np.zeros_like(p) for key, p in parameters.items()}


def update_parameters_with_momentum(parameters, grads, v, beta, learning_rate):
    """
    Momentum（动量）梯度下降的一步（讲义 §4.2，Andrew Ng 的写法）：
            v ← β v + (1 − β) dW          （v 是梯度的指数加权平均）
            W ← W − α v

    【思路】在来回震荡的方向上，相邻几步的梯度正负交替，平均起来接近 0 —— 震荡被"平均掉"了；
            在一路同向的方向上，平均值仍然是那么大，前进不受影响（讲义 §4.3）。
            注意这个写法里，梯度长期不变时 v 会趋近 dW 本身（不会变成 dW 的 10 倍）；另一种常见写法
            v ← β v + dW 会趋近 dW / (1 − β)，两者只差学习率的一个倍数（讲义 §4.5、代码 03 演示 4）。
    【参数】
        parameters、grads : dict
        v                 : initialize_velocity 返回的 dict（会被更新）
        beta              : β，常用 0.9（≈ 平均最近 10 步的梯度）
        learning_rate     : α
    【返回】(parameters, v)
    【边界】β = 0 时退化成普通梯度下降。没有做偏差修正：开头约 1/(1 − β) 步里 v 偏小，
            相当于前几步的学习率小一些。Ng 在课上说实践中一般不管它（讲义 §3.4）。
    """
    for key in parameters:
        g = grads["d" + key]
        v["d" + key] = beta * v["d" + key] + (1.0 - beta) * g
        parameters[key] = parameters[key] - learning_rate * v["d" + key]
    return parameters, v


def initialize_rmsprop(parameters):
    """RMSprop 的 s（梯度平方的指数加权平均）初始化为 0，键和形状同 initialize_velocity。"""
    return {"d" + key: np.zeros_like(p) for key, p in parameters.items()}


def update_parameters_with_rmsprop(parameters, grads, s, beta2, learning_rate, epsilon=1e-8):
    """
    RMSprop 的一步（讲义 §5，Hinton 在 Coursera 课程里提出）：
            s ← β₂ s + (1 − β₂) dW²       （dW² 是逐元素平方：每个参数各自记住"最近梯度有多大"）
            W ← W − α dW / (√s + ε)

    【思路】√s 近似"最近一段时间梯度的均方根"（root mean square，名字就是这么来的）。
            用它去除梯度，每个参数的步长就被归一化了：梯度一直很大的方向（来回震荡的方向）步子变小，
            梯度一直很小的方向（平缓的方向）步子变大。效果是"按维度"给每个参数配一个学习率。
            ε（如 1e−8）只是防止除以 0：某个参数的梯度一直是 0 时 √s = 0。
    【参数】s：initialize_rmsprop 返回的 dict（会被更新）；beta2：β₂，Hinton 的讲义里用 0.9；
            其余同上
    【返回】(parameters, s)
    【边界】没有偏差修正：第 1 步 s = (1 − β₂) dW²，步长是 α / √(1 − β₂) 倍的 sign(dW)
            —— β₂ = 0.9 时约 3.2α，β₂ = 0.999 时约 31.6α。这正是 Adam 要做偏差修正的原因之一（讲义 §6.3）。
    【numpy 小知识】g ** 2、np.sqrt(s) 都是逐元素运算，不是矩阵乘法。
    """
    for key in parameters:
        g = grads["d" + key]
        s["d" + key] = beta2 * s["d" + key] + (1.0 - beta2) * g ** 2
        parameters[key] = parameters[key] - learning_rate * g / (np.sqrt(s["d" + key]) + epsilon)
    return parameters, s


def initialize_adam(parameters):
    """
    Adam 的两个状态都初始化为 0：v（梯度的指数加权平均，"一阶矩"）和 s（梯度平方的，"二阶矩"）。

    【返回】(v, s)，两个 dict，键是 "dW1"、"db1"……
    """
    v = {"d" + key: np.zeros_like(p) for key, p in parameters.items()}
    s = {"d" + key: np.zeros_like(p) for key, p in parameters.items()}
    return v, s


def update_parameters_with_adam(parameters, grads, v, s, t, learning_rate=0.01, beta1=0.9, beta2=0.999,
                                epsilon=1e-8, bias_correction=True):
    """
    Adam 的一步（Kingma & Ba 2015；讲义 §6）：
            v ← β₁ v + (1 − β₁) dW            Momentum 的部分
            s ← β₂ s + (1 − β₂) dW²           RMSprop 的部分
            v̂ = v / (1 − β₁ᵗ)，ŝ = s / (1 − β₂ᵗ)   偏差修正
            W ← W − α v̂ / (√ŝ + ε)

    【思路】分子 v̂ 是"平均下来往哪边走"（平均掉震荡），分母 √ŝ 是"这个参数的梯度通常有多大"（按维度缩放）。
            两者相除后，每个参数一步大约走 α 那么远（梯度方向稳定时恰好是 α），和梯度本身的尺度无关。
            偏差修正必须做：β₂ = 0.999 时 s 比 v "热身"得慢得多，不修正的话前几百步的步长会被放大好几倍
            （讲义 §6.3、代码 02 演示 4）。
    【参数】
        parameters、grads : dict
        v、s              : initialize_adam 返回的两个 dict（会被更新）
        t                 : 这是第几次更新（从 1 开始数）。偏差修正要用它，所以调用者要自己维护计数
        learning_rate     : α，需要调；论文建议的默认值是 0.001
        beta1、beta2、epsilon : 论文的默认值 0.9、0.999、1e−8，一般不用改
        bias_correction   : 只为讲义 §6.3 的对照实验而设；正常使用永远是 True
    【返回】(parameters, v, s, v_corrected, s_corrected)，和 Coursera 作业的返回值一样
    【边界】t 从 0 开始传会除以 1 − β⁰ = 0；所以 t 必须 ≥ 1。
    【DL 联系】第 1 讲 tiny_nn.Adam 是同一个公式的"类"写法；本文件的自检验证两者在同一串梯度上逐位相同。
            第 9 讲起 PyTorch 的 torch.optim.Adam 也是这个公式。
    """
    if t < 1:
        raise ValueError("Adam 的步数 t 从 1 开始数（偏差修正要除以 1 − β^t）")
    v_corrected, s_corrected = {}, {}
    for key in parameters:
        g = grads["d" + key]
        dk = "d" + key
        v[dk] = beta1 * v[dk] + (1 - beta1) * g
        s[dk] = beta2 * s[dk] + (1 - beta2) * g ** 2
        if bias_correction:
            v_corrected[dk] = v[dk] / (1 - beta1 ** t)
            s_corrected[dk] = s[dk] / (1 - beta2 ** t)
        else:
            v_corrected[dk], s_corrected[dk] = v[dk], s[dk]
        parameters[key] = parameters[key] - learning_rate * v_corrected[dk] / (np.sqrt(s_corrected[dk]) + epsilon)
    return parameters, v, s, v_corrected, s_corrected


class Optimizer:
    """
    把四种更新规则包成同一个接口：opt = Optimizer("adam", params)；每一步 opt.step(params, grads, lr)。

    【思路】训练循环不应该关心"用的是哪种优化器"——它只管算梯度，然后说"更新一步"。
            不同的优化器要记住不同的状态（momentum 记 v，RMSprop 记 s，Adam 记 v、s 和步数 t），
            这些状态放在对象里（self.v、self.s、self.t），训练循环看不见、也不用管。
            学习率 lr 每一步由调用者传进来，这样学习率调度（讲义 §8）可以在训练循环里做。
    【参数】
        name   : "gd" | "momentum" | "rmsprop" | "adam"
        params : 参数 dict（只用来知道每个参数的形状，好把状态初始化成同形状的 0）
        beta   : momentum 的 β（默认 0.9）
        beta1、beta2、epsilon : Adam 的超参数（默认 0.9、0.999、1e−8）
        rms_beta : RMSprop 的 β₂（默认 0.9，Hinton 讲义里的值；讲义 §5 解释为什么不用 0.999）
        bias_correction : 只对 Adam 有效（对照实验用）
    【边界】名字写错会抛出 ValueError。
    【Python 小知识】class 里的 __init__ 在创建对象时运行一次；self 指这个对象本身。
            第 1 讲的 tiny_nn.Adam 也是这样写的。
    """

    NAMES = ("gd", "momentum", "rmsprop", "adam")

    def __init__(self, name, params, beta=0.9, beta1=0.9, beta2=0.999, epsilon=1e-8, rms_beta=0.9,
                 bias_correction=True):
        if name not in self.NAMES:
            raise ValueError(f"unknown optimizer: {name}（可选：{self.NAMES}）")
        self.name = name
        self.beta, self.beta1, self.beta2, self.epsilon = beta, beta1, beta2, epsilon
        self.rms_beta, self.bias_correction = rms_beta, bias_correction
        self.t = 0
        if name == "momentum":
            self.v = initialize_velocity(params)
        elif name == "rmsprop":
            self.s = initialize_rmsprop(params)
        elif name == "adam":
            self.v, self.s = initialize_adam(params)

    def step(self, params, grads, lr):
        """用梯度 grads 更新 params 一步（params 会被修改），返回 params。lr 是这一步的学习率。"""
        self.t += 1
        if self.name == "gd":
            return update_parameters_with_gd(params, grads, lr)
        if self.name == "momentum":
            params, self.v = update_parameters_with_momentum(params, grads, self.v, self.beta, lr)
            return params
        if self.name == "rmsprop":
            params, self.s = update_parameters_with_rmsprop(params, grads, self.s, self.rms_beta, lr, self.epsilon)
            return params
        params, self.v, self.s, _, _ = update_parameters_with_adam(
            params, grads, self.v, self.s, self.t, lr, self.beta1, self.beta2, self.epsilon, self.bias_correction)
        return params


# =============================================================================
# 4. 学习率调度 Learning-rate schedules（讲义 §8）
# =============================================================================
# 约定：epoch_num 从 0 开始数（和 Python 的 for epoch in range(...) 一致），所以第 0 个 epoch 的学习率是 α₀。
# Andrew Ng 在视频里从 epoch 1 开始数（epoch 1 时 α = α₀ / 2）；两种数法只差一个 epoch。

def update_lr(learning_rate0, epoch_num, decay_rate):
    """
    1/t 衰减（Ng 视频里的第一个公式；Coursera 作业里的同名函数）：α = α₀ / (1 + decay_rate · epoch_num)。

    【例子】α₀ = 0.2、decay_rate = 1：epoch 1 → 0.1，epoch 2 → 0.0667，epoch 3 → 0.05，epoch 4 → 0.04。
    【边界】每个 epoch 都衰减，epoch 数很多（比如 5000）时学习率很快就小到几乎不动 ——
            作业里正是用这一点引出了下面的 schedule_lr_decay。
    """
    return learning_rate0 / (1.0 + decay_rate * epoch_num)


def schedule_lr_decay(learning_rate0, epoch_num, decay_rate, time_interval=1000):
    """
    每隔 time_interval 个 epoch 才衰减一次的 1/t 衰减（较新版 Coursera 作业里的 "fixed interval scheduling"）：
            α = α₀ / (1 + decay_rate · ⌊epoch_num / time_interval⌋)

    【思路】学习率是一级一级的台阶，而不是每个 epoch 都变。epoch 数很多时，衰减速度由 time_interval 控制。
    【Python 小知识】epoch_num // time_interval 是整除（向下取整），等价于 math.floor(epoch_num / time_interval)。
    """
    return learning_rate0 / (1.0 + decay_rate * (epoch_num // time_interval))


def exponential_decay(learning_rate0, epoch_num, base=0.95):
    """指数衰减：α = base^epoch_num · α₀（Ng 举的例子 base = 0.95）。"""
    return learning_rate0 * base ** epoch_num


def step_decay(learning_rate0, epoch_num, factor=0.5, every=10):
    """阶梯衰减（discrete staircase）：每过 every 个 epoch，学习率乘以 factor。"""
    return learning_rate0 * factor ** (epoch_num // every)


def warmup_cosine(learning_rate0, step, total_steps, warmup_steps=0, min_lr=0.0):
    """
    warmup + cosine：先在 warmup_steps 步里把学习率从接近 0 线性升到 α₀，再沿余弦曲线降到 min_lr。

    【思路】这是今天训练 Transformer / 大模型最常见的调度之一（讲义 §8.3）：
            · warmup（热身）：Goyal et al. 2017 用它训练大 batch 的 ResNet；Transformer 原论文也用了 warmup。
              训练刚开始时参数是随机的、梯度很大且不稳定，Adam 的二阶矩估计也还不准，先用小步子走一段更稳。
            · cosine：Loshchilov & Hutter 2017（SGDR）。前期降得慢、中间降得快、最后又慢慢贴近 min_lr。
            公式：step < warmup_steps 时 α = α₀ · (step + 1) / warmup_steps；
                  之后 p = (step − warmup_steps) / (total_steps − warmup_steps) ∈ [0, 1]，
                  α = min_lr + (α₀ − min_lr) · (1 + cos(π p)) / 2
    【参数】step：当前是第几步（从 0 开始）；total_steps：总步数；warmup_steps：热身步数；min_lr：最低学习率
    【返回】float
    【边界】step ≥ total_steps 时返回 min_lr。
    """
    if step < warmup_steps:
        return learning_rate0 * (step + 1) / warmup_steps
    if step >= total_steps:
        return min_lr
    p = (step - warmup_steps) / max(1, total_steps - warmup_steps)
    return min_lr + (learning_rate0 - min_lr) * 0.5 * (1.0 + math.cos(math.pi * p))


# =============================================================================
# 5. 训练循环：Coursera "Optimization Methods" 作业里的 model()（讲义 §7）
# =============================================================================
def full_cost(params, X, Y, hidden="relu"):
    """在整个数据集上算一次交叉熵 cost（用 deep_nn 的前向传播）。返回 float。"""
    AL, _ = deep_nn.L_model_forward(X, params, hidden, "sigmoid")
    return deep_nn.compute_cost(AL, Y)


def model(X, Y, layers_dims, optimizer="gd", learning_rate=0.0007, mini_batch_size=64, beta=0.9,
          beta1=0.9, beta2=0.999, epsilon=1e-8, num_epochs=5000, rng=None, hidden="relu", init="he",
          decay=None, decay_rate=1.0, time_interval=1000, record_every=100, rms_beta=0.9):
    """
    用 mini-batch 训练一个 L 层网络（Coursera 作业里的 model()，函数名、参数名、默认值都照搬）。

    【思路】两层循环：
                for epoch in range(num_epochs):                       # 外层：一个 epoch = 把训练集完整过一遍
                    重新打乱、切成 mini-batch（random_mini_batches）
                    for (Xb, Yb) in mini_batches:                     # 内层：每个 mini-batch 更新一次参数
                        前向 → cost → 反向 → 优化器更新一步
                    （可选）按 epoch 调整学习率（decay）
            记录的 cost 和作业一样：一个 epoch 里各个 mini-batch 的 cost 按样本数加权平均
            （= 这个 epoch "一边训练一边看到"的平均 cost，比"epoch 结束时在全部数据上再算一次"便宜）。
    【参数】
        X, Y            : (n_x, m)、(1, m)
        layers_dims     : [n_x, n[1], ..., 1]；作业里是 [2, 5, 2, 1]（三层网络）
        optimizer       : "gd" | "momentum" | "rmsprop" | "adam"
        learning_rate   : α₀（有 decay 时是起始学习率）；作业的默认值 0.0007
        mini_batch_size : 作业的默认值 64
        beta            : momentum 的 β；beta1、beta2、epsilon：Adam 的超参数；rms_beta：RMSprop 的 β₂
        num_epochs      : 作业（较新版本）的默认值 5000
        rng             : np.random.Generator（初始化和每个 epoch 的打乱都用它）；None 时用种子 0
        hidden, init    : 隐藏层激活函数、初始化方式（deep_nn 的选项；作业是 ReLU + He 初始化）
        decay           : None，或一个函数 decay(α₀, epoch, decay_rate)（如 update_lr）；
                          也可以传 "interval"，表示用 schedule_lr_decay(α₀, epoch, decay_rate, time_interval)
        record_every    : 每隔多少个 epoch 记录一次 cost 和学习率
    【返回】
        params  : 训练后的参数
        history : dict，"epoch"、"cost"、"lr" 三个等长 list
        info    : dict，"diverged"（bool，出现 inf / nan 时立即停止）、"updates"（一共更新了多少步）
    【边界】输出层固定为 sigmoid + 交叉熵；反向传播用 deep_nn 的 "fused" 起步（dZ[L] = A[L] − Y，数值更稳）。
            BLAS 矩阵乘法里产生的 inf / nan 不会触发 numpy 的浮点警告（第 1 讲踩过的坑），所以每个 epoch
            显式检查 cost 是否有限。
    """
    if rng is None:
        rng = np.random.default_rng(0)
    m = X.shape[1]
    params = deep_nn.initialize_parameters_deep(layers_dims, rng, init)
    opt = Optimizer(optimizer, params, beta=beta, beta1=beta1, beta2=beta2, epsilon=epsilon, rms_beta=rms_beta)
    history = {"epoch": [], "cost": [], "lr": []}
    lr = learning_rate
    updates = 0
    diverged = False
    for epoch in range(num_epochs):
        if decay == "interval":
            lr = schedule_lr_decay(learning_rate, epoch, decay_rate, time_interval)
        elif decay is not None:
            lr = decay(learning_rate, epoch, decay_rate)
        cost_total = 0.0
        for Xb, Yb in random_mini_batches(X, Y, mini_batch_size, rng):
            AL, caches = deep_nn.L_model_forward(Xb, params, hidden, "sigmoid")
            cost_total += deep_nn.compute_cost(AL, Yb) * Yb.shape[1]     # compute_cost 是平均值，乘回样本数再累加
            grads = deep_nn.L_model_backward(AL, Yb, caches, hidden, "sigmoid", output_grad="fused")
            params = opt.step(params, grads, lr)
            updates += 1
        cost_avg = cost_total / m
        if not np.isfinite(cost_avg):
            diverged = True
            break
        if epoch % record_every == 0 or epoch == num_epochs - 1:
            history["epoch"].append(epoch)
            history["cost"].append(cost_avg)
            history["lr"].append(lr)
    return params, history, {"diverged": diverged, "updates": updates}


# =============================================================================
# 自检
# =============================================================================
def _selftest():
    """
    三个检查：
      1. random_mini_batches：块的大小对不对；打乱之后每个输入是否还配着原来的标签。
      2. 本模块的 Adam 和第 1 讲 tiny_nn.Adam 在同一串随机梯度上走 200 步，参数是否逐位相同。
      3. momentum 的两种写法（v ← βv + (1 − β)g，W −= αv）与（v ← βv + g，W −= α(1 − β)v）给出相同的参数。
    """
    from tiny_nn import Adam as TinyAdam            # 第 1 讲的 Adam（只读导入，用来对答案）

    rng = np.random.default_rng(0)
    X = np.arange(148, dtype=float).reshape(1, 148)
    Y = (X % 2)                                      # 标签 = 输入是不是奇数，用来检查配对是否还对
    batches = random_mini_batches(X, Y, 64, rng)
    sizes = [b[0].shape[1] for b in batches]
    paired = all(np.array_equal(bx % 2, by) for bx, by in batches)
    print(f"random_mini_batches(m = 148, 64)：块大小 {sizes}；X 和 Y 仍然配对：{paired}")
    print(f"  drop_last=True 时块大小 {[b[0].shape[1] for b in random_mini_batches(X, Y, 64, rng, drop_last=True)]}")

    rng = np.random.default_rng(1)
    p_mine = {"W1": rng.normal(size=(3, 2)), "b1": rng.normal(size=(3, 1))}
    p_tiny = {k: v.copy() for k, v in p_mine.items()}
    v, s = initialize_adam(p_mine)
    tiny = TinyAdam(p_tiny, lr=0.01)
    for t in range(1, 201):
        g = {"d" + k: rng.normal(size=val.shape) for k, val in p_mine.items()}
        p_mine, v, s, _, _ = update_parameters_with_adam(p_mine, g, v, s, t, learning_rate=0.01)
        tiny.step(p_tiny, g)
    diff = max(float(np.max(np.abs(p_mine[k] - p_tiny[k]))) for k in p_mine)
    print(f"本模块的 Adam vs 第 1 讲 tiny_nn.Adam，同一串梯度 200 步后参数的最大差别：{diff:.1e}")

    rng = np.random.default_rng(2)
    pa = {"W1": np.ones((2, 2))}
    pb = {"W1": np.ones((2, 2))}
    va, vb = initialize_velocity(pa), initialize_velocity(pb)
    beta, alpha = 0.9, 0.1
    for _ in range(50):
        g = {"dW1": rng.normal(size=(2, 2))}
        pa, va = update_parameters_with_momentum(pa, g, va, beta, alpha)
        vb["dW1"] = beta * vb["dW1"] + g["dW1"]              # 另一种写法：不乘 (1 − β)
        pb["W1"] = pb["W1"] - alpha * (1 - beta) * vb["dW1"]  # ……于是学习率要乘 (1 − β) 才等价
    print(f"momentum 两种写法 50 步后参数的最大差别：{float(np.max(np.abs(pa['W1'] - pb['W1']))):.1e}"
          "（只差浮点舍入）")


if __name__ == "__main__":
    _selftest()
