1. 从"分类"这件事说起:为什么Logistic回归值得单独整理一篇笔记
很多人第一次接触机器学习,都是从线性回归入门的,然后紧接着就会撞上Logistic回归。我当初也是这样,看到"回归"两个字,第一反应是"这不还是预测连续值吗",结果翻开书才发现它干的是分类的活。这个命名上的"误导"其实劝退过不少人,但只要理解了它背后的逻辑,你会发现Logistic回归是整个机器学习体系里性价比最高的算法之一——它简单、可解释、训练快,而且在工业界至今仍然是很多分类任务的第一选择。
这篇笔记整理的是我在学习《机器学习实战》这本书时关于Logistic回归的全部心得。我会从最基础的问题出发:为什么不能直接用线性回归做分类?Sigmoid函数到底在干什么?梯度上升和梯度下降有什么区别?然后一步步推进到代码实现层面,包括数据加载、训练、可视化,以及书中提到的那个经典案例——从疝气病症预测病马的死亡率。最后还会聊几个实际使用中容易踩的坑,比如步长选择、缺失值处理、过拟合判断等。
适合谁看?如果你已经了解线性回归的基本概念,知道什么是假设函数、代价函数、梯度下降,那这篇笔记正好能帮你把分类问题的基础打牢。如果你是完全零基础,建议先补一下线性代数和概率论的基本概念,至少要知道矩阵乘法怎么算、条件概率是什么意思,否则后面推导会有点吃力。
我整理这篇笔记的方式是:先讲清楚"为什么",再讲"怎么做",最后讲"怎么做得更好"。每个环节都会配上具体的计算过程和代码逻辑,确保你看完之后能自己从头实现一遍,而不是只会调库。
2. 线性回归做分类为什么不行:从假设函数到Sigmoid的必然选择
2.1 线性回归在分类任务上的根本矛盾
假设我们有一个二分类问题,输出标签是0或1。如果直接用线性回归去拟合,假设函数是 ( h_\theta(x) = \theta^T x ),那么问题来了:这个函数的输出范围是负无穷到正无穷,而我们需要的是0到1之间的概率值。你可能会想,那设定一个阈值不就行了?比如输出大于0.5就判为1,小于0.5就判为0。
这个思路在理论上勉强能用,但实际中会遇到几个致命问题。第一,线性回归对异常值非常敏感。假设有一个正样本的特征值特别大,线性回归为了拟合这个点,会把整条直线往那个方向拉,导致决策边界发生偏移,原本能正确分类的样本可能就被分错了。第二,线性回归的输出不是概率,你没法解释"输出等于0.8"到底意味着什么。第三,最小二乘法的损失函数在分类问题上的几何意义不明确,优化过程容易受到极端样本的干扰。
我在实际项目中试过用线性回归硬做分类,在样本分布比较均匀、没有极端值的情况下,效果勉强能看。但只要数据里出现一两个离群点,准确率就会明显下降。这就是为什么我们需要一个能把输出压缩到(0,1)区间、并且对异常值更鲁棒的函数。
2.2 Sigmoid函数的数学性质与直观解释
Logistic回归的核心就是Sigmoid函数,也叫Logistic函数:
[ \sigma(z) = \frac{1}{1 + e^{-z}} ]
这个函数的图像是一条S形曲线,当z趋近于正无穷时,输出趋近于1;当z趋近于负无穷时,输出趋近于0;当z等于0时,输出恰好是0.5。它的导数有一个非常优雅的性质:
[ \sigma'(z) = \sigma(z)(1 - \sigma(z)) ]
这个性质在推导梯度的时候会大大简化计算,也是Logistic回归能用梯度法高效训练的关键原因之一。
从直观上理解,Sigmoid函数做的事情就是:把线性组合 ( z = \theta^T x ) 的结果映射成一个概率值。当线性组合的结果很大时,说明这个样本属于正类的"证据"很强,概率就接近1;反之则接近0。而0.5这个点就是决策边界,对应 ( \theta^T x = 0 )。
我习惯把Sigmoid理解成一个"软开关":它不是非黑即白地判断,而是给出一个置信度。这种软输出的好处是,你不仅知道分类结果,还能知道模型有多确定。在实际应用中,这个置信度可以用来做阈值调整,比如在医疗诊断场景中,我们可能希望把阈值调低一些,宁可误报也不要漏报。
2.3 从概率角度推导Logistic回归的损失函数
Logistic回归的损失函数不是随便选的,它来自最大似然估计。假设样本独立同分布,对于每个样本,模型预测它属于正类的概率是 ( h_\theta(x) ),属于负类的概率是 ( 1 - h_\theta(x) )。那么单个样本的似然函数可以写成:
[ P(y|x;\theta) = h_\theta(x)^y (1 - h_\theta(x))^{1-y} ]
当y=1时,这个式子等于 ( h_\theta(x) );当y=0时,等于 ( 1 - h_\theta(x) )。把所有样本的似然函数乘起来,再取对数,就得到对数似然函数:
[ \ell(\theta) = \sum_{i=1}^{m} [y^{(i)} \log h_\theta(x^{(i)}) + (1 - y^{(i)}) \log(1 - h_\theta(x^{(i)}))] ]
我们的目标是最大化这个对数似然函数。如果你习惯用损失函数(越小越好),那就取负号,得到交叉熵损失:
[ J(\theta) = -\frac{1}{m} \sum_{i=1}^{m} [y^{(i)} \log h_\theta(x^{(i)}) + (1 - y^{(i)}) \log(1 - h_\theta(x^{(i)}))] ]
这个损失函数有一个很好的性质:它是凸函数,不存在局部最优解,所以梯度下降一定能找到全局最优。这一点比神经网络友好太多了,也是Logistic回归训练稳定的根本原因。
注意:虽然Logistic回归的损失函数是凸的,但如果你加了L2正则化,它仍然是凸的;如果加了非凸的正则项,那就不好说了。实际使用中,L2正则化是最常见的选择。
3. 梯度上升与梯度下降:两种视角下的参数更新
3.1 梯度上升法的推导过程
《机器学习实战》这本书用的是梯度上升法,因为它的目标是最大化对数似然函数。梯度上升的更新公式是:
[ \theta_j := \theta_j + \alpha \frac{\partial}{\partial \theta_j} \ell(\theta) ]
我们需要求对数似然函数对每个参数 ( \theta_j ) 的偏导数。推导过程如下:
首先,Sigmoid函数的导数性质:( \sigma'(z) = \sigma(z)(1 - \sigma(z)) )。
对于单个样本,对数似然对 ( \theta_j ) 的偏导:
[ \frac{\partial}{\partial \theta_j} \ell(\theta) = \left( y - h_\theta(x) \right) x_j ]
这个结果非常简洁,直观上也好理解:当预测值 ( h_\theta(x) ) 与真实标签y差距越大时,梯度越大,参数更新幅度也越大。当预测值接近真实值时,梯度趋近于0,参数基本不再变化。
所以梯度上升的完整更新规则是:
[ \theta_j := \theta_j + \alpha \sum_{i=1}^{m} (y^{(i)} - h_\theta(x^{(i)})) x_j^{(i)} ]
如果用矩阵形式表示,就是:
[ \theta := \theta + \alpha X^T (y - h_\theta(X)) ]
3.2 批量梯度上升与随机梯度上升的取舍
上面的公式是批量梯度上升,每次更新都要遍历所有样本。当样本量很大时(比如几十万条),计算量会非常大,训练速度很慢。这时候就需要随机梯度上升:每次只用一个样本来更新参数。
随机梯度上升的更新公式是:
[ \theta_j := \theta_j + \alpha (y^{(i)} - h_\theta(x^{(i)})) x_j^{(i)} ]
它的优点是计算快,适合在线学习场景;缺点是更新方向不稳定,会在最优解附近震荡。实际使用中,通常会采用小批量梯度上升,每次用一小批样本(比如32或64个)来更新,兼顾稳定性和效率。
我在实际项目中做过对比:在样本量小于1万时,批量梯度上升和随机梯度上升的最终效果差不多,但批量法收敛更稳定;当样本量超过10万时,随机梯度上升的训练时间只有批量法的十分之一左右,虽然最终损失值略高一点,但完全在可接受范围内。
3.3 步长选择与收敛判断的实操经验
步长 ( \alpha ) 的选择是Logistic回归训练中最关键的参数之一。步长太大,损失函数会震荡甚至发散;步长太小,收敛速度慢,训练时间长。
我的经验是:先用一个中等大小的步长(比如0.01)跑几百次迭代,观察损失函数的变化曲线。如果损失函数在下降但速度很慢,可以适当增大步长;如果损失函数上下震荡,说明步长太大了,需要减小。对于标准化后的数据,步长在0.001到0.1之间通常都能工作,具体选哪个要看数据规模和特征数量。
收敛判断方面,我通常看两个指标:一是损失函数的变化量,如果连续多次迭代的变化量小于某个阈值(比如1e-4),就认为收敛了;二是参数的变化量,如果参数基本不再变化,也可以停止训练。实际使用中,我一般设置一个最大迭代次数(比如500或1000),同时监控损失函数,如果提前收敛就提前停止。
提示:在训练之前一定要做特征标准化,否则不同特征的尺度差异会导致梯度下降走"之"字形路线,收敛非常慢。标准化方法是减去均值再除以标准差,或者直接缩放到[0,1]区间。
4. 从零实现Logistic回归:数据加载、训练与可视化的完整链路
4.1 数据准备与特征工程中的关键细节
《机器学习实战》中使用的数据集是一个简单的二分类数据集,包含两个特征和对应的类别标签。数据存储在文本文件中,每行格式是:特征1 特征2 类别标签。
加载数据的代码逻辑很简单,但有几个细节需要注意:
def loadDataSet(): dataMat = [] labelMat = [] fr = open('testSet.txt') for line in fr.readlines(): lineArr = line.strip().split() dataMat.append([1.0, float(lineArr[0]), float(lineArr[1])]) labelMat.append(int(lineArr[2])) fr.close() return dataMat, labelMat注意这里手动添加了一个值为1.0的特征,这是为了把截距项 ( \theta_0 ) 统一到矩阵运算中。如果不加这一列,就需要单独处理截距项,代码会稍微麻烦一些。
特征工程方面,对于这个简单数据集,两个原始特征已经足够。但在实际项目中,我通常会做几件事:检查特征是否有缺失值、是否需要标准化、是否有高度相关的特征需要剔除。Logistic回归对多重共线性比较敏感,如果两个特征高度相关,会导致参数估计不稳定,系数的正负号甚至可能和预期相反。
4.2 Sigmoid函数与梯度上升的代码实现
Sigmoid函数的实现非常直接:
def sigmoid(inX): return 1.0 / (1 + np.exp(-inX))梯度上升的训练函数:
def gradAscent(dataMatIn, classLabels): dataMatrix = np.mat(dataMatIn) labelMat = np.mat(classLabels).transpose() m, n = np.shape(dataMatrix) alpha = 0.001 maxCycles = 500 weights = np.ones((n, 1)) for k in range(maxCycles): h = sigmoid(dataMatrix * weights) error = labelMat - h weights = weights + alpha * dataMatrix.transpose() * error return weights这段代码的核心就是三行:计算预测值h、计算误差error、更新权重weights。注意这里用的是矩阵运算,dataMatrix是m×n的矩阵,weights是n×1的向量,相乘得到m×1的预测值向量。
我在实际写代码时踩过一个坑:一开始没有把labelMat转置成列向量,导致矩阵维度不匹配,报错信息也不直观,排查了好一会儿。所以建议在写矩阵运算时,先打印一下各个变量的shape,确认维度正确再往下走。
4.3 决策边界的可视化与参数解读
训练完成后,我们可以把决策边界画出来,直观地看分类效果:
def plotBestFit(weights): dataMat, labelMat = loadDataSet() dataArr = np.array(dataMat) n = np.shape(dataArr)[0] xcord1 = []; ycord1 = [] xcord2 = []; ycord2 = [] for i in range(n): if int(labelMat[i]) == 1: xcord1.append(dataArr[i,1]) ycord1.append(dataArr[i,2]) else: xcord2.append(dataArr[i,1]) ycord2.append(dataArr[i,2]) fig = plt.figure() ax = fig.add_subplot(111) ax.scatter(xcord1, ycord1, s=30, c='red', marker='s') ax.scatter(xcord2, ycord2, s=30, c='green') x = np.arange(-3.0, 3.0, 0.1) y = (-weights[0] - weights[1] * x) / weights[2] ax.plot(x, y) plt.xlabel('X1') plt.ylabel('X2') plt.show()决策边界的方程是 ( \theta_0 + \theta_1 x_1 + \theta_2 x_2 = 0 ),解出 ( x_2 ) 就得到上面的y表达式。从图中可以直观看到,决策边界把两类样本大致分开了,但有几个样本被分错了,这是正常现象,因为数据本身可能不是完全线性可分的。
参数解读方面,( \theta_1 ) 和 ( \theta_2 ) 的符号告诉我们特征对分类结果的影响方向。如果 ( \theta_1 ) 为正,说明 ( x_1 ) 越大,样本越可能属于正类;如果为负,则相反。系数的大小反映了影响的强度,但要注意,如果特征没有标准化,系数的大小不能直接比较。
4.4 随机梯度上升的改进实现
批量梯度上升在数据量大的时候效率太低,随机梯度上升是更实用的选择:
def stocGradAscent1(dataMatrix, classLabels, numIter=150): m, n = np.shape(dataMatrix) weights = np.ones(n) for j in range(numIter): dataIndex = list(range(m)) for i in range(m): alpha = 4 / (1.0 + j + i) + 0.01 randIndex = int(np.random.uniform(0, len(dataIndex))) h = sigmoid(sum(dataMatrix[randIndex] * weights)) error = classLabels[randIndex] - h weights = weights + alpha * error * dataMatrix[randIndex] del(dataIndex[randIndex]) return weights这个改进版本有两个亮点:一是步长随着迭代次数逐渐减小,初期步长大、收敛快,后期步长小、更稳定;二是每次随机选择一个样本,避免周期性波动。我在实际使用中发现,这个版本在大多数数据集上都能在150次迭代内收敛到不错的效果,比批量法快很多。
5. 病马死亡率预测案例:缺失值处理与模型评估的实战考验
5.1 案例背景与数据特征分析
《机器学习实战》中有一个经典案例:从疝气病症预测病马的死亡率。数据集包含368个样本和28个特征,其中有一些特征存在缺失值。这个案例比前面的简单二分类问题更接近真实场景,因为真实数据几乎不可能没有缺失值。
数据中的缺失值用0表示,但这里有一个陷阱:0既可能是真实的观测值,也可能是缺失值。书中采用的处理方法是:对于缺失值,用0代替,同时把对应的特征权重也设为0,这样缺失值就不会影响预测结果。这个思路在Logistic回归中特别适用,因为权重为0意味着该特征不参与决策。
我在实际项目中处理缺失值时,通常会先分析缺失的比例和模式。如果某个特征的缺失率超过80%,我会考虑直接删除这个特征;如果缺失率在20%到80%之间,可以考虑填充;如果缺失率低于20%,删除缺失样本或者用均值/中位数填充都是可行的。填充方法的选择要看数据分布,如果特征近似正态分布,用均值填充比较合理;如果分布偏斜,用中位数更稳健。
5.2 缺失值填充策略与模型训练
对于病马死亡率预测这个案例,我采用的策略是:缺失值用0填充,同时在训练时把对应特征的权重初始化为0。这样做的逻辑是,如果某个特征缺失,模型就不会依赖这个特征做判断,相当于自动降级到只用可用特征进行预测。
训练过程和前面的梯度上升类似,只是数据加载部分需要处理缺失值:
def classifyVector(inX, weights): prob = sigmoid(sum(inX * weights)) if prob > 0.5: return 1.0 else: return 0.0 def colicTest(): frTrain = open('horseColicTraining.txt') frTest = open('horseColicTest.txt') trainingSet = []; trainingLabels = [] for line in frTrain.readlines(): currLine = line.strip().split('\t') lineArr = [] for i in range(21): lineArr.append(float(currLine[i])) trainingSet.append(lineArr) trainingLabels.append(float(currLine[21])) trainWeights = stocGradAscent1(np.array(trainingSet), trainingLabels, 500) errorCount = 0; numTestVec = 0.0 for line in frTest.readlines(): numTestVec += 1.0 currLine = line.strip().split('\t') lineArr = [] for i in range(21): lineArr.append(float(currLine[i])) if int(classifyVector(np.array(lineArr), trainWeights)) != int(currLine[21]): errorCount += 1 errorRate = float(errorCount) / numTestVec print("the error rate of this test is: %f" % errorRate) return errorRate注意这里只用了前21个特征,因为原始数据中有些特征缺失率太高,直接舍弃了。这个取舍在实际项目中很常见:特征不是越多越好,质量比数量重要。
5.3 多次运行取平均:模型评估的稳定性考量
由于随机梯度上升每次选择的样本顺序不同,训练结果会有波动。书中建议运行10次取平均错误率,这是一个很实用的技巧:
def multiTest(): numTests = 10; errorSum = 0.0 for k in range(numTests): errorSum += colicTest() print("after %d iterations the average error rate is: %f" % (numTests, errorSum / float(numTests)))我在实际使用中把这个思路进一步扩展:不仅多次运行取平均,还会记录每次的错误率,观察波动范围。如果波动很大,说明模型不稳定,可能需要调整步长或者增加迭代次数;如果波动很小,说明模型已经收敛得比较好了。
这个案例的平均错误率大约在35%左右,看起来很高,但考虑到数据集中有大量缺失值,而且特征与目标之间的关系可能不是线性的,这个结果是可以接受的。在实际医疗场景中,35%的错误率显然不够用,需要更复杂的模型或者更好的特征工程。但作为学习案例,它很好地展示了Logistic回归在真实数据上的表现和局限性。
5.4 从错误率反推模型改进方向
拿到35%的错误率之后,我通常会做几件事来定位问题:第一,检查混淆矩阵,看是正类错得多还是负类错得多;第二,分析错误样本的特征分布,看是否有规律;第三,尝试不同的步长和迭代次数,看错误率是否有明显变化。
如果正类错误率高,可能是正类样本太少导致模型偏向负类,可以考虑调整类别权重或者过采样;如果错误样本集中在某个特征区间,可能是特征与目标的关系是非线性的,需要考虑特征交叉或者多项式特征。这些排查思路不仅适用于Logistic回归,对其他分类模型也同样有效。
6. 实际使用中的几个坑:步长震荡、过拟合与特征尺度
6.1 步长过大导致的震荡与发散
步长选择是Logistic回归训练中最容易出问题的地方。我遇到过好几次损失函数不降反升的情况,排查后发现都是步长太大导致的。当步长超过某个临界值时,参数更新会跳过最优解,甚至越跳越远,最终发散。
判断步长是否过大的方法很简单:打印每次迭代的损失函数值,如果损失函数上下震荡或者持续上升,就是步长太大了。解决方法有两种:一是直接减小步长,比如从0.01降到0.001;二是采用自适应步长,比如每次迭代后把步长乘以一个衰减因子(如0.99)。
我在实际项目中更倾向于第二种方法,因为它在训练初期能用较大步长快速收敛,后期用小步长精细调整。但衰减因子的选择需要实验,太大起不到衰减效果,太小会导致后期收敛过慢。
6.2 过拟合的识别与正则化应对
Logistic回归虽然简单,但在特征数量多、样本数量少的情况下也会过拟合。过拟合的典型表现是:训练集上的准确率很高,但测试集上的准确率明显偏低。
识别过拟合的方法是:把数据分成训练集和验证集,训练过程中同时监控两个集合上的损失函数。如果训练集损失持续下降但验证集损失开始上升,就是过拟合了。
应对过拟合最直接的方法是加正则化。L2正则化在损失函数中加入参数平方和的惩罚项,L1正则化加入参数绝对值之和的惩罚项。L2正则化会让参数趋向于较小的值,L1正则化会让部分参数变为0,起到特征选择的作用。在Logistic回归中,L2正则化更常用,因为它的优化过程更稳定。
注意:正则化项的系数需要调参。系数太大,模型会欠拟合;系数太小,起不到正则化效果。我通常从0.01开始试,根据验证集效果调整。
6.3 特征尺度不一致带来的收敛问题
如果不同特征的取值范围差异很大,比如一个特征在0到1之间,另一个在0到10000之间,梯度下降会走"之"字形路线,收敛非常慢。这是因为损失函数的等高线是椭圆形的,梯度方向不是指向最优解的方向。
解决方法就是特征标准化。最常用的是Z-score标准化:减去均值,除以标准差。标准化之后,所有特征的均值为0,标准差为1,损失函数的等高线接近圆形,梯度下降能更快收敛。
我在实际项目中养成了一个习惯:拿到数据后第一件事就是检查各个特征的均值和标准差,如果差异超过一个数量级,就一定要做标准化。这个习惯帮我避免了很多训练慢、效果差的问题。
6.4 类别不平衡的处理思路
Logistic回归默认假设正负样本比例均衡,如果正类样本只占1%,模型会倾向于把所有样本都预测为负类,因为这样也能达到99%的准确率。但这样的模型在实际使用中毫无价值。
处理类别不平衡的方法有几种:一是调整阈值,把决策阈值从0.5调低,比如调到0.2,让更多样本被预测为正类;二是对正类样本过采样,或者对负类样本欠采样;三是在损失函数中给正类样本更高的权重。
我在实际项目中用过第一种和第三种方法。调整阈值最简单,但需要根据业务需求确定合适的阈值;加权损失函数更灵活,但需要调整权重参数。两种方法可以结合使用,效果通常比单独使用一种更好。
7. 写在笔记最后:Logistic回归的边界与延伸
Logistic回归虽然叫"回归",但它本质上是一个线性分类器。它的决策边界是线性的,这意味着它只能处理线性可分或者近似线性可分的问题。对于非线性问题,需要先做特征变换,比如加入多项式特征,或者使用核方法。
我在实际使用中的体会是:Logistic回归最大的价值不在于它的准确率有多高,而在于它的可解释性和训练速度。在需要快速迭代、需要解释模型决策依据的场景中,Logistic回归往往是首选。即使后来用了更复杂的模型,我也习惯先跑一个Logistic回归作为基线,看看数据本身能提供多少信息量。
这个内容后续还可以这样扩展:一是尝试用sklearn的LogisticRegression类对比自己实现的版本,看看差异在哪里;二是把二分类扩展到多分类,了解softmax回归的原理;三是研究不同的优化算法,比如牛顿法、L-BFGS,看看它们和梯度下降的优劣。这些方向每一个都值得单独整理一篇笔记,后面有机会再慢慢补上。