逻辑回归
该方法用于分类问题(二元分类,也就是y取0或1)
逻辑函数(sigmoid function)
决策边界
逻辑回归决定的成本函数
针对一个多参数,二分类问题,我们可以定义其函数为:
可以回顾一下线性回归的成本函数:
如果对二分类定义的函数使用线性回归的成本函数,那么构成的损失函数的图像是非凸成本函数,会包含许多局部最小值,导致梯度下降效率不高。这时候,需要重新定义,逻辑回归函数的成本函数。
这里特别定义:
逻辑回归损失函数:
逻辑回归的简化版代价函数
将梯度下降用于逻辑回归
梯度下降:
将逻辑回归代价函数代入得到:
(这里的x_j表示,第i个参数的第j个特征)
过拟合
欠拟合:high bias
过拟合:对于训练集的适应效果太好,导致泛化能力很差,或者说对于其他特征很敏感。high variance
如何解决过拟合
获取更多的数据集
使用较少的(最相关的)特征(参数),但是可能会较大程度的影响模型效果
使用正则化减少参数的权重大小
正则化
正则化线性回归实现梯度下降
正则化逻辑回归实现梯度下降
从公式到代码:实现正则化逻辑回归 下面把前面的公式逐步翻译为浏览器中真正运行的 JavaScript。完整实验没有使用机器学习框架,这样可以直接看到数据、梯度、正则项和参数更新之间的关系。
在线实验:在本博客中打开 | 独立公开页面 | 下载完整源码
1. 程序中的参数分别对应什么
数学符号 / 设置
代码中的变量
主要影响
weights[1...]
每个特征对分类结果的影响强度
weights[0]
整体平移决策边界,不参与 L2 正则化
learningRate
每次梯度更新走多远;过大可能震荡或发散
lambdaOptions[lambdaIndex]
收缩权重,控制模型对复杂特征的依赖程度
epoch
samplesSeen / train.length
全部训练样本被使用过多少遍
特征次数
linear / quadratic / degree6
决定模型最多能表示多复杂的边界
需要特别区分:特征次数决定模型有没有能力弯曲, 决定是否压制这种能力。 并不是直接修改边界线,而是先改变权重,再间接改变预测概率和边界。
2. 构建带随机性的数据 实验先从截断高斯分布采样 ,然后通过真实边界计算概率,最后按伯努利分布抽取标签。这比用一条曲线把标签硬切成 0 和 1 更符合现实:即使两个样本位置相近,它们也不一定拥有相同标签。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function trueBoundary (x1 ) { return 0.42 * x1 * x1 - 0.08 * x1 - 0.65 ; } function generatePoint (rng, noise, labelFlipRate ) { const x1 = Math .max (-2.65 , Math .min (2.65 , normal (rng) * 1.16 )); const x2 = Math .max (-2.35 , Math .min (2.35 , normal (rng) * 1.02 )); const logit = (x2 - trueBoundary (x1)) / noise; let y = rng () < sigmoid (logit) ? 1 : 0 ; if (rng () < labelFlipRate) y = 1 - y; return { x1, x2, y }; }
页面使用固定种子的伪随机数生成器,因此相同种子会得到完全相同的数据。比较不同 时应保持种子不变,否则差异可能来自数据变化,而不是正则化。
数据按类别进行 75%/25% 分层划分:
训练集参与梯度更新;
测试集只用于观察泛化能力;
均值、标准差只由训练集计算,避免测试集信息泄漏。
3. 构造特征并只用训练集标准化 线性模型只有 ,二次模型加入 。六次模型包含所有总次数不超过 6 的二元单项式,共 27 个特征,加上偏置后一共 28 个参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 function rawFeatures (point, model ) { const a = (point.x1 - stats.mean1 ) / stats.sd1 ; const b = (point.x2 - stats.mean2 ) / stats.sd2 ; const degree = model === "linear" ? 1 : model === "quadratic" ? 2 : 6 ; const values = []; for (let total = 1 ; total <= degree; total++) { for (let p1 = total; p1 >= 0 ; p1--) { const p2 = total - p1; values.push ((a ** p1) * (b ** p2)); } } return values; }
高次幂会迅速放大数值,因此多项式展开后还要逐列标准化。统计量仍然只能来自训练集:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function fitFeatureStats (train ) { const rows = train.map (point => rawFeatures (point, model)); const count = rows[0 ].length ; const means = Array .from ( { length : count }, (_, j ) => rows.reduce ((sum, row ) => sum + row[j], 0 ) / rows.length ); const sds = means.map ((mean, j ) => Math .sqrt ( rows.reduce ((sum, row ) => sum + (row[j] - mean) ** 2 , 0 ) / rows.length ) || 1 ); return { means, sds }; } function features (point ) { const raw = rawFeatures (point, model); const scaled = raw.map ( (value, j ) => (value - featureStats.means [j]) / featureStats.sds [j] ); return [1 , ...scaled]; }
标准化很重要,因为 L2 惩罚的是权重数值。如果不同特征量纲相差很大,单纯比较权重大小或使用同一个 就不公平。
4. 稳定地计算 Sigmoid 直接计算 Math.exp(-z) 时,极端的 可能造成数值溢出。可以根据正负分支计算:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function sigmoid (z ) { if (z >= 0 ) { return 1 / (1 + Math .exp (-Math .min (z, 40 ))); } const expZ = Math .exp (Math .max (z, -40 )); return expZ / (1 + expZ); } function dot (a, b ) { return a.reduce ((sum, value, i ) => sum + value * b[i], 0 ); } function predictProbability (point ) { return sigmoid (dot (weights, features (point))); }
5. 交叉熵和 L2 正则项 程序真正优化的目标为:
交叉熵 正则项
代码中把概率限制在安全范围,避免出现 log(0):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function evaluate (dataset, includeRegularization ) { let loss = 0 ; let correct = 0 ; for (const point of dataset) { const p = sigmoid (dot (weights, features (point))); const safeP = Math .min (1 - 1e-12 , Math .max (1e-12 , p)); loss += -(point.y * Math .log (safeP) + (1 - point.y ) * Math .log (1 - safeP)); correct += (p >= 0.5 ? 1 : 0 ) === point.y ? 1 : 0 ; } loss /= dataset.length ; if (includeRegularization) { const squaredNorm = weights.slice (1 ) .reduce ((sum, w ) => sum + w * w, 0 ); loss += lambda * squaredNorm / (2 * train.length ); } return { loss, accuracy : correct / dataset.length }; }
页面显示的训练损失和测试损失都不包含 L2 项,方便公平比较;梯度下降和早停判断使用的训练目标则包含 L2 项。
6. 正则化逻辑回归最核心的梯度更新 对单个权重 ,数据项的梯度为:
L2 项的导数为:
合并后对应下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function oneUpdate (batch ) { const gradient = new Array (weights.length ).fill (0 ); for (const point of batch) { const x = features (point); const probability = sigmoid (dot (weights, x)); const error = probability - point.y ; for (let j = 0 ; j < gradient.length ; j++) { gradient[j] += error * x[j] / batch.length ; } } for (let j = 1 ; j < gradient.length ; j++) { gradient[j] += (lambda / train.length ) * weights[j]; } for (let j = 0 ; j < weights.length ; j++) { weights[j] -= learningRate * gradient[j]; } }
这是整个正则化逻辑回归实现中最重要的一段。可以把每一项理解为:
1 新权重 = 旧权重 - 学习率 ×(分类误差产生的梯度 + 正则化产生的梯度)
其中正则梯度 总是把非零权重往 0 的方向拉。 越大,拉回去的力量越强。
为什么偏置不正则化? 偏置 只负责整体移动边界,不表示模型对某个输入特征的依赖程度。通常只惩罚 ,因此代码中的正则循环从 j = 1 开始。
批量、小批量和随机梯度的区别 三种方法使用同一个 oneUpdate(),区别只是传入的 batch 大小:
1 2 3 if (optimizer === "batch" ) batchSize = train.length ;if (optimizer === "mini" ) batchSize = Math .min (16 , train.length );if (optimizer === "sgd" ) batchSize = 1 ;
批量梯度:每次使用全部训练样本,轨迹稳定;
小批量梯度:计算效率与稳定性之间的折中;
随机梯度:每次只看一个样本,轨迹抖动更明显。
7. 训练轮数和早停 epoch 不能直接等同于更新次数。一次批量更新等于 1 epoch,但随机梯度需要看到全部 个样本才等于 1 epoch:
1 2 3 function currentEpoch ( ) { return samplesSeen / train.length ; }
普通实验会开启早停:至少训练 10 epoch,若连续 10 epoch 没有超过 1e-4 的目标函数改善,就停止训练。专门观察过拟合时应关闭早停,否则模型可能在测试损失明显回升之前就停下。
8. 不能只看边界判断过拟合 实验中同时显示四类证据:
最强信号: 训练损失继续下降,但测试损失开始上升;
泛化差距: 训练准确率明显高于测试准确率;
比较指标: 在相同数据和特征尺度下,权重范数 变得很大;
辅助证据: 边界出现不必要的弯曲。
边界弯曲本身不等于过拟合,因为真实规律也可能是弯曲的。准确率也可能不敏感:预测从 0.55 变成错误且自信的 0.99 时,准确率都只记作一次错误,但交叉熵会显著增大。因此应优先观察训练/测试损失是否分叉。
9. 推荐的对照实验
实验
样本
特征
标签翻转
早停
重点观察
欠拟合
160
线性
0.1
0%
开
直线无法还原真实曲线
合理模型
160
二次
0.1
0%
开
训练与测试指标接近
过度正则化
160
二次
10
0%
开
权重过小,模型重新欠拟合
过拟合演示
80
六次
0
12%
关
训练/测试损失逐渐分叉
比较 时应保持数据种子、模型特征、学习率和训练轮数一致,一次只改变一个变量。
10. 直接在文章中运行实验
如果当前阅读器没有显示嵌入页面,可以使用下面的入口: