SGD优化方法总结

凸函数性质:

  • 它通常有唯一的极值点。
  • Hessian矩阵正定。

求解非凸函数: \(f(x,y)=x^2-2x+100xy+10y^2+20y\)

Gradient Descent

\[\theta = \theta - \eta \cdot \nabla_\theta J( \theta)\]
def batch_gd(x_start, step, g):
    x = np.array(x_start, dtype='float64')
    for i in range(50):
        grad = g(x)
        x -= grad * step
        if abs(sum(grad)) < 1e-6:
            break;
    return x

GD算法的挑战:

  • 难以挑选合适的学习率 $\eta$,太小收敛慢,太大容易振荡,甚至不收敛
  • 期望学习率可以随着迭代次数变化
  • 学习率对所有对参数是固定的,如果数据稀疏或特征具备不同的频率,会不合理
  • 对非凸的损失函数(如NN),GD可能被困在鞍点(saddle point)

Mementum

动量算法把上一次迭代的梯度以一定权重(即 $\gamma$ 通常取0.9)叠加到本轮的梯度上,这样可以加速相应方向的SGD,避免振荡。

\[v_t = \gamma v_{t-1} + \eta \nabla_\theta J( \theta) \\ \theta = \theta - v_t\]
def momentum(x_start, step, g, discount = 0.9):
    x = np.array(x_start, dtype='float64')
    passing_dot = [x.copy()]
    for i in range(50):
        grad = g(x)
        pre_grad = pre_grad * discount + grad
        x -= pre_grad * step
        if abs(sum(grad)) < 1e-6:
            break;
    return x

Nesterov accelerated gradient

动量方法首先计算本轮的梯度,然后沿着更新后的累积梯度方向前进一大步。 NAG首先在上轮梯度方向前进一大步,然后计算梯度再做微调,这样避免跑得太快,反应过激。

\[v_t = \gamma v_{t-1} + \eta \nabla_\theta J( \theta - \gamma v_{t-1} ) \\ \theta = \theta - v_t\]

可以看出Mementum其实并没有改变本轮梯度,而Nesterov利用之前的动量校正了本轮的梯度。

def nesterov(x_start, step, g, discount = 0.7):
    x = np.array(x_start, dtype='float64')
    passing_dot = [x.copy()]
    pre_grad = np.zeros_like(x)
    for i in range(50):
        x_future = x - step * discount * pre_grad
        grad = g(x_future)
        pre_grad = pre_grad * 0.7 + grad
        x -= pre_grad * step
        if abs(sum(grad)) < 1e-6:
            break;
    return x

Adagrad

动量和NAG方法都是在梯度上做文章,还没有涉及到学习率,即对所有参数学习率都一样。

Adagrad可以让学习率自适应参数,对低频的参数加大更新,对高频的参数减小更新,因此它非常适合处理稀疏数据。

定义 $g_{t,i}$ 为参数 $\theta_i$ 第 $t$ 步的梯度:

\[g_{t, i} = \nabla_\theta J( \theta_i )\]

则,SGD对每个参数 $\theta_i$ 在第 $t$ 步的更新公式(update rule)为:

\[\theta_{t+1, i} = \theta_{t, i} - \eta \cdot g_{t, i}\]

而Adagrad调整相应的学习率 $\eta$,变成这样:

\[\theta_{t+1, i} = \theta_{t, i} - \dfrac{\eta}{\sqrt{G_{t, i} + \epsilon}} \cdot g_{t, i}\]

整体用矩阵表示,$G_{t} \in \mathbb{R}^{d \times d}$ 是一个对角矩阵,对角线上的元素是参数 $\theta_i$ 从开始到第 $t$ 步的所有梯度的平方和,即 $G_{t,i}=\sum_t g_{t,i}^2$;$\epsilon$ 是个平滑因子,避免分母为0。

\[\theta_{t+1} = \theta_{t} - \dfrac{\eta}{\sqrt{G_{t} + \epsilon}} \odot g_{t}\]

因为,$G_{t}$积累了参数过去所有梯度的平方和,所以如果初期某参数的梯度变化很小,则会加大该参数的学习率;但也由于 $G_{t}$ 是始终递增的,中后期学习率会越来越小,这有可能导致迭代提前结束(因为每轮之间的参数基本没有变化了)。

def adagrad(x_start, step, g, delta=1e-8):
    x = np.array(x_start, dtype='float64')
    sum_grad = np.zeros_like(x)
    for i in range(50):
        grad = g(x)
        sum_grad += grad * grad
        x -= step * grad / (np.sqrt(sum_grad) + delta)
        if abs(sum(grad)) < 1e-6:
            break;
    return x

Adadelta

为了解决adagrad的学习率单调递减问题,adadelta并不累加过去所有梯度平方 $g^2$,而是约束在一个窗口 $w$ 里。

但直接保存过去 $w$ 步的所有 $g^2$ 并不是个高效的方法(尤其对于DNN这种大模型),adadelta的办法是计算过去 $g^2$ 的期望,并和当前梯度做一个折中(类似动量方法的思路,这里 $\gamma$ 一般也取 0.9):

\[E[g^2]_t = \gamma E[g^2]_{t-1} + (1 - \gamma) g^2_t\]

这里直接用 $E[g^2]_t$ 替代adagrad的 $G_t$ 就得到了 RMSprop 算法:

\[\theta_{t+1} = \theta_{t} - \dfrac{\eta}{\sqrt{E[g^2]_t + \epsilon}} \odot g_{t}\]

进一步推导

把SGD参数更新部分用 $\Delta \theta$ 表示。

adadelta作者发现 $\Delta \theta$ 的量纲不对(包括之前的SGD、动量、adagrad),至少应该包含和参数 $\theta$ 同样的单位。类似 $E[g^2]_t$ 的方法 ,作者定义了参数增量的平方:

\[E[\Delta \theta^2]_t = \gamma E[\Delta \theta^2]_{t-1} + (1 - \gamma) \Delta \theta^2_t\]

然后,然后把它开方再添加到 $\Delta \theta$ 的分子上,这样量纲就对了。而平方和开方其实就是计算RMS:

\[RMS[\Delta \theta]_{t} = \sqrt{E[\Delta \theta^2]_t + \epsilon}\]

这样就得到了adadelta算法:

\[\Delta \theta_t = - \dfrac{RMS[\Delta \theta]_{t-1}}{RMS[g]_{t}} \odot g_{t} \\ \theta_{t+1} = \theta_t + \Delta \theta_t\]
def adadelta(x_start, step, g, momentum = 0.9, delta=1e-1):
    x = np.array(x_start, dtype='float64')
    sum_grad = np.zeros_like(x)
    sum_diff = np.zeros_like(x)
    for i in range(50):
        grad = g(x)
        sum_grad = momentum * sum_grad + (1 - momentum) * grad * grad
        diff = np.sqrt((sum_diff + delta) / (sum_grad + delta)) * grad
        x -= step * diff
        sum_diff = momentum * sum_diff + (1 - momentum) * (diff * diff)
        if abs(sum(grad)) < 1e-6:
            break;
    return x

RMSprop

RMSprop是adadelta的特例($\gamma=0.5$),不赘述。

def rmsprop(x_start, step, g, rms_decay = 0.9, delta=1e-8):
    x = np.array(x_start, dtype='float64')
    sum_grad = np.zeros_like(x)
    for i in range(50):
        grad = g(x)
        sum_grad = rms_decay * sum_grad + (1 - rms_decay) * grad * grad
        x -= step * grad / (np.sqrt(sum_grad) + delta)
        if abs(sum(grad)) < 1e-6:
            break;
    return x

Adam

Adam(Adaptive Moment Estimation)是另一种自适应学习率算法。它综合adadelta和动量算法,既使用历史梯度平方和,也使用历史梯度和:

\[m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t \\ v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2\]

这里 $m_t$ 和 $v_t$ 对应均值和方差,其实是梯度的估计值,这也是adam算法命名的原因。由于 $m_t$ 和 $v_t$ 均初始化为 0 向量,这里做了一下平滑:

\[\hat{m}_t = \dfrac{m_t}{1 - \beta^t_1} \\ \hat{v}_t = \dfrac{v_t}{1 - \beta^t_2}\]

这样得到最终的adam算法:

\[\theta_{t+1} = \theta_{t} - \dfrac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t\]

其中,一般 $\beta_1$ 取值0.9, $\beta_2$ 取值 0.999, $\epsilon$ 取值 1e-8。

def adam(x_start, step, g, beta1 = 0.9, beta2 = 0.999, delta=1e-8):
    x = np.array(x_start, dtype='float64')
    sum_m = np.zeros_like(x)
    sum_v = np.zeros_like(x)
    for i in range(50):
        grad = g(x)
        sum_m = beta1 * sum_m + (1 - beta1) * grad
        sum_v = beta2 * sum_v + (1 - beta2) * grad * grad
        correction = np.sqrt(1 - beta2 ** i) / (1 - beta1 ** i)
        x -= step * sum_m / (np.sqrt(sum_v + delta))
        if abs(sum(grad)) < 1e-6:
            break;
    return x

参考

  • http://sebastianruder.com/optimizing-gradient-descent/
  • http://blog.csdn.net/luo123n/article/details/48239963
  • https://github.com/hsmyy/zhihuzhuanlan/blob/master/momentum.ipynb