梯度下降及自适应优化:参考答案

目录

\[ \begin{align}\begin{aligned}\newcommand{\ba}{\boldsymbol{a}} \newcommand{\bb}{\boldsymbol{b}} \newcommand{\be}{\boldsymbol{e}} \newcommand{\bq}{\boldsymbol{q}} \newcommand{\bk}{\boldsymbol{k}} \newcommand{\bw}{\boldsymbol{w}} \newcommand{\bx}{\boldsymbol{x}} \newcommand{\by}{\boldsymbol{y}} \newcommand{\bz}{\boldsymbol{z}} \newcommand{\bd}{\boldsymbol{d}} \newcommand{\bv}{\boldsymbol{v}} \newcommand{\bs}{\boldsymbol{s}}\\\newcommand{\btheta}{\boldsymbol{\theta}} \newcommand{\bbeta}{\boldsymbol{\beta}} \newcommand{\bgamma}{\boldsymbol{\gamma}} \newcommand{\bsigma}{\boldsymbol{\sigma}} \newcommand{\md}{\mbox{d}} \newcommand{\bmu}{\boldsymbol{\mu}} \newcommand{\bone}{\boldsymbol{1}} \newcommand{\bzero}{\boldsymbol{0}} \newcommand{\bepsilon}{\boldsymbol{\epsilon}} \newcommand{\bphi}{\boldsymbol{\phi}} \newcommand{\bh}{\boldsymbol{h}} \newcommand{\bc}{\boldsymbol{c}} \newcommand{\br}{\boldsymbol{r}} \newcommand{\bQ}{\boldsymbol{Q}} \newcommand{\bK}{\boldsymbol{K}} \newcommand{\bV}{\boldsymbol{V}} \newcommand{\bSigma}{\boldsymbol{\Sigma}} \newcommand{\bg}{\boldsymbol{g}} \newcommand{\bxi}{\boldsymbol{\xi}} \newcommand{\bvarepsilon}{\boldsymbol{\varepsilon}} \newcommand{\bdelta}{\boldsymbol{\delta}} \newcommand{\bq}{\boldsymbol{q}} \newcommand{\bk}{\boldsymbol{k}} \newcommand{\bJ}{\boldsymbol{J}} \newcommand{\bp}{\boldsymbol{p}} \newcommand{\bi}{\boldsymbol{i}} \newcommand{\bo}{\boldsymbol{o}} \newcommand{\bE}{\boldsymbol{E}} \newcommand{\bH}{\boldsymbol{H}} \newcommand{\bL}{\boldsymbol{L}} \newcommand{\bu}{\boldsymbol{u}} \newcommand{\bLambda}{\boldsymbol{\Lambda}} \newcommand{\trans}{^{\rm\scriptsize T}} \newcommand{\var}{\mathrm{var}}\\\newcommand{\bA}{\boldsymbol{A}} \newcommand{\bB}{\boldsymbol{B}} \newcommand{\bC}{\boldsymbol{C}} \newcommand{\bD}{\boldsymbol{D}} \newcommand{\bG}{\boldsymbol{G}} \newcommand{\bI}{\boldsymbol{I}} \newcommand{\bM}{\boldsymbol{M}} \newcommand{\bP}{\boldsymbol{P}} \newcommand{\bS}{\boldsymbol{S}} \newcommand{\bU}{\boldsymbol{U}} \newcommand{\bW}{\boldsymbol{W}} \newcommand{\bX}{\boldsymbol{X}} \newcommand{\bY}{\boldsymbol{Y}} \newcommand{\bZ}{\boldsymbol{Z}} \newcommand{\cotp}{\textcolor[RGB]{48,209,88}{TP}} \newcommand{\cotn}{\textcolor[RGB]{100,210,255}{TN}} \newcommand{\cofp}{\textcolor[RGB]{94,92,230}{FP}} \newcommand{\cofn}{\textcolor[RGB]{191,90,242}{FN}}\\\newcommand{\numcotp}{\textcolor[RGB]{48,209,88}{50}} \newcommand{\numcotn}{\textcolor[RGB]{100,210,255}{30}} \newcommand{\numcofp}{\textcolor[RGB]{94,92,230}{10}} \newcommand{\numcofn}{\textcolor[RGB]{191,90,242}{10}} \DeclareMathOperator*{\argmin}{arg\,min}\end{aligned}\end{align} \]

梯度下降及自适应优化:参考答案#

返回正文练习 · 返回答案索引

说明#

以下答案与正文 10 道题逐题对应。不同资料可能采用不同的 Momentum 缩放或 \(\epsilon\) 位置;核验时应先明确算法定义,再比较公式与程序。

  1. 设第 \(t\) 次更新前的参数数组为 \(\btheta_{t-1}\)⁠,由该参数计算得到的同形梯度为 \(\boldsymbol g_t\)⁠。四种更新可统一写为

    \[\begin{split}\begin{array}{ll} \text{梯度下降:}& \btheta_t=\btheta_{t-1}-\alpha\boldsymbol g_t,\\[2mm] \text{Momentum:}& \bv_t=\beta_1\bv_{t-1}+(1-\beta_1)\boldsymbol g_t, \quad\btheta_t=\btheta_{t-1}-\alpha\bv_t,\\[2mm] \text{RMSprop:}& \bs_t=\beta_2\bs_{t-1}+(1-\beta_2) (\boldsymbol g_t\odot\boldsymbol g_t), \quad\btheta_t=\btheta_{t-1}- \alpha\dfrac{\boldsymbol g_t}{\sqrt{\bs_t}+\epsilon},\\[3mm] \text{Adam:}& \begin{cases} \bv_t=\beta_1\bv_{t-1}+(1-\beta_1)\boldsymbol g_t,\\ \bs_t=\beta_2\bs_{t-1}+(1-\beta_2) (\boldsymbol g_t\odot\boldsymbol g_t),\\ \widehat{\bv}_t=\bv_t/(1-\beta_1^t),\quad \widehat{\bs}_t=\bs_t/(1-\beta_2^t),\\ \btheta_t=\btheta_{t-1}- \alpha\dfrac{\widehat{\bv}_t}{\sqrt{\widehat{\bs}_t}+\epsilon} \end{cases} \end{array}.\end{split}\]

    梯度下降不保存历史状态;Momentum 与 RMSprop 各保存一个同形数组;Adam 保存 \(\bv_t\)⁠、\(\bs_t\) 和标量步数。平方、开方及分式均逐元素计算,不是矩阵乘法。

  2. \(\bv_0=\boldsymbol 0\)\(0\leq\beta<1\) 时,递归展开一阶移动平均可得

    \[\bv_t=(1-\beta)\sum_{i=1}^t\beta^{t-i}\boldsymbol g_i.\]

    \(\boldsymbol g_i=\boldsymbol g\) 时,有限几何级数给出

    \[\bv_t=(1-\beta)\frac{1-\beta^t}{1-\beta}\boldsymbol g =(1-\beta^t)\boldsymbol g.\]

    若各步梯度的一阶矩相同,即 \(\mathbb E[\boldsymbol g_i]=\boldsymbol\mu\)⁠,则

    \[\mathbb E[\bv_t]=(1-\beta^t)\boldsymbol\mu.\]

    对平方梯度的移动平均同理。若 \(\mathbb E[\boldsymbol g_i\odot\boldsymbol g_i]=\boldsymbol\nu\)⁠,则 \(\mathbb E[\bs_t]=(1-\beta_2^t)\boldsymbol\nu\)⁠。因此,除以 \(1-\beta_1^t\)\(1-\beta_2^t\) 可以消除零初始化造成的乘性缩小;这一结论不要求各步梯度相互独立。训练过程中梯度分布通常会发生变化,此时偏差修正只是把有限历史权重的和归一化,并不表示移动平均等于当前梯度。

  3. 按正文采用的 Momentum 定义,前两步结果如下:

    \[\begin{split}\begin{array}{c|cc} \text{方法}&t=1&t=2\\ \hline \text{梯度下降}&\theta_1=0.8&\theta_2=0.4\\ \text{Momentum}&v_1=0.2,\ \theta_1=0.98& v_2=0.58,\ \theta_2=0.922\\ \text{RMSprop}&s_1=0.04,\ \theta_1=0& s_2=0.1996,\ \theta_2\approx-0.895323\\ \text{Adam}&\widehat v_1=2,\ \widehat s_1=4,\ \theta_1=0.9& \widehat v_2\approx3.052632,\ \widehat s_2\approx10.030151,\ \theta_2\approx0.803613 \end{array}.\end{split}\]

    Adam 实际保存的原始状态为

    \[v_1=0.2,\qquad s_1=0.04,\qquad v_2=0.58,\qquad s_2=0.1996.\]

    表中的 \(\widehat v_t\)\(\widehat s_t\) 是根据原始状态计算得到的校正量,并不是另外保存的优化器状态。例如,第 2 步的校正结果为

    \[\widehat v_2=\frac{0.58}{1-0.9^2} \approx3.052631579,\qquad \widehat s_2=\frac{0.1996}{1-0.99^2} \approx10.030150754.\]

    有些资料使用 \(\bv_t=\beta\bv_{t-1}+\boldsymbol g_t\)⁠,把因子 \(1-\beta\) 吸收到学习率中。两种约定在相同名义学习率下数值不同,因此单元测试必须以所采用的明确定义为准,不能混用状态递推与学习率。

  4. 对第 \(j\) 个坐标,RMSprop 与 Adam 的更新量分别为

    \[-\alpha\frac{g_{t,j}}{\sqrt{s_{t,j}}+\epsilon}, \qquad -\alpha\frac{\widehat v_{t,j}}{\sqrt{\widehat s_{t,j}}+\epsilon}.\]

    在所有状态均从 0 初始化的前提下,把整个梯度序列乘以正常数 \(c>0\)⁠,会使一阶矩乘以 \(c\)⁠、二阶矩乘以 \(c^2\)⁠。当 \(\epsilon=0\) 且分母非零时,上式中的比值不变;当 \(\epsilon>0\) 时,缩放后的分母相当于把原来的 \(\epsilon\) 换成 \(\epsilon/c\)⁠,所以更新不再严格保持不变。

    若从零状态开始且梯度序列恒为 0,取 \(\epsilon>0\) 时更新量为 0;取 \(\epsilon=0\) 时则会出现 \(0/0\)⁠。若已有非零梯度历史,只是当前梯度变为 0,RMSprop 在分母非零时的当前更新量为 0,而 Adam 可能因为一阶矩仍然非零而继续更新。

    \(\beta_1=0\) 时,Momentum 退化为普通梯度下降,Adam 的校正后一阶矩等于当前梯度。当 \(\beta_2=0\) 时,

    \[\Delta\theta_{t,j}^{\mathrm{RMSprop}} =-\alpha\frac{g_{t,j}}{|g_{t,j}|+\epsilon}, \qquad \Delta\theta_{t,j}^{\mathrm{Adam}} =-\alpha\frac{\widehat v_{t,j}}{|g_{t,j}|+\epsilon}.\]

    因此,当 \(\epsilon=0\)\(g_{t,j}\neq0\) 时,RMSprop 在该坐标上变为符号更新;Adam 只有在同时取 \(\beta_1=0\) 时才有同样的结果。只要某个坐标的二阶矩为 0,令 \(\epsilon=0\) 就存在除以 0 的风险。

    sqrt(s) + epsilonsqrt(s + epsilon) 的量纲和小梯度下的缩放不同,对应不同算法。程序必须与所声明的公式一致,不能把位置差异当成无关紧要的实现细节。

  5. init_state 根据参数的名称、维度和数据类型创建指定优化算法需要的初始辅助量;step 使用当前参数、梯度和辅助量完成一次更新,并返回新的参数与状态。这里规定 state["t"] 表示已经完成的更新次数,因此初始化为 0,第一次调用 step 后变为 1。以下实现使用正文中 \(\epsilon\) 位于平方根外的定义,并对题目要求的输入条件进行完整检查:

    import numpy as np
    
    _METHODS = {"gd", "momentum", "rmsprop", "adam"}
    _STATE_KEYS = {
        "gd": {"t"},
        "momentum": {"t", "v"},
        "rmsprop": {"t", "s"},
        "adam": {"t", "v", "s"},
    }
    
    def _check_method(method):
        if not isinstance(method, str) or method not in _METHODS:
            raise ValueError(f"未知优化器: {method!r}")
    
    def _check_arrays(values, label):
        if not isinstance(values, dict):
            raise TypeError(f"{label} 必须是字典")
        if not values:
            raise ValueError(f"{label} 不能为空")
    
        for name, value in values.items():
            if not isinstance(name, str):
                raise TypeError(f"{label} 的键必须是字符串")
            if not isinstance(value, np.ndarray):
                raise TypeError(
                    f"{label}[{name!r}] 必须是 NumPy 数组"
                )
            if not np.issubdtype(value.dtype, np.floating):
                raise TypeError(
                    f"{label}[{name!r}] 必须使用浮点类型"
                )
            if not np.all(np.isfinite(value)):
                raise ValueError(
                    f"{label}[{name!r}] 含有非有限值"
                )
    
    def _real_scalar(value, name):
        array = np.asarray(value)
        valid_dtype = (
            np.issubdtype(array.dtype, np.integer)
            or np.issubdtype(array.dtype, np.floating)
        )
        if (array.ndim != 0
                or np.issubdtype(array.dtype, np.bool_)
                or not valid_dtype):
            raise TypeError(f"{name} 必须是实数标量")
    
        value = array.item()
        if not np.isfinite(value):
            raise ValueError(f"{name} 必须是有限值")
        return value
    
    def _check_state(params, state, method):
        if not isinstance(state, dict):
            raise TypeError("state 必须是字典")
        if set(state) != _STATE_KEYS[method]:
            raise ValueError(f"{method} 的状态键不正确")
    
        t = state["t"]
        if (isinstance(t, (bool, np.bool_))
                or not isinstance(t, (int, np.integer))):
            raise TypeError("state['t'] 必须是整数")
        t = int(t)
        if t < 0:
            raise ValueError("state['t'] 不能为负")
    
        for slot in ("v", "s"):
            if slot not in state:
                continue
            values = state[slot]
            if not isinstance(values, dict):
                raise TypeError(f"state[{slot!r}] 必须是字典")
            if values.keys() != params.keys():
                raise ValueError(
                    f"state[{slot!r}] 的键与参数键不一致"
                )
    
            for name, value in values.items():
                param = params[name]
                if not isinstance(value, np.ndarray):
                    raise TypeError(
                        f"state[{slot!r}][{name!r}] 不是数组"
                    )
                if value.shape != param.shape:
                    raise ValueError(
                        f"state[{slot!r}][{name!r}] 维度错误"
                    )
                if value.dtype != param.dtype:
                    raise TypeError(
                        f"state[{slot!r}][{name!r}] dtype 错误"
                    )
                if not np.all(np.isfinite(value)):
                    raise ValueError(
                        f"state[{slot!r}][{name!r}] 含非有限值"
                    )
                if slot == "s" and np.any(value < 0):
                    raise ValueError(
                        f"state['s'][{name!r}] 不能为负"
                    )
        return t
    
    def init_state(params, method):
        _check_method(method)
        _check_arrays(params, "params")
    
        state = {"t": 0}
        if method in {"momentum", "adam"}:
            state["v"] = {
                name: np.zeros_like(param)
                for name, param in params.items()
            }
        if method in {"rmsprop", "adam"}:
            state["s"] = {
                name: np.zeros_like(param)
                for name, param in params.items()
            }
        return state
    
    def step(params, grads, state, method, lr,
             beta1=0.9, beta2=0.999, eps=1e-8):
        _check_method(method)
        _check_arrays(params, "params")
        _check_arrays(grads, "grads")
    
        if params.keys() != grads.keys():
            raise ValueError("参数键与梯度键不一致")
        for name, param in params.items():
            grad = grads[name]
            if param.shape != grad.shape:
                raise ValueError(
                    f"参数与梯度维度不一致: {name}"
                )
            if param.dtype != grad.dtype:
                raise TypeError(
                    f"参数与梯度 dtype 不一致: {name}"
                )
    
        t = _check_state(params, state, method)
        lr = _real_scalar(lr, "lr")
        beta1 = _real_scalar(beta1, "beta1")
        beta2 = _real_scalar(beta2, "beta2")
        eps = _real_scalar(eps, "eps")
    
        if lr <= 0:
            raise ValueError("lr 必须大于 0")
        if not 0 <= beta1 < 1 or not 0 <= beta2 < 1:
            raise ValueError(
                "beta1 和 beta2 必须位于 [0, 1)"
            )
        if eps < 0:
            raise ValueError("eps 不能为负")
    
        new_t = t + 1
        new_params = {}
        new_state = {"t": new_t}
        if method in {"momentum", "adam"}:
            new_state["v"] = {}
        if method in {"rmsprop", "adam"}:
            new_state["s"] = {}
    
        for name, theta in params.items():
            grad = grads[name]
            dtype = theta.dtype
            one = np.asarray(1, dtype=dtype)
            lr_d = np.asarray(lr, dtype=dtype)
            b1 = np.asarray(beta1, dtype=dtype)
            b2 = np.asarray(beta2, dtype=dtype)
            eps_d = np.asarray(eps, dtype=dtype)
    
            converted = (lr_d, b1, b2, eps_d)
            if not all(np.isfinite(x) for x in converted):
                raise ValueError(
                    f"超参数无法用 {dtype} 有限表示"
                )
            if lr_d <= 0:
                raise ValueError(
                    f"lr 无法用 {dtype} 表示为正数"
                )
            if not 0 <= b1 < 1 or not 0 <= b2 < 1:
                raise ValueError(
                    f"beta1 或 beta2 无法用 {dtype}"
                    " 表示为 [0, 1) 内的数"
                )
            if eps_d < 0:
                raise ValueError(
                    f"eps 无法用 {dtype} 表示为非负数"
                )
            if eps > 0 and eps_d == 0:
                raise ValueError(
                    f"正的 eps 用 {dtype} 表示时变成了 0"
                )
    
            try:
                with np.errstate(
                    divide="raise", over="raise", invalid="raise"
                ):
                    if method == "gd":
                        direction = grad
                    elif method == "momentum":
                        v = (b1 * state["v"][name]
                             + (one - b1) * grad)
                        new_state["v"][name] = v
                        direction = v
                    elif method == "rmsprop":
                        s = (b2 * state["s"][name]
                             + (one - b2) * np.square(grad))
                        new_state["s"][name] = s
                        denominator = np.sqrt(s) + eps_d
                        if np.any(denominator <= 0):
                            raise FloatingPointError(
                                "RMSprop 分母为 0"
                            )
                        direction = grad / denominator
                    else:
                        v = (b1 * state["v"][name]
                             + (one - b1) * grad)
                        s = (b2 * state["s"][name]
                             + (one - b2) * np.square(grad))
                        new_state["v"][name] = v
                        new_state["s"][name] = s
    
                        correction1 = (
                            one - np.power(b1, new_t)
                        )
                        correction2 = (
                            one - np.power(b2, new_t)
                        )
                        if correction1 <= 0 or correction2 <= 0:
                            raise FloatingPointError(
                                "偏差修正分母为 0"
                            )
                        v_hat = v / correction1
                        s_hat = s / correction2
                        denominator = np.sqrt(s_hat) + eps_d
                        if np.any(denominator <= 0):
                            raise FloatingPointError(
                                "Adam 分母为 0"
                            )
                        direction = v_hat / denominator
    
                    theta_new = theta - lr_d * direction
            except FloatingPointError as exc:
                raise FloatingPointError(
                    f"更新参数 {name!r} 时发生非法数值运算"
                ) from exc
    
            if theta_new.dtype != dtype:
                raise TypeError(
                    f"更新改变了参数 dtype: {name}"
                )
            if not np.all(np.isfinite(theta_new)):
                raise ValueError(
                    f"更新后的参数不是有限值: {name}"
                )
            for slot in ("v", "s"):
                if slot in new_state:
                    value = new_state[slot][name]
                    if (value.dtype != dtype
                            or not np.all(np.isfinite(value))):
                        raise ValueError(
                            f"更新后的状态不合法: {slot}.{name}"
                        )
            new_params[name] = theta_new
    
        return new_params, new_state
    

    该实现不把输入数组静默转换为 float64并通过创建新数组保留更新前的参数与状态。为了完成第 3 题的核验,程序允许 \(\epsilon=0\)⁠;但如果此时某个坐标的分母为 0,则会明确报错。实际训练通常使用正的 \(\epsilon\)⁠。

  6. 下列程序直接核对第 3 题的两步结果。由于 step 不修改输入对象,每一步返回的结果都可以保存在 trace 中继续检查:

    def run_two_steps(method):
        params = {
            "theta": np.array([1.0], dtype=np.float64)
        }
        state = init_state(params, method)
        trace = []
        for value in (2.0, 4.0):
            grads = {
                "theta": np.array([value], dtype=np.float64)
            }
            params, state = step(
                params, grads, state, method,
                lr=0.1, beta1=0.9, beta2=0.99, eps=0.0
            )
            trace.append((params, state))
        return trace
    
    traces = {
        method: run_two_steps(method)
        for method in ("gd", "momentum", "rmsprop", "adam")
    }
    
    expected_theta = {
        "gd": (0.8, 0.4),
        "momentum": (0.98, 0.922),
        "rmsprop": (0.0, -0.8953229621),
        "adam": (0.9, 0.8036125119),
    }
    for method, expected in expected_theta.items():
        for (params, _), target in zip(
            traces[method], expected
        ):
            np.testing.assert_allclose(
                params["theta"], [target], rtol=1e-9, atol=1e-9
            )
    
    momentum = traces["momentum"]
    np.testing.assert_allclose(
        momentum[0][1]["v"]["theta"], [0.2]
    )
    np.testing.assert_allclose(
        momentum[1][1]["v"]["theta"], [0.58]
    )
    
    rmsprop = traces["rmsprop"]
    np.testing.assert_allclose(
        rmsprop[0][1]["s"]["theta"], [0.04]
    )
    np.testing.assert_allclose(
        rmsprop[1][1]["s"]["theta"], [0.1996]
    )
    
    adam = traces["adam"]
    assert adam[0][1]["t"] == 1
    assert adam[1][1]["t"] == 2
    np.testing.assert_allclose(
        adam[0][1]["v"]["theta"], [0.2]
    )
    np.testing.assert_allclose(
        adam[0][1]["s"]["theta"], [0.04]
    )
    np.testing.assert_allclose(
        adam[1][1]["v"]["theta"], [0.58]
    )
    np.testing.assert_allclose(
        adam[1][1]["s"]["theta"], [0.1996]
    )
    v_hat_2 = adam[1][1]["v"]["theta"] / (1 - 0.9 ** 2)
    s_hat_2 = adam[1][1]["s"]["theta"] / (1 - 0.99 ** 2)
    np.testing.assert_allclose(v_hat_2, [3.0526315789])
    np.testing.assert_allclose(s_hat_2, [10.0301507538])
    

    这里必须断言更新后的参数,而不能把原始的一阶矩和二阶原点矩“比梯度小”当成漏做偏差修正,因为正确的原始状态在前几步本来就会受到 \(1-\beta_1\)\(1-\beta_2\) 的缩放。第 2 步的 Adam 参数值可以发现漏做偏差修正的程序;第一次更新得到有限的 \(\theta_1=0.9\)⁠,可以核验偏差修正使用了 \(t=1\)⁠。断言返回状态中的 t == 1 只能核验计数状态已经更新,不能单独证明偏差修正公式使用了这个步数。

    为检查逐元素平方,可同时使用一维向量和维度为 \(2\times3\) 的非方阵,并逐元素比较

    expected_s = (
        beta2 * old_s
        + (1 - beta2) * np.square(grad)
    )
    np.testing.assert_allclose(new_s, expected_s)
    

    一维数组的 grad @ grad 会得到标量,随后可能被错误地广播,因此只检查维度仍可能漏掉错误,必须同时检查数值。非方阵则通常会直接暴露不合法的矩阵乘法。

    为区分 \(\sqrt{s}+\epsilon\)\(\sqrt{s+\epsilon}\)⁠,可令 RMSprop 第一步的梯度为 \(10^{-6}\)⁠、\(\beta_2=0\)⁠、\(\epsilon=10^{-4}\)⁠。正文定义给出的更新方向为

    \[\frac{10^{-6}}{10^{-6}+10^{-4}} \approx0.00990099,\]

    而把 \(\epsilon\) 错放到平方根内时,结果约为 \(0.0001\)⁠,两者容易区分。

    其余接口测试应逐项检查:参数、梯度和状态的键或维度不一致;float32 参数与 float64 梯度混用;输入整数、复数或布尔数组;参数、梯度或状态中含有 NaNInf状态键缺失或多余;s 中出现负数;t 为负数、小数或布尔值;超参数超出允许范围;以及 \(\epsilon=0\) 且分母为 0。对于合法的 float32 向量和非方阵,还应断言输出保持 float32调用前分别复制参数、梯度和嵌套状态,调用后将三类输入逐一与副本比较,并用 np.shares_memory 检查新旧参数及状态数组不共享内存,即可核验程序没有在原位置修改任何输入对象。

  7. 该目标的梯度为

    \[\nabla f(\theta_1,\theta_2)=(2\theta_1,200\theta_2)\trans.\]

    Hessian 矩阵为 \(\operatorname{diag}(2,200)\)⁠,两个方向的曲率相差 100 倍。为了保证普通梯度下降从任意起点收敛,学习率应满足

    \[0<\alpha<\frac{2}{200}=0.01.\]

    因此,陡峭方向会限制学习率,使平缓方向的推进速度较慢。Momentum 可以积累方向一致的更新,RMSprop 与 Adam 会根据历史梯度的大小调整各坐标的更新尺度,但这些机制并不保证某种算法在所有学习率下都最快。

    这个问题没有训练集、验证集和测试集,因此不能用“验证集选择学习率”。应先确定同一起点、非学习率超参数、候选学习率、调参预算 \(K_{\mathrm{tune}}\) 和选择规则。每个候选值运行 \(K_{\mathrm{tune}}\) 次更新,排除产生非有限值的运行;对每种算法选择第 \(K_{\mathrm{tune}}\) 步函数值最小的学习率,若并列则取较小者。确定学习率后,从同一起点重新运行正式实验。

    每一步至少记录函数值、梯度范数和更新范数

    \[\lVert\Delta\btheta_t\rVert_2 =\lVert\btheta_t-\btheta_{t-1}\rVert_2,\]

    并记录首次满足 \(f(\btheta_t)\leq\tau\) 的实际运行时间;在预算内没有达到阈值时,应写明“未达到”。在更新次数相同的条件下比较,可以观察每一步更新带来的效果;在运行时间相同的条件下比较,可以同时反映各算法每一步的计算成本。二维确定性函数的结果只能说明这些算法在本问题上的优化行为,不能直接用于推断小批量神经网络的泛化性能。

  8. 四个优化器必须共享训练集、验证集和测试集的划分。对于同一个随机种子,还应使用配对的初始参数和批量顺序,使算法之间的差异不会与初始化或数据顺序的差异混在一起。不同算法可以选择不同的学习率,但候选学习率的数量、选择规则、训练预算和验证频率必须相同,而且只能根据验证集进行选择。全部方案确定后,测试集只用于一次最终评价,不能再根据测试结果改变学习率或其他设置。

    对每种优化器,报告多个随机种子下测试集准确率和对数损失的均值与标准差,并同时报告总训练时间、首次达到目标验证损失所需的时间和峰值内存。如果某次运行没有达到目标损失,应记录“未达到”,并报告达到目标的运行比例,不能只计算成功运行的平均时间。AUC 可以作为补充指标,但不是本题要求的主指标。

    相同更新次数不一定对应相同运行时间,因为 Momentum、RMSprop 和 Adam 还要更新辅助状态,RMSprop 和 Adam 还包含逐元素平方、开方或除法。任何“某种优化器更好”的结论都应限定在本题使用的数据、网络、批量大小、学习率搜索范围、训练预算和运行设备内。

  9. 可为每种算法设置相同数量的对数间隔候选学习率。例如,np.logspace(-4, -1, 7) 生成的近似值为 \(\{0.0001,0.000316,0.001,0.00316,0.01,0.0316,0.1\}\)⁠,在以 10 为底的对数坐标上相邻点的距离相同。实验还应确定数据划分、初始参数、批量顺序、随机种子、\(\beta_1\)⁠、\(\beta_2\)⁠、训练预算和验证频率。

    在实验前给出明确的发散判断标准,例如参数或损失出现非有限值,或者损失超过预先规定的上限。对每个候选学习率和随机种子,记录是否发散、最终验证损失、是否达到目标损失,以及首次达到目标损失的时间。分别绘制以下三种关系:学习率与最终验证损失、学习率与发散比例、学习率与达到目标损失所需时间。对于没有达到目标的运行,应标记为“超过预算”,并报告每个学习率达到目标的比例,不能只对成功运行计算平均时间,也不能只展示每种算法表现最好的一点。

    Momentum、RMSprop 和 Adam 改变了更新方向或各坐标的相对更新尺度,但全局因子 \(\alpha\) 仍然控制整体更新的大小。学习率过小时,模型在有限预算内进展缓慢;学习率过大时,这些算法仍可能振荡或发散。因此,“自适应”表示算法会根据梯度历史调整更新,并不表示可以省略全局学习率的选择。

  10. 例如,可以使用只有一个隐藏层的全连接神经网络,并把隐藏神经元数量依次设为 32、128 和 512,同时报告每个模型的实际参数量 \(P\)⁠。对于同一种网络规模,四种优化器应使用相同的数据、初始参数、批量顺序、数据类型、最大训练轮数、验证频率和运行设备;学习率搜索的成本应与最终训练时间分开记录。

    “每次更新所需时间”有两种常见口径:一种只测量优化器根据梯度更新参数的 step另一种测量完整的“前向传播—后向传播—参数更新”训练步骤。实验应明确所用口径,最好分别报告。计时前先预热,再重复测量;使用 GPU 等异步计算设备时,计时前后都应等待设备完成计算。总训练时间是否包含验证也应明确说明。所有算法使用相同的目标验证指标和检查频率,未达到目标时记录“未达到”。

    在不计所有方法都需要的梯度数组时,若模型共有 \(P\) 个参数,则梯度下降几乎不需要额外的同维度态,Momentum 与 RMSprop 各需要约 \(P\) 个状态数值,Adam 需要约 \(2P\) 个。程序中可以对状态数组的 nbytes 求和,得到与实际数据类型一致的内存用量。RMSprop 和 Adam 还增加逐元素平方、开方、除法与状态读写,因此每次更新通常需要更多计算和内存;它们是否能更快达到指定验证指标,仍需根据实验结果判断。

    测量预测效率时,应将模型切换到预测状态并停止记录梯度;各模型使用相同的批量、数据类型和设备,经过预热后重复计时,异步设备同样需要等待计算完成。固定批量预测时间使用多次测量结果的中位数表示,并同时报告波动范围。

    优化器状态只在训练时用于更新参数,预测阶段不使用 \(\bv_t\)\(\bs_t\)⁠。因此,对于结构和规模相同的标准全连接网络,四种优化器训练所得模型具有相同的前向运算量,预测时间通常接近;但其预测结果和准确率可能不同,因为四种优化器最终得到的参数可能不同。报告时应分别说明训练成本、达到目标验证指标所需的时间和预测效率,不能把训练速度的变化写成网络预测结构的变化。