在线优化算法 FTRL 的原理与实现
在线学习想要解决的问题
在线学习 ( \(\it{Online \;Learning}\) ) 代表了一系列机器学习算法,特点是每来一个样本就能训练,能够根据线上反馈数据,实时快速地进行模型调整,使得模型及时反映线上的变化,提高线上预测的准确率。相比之下,传统的批处理方式需要一次性收集所有数据,新数据到来时重新训练的代价也很大,因而更新周期较长,可扩展性不高。
一般对于在线学习来说,我们致力于解决两个问题: 降低 regret 和提高 sparsity。其中 regret 的定义为:
其中 \(t\) 表示总共 \(T\) 轮中的第 \(t\) 轮迭代,\(\ell_t\) 表示损失函数,\(\bold{w}\) 表示要学习的参数。第二项 \(\min_\bold{w}\sum_{t=1}^T\ell_t(\bold{w})\) 表示得到了所有样本后损失函数的最优解,因为在线学习一次只能根据少数几个样本更新参数,随机性较大,所以需要一种稳健的优化方式,而 regret 字面意思是 “后悔度”,意即更新完不后悔。
在理论上可以证明,如果一个在线学习算法可以保证其 regret 是 \(t\) 的次线性函数,则:
那么随着训练样本的增多,在线学习出来的模型无限接近于最优模型。而毫不意外的,FTRL 正是满足这一特性。
另一方面,现实中对于 sparsity,也就是模型的稀疏性也很看中。上亿的特征并不鲜见,模型越复杂,需要的存储、时间资源也随之升高,而稀疏的模型会大大减少预测时的内存和复杂度。另外稀疏的模型相对可解释性也较好,这也正是通常所说的 L1 正则化的优点。
后文主要考察 FTRL 是如何实现降低 regret 和提高 sparsity 这两个目标的。
FTRL 原理
网上很多资料都是从 FTRL 的几个前辈,FOBOS、RDA 等一步步讲起,本篇就不绕那么大的圈子了,直接从最基本的 OGD 开路到 FTRL 。OGD ( \(\it{online \;gradient \; descent}\) ) 是传统梯度下降的 online 版本,参数更新公式为:
\(t\) 表示第 \(t\) 轮迭代,注意上式中的学习率 \(\eta_t\) 每轮都会变,一般设为 \(\eta_t = \frac{1}{\sqrt{t}}\)
OGD 在准确率上表现不错,即 regret 低,但在上文的另一个考量因素 sparsity 上则表现不佳,即使加上了 L1 正则也很难使大量的参数变零。一个原因是浮点运算很难让最后的参数出现绝对零值;另一个原因是不同于批处理模式,online 场景下每次 \(\bold {w}\) 的更新并不是沿着全局梯度进行下降,而是沿着某个样本的产生的梯度方向进行下降,整个寻优过程变得像是一个“随机” 查找的过程,这样 online 最优化求解即使采用 L1 正则化的方式, 也很难产生稀疏解。正因为 OGD 存在这样的问题,FTRL 才致力于在准确率不降低的前提下提高稀疏性。
相信大部分人对于 \((1.1)\) 式都不陌生,然而其实际等价于下式:
对 \((1.2)\) 式直接求导即可,\(\bold{g}_t + \frac{1}{\eta_t}(\bold{w} - \bold{w}_t) = 0 \;\;\implies\;\; \bold{w} = \bold{w}_t - \eta_t \bold{g}_t\) 。有了 \((1.2)\) 式的基础后,后面 FTRL 的那些奇奇怪怪的变换就能明了了,目的无非也是降低 regret 和提高 sparsity 。
首先,为了降低 regret,FTRL 用 \(\bold{g}_{1:t}\) 代替 \(\bold{g}_t\) ,\(\bold{g}_{1:t}\) 为前 1 到 t 轮损失函数的累计梯度,即 \(\bold{g}_{1:t} = \sum_{s=1}^t \bold{g}_s = \sum_{s=1}^t \nabla \ell_s(\bold{w}_s)\) 。由于在线学习随机性大的特点,累计梯度可避免由于某些维度样本局部抖动太大导致错误判断。这是从 FTL ( \(\it{Follow \; the\; Leader}\) ) 那借鉴而来的,而 FTRL 的全称为 \(\it{Follow \; the\; Regularized \;Leader}\) ,从名字上看其实就是在 FTL 的基础上加上了正则化项,即 \((1.2)\) 式中的
\(||\bold{w} - \bold{w}_t||_2^2\) 项。这意味着每次更新时我们不希望新的 \(\bold{w}\) 离之前的 \(\bold{w}_t\) 太远 (这也是有时其被称为 FTRL-proximal 的原因),这同样是为了降低 regret,在线学习噪音大,若一次更新错得太远后面难以收回来,没法轻易“后悔”。
其次,为提高 sparsity ,最直接的方法就是无脑加 L1 正则。但这里的问题是上文中 OGD 加了 L1 正则不能产生很好的稀疏性,那么 FTRL 为什么就能呢?这在后文的具体推导中会逐一显现,耐心看下去就是。另外 FTRL 2013 年的工程论文中也加上了 L2 正则,所以综合上述几点,FTRL 的更新公式变为:
其中 \(\sigma_s = \frac{1}{\eta_s} - \frac{1}{\eta_{s-1}}\) ,则 \(\sigma_{1:t} = \sum_{s=1}^t \sigma_s = \frac{1}{\eta_s}\) ,主要是为了后面推导和实现方便而这么设置,后文再述。
下面可以推导 FTRL 的算法流程,将 \((1.3)\) 式中的 \(||\bold{w} - \bold{w}_s||_2^2\) 展开:
由于 \(\frac12 \sum\limits_{s=1}^t \sigma_s||\bold{w}_s||_2^2\) 相对于要优化的 \(\bold{w}\) 是一个常数可以消去,并令 \(\bold{z}_t = \bold{g}_{1:t} - \sum\limits_{s=1}^t \sigma_s\bold{w}_s\) ,于是 \((1.4)\) 式变为:
将特征的各个维度拆开成独立的标量最小化问题,\(i\) 为 第 \(i\) 个特征:
\((1.6)\) 式是一个无约束的非平滑参数优化问题,其中第二项 \(\lambda_1|w_i|\) 在 \(w_i = 0\) 处不可导,因而常用的方法是使用次导数 (详见附录1),这里直接上结论: 定义 \(\phi \in \partial |w_i^*|\) 为 \(|w_i|\) 在 \(w_i^*\) 处的次导数,于是有:
有了 \(|w_i|\) 的次导数定义后,对 \((1.6)\) 式求导并令其为零:
上式中 \(\lambda_1 > 0\, , \;\; \left(\lambda_2 + \sum_{s=1}^t \sigma_s \right) > 0\) ,下面对 \(z_{t,i}\) 的取值分类讨论:
(1) \(|z_{t, i}| < \lambda_1\) ,那么 \(w_i = 0\) 。因为若 \(w_i > 0\) ,根据 \((1.7)\) 式 \(\phi = 1\) ,则 \((1.8)\) 式左侧 \(> 0\) ,该式不成立;同样若 \(w_1 < 0\),则 \((1.8)\) 式左侧 \(< 0\),不成立。
(2) \(z_{t, i} > \lambda_1\),则 \(\phi = -1 \implies w_i = -\frac{1}{\lambda_2 + \sum_{s=1}^t \sigma_s} (z_{t, i} - \lambda_1) < 0\) 。因为若 \(w_i > 0\),\(\phi = 1\),\((1.8)\) 式左侧 \(> 0\),不成立;若 \(w_i = 0\),由 \((1.8)\) 式 \(\phi = -\frac{z_{t,i}}{\lambda_1} < -1\) ,与 \((1.7)\) 式矛盾。
(3) \(z_{t,i} < -\lambda_1\),则 \(\phi = 1 \implies w_i = -\frac{1}{\lambda_2 + \sum_{s=1}^t \sigma_s} (z_{t, i} + \lambda_1) > 0\) 。因为若 \(w_i < 0\),\(\phi = -1\),\((1.8)\) 式左侧 $ < 0$,不成立;若 \(w_i = 0\),由 \((1.8)\) 式 \(\phi = -\frac{z_{t,i}}{\lambda_1} > 1\) ,与 \((1.7)\) 式矛盾。
综合这几类情况,由 $(1.8) $ 式得到 \(w_{t,i}\) 的更新公式:
可以看到当 \(z_{t,i} = \left(g_{1:t, i} - \sum_{s=1}^t \sigma_s w_{s, i} \right) < \lambda_1\) 时,参数置为零,这就是 FTRL 稀疏性的由来。另外加入 L2 正则并没有影响模型的稀疏性,从 \((1.9)\) 式看只是使得分母变大,进而 \(w_i\) 更趋于零了,这在直觉上是符合正则化本身的定义的。
观察 \((1.9)\) 式还遗留一个问题,$\sigma $ 的值是什么呢?这牵涉到 FTRL 的学习率设置。当然严格意义上的学习率是 \(\eta_t\) ,而 \(\sigma_t = \frac{1}{\eta_t} - \frac{1}{\eta_{t-1}}\) ,论文中这样定义可能是为了推导和实现的方便。前文 \((1.1)\) 式中 OGD 使用的是一个全局学习率 \(\eta_t = \frac{1}{\sqrt{t}}\) ,会随着迭代轮数的增加而递减,但该方法的问题是所有特征维度都使用了一样的学习率。
FTRL 采用的是 Per-Coordinate Learning Rate,即每个特征采用不同的学习率,这种方法考虑了训练样本本身在不同特征上分布的不均匀性。如果一个特征变化快,则对应的学习率也会下降得快,反之亦然。其实近年来随着深度学习的流行这种操作已经是很常见了,常用的 AdaGrad、Adam 等梯度下降的变种都蕴含着这类思想。FTRL 中第 \(t\) 轮第 \(i\) 个特征的学习率为:
这样 \((1.9)\) 式中的 \(\sum_{s=1}^t \sigma_s\) 为:
其中 \(\alpha, \beta\) 为超参数,论文中建议 \(\beta\) 设为 1,而 \(\alpha\) 则根据情况选择。\(g_{s,i}\) 为第 \(s\) 轮第 \(i\) 个特征的偏导数,于是 \((1.9)\) 式变为:
综合 \((1.10)\) 式和 \((1.12)\) 式可以看出,学习率 \(\eta_{t,i}\) 越大,则参数 \(w\) 更新幅度越大,这与学习率的直觉定义相符。
FTRL 实现
完整代码见 ( https://github.com/massquantity/Ftrl-LR ) ,实现了多线程版本 FTRL 训练 Logistic Regression 。
对于算法的实现来说,首先需要得到完整的算法流程。仔细审视 \((1.12)\) 式,要在 \(t + 1\) 轮更新 \(w_{t+1, i}\) 需要哪些值? 需要 \(\{ \;z_{t,i}, \,g_{t,i}, \,\alpha, \,\beta, \,\lambda_1, \,\lambda_2 \; \}\) ,后四个为预先指定的超参数,对于 \(z_{t,i}\) ,注意其定义有可以累加的特性 :
所以我们只需存储上一轮迭代得到的三个量 : \(z_{t-1,i}, \, w_{t,i}, \sqrt{\sum_{s=1}^t g_{s,i}^2}\) ,并在本轮迭代中计算 \(g_{t,i}\) ,就能不断更新参数了。\(g_{t,i}\) 为损失函数对第 \(i\) 个特征的偏导数,\(\text{Logistic Regression}\) 的损失函数是 \(\text{Log Loss}\),这里直接给出结论,具体推导见附录 2:
其中 \(S(\cdot)\) 为 Sigmoid函数,\(x_i\) 为第 \(i\) 个特征值, \(y \in \{-1, + 1\}\) 为标签,\(f(\bold{x}_t) = \sum_{i=1}^I w_ix_i\) 。下面就可以给出完整的算法流程了,其中为方便表示定义了 \(n_i = \sum_{s=1}^t g_{s,i}^2\) :
输入: 参数 \(\alpha, \,\beta, \,\lambda_1, \,\lambda_2\)
初始化: \(\bold{z}_0 = \bold{0}, \; \bold{n}_0 = \bold{0}\)
$\text{for t = 1 to T} : $
\(\qquad\) 收到一个样本 \(\{\bold{x}_t, y_t\}\) ,\(y_t \in \{-1, + 1\}\) 。令 \(I\) 为所有不为零的特征集合,即 \(I = \{i \,|\, x_i \neq 0\}\)
\(\qquad\) \(\text{for} \;\;i \in I\) 更新 \(w_{t,i}\) :
\[w_{t,i} = \begin{cases} \qquad\qquad \large{0} & \quad\text{if}\;\; |z_{i}| < \lambda_1 \\[2ex] - \left(\lambda_2 + \frac{\beta + \sqrt{n_i}}{\alpha} \right)^{-1} \left(z_{i} - \text{sgn}(z_{i})\cdot\lambda_1 \right) & \quad \text{otherwise} \end{cases} \] \(\qquad\) 使用更新后的 \(\bold{w}_t\) 计算 \(f(\bold{x}_t) = \bold{w}_t \cdot \bold{x}_t\)
\(\qquad\) \(\text{for all} \; i \in I :\)
\(\qquad\qquad\) \(g_i = y_t (S(y_t f(\bold{x}_t)) - 1) x_i\)
\(\qquad\qquad\) \(\sigma_i = \frac{\sqrt{n_i + g_i^2} - \sqrt{n_i}}{\alpha}\)
\(\qquad\qquad\) \(z_i \leftarrow z_i + g_i - \sigma_i w_{t,i}\)
\(\qquad\qquad\) \(n_i \leftarrow n_i + g_i^2\)
\(\qquad\) \(\text{end for}\)
\(\text{end for}\)
如上文所述,代码中使用了一个 ftrl_model_unit
类来存储三个量 \(z_{i}, \, w_{i}, n_i\) :
class ftrl_model_unit
{
public:
double wi;
double w_ni;
double w_zi;
ftrl_model_unit()
{
wi = 0.0;
w_ni = 0.0;
w_zi = 0.0;
}
ftrl_model_unit(double mean, double stddev)
{
wi = utils::gaussian(mean, stddev);
w_ni = 0.0;
w_zi = 0.0;
}
}
更新参数的核心步骤为:
void ftrl_trainer::train(int y, const vector<pair<string, double> > &x)
{
ftrl_model_unit *thetaBias = pModel->getOrInitModelUnitBias();
vector<ftrl_model_unit *> theta(x.size(), nullptr);
int xLen = x.size();
for (int i = 0; i < xLen; ++i) {
const string &index = x[i].first;
theta[i] = pModel->getOrInitModelUnit(index); // 获取相应的 ftrl_model_unit
}
for (int i = 0; i <= xLen; ++i) {
ftrl_model_unit &mu = i < xLen ? *(theta[i]) : *thetaBias;
if (fabs(mu.w_zi) <= w_l1)
mu.wi = 0.0;
else {
mu.wi = (-1) *
(1 / (w_l2 + (w_beta + sqrt(mu.w_ni)) / w_alpha)) *
(mu.w_zi - utils::sgn(mu.w_zi) * w_l1); // 更新 wi
}
}
double bias = thetaBias->wi;
double p = pModel->forecast(x, bias, theta); // 计算 f(x)
double mult = y * (1 / (1 + exp(-p * y)) - 1);
for (int i = 0; i <= xLen; ++i) {
ftrl_model_unit &mu = i < xLen ? *(theta[i]) : *thetaBias;
double xi = i < xLen ? x[i].second : 1.0;
double w_gi = mult * xi; // 更新 gi
double w_si = 1 / w_alpha * (sqrt(mu.w_ni + w_gi * w_gi) - sqrt(mu.w_ni));
mu.w_zi += w_gi - w_si * mu.wi; // 更新 zi
mu.w_ni += w_gi * w_gi; // 更新 ni
}
}
Appendix 1: 次导数
\((1.7)\) 式中使用了 \(f(x) = |x|\) 的次导数,这里做一下具体推导。首先次导数的定义 —— 凸函数 \(f: \text{I} \rightarrow \mathbb{R}\) 在开区间 \(\text{I}\) 内的点 \(x_0\) 的次导数 \(c\) 满足:
其物理含义是通过 \(x_0\) 下方的直线的斜率,如下图,\(f(x)\) 在 \(x_0\) 处不可导,则经过 \(x_0\) 画一条红线,总是位于 \(f(x)\) 下方,其斜率就是次导数 \(c\) 。可以看出很多时候次导数并不唯一。
对于 \(f(x) = |x|\) 来说,\(x < 0\) 时,次导数为单元素集合 \(\{-1\}\) ,\(x > 0\) 时为 \(\{1\}\) 。而在 \(x = 0\) 处则不连续,根据次导数的定义,\(f(x) - f(0) \geqslant c\,(x - 0), \; f(x) \geqslant c\,x\) ,则满足该式的 \(c \in [-1, 1]\) ,因而 \(f(x) = |x|\) 的次导数为 (如下图所示):
Appendix 2: $\text{Log Loss} $
FTRL 的算法流程中每一轮更新都要计算损失函数对每个特征分量的偏导数, 论文 中写的是使用梯度,但实际上是梯度的一个分量,即偏导数,这里就不作区分了。\(\text{Log Loss}\) 即 \(\text{Logistic Loss}\) ,其具体由来可参阅前文 《常见回归和分类损失函数比较》。仅考虑一个特征的参数 \(w_i\) ,\(y \in \{-1, + 1\}\) 为标签,$\text{Log Loss} $ 的形式为:
其中 \(f(\bold{x}_t) = \sum_{i=1}^I w_ix_i\) ,对 \(w_i\) 的偏导数为:
FTRL 论文 中采用的标签形式是 \(y \in \{0,1\}\) ,因而损失函数的形式稍有不同:
其中 \(p(\bold{w}^T\bold{x}) = \frac{1}{1 + e^{- \bold{w}^T \bold{x}}}\) 为 Sigmoid 函数,其导数为:
因而利用这个性质我们可以求出 \((3.2)\) 式关于 \(w_i\) 的偏导数:
/