
逻辑回归在机器学习里属于那种“看着简单用好了是真香”的模型。很多人一开始接触分类问题第一个上手的就是逻辑回归因为它不像决策树那样需要纠结剪枝也不像支持向量机那样要想核函数的选择。但真正把它讲透、能从零推导到代码实现的人其实并不多。尤其是用Matlab实现的时候很多教程要么只给一个封装好的fitglm调用要么直接跳过了中间那些容易踩坑的细节。我看过不少人在网上搜“逻辑回归代码”拿到的往往是Python版本换到Matlab环境就不知道该怎么处理矩阵维度、梯度更新这些事了。所以这篇就用Matlab从零手写一个逻辑回归把原理、公式推导、代码实现和调试经验一次讲清楚。这篇文章适合以下几类人正在学机器学习但被数学推导劝退的初学者需要快速在Matlab里做分类实验的科研党以及想搞懂逻辑回归内部细节而不是只调用工具箱的工程师。无论是笔试面试还是实际项目里的二分类任务看完这篇你都能有一套能跑的代码也知道了为什么这么写是对的。1. 逻辑回归到底在解决什么问题1.1 从线性回归说起为什么线性拟合不够用先回到最熟悉的场景。如果你是做数据分析的手里有一组数据特征和标签之间的关系近似直线你会很自然想到线性回归也就是找一组权重让预测值尽量靠近真实值。线性回归的公式是 y w^T x b它输出的是一个连续实数取值范围从负无穷到正无穷。但现实里我们经常遇到的是分类问题。比如判断一封邮件是不是垃圾邮件判断一个客户明天会不会流失判断一个用户会不会点击广告。这些问题的标签不是连续的实数而是离散的类别最常见的是0和1。这时候你还能直接套线性回归吗可以试但会出现几个问题。第一线性回归没有“概率”概念。它输出的可能是3.7也可能是-2.1你很难解释这个值代表什么含义。第二线性回归对异常值极其敏感。如果训练集里某个样本的标签是1但特征组合让模型输出了10那么为了减小这个误差模型会拼命调整参数最终导致决策边界严重偏移。第三线性回归的误差函数是非凸的。用梯度下降法优化时很容易陷入局部最小值找不到全局最优解。所以我们需要一个办法让模型输出值被限制在0到1之间并且能解释为“属于正类的概率”。这就是逻辑回归登场的理由。1.2 逻辑回归的核心思想用概率说话逻辑回归的做法很简单在线性回归的基础上套一个Sigmoid函数。Sigmoid函数的表达式是g(z) 1 / (1 e^(-z))这个函数有一个很好的性质自变量z趋近正无穷时g(z)趋近1z趋近负无穷时g(z)趋近0z 0时g(z) 0.5。也就是说不管线性部分 wx b 算出多大的数经过Sigmoid一压输出就乖乖落到0到1区间内。这个输出值可以被理解为“样本属于正类标签为1的概率”。比如某条样本经过计算得到 p 0.8我们就认为它有80%的概率属于类别1剩下的20%属于类别0。然后用一个阈值默认0.5来划分类别p 0.5判定为正类p 0.5判定为负类。你可以把逻辑回归理解成一个“带概率输出的线性分类器”。它本质上还是在学一条直线或一个超平面作为决策边界但区别于硬分类的是它给出了一个置信度。这个置信度在许多场景下非常有用比如在风控里我们不仅想知道某个用户有没有风险还想知道风险有多大然后据此决定人工审核还是自动放行。理解了这一点就知道为什么逻辑回归被称作“回归”却用在分类任务上——它确实是在做回归只是回归的目标是“对数几率”也就是 log(p/(1-p))这个值域是负无穷到正无穷依然是个回归问题。这就是名字的由来也是面试官最喜欢问的一个点。2. 数学推导看懂逻辑回归的“逻辑”2.1 假设函数与决策边界逻辑回归的假设函数可以写成h_θ(x) g(θ^T x) 1 / (1 e^(-θ^T x))其中 θ 是参数向量x 是特征向量。为了简洁通常把偏置 b 合并到 θ 里也就是在特征向量末尾加一个常数1θ 里也相应多出一项。这样做的好处是所有参数统一用一个向量表示代码里矩阵运算更方便。决策边界则由 θ^T x 0 决定。当 θ^T x 大于0时h_θ(x) 大于0.5判为正类小于0时判为负类。所以从几何角度看逻辑回归学的是一条或一条超平面线性边界。如果数据本身不是线性可分的我们还可以通过特征工程比如加入多项式特征让边界变成曲线但模型本身仍然是一个线性模型。这里有一个常见误区。很多人以为逻辑回归只能处理线性可分的数据其实不是这样。它的决策边界是线性的但只要你构造出合适的特征映射非线性分类问题也能解决。比如二维平面里一堆样本分布在一个圆圈内你可以添加 x1^2 x2^2 这个特征逻辑回归就能学到圆形边界。不过特征工程这件事需要人工来做这也是它和神经网络的区别之一。2.2 损失函数为什么是交叉熵而不是均方误差很多初学者第一次接触逻辑回归会忍不住想既然它是在线性回归基础上套了个Sigmoid那损失函数是不是也可以沿用线性回归的均方误差也就是 J(θ) 1/m * Σ(h_θ(x^(i)) - y^(i))^2。表面上看没问题但一算梯度就发现坑了。由于Sigmoid函数是非线性的把 h_θ(x) 代入均方误差后代价函数关于 θ 不是凸函数而是有很多局部极小值的“波浪形”。梯度下降法只能找到局部最优解依赖于初始值效果极不稳定。所以逻辑回归采用交叉熵损失函数。单个样本的损失定义为cost(h_θ(x), y) -y * log(h_θ(x)) - (1 - y) * log(1 - h_θ(x))这个式子怎么理解当 y 1 时后一项为0损失变成 -log(h_θ(x))。如果 h_θ(x) 趋近1说明预测正确损失趋近0如果 h_θ(x) 趋近0说明预测错误损失趋近无穷大。当 y 0 时正好反过来。这种设计让模型对“高置信度的错误预测”给予极大的惩罚训练过程更加稳定。对整个训练集代价函数是所有样本损失的平均值再除以 mJ(θ) -1/m * Σ [y^(i) * log(h_θ(x^(i))) (1 - y^(i)) * log(1 - h_θ(x^(i)))]这个式子写成向量化形式就是J(θ) -1/m * (y^T * log(h) (1 - y)^T * log(1 - h))其中 h sigmoid(X * θ)X 是 m×n 的样本矩阵y 是 m×1 的标签向量。向量化在Matlab里是提升性能的关键后面代码部分会重点用到。2.3 梯度下降法推导有了代价函数下一步就是求梯度。目标是对每个参数 θ_j 求偏导然后更新。推导过程分三步。第一步令 z θ^T x那么 h g(z) 1 / (1 e^(-z))。Sigmoid的导数有一个非常漂亮的性质g(z) g(z) * (1 - g(z))这个性质推导起来很简洁直接对1/(1e^(-z))求导即可得到。它让后面的梯度表达式变得特别简洁。第二步求单样本损失对 θ_j 的偏导。由链式法则∂L/∂θ_j ∂L/∂h * ∂h/∂z * ∂z/∂θ_j逐项代入最终得到∂L/∂θ_j (h - y) * x_j第三步把 m 个样本累加起来得到整个代价函数的梯度∂J/∂θ_j 1/m * Σ (h_θ(x^(i)) - y^(i)) * x_j^(i)写成向量化形式∇J(θ) 1/m * X^T * (h - y)这个形式和线性回归的梯度表达式完全一样差别只在 h 的计算方式。这也是逻辑回归在工程上极其高效的原因——梯度计算只需要一次矩阵乘法和一次减法性能非常好。梯度下降更新规则就是θ : θ - α * (1/m * X^T * (h - y))其中 α 是学习率。整个训练过程就是不断喂入数据、计算预测、更新参数直到代价函数收敛。3. Matlab手写逻辑回归从零开始的实现3.1 环境准备与数据生成我平时用的版本是Matlab R2023b但其实只要是近几年的版本这段代码都能直接运行。逻辑回归本身不依赖任何工具箱基础函数就够用。为了便于演示我先生成一组二维数据这样训练完还能把决策边界画出来直观感受模型学到的边界长什么样。% 生成两类数据每类100个样本 rng(42); % 固定随机种子保证结果可复现 mu1 [2, 2]; sigma1 [1, 0.5; 0.5, 1]; mu2 [4, 4]; sigma2 [1, -0.3; -0.3, 1]; X1 mvnrnd(mu1, sigma1, 100); X2 mvnrnd(mu2, sigma2, 100); X [X1; X2]; y [zeros(100, 1); ones(100, 1)]; % 负类为0正类为1这里默认标签取0和1。很多人写成 -1 和 1虽然也能用但会导致损失函数和梯度表达式都要改。建议入门阶段统一用0/1标签所有公式对得上排查问题容易。因为两个类别的中心位置不同数据存在一定重叠逻辑回归不可能100%分对这才是正常情况正好用来观察模型怎么折中。3.2 自定义Sigmoid与代价函数在Matlab里函数可以单独存成 .m 文件也可以直接写在脚本末尾。我习惯把核心函数单独放文件主脚本只负责数据加载和训练流程调用。先看Sigmoid函数function g sigmoid(z) g 1 ./ (1 exp(-z)); end注意这里的点除./和点乘.*因为输入 z 可能是一个向量或矩阵必须用元素级运算。很多人写1 / (1 exp(-z))结果z是向量时报错或者计算出错这就是没注意矩阵运算和元素运算的区别。接下来是代价函数和梯度。这里我直接写了一个返回代价和梯度的函数function [J, grad] costFunctionReg(theta, X, y, lambda) m length(y); h sigmoid(X * theta); J (1/m) * sum(-y .* log(h) - (1 - y) .* log(1 - h)) ... (lambda/(2*m)) * sum(theta(2:end).^2); grad (1/m) * X * (h - y); grad(2:end) grad(2:end) (lambda/m) * theta(2:end); end这个函数带了正则化参数 lambda。正则化的作用后面会讲这里先把它加上。梯度更新时为了代码通用可以在计算代价时不加正则项只加lambda * theta但为了清晰我保留了带正则的版本。你可能会问为什么theta(1)不参与正则化因为偏置项不应该被惩罚。我们不对偏置项做收缩这样才能保证决策边界自由平移否则模型可能只关注降低参数大小而忽略了数据本身的结构。这叫“偏置不惩罚”原则。3.3 梯度下降训练主循环有了代价函数和梯度就可以写主训练循环了。逻辑回归的梯度下降实现参数更新方式有两种一种是自己写for循环另一种用Matlab内置的fminunc优化器。这里先演示自己写循环便于看到每一步的代价变化理解训练过程。% 添加偏置项在X前面加一列1 X [ones(m, 1), X]; % 初始化权重 initial_theta zeros(size(X, 2), 1); % 训练参数 alpha 0.5; num_iters 500; lambda 0.1; theta initial_theta; J_history zeros(num_iters, 1); for iter 1:num_iters % 计算预测 h sigmoid(X * theta); % 更新参数向量化 theta theta - (alpha/m) * (X * (h - y) [0; lambda * theta(2:end)]); % 记录代价 [J_history(iter), ~] costFunctionReg(theta, X, y, lambda); end这段代码的核心就一行更新公式但注意我在梯度计算时把正则项单独加了[0; lambda * theta(2:end)]这是因为正则项只作用于除偏置外的参数。如果不加这个向量拼接直接lambda * theta偏置项也会被惩罚导致边界偏移。训练500轮后把代价下降曲线画出来figure; plot(1:num_iters, J_history, LineWidth, 2); xlabel(迭代次数); ylabel(代价 J); title(梯度下降收敛过程); grid on;正常的收敛曲线应该是单调下降最后趋于平缓。如果看到代价先降后升或者来回震荡说明学习率太大了如果下降得非常慢500轮还没收敛说明学习率太小。3.4 模型测试与决策边界可视化训练完参数就可以预测了。测试样本类别判断很简单% 预测一个样本 sample [1, 3, 2]; % 注意要包含偏置项那列1 prob sigmoid(sample * theta); if prob 0.5 predict 1; else predict 0; end fprintf(预测概率: %.3f, 预测类别: %d\n, prob, predict);当然实际场景里我们有大量测试数据应该写成批量预测。把测试特征矩阵X_test的前面也加一列1然后h_test sigmoid(X_test * theta)最后predictions round(h_test)就得到了所有样本的预测类别。这里用 round 是因为 sigmoid 输出的概率如果在0.5以上取整就是1否则是0。决策边界可视化需要知道边界的数学形式。对于一个二维特征加偏置的模型边界方程是theta(1) theta(2) * x1 theta(3) * x2 0整理一下可以得到 x2 -(theta(1) theta(2) * x1) / theta(3)。所以画边界只需要取两个点的坐标连成直线plot_x [min(X(:,2))-0.5, max(X(:,2))0.5]; plot_y (-1/theta(3)) * (theta(1) theta(2) * plot_x); hold on; plot(plot_x, plot_y, k-, LineWidth, 2);如果数据维度超过二维就没办法直观画边界了但核心原理一样超平面由 θ^T x 0 定义。4. 进阶优化与工程化要点4.1 向量化到底提升多少性能我刚学逻辑回归时喜欢写for循环一层套一层先是迭代样本再是迭代特征更新一个参数就套一层循环。这样写虽然在少量数据上也能跑但一旦数据到几万甚至几十万条速度慢到让你怀疑人生。向量化之后一次矩阵乘法X * theta就算出了所有样本的预测值X * (h - y)一次就算出了所有参数的梯度。整个训练循环里没有内部循环只有迭代次数这一层外层循环效率提升非常明显。我做过一个简单测试10000条样本、20个特征的数据向量化版本比三重循环版本快了大概200倍。这个差距在真实项目里是决定性的。Matlab本身就是为矩阵运算设计的向量化不只是“更好的写法”而是“正确的高效写法”。如果你在代码里看到很多for循环逐样本处理第一反应就应该是能不能用矩阵运算替代。4.2 正则化与过拟合逻辑回归虽然简单但同样会过拟合。什么是过拟合就是模型在训练集上表现很好但遇到没见过的数据表现很差。特征很多、样本很少的时候尤其容易发生。解决办法之一就是加正则化。我们刚才代价函数里已经加了 λ/(2m) * Σ θ_j² 这一项这叫L2正则化也叫权重衰减。它的作用是惩罚过大的参数值迫使模型学到的边界更平滑。在实际调参经验里λ 的选择需要试验。λ 太大模型过于保守可能欠拟合λ 太小正则化不起作用。我通常从 [0, 0.01, 0.1, 1, 10] 这个列表里从小到大试观察验证集上的表现。一般工业场景下λ 在0.01到1之间比较常见。注意一点正则化项的加入需要同步修改梯度公式。对 θ_j (j ≥ 1) 的梯度要额外加上 λ/m * θ_j刚才代码里的[0; lambda * theta(2:end)]就是干这个的。4.3 特征缩放与学习率调参逻辑回归对特征的尺度是敏感的。为什么因为梯度更新公式里有X这一项如果某个特征的取值范围是0到100000而另一个是0到1那么梯度大小主要由大尺度特征主导小尺度特征几乎学不到东西模型收敛会非常慢甚至震荡。解决办法就是对特征做标准化。常用做法是Z-score标准化把每列特征减去均值再除以标准差X_scaled (X - mean(X)) ./ std(X);注意标准化参数均值、标准差要从训练集计算然后应用到测试集不能把测试集和训练集混合起来算否则会有数据泄露问题模型评估结果偏乐观。学习率 α 的选择同样关键。我一般从0.1开始试观察代价曲线。如果收敛太快但代价还很高说明学习率偏大如果曲线平稳下降但步数不够就调大一些或者增加迭代次数。梯度下降的学习率是需要和迭代次数搭配着调的一种更省心的做法是使用Matlab的fminunc优化器它能自动选择学习步长收敛速度通常远好于手写固定学习率。options optimoptions(fminunc, Algorithm, GradientDescent, MaxIterations, 1000); [theta_opt, cost_opt] fminunc((t) costFunctionReg(t, X, y, lambda), initial_theta, options);这个我会在常见问题里再详细展开因为很多初学者不知道还能这样用。5. 常见问题排查与调试技巧实录5.1 代价函数不下降怎么办这是初学者最容易遇到的情况之一。代价函数先小幅下降然后卡住不降了或者干脆一直不降。我排查这类问题有自己的固定顺序。第一步检查学习率。学习率太小模型更新幅度不够代价下降极其缓慢看起来像卡住了。可以把学习率调大到1甚至2试试如果代价开始剧烈震荡说明之前确实是学习率太小。第二步检查数据是否标准化。如果特征的量纲差异过大比如一个特征在0~1另一个在0~100000梯度方向会被大尺度特征主导收敛会非常慢。第三步检查梯度公式是否正确。有一个很有效的验证方法用数值梯度对照解析梯度。数值梯度的计算公式是 (J(θ ε) - J(θ - ε)) / (2ε)ε取1e-5左右。两者最大差值在1e-5量级就说明推导正确。% 数值梯度检查示例 epsilon 1e-5; num_grad zeros(size(theta)); for i 1:length(theta) theta_plus theta; theta_minus theta; theta_plus(i) theta_plus(i) epsilon; theta_minus(i) theta_minus(i) - epsilon; [J_plus, ~] costFunctionReg(theta_plus, X, y, 0); [J_minus, ~] costFunctionReg(theta_minus, X, y, 0); num_grad(i) (J_plus - J_minus) / (2 * epsilon); end [~, grad] costFunctionReg(theta, X, y, 0); disp(max(abs(num_grad - grad)));这个调试技巧可以帮你确认梯度计算没有bug。很多人写了半天代码找不到问题一问就是梯度算错了但我没见过几个人真的会跑一遍数值梯度检查实际上这是最省时间的定位方式。5.2 训练结果出现NaN是什么原因训练时偶尔会遇到代价变成NaN或者梯度里面出现NaN。根据我踩过的坑原因通常是这几种。最常见的是学习率过大导致参数更新一步跨得太远Sigmoid函数里exp(-z)发生溢出变成Inf或0后续计算就全乱了。解决办法很简单降低学习率或者加一些保护机制比如梯度裁剪。第二种可能是在代价函数里计算log(0)。当预测概率h严格等于0时log(h)就是 -Inf加上前面负号就变成Inf代价直接崩掉。解决办法是在 log 里面加一个极小值比如log(h 1e-10)虽然有点hack但很实用。第三种可能是数据里含有NaN或Inf值。在训练前先检查一遍数据any(isnan(X(:)))这个习惯能帮你省下大量排查时间。5.3 多分类任务怎么做OvR还是Softmax逻辑回归原生解决的是二分类问题但实际任务里经常会遇到多分类。最简单的方法是一对多也叫One-vs-Rest。比如有三类A、B、C训练三个二分类器A vs 非AB vs 非BC vs 非C。预测的时候把样本分别丢进三个模型取预测概率最高的那个类别作为最终结果。这种做法的优点是实现简单缺点是需要训练K个模型k比较大时开销上升。另一种做法是直接把逻辑回归推广到多分类也就是Softmax回归。Softmax把模型的输出从单个概率变成多个类别的概率分布并且使用交叉熵作为损失函数。在Matlab里你可以自己实现softmax多项式逻辑回归也可以用工具箱自带的多分类逻辑回归fitcecoc或mnrfit都行。我个人的建议是类别数不多比如小于10且每个类别的样本量不太失衡时用OvR就够了简单可靠。类别数很多或者类别间有相似关系时考虑Softmax。如果你用的是Matlab自带的fitclinear它内部已经支持了多分类逻辑回归并会自动选择合适的策略。6. 我在实际项目里的一些运行体会代码写完之后最后分享一点掏心窝子的经验。第一逻辑回归的“可解释性”是它最大的隐形优势。我在一些实际业务项目里领导最关心的不是准确率而是“为什么这个客户被标记为高风险”。逻辑回归的系数可以直接回答这个问题系数大的特征对应的影响最大正负号代表正向还是负向影响。这种透明度是树模型和神经网络都给不了的。如果你做的是风控、医疗这类强监管场景逻辑回归往往是首选即使性能比深度学习模型差几个点也还是会选它。第二不要在工程里直接拿自己手写的梯度下降去硬拟合大规模数据。数据量一旦大到内存放不下就要考虑fmincg或者sgd这类更高效的优化方法。Matlab的fminunc和fmincg都比固定学习率的梯度下降聪明得多它们会自动调整步长收敛更快也更稳。自己实现梯度下降的目的是为了理解原理而不是为了替代成熟的求解器。第三数据处理的重要性被严重低估。我见过太多人纠结于调参结果发现把数据标准化、处理好缺失值之后模型的评估指标直接提升了好几个点。逻辑回归对数据质量很敏感训练前花点时间做特征清洗和缩放回报远大于调参一整天。第四关于Matlab版本的坑。不同版本之间尤其是fitclinear、mnrfit这些统计/机器学习工具箱的函数接口有过调整。如果你在旧代码里发现函数名对不上先去查当前版本的帮助文档。我现在虽然主力环境换到了R2023b但代码里基本只用基础语法和自定义函数就是怕版本迁移踩坑。总结一句话逻辑回归不是“最炫”的模型却是最值得吃透的模型之一。搞懂它的原理你就理解了监督学习的骨架——模型、损失函数、优化算法这三者的关系。这也是为什么无论是面试还是实际项目它永远不过时的原因。希望这篇手写实现的经验对你有帮助。