梯度下降及自适应优化:参考答案#
说明#
以下答案与正文 10 道题逐题对应。不同资料可能采用不同的 Momentum 缩放或 \(\epsilon\) 位置;核验时应先明确算法定义,再比较公式与程序。
设第 \(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\) 和标量步数。平方、开方及分式均逐元素计算,不是矩阵乘法。
在 \(\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\) 可以消除零初始化造成的乘性缩小;这一结论不要求各步梯度相互独立。训练过程中梯度分布通常会发生变化,此时偏差修正只是把有限历史权重的和归一化,并不表示移动平均等于当前梯度。
按正文采用的 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\) 吸收到学习率中。两种约定在相同名义学习率下数值不同,因此单元测试必须以所采用的明确定义为准,不能混用状态递推与学习率。
对第 \(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) + epsilon与sqrt(s + epsilon)的量纲和小梯度下的缩放不同,对应不同算法。程序必须与所声明的公式一致,不能把位置差异当成无关紧要的实现细节。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\)。下列程序直接核对第 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梯度混用;输入整数、复数或布尔数组;参数、梯度或状态中含有NaN、Inf;状态键缺失或多余;s中出现负数;t为负数、小数或布尔值;超参数超出允许范围;以及 \(\epsilon=0\) 且分母为 0。对于合法的float32向量和非方阵,还应断言输出保持float32。调用前分别复制参数、梯度和嵌套状态,调用后将三类输入逐一与副本比较,并用np.shares_memory检查新旧参数及状态数组不共享内存,即可核验程序没有在原位置修改任何输入对象。该目标的梯度为
\[\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\) 的实际运行时间;在预算内没有达到阈值时,应写明“未达到”。在更新次数相同的条件下比较,可以观察每一步更新带来的效果;在运行时间相同的条件下比较,可以同时反映各算法每一步的计算成本。二维确定性函数的结果只能说明这些算法在本问题上的优化行为,不能直接用于推断小批量神经网络的泛化性能。
四个优化器必须共享训练集、验证集和测试集的划分。对于同一个随机种子,还应使用配对的初始参数和批量顺序,使算法之间的差异不会与初始化或数据顺序的差异混在一起。不同算法可以选择不同的学习率,但候选学习率的数量、选择规则、训练预算和验证频率必须相同,而且只能根据验证集进行选择。全部方案确定后,测试集只用于一次最终评价,不能再根据测试结果改变学习率或其他设置。
对每种优化器,报告多个随机种子下测试集准确率和对数损失的均值与标准差,并同时报告总训练时间、首次达到目标验证损失所需的时间和峰值内存。如果某次运行没有达到目标损失,应记录“未达到”,并报告达到目标的运行比例,不能只计算成功运行的平均时间。AUC 可以作为补充指标,但不是本题要求的主指标。
相同更新次数不一定对应相同运行时间,因为 Momentum、RMSprop 和 Adam 还要更新辅助状态,RMSprop 和 Adam 还包含逐元素平方、开方或除法。任何“某种优化器更好”的结论都应限定在本题使用的数据、网络、批量大小、学习率搜索范围、训练预算和运行设备内。
可为每种算法设置相同数量的对数间隔候选学习率。例如,
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\) 仍然控制整体更新的大小。学习率过小时,模型在有限预算内进展缓慢;学习率过大时,这些算法仍可能振荡或发散。因此,“自适应”表示算法会根据梯度历史调整更新,并不表示可以省略全局学习率的选择。
例如,可以使用只有一个隐藏层的全连接神经网络,并把隐藏神经元数量依次设为 32、128 和 512,同时报告每个模型的实际参数量 \(P\)。对于同一种网络规模,四种优化器应使用相同的数据、初始参数、批量顺序、数据类型、最大训练轮数、验证频率和运行设备;学习率搜索的成本应与最终训练时间分开记录。
“每次更新所需时间”有两种常见口径:一种只测量优化器根据梯度更新参数的
step,另一种测量完整的“前向传播—后向传播—参数更新”训练步骤。实验应明确所用口径,最好分别报告。计时前先预热,再重复测量;使用 GPU 等异步计算设备时,计时前后都应等待设备完成计算。总训练时间是否包含验证也应明确说明。所有算法使用相同的目标验证指标和检查频率,未达到目标时记录“未达到”。在不计所有方法都需要的梯度数组时,若模型共有 \(P\) 个参数,则梯度下降几乎不需要额外的同维度态,Momentum 与 RMSprop 各需要约 \(P\) 个状态数值,Adam 需要约 \(2P\) 个。程序中可以对状态数组的
nbytes求和,得到与实际数据类型一致的内存用量。RMSprop 和 Adam 还增加逐元素平方、开方、除法与状态读写,因此每次更新通常需要更多计算和内存;它们是否能更快达到指定验证指标,仍需根据实验结果判断。测量预测效率时,应将模型切换到预测状态并停止记录梯度;各模型使用相同的批量、数据类型和设备,经过预热后重复计时,异步设备同样需要等待计算完成。固定批量预测时间使用多次测量结果的中位数表示,并同时报告波动范围。
优化器状态只在训练时用于更新参数,预测阶段不使用 \(\bv_t\) 或 \(\bs_t\)。因此,对于结构和规模相同的标准全连接网络,四种优化器训练所得模型具有相同的前向运算量,预测时间通常接近;但其预测结果和准确率可能不同,因为四种优化器最终得到的参数可能不同。报告时应分别说明训练成本、达到目标验证指标所需的时间和预测效率,不能把训练速度的变化写成网络预测结构的变化。