最近在整理逻辑回归的课程笔记时,发现很多同学包括当时的我自己,都在“为什么逻辑回归要用这样一套成本函数”这个问题上卡了很久。网上教程一上来就甩出交叉熵公式,却没人说清楚它到底是怎么来的、为什么不能直接套线性回归的平方误差、梯度下降又凭什么能收敛。这篇文章就围绕逻辑回归的成本函数,把从直觉到数学、从推导到代码的一整条线完整过一遍。读完你不仅知道逻辑回归的成本函数长什么样,还能手推它的梯度,并且在自己的数据集上从零实现一个可用的训练过程。
1. 为什么线性回归的成本函数不能直接拿过来用
1.1 平方误差会给逻辑回归挖什么坑
先说结论:如果逻辑回归照搬线性回归的平方误差作为成本函数,梯度下降大概率会卡在某个奇怪的地方,模型学不出来。
原因要从逻辑回归的模型结构说起。线性回归的输出是连续值,模型长这样:
code复制y = w^T x + b
逻辑回归只是在线性输出外面套了一层 sigmoid 函数,把结果压缩到 0 到 1 之间:
code复制h(x) = sigmoid(w^T x + b)
问题就出在这个 sigmoid 上。它是一条 S 形曲线,两端非常平缓。当你把平方误差作为成本函数时,成本曲面会变成什么样子?
我拿一个只有两个特征的简单数据集实测过。把逻辑回归的损失函数换成平方误差,然后在二维参数空间画出等高线,结果非常难看:成本函数的等高线不再是光滑的碗状,而是呈现出明显的波浪形,有多个局部极小值点。梯度下降从不同的初始位置出发,会收敛到完全不同的参数组合,模型效果天差地别。
为什么平方误差会产生非凸的成本曲面?可以从二阶导的角度理解。平方误差对参数的二阶偏导里含着 sigmoid 的导数项,而 sigmoid 的导数在两端趋近于 0,导致 Hessian 矩阵不再正定。数学上不“凸”,梯度下降这类基于局部梯度信息的优化方法就失去了全局收敛的保证。
打个比方,线性回归的成本函数像一个光滑的碗,你把小球随便放在碗壁上,它最终都会滚到碗底。逻辑回归如果用平方误差,成本函数就变成了一片连绵的丘陵,球滚下去可能卡在任何一个小洼地里,而不是真正的最低点。
1.2 从概率角度看为什么平方误差不合理
平方误差的另一个致命问题,是把模型输出当成普通的实数来度量误差。但逻辑回归的输出是有明确概率含义的,它表示样本属于正类的概率。
当真实标签是 1、模型预测概率是 0.9 时,平方误差是 0.01,看起来很“接近”。但当真实标签是 1、模型预测概率是 0.6 时,平方误差 0.16 看起来也还行。可如果我们把这个预测当成概率来用,0.6 和 0.9 的差别在实际决策中是非常大的:前者意味着模型对判断为“正类”这件事只有六成把握,后者则有九成把握。
概率层面的差异应该被成本函数放大,而不是被平方误差“平均化”掉。这就引出了逻辑回归真正该用的成本函数,它是从极大似然估计这个更本质的角度自然推导出来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从极大似然估计到对数损失
2.1 一句话理解极大似然
极大似然估计的思想可以用一句话概括:找一组参数,使得“当前已观测到的训练数据出现的概率”最大。
举个例子,一个袋子里有红球和白球,你抽了 10 次,抽到 8 次红球。你说袋子里红球比例最有可能是多少?直觉告诉你是 80%。这个过程背后就是极大似然:既然实际抽出来 8 红 2 白,那“红球概率 0.8”这个假设让这组观测结果出现的概率最大。
逻辑回归做的事情本质上完全一样。训练数据就是“观测结果”,我们要找一组参数 w 和 b,使得模型对全体训练样本给出的概率最接近真实标签。
2.2 逻辑回归的似然函数怎么构造
一个样本的概率可以统一写成一个表达式。假设模型输出 h(x) 表示预测为正类的概率,真实标签 y 取 0 或 1,那么模型对该样本的预测概率可以写成:
code复制p(y|x) = h(x)^y · (1 - h(x))^(1-y)
当 y=1 时,这个式子等于 h(x),即预测为正类的概率;当 y=0 时,等于 1-h(x),即预测为负类的概率。一个式子同时覆盖两种情况,这个写法在后续推导中非常方便。
全体训练样本的似然函数,就是把每个样本的概率乘起来:
code复制L(w,b) = Π_i h(x_i)^y_i · (1-h(x_i))^(1-y_i)
为什么用连乘?因为每个样本是独立采样得到的,独立事件同时发生的概率就是各自概率的乘积。
2.3 连乘变连加:对数似然登场
连乘在数学上不好优化。一来乘法涉及大量小数连乘,数值下溢严重;二来求导时乘积法则非常复杂。解决办法是取对数。
对数函数是单调递增的,所以让 L 最大和让 log(L) 最大是同一个问题。取对数之后,连乘变成连加:
code复制log L(w,b) = Σ_i [y_i · log(h(x_i)) + (1-y_i) · log(1-h(x_i))]
这就是对数似然。机器学习里习惯最小化损失而不是最大化似然,所以把这个式子取负号,就得到了逻辑回归的成本函数:
code复制J(w,b) = -(1/m) Σ_i [y_i · log(h(x_i)) + (1-y_i) · log(1-h(x_i))]
除以 m 是为了求平均,让成本不随样本量增加而变大,方便在不同大小的数据集上比较成本值。这个函数在深度学习中还有另一个更通用的名字,叫二分类交叉熵损失,但在逻辑回归语境下,我们通常直接叫它对数损失。
2.4 对数惩罚到底是怎么惩罚的
我刚开始学的时候,对交叉熵只有一个模糊的印象,直到亲手算了几组数值,才真正理解了它的行为。
假设真实标签 y=1,预测概率 h=0.9,这时代价是:
code复制-log(0.9) ≈ 0.105
如果预测概率 h=0.6,代价是:
code复制-log(0.6) ≈ 0.511
再看 h=0.1 的情况,模型几乎完全猜反了,代价是:
code复制-log(0.1) ≈ 2.303
看这几组数能直观感受到对数损失的特性:预测越离谱,代价增长得越猛。特别是当 h 趋向于 0 时,-log(h) 趋向于无穷大,这就是所谓“有界概率、无界损失”的含义。模型会拼尽全力避免出现“真实标签是 1 却给了极低概率”这种灾难性错误。
这正好补上了平方误差的短板:概率层面的误差被放大到与实际决策影响相匹配的程度,而不是被二次方抹平。
3. 两种等价写法与梯度推导细节
3.1 吴恩达课程里的写法为什么不冲突
如果你上过吴恩达的机器学习课,会看到他课上用的是分段写法的成本函数:
code复制if y=1: J = -(1/m) Σ log(h(x_i))
if y=0: J = -(1/m) Σ log(1-h(x_i))
这和前面统一的交叉熵写法是完全等价的。分段写法的好处是直观,容易看出正负两个类别各自在优化什么:对正类样本,要让模型给它的正类概率尽量高;对负类样本,要让模型给它的负类概率尽量高。
分段写法在实践中也很有用。比如调试代码时,你发现损失怎么都降不下去,就可以分别统计正类样本的平均损失和负类样本的平均损失,看是哪一类在拖后腿。很多教材里的代码实现也是用分段形式来计算的,逻辑更清晰。
统一写法也有自己的优势:公式简洁、代码实现时不需要 if 分支,而且可以直接套用深度学习框架里封装好的交叉熵 API。两种写法只是同一枚硬币的两面,梯度完全一致。
3.2 梯度推导的关键一步
逻辑回归梯度推导中最容易卡住的地方,是 sigmoid 函数求导。这个导数公式值得单独记一下:
code复制sigmoid(z) = 1 / (1 + e^(-z))
sigmoid'(z) = sigmoid(z) · (1 - sigmoid(z))
它的形式相当优雅:导数等于函数值乘以 1 减函数值。可以这样直观理解,当 z 很大时,sigmoid 输出接近 1,导数为 1×(1-1)=0,函数趋于饱和;当 z 接近 0 时,输出 0.5,导数 0.5×0.5=0.25 最大,函数恰好处于最灵敏的区域。
有了这个公式,我们来看成本函数对参数的偏导。这里直接给出推导的核心结果,中间细节可以自己推一遍:
code复制∂J/∂w_j = (1/m) Σ_i (h(x_i) - y_i) · x_ij
∂J/∂b = (1/m) Σ_i (h(x_i) - y_i)
这个结果有没有让你想起什么?它和线性回归平方误差的梯度形式完全一样,都是“预测值减真实值再乘以特征值”。区别在于,线性回归里的预测值是线性输出本身,而逻辑回归里的预测值是经过 sigmoid 压缩后的概率。
我第一次推到这一步时非常震撼:一个看似复杂的对数损失,梯度竟然如此简洁。这也从侧面解释了为什么逻辑回归在实践中的训练非常稳定,它的梯度形式本质上和最小二乘是统一的。
3.3 详解一个样本的推导过程
对于单个样本,记 z = w^T x + b,h = sigmoid(z)。成本函数对该样本的贡献是:
code复制J_i = -[y · log(h) + (1-y) · log(1-h)]
对 w_j 求偏导,使用链式法则分为三层:
先对 h 求导:
code复制∂J_i/∂h = -[y/h - (1-y)/(1-h)]
然后 h 对 z 求导:
code复制∂h/∂z = h(1-h)
最后 z 对 w_j 求导:
code复制∂z/∂w_j = x_j
合并三层:
code复制∂J_i/∂w_j = -[y/h - (1-y)/(1-h)] · h(1-h) · x_j
这个式子看起来复杂,但把它整理一下,会发生一件非常巧妙的事:
code复制-[y/h - (1-y)/(1-h)] · h(1-h)
= -(y(1-h) - (1-y)h)
= -(y - yh - h + yh)
= -(y - h)
= h - y
括号里那些 h(1-h) 的项相互抵消,最终得到:
code复制∂J_i/∂w_j = (h - y) · x_j
这个化简过程堪称教科书级别的优雅。它意味着 sigmoid 的非线性在梯度中被完美地“抵消”了,只剩下原始残差乘以特征值。
4. 从零实现的训练过程与关键细节
4.1 Python 实现逻辑回归成本函数与梯度
理解理论之后,用 NumPy 手动实现一遍,比任何画图都能加深理解。下面给出一个完整的最小实现:
python复制import numpy as np
def sigmoid(z):
# 用 np.clip 防止 exp 溢出,同时保持计算稳定
z = np.clip(z, -500, 500)
return 1.0 / (1.0 + np.exp(-z))
def compute_cost(X, y, w, b):
m = X.shape[0]
z = np.dot(X, w) + b
h = sigmoid(z)
# 添加微小 epsilon 防止 log(0) 导致 nan
eps = 1e-15
cost = -(1.0 / m) * np.sum(
y * np.log(h + eps) + (1 - y) * np.log(1 - h + eps)
)
return cost
def compute_gradient(X, y, w, b):
m = X.shape[0]
z = np.dot(X, w) + b
h = sigmoid(z)
dw = (1.0 / m) * np.dot(X.T, h - y)
db = (1.0 / m) * np.sum(h - y)
return dw, db
def gradient_descent(X, y, w, b, alpha, iterations):
costs = []
for i in range(iterations):
cost = compute_cost(X, y, w, b)
dw, db = compute_gradient(X, y, w, b)
w -= alpha * dw
b -= alpha * db
costs.append(cost)
if i % 100 == 0:
print(f"Iteration {i}, cost = {cost:.6f}")
return w, b, costs
这里有几个细节值得单独点名。
sigmoid 函数里的 np.clip 是为了防止 z 过大时 exp(-z) 溢出。当 z 取 -1000 时,np.exp(1000) 会直接返回 inf;对 z 做 clip 限制后,计算就不会报 RuntimeWarning 了。
log 函数里的 eps 同样是防止出现 log(0) 的情况。当模型对某个负类样本给出接近 1 的概率时,1-h 会非常接近 0,直接取 log 会得到负无穷,进而让成本变成 nan。加上一个 1e-15 的小常数不影响梯度方向,但能让训练过程稳定得多。
4.2 在真实数据上完整跑一遍训练
为了验证代码的正确性,我拿一个简单的二分类数据集做了完整实验。数据有三个特征,正负样本各 500 个。
python复制np.random.seed(42)
# 生成两类二维高斯分布数据
X_pos = np.random.randn(500, 2) + np.array([2.0, 2.0])
X_neg = np.random.randn(500, 2) + np.array([-2.0, -2.0])
X = np.vstack([X_pos, X_neg])
y = np.hstack([np.ones(500), np.zeros(500)])
# 初始化参数
w = np.zeros(2)
b = 0.0
alpha = 0.5
iterations = 2000
w, b, costs = gradient_descent(X, y, w, b, alpha, iterations)
print(f"Final weights: {w}")
print(f"Final bias: {b}")
print(f"Final cost: {costs[-1]:.6f}")
训练过程的输出大概是这样的趋势:第 0 轮成本在 0.693 附近,这是 w=0 时的初始损失,对应的正好是 -log(0.5)=0.693,可以用这个值来验证实现的正确性。随着迭代进行,成本逐渐下降到 0.1 以下,最终稳定在 0.06 左右。
为什么初始成本一定是 0.693?因为当 w 和 b 都初始化为 0 时,sigmoid(0)=0.5,模型对每个样本给出的概率都是 0.5。代入成本函数后,无论 y 是 1 还是 0,单个样本的损失都是 -log(0.5)=0.693。这个数值是检验代码是否写对的一个天然基准点。
训练完成后,用学到的参数对训练集做预测,准确率大约 98%。不算特别高,因为生成的两类数据有一部分重叠区域,这正好说明逻辑回归是一种线性模型,无法完美切开有重叠的类别分布。
4.3 学习率与特征归一化对收敛的影响
学习率的选择直接影响训练效果。我用同一个数据集做了对比实验,学习率分别取 0.01、0.1 和 1.0。
学习率 0.01 时,2000 轮迭代后成本只降到 0.5 左右,还在缓慢下降,说明步子太小,需要更多迭代才能收敛。学习率 0.5 时,约 800 轮就能降到 0.1 以下,收敛速度非常理想。学习率 1.5 时,成本不但没有下降,反而在几轮内跳到 2 以上,因为步长过大直接跳过了最优点,陷入震荡甚至发散。
这些现象背后是损失函数的曲率在起作用。梯度下降每一步沿着负梯度方向移动,移动距离由学习率控制。如果曲率大而学习率也大,就会出现过冲;如果曲率小而学习率小,速度又太慢。逻辑回归的成本函数虽然是凸的,但仍需要选择合适的学习率才能高效收敛。
特征归一化在这种场景下也非常重要。如果某个特征的取值范围是 0.1 到 0.9,另一个特征是 1000 到 9000,那么梯度中前者的贡献相对后者几乎可以忽略,参数估计会变得不均衡。实践中一般对特征做标准化,让每个维度均值 0、方差 1,这样梯度在各方向上的尺度更均匀,收敛更快,也更容易选择全局合适的学习率。
5. 常见报错、排查思路与工程实践建议
5.1 成本函数计算出现 nan 的几种原因
我调试逻辑回归时最常遇到的就是成本变成 nan。总结下来无非三种原因。
第一种是数据里有特征值特别大的样本,导致 z 非常大,sigmoid 后 h 变成 1 或 0,log 就变成 0 或负无穷。这种情况下做特征标准化基本就能解决。
第二种是学习率太大,梯度更新一步后参数直接飞出去,z 变得极端,训练过程一发不可收拾。把学习率调小,或者对梯度做裁剪就能稳住。
第三种是数据本身分布非常极端,比如正类样本中某个特征全部是 0,导致该维度梯度一直为 0,参数无法更新。这种问题要靠检查数据分布来发现,不能只盯着损失函数看。
我之前调试时有个习惯:每次训练都在前 100 轮打印成本值,一旦出现 nan,先把学习率除以 10 重新跑。如果还是 nan,就检查数据和初始化。这个方法很笨,但非常有效。
5.2 损失下降但准确率不动是怎么回事
这种情况我在实际项目中踩过不止一次。损失曲线看着一路走低,训练集准确率却纹丝不动。
最常见的原因是数据本身类别不平衡。假设 95% 的样本是负类,模型只需要把所有样本都预测为负类,准确率就有 95%,而交叉熵损失会随着模型概率接近 0.95 而缓慢下降。这时准确率曲线看起来就是平的。
解决方案有几个:用类别权重调整损失函数,给少数类更高的权重;或者改用 F1-score、AUC 等对类别不平衡更敏感的评估指标;再或者对少数类样本做重采样。实操中最快捷的办法是检查 sklearn 里 LogisticRegression 的 class_weight 参数,设置为 balanced 后,代码会自动根据类别频率调整权重。
另一个可能性是 sigmoid 输出本来就不够“锐利”,大量样本的概率集中在 0.4 到 0.6 之间,阈值在 0.5 时分类结果变化很小。这种情况下增强特征工程或改用非线性模型才是正道。
5.3 手写实现和 sklearn 结果对比的经验
为了确认手写梯度实现的正确性,我把同一份数据喂给了 sklearn 的 LogisticRegression 做对比。sklearn 默认使用 L2 正则化,参数 C 控制正则强度,C 越小正则越强。我的实现里没有加正则,所以对比时需要把 C 设得很大,比如 1e10,让正则项几乎不起作用。
对比结果是参数非常接近,成本曲线也几乎重合。这个对比实验强烈建议每个人都做一遍,它能够确认你对梯度推导的理解是否正确。当你手写梯度和成熟框架的结果一致时,说明你对逻辑回归成本函数的理解已经到位了。
如果发现手写结果和 sklearn 差异明显,优先检查梯度公式中除以 m 的位置、sigmoid 写没写错、以及成本函数里 y 和 1-y 的乘法顺序。这三个坑我全踩过。
5.4 关于正则化的一个补充
严格来说,实际工程中很少用不带正则的逻辑回归。L2 正则化会在成本函数后面加一项:
code复制J(w,b) = 原损失 + (λ/2) · ||w||²
加了这一项之后,梯度里也要对应加上 λ·w。正则化的作用是让参数不要过大,降低模型在训练集上的过拟合风险。当特征维度非常高(比如文本分类里上万维)时,正则化几乎是必须的。
有趣的是,加上 L2 正则后,成本函数不再是最原始的极大似然对应的问题,而是变成了最大后验估计问题,相当于给参数加了一个高斯先验。不过日常使用中不需要纠结这个数学背景,只需要理解:损失函数里那一项额外的参数惩罚,本质上是在“更贴近数据”和“参数不过分膨胀”之间找平衡。
6. 成本函数选择的底层直觉与学习路径建议
最后分享一个我当初花了很久才想明白的问题:为什么逻辑回归和线性回归在梯度形式上都那么简洁?
它们的梯度本质上都是 (h-y)x,区别只是那个 h 怎么算。线性回归里 h 是线性的,逻辑回归里 h 要经过 sigmoid 变换。但 sigmoid 变换的导数自带 h(1-h) 因子,恰好消掉了交叉熵损失求导产生的 1/(h(1-h)) 因子,两层一抵消,梯度就只剩残差和特征的乘积。
这种“巧合”其实是统计建模里精心设计的结构:指数族分布与对应链接函数配合,能够保证梯度形式统一。逻辑回归对应伯努利分布和 logit 链接,线性回归对应正态分布和恒等链接,它们都属于广义线性模型的框架。理解了这层关系,再看逻辑回归的成本函数,就不再是“一个莫名奇妙的公式”,而是极大似然估计和分布假设直接推出来的必然结果。
如果想把这一块吃得再透一点,我的学习路径建议是:先读懂吴恩达课程里的分段式写法,把梯度推导亲手推三遍以上;然后用 NumPy 从零实现逻辑回归,对比 sklearn 结果;再读李航的统计学习方法里关于逻辑回归的章节,理解它对数似然角度的处理方式;最后有余力可以研究 sklearn 源码里 solver(lbfgs、liblinear、newton-cg 等)的差异,看看算法实现层面对优化问题做的各种工程化取舍。这样一条线走下来,逻辑回归的成本函数绝对能转化成你自己的东西,而不只是背下来的公式。
