之前做用户流失预测和信用评分时,我第一版模型经常直接用逻辑回归,原因很直接:可解释性强、训练快、而且能输出概率。这两年Matlab用得比较多,发现网上很多“逻辑回归分类预测”的教程代码,要么只跑自带数据集,要么把数据格式写死,换到自己的Excel或者CSV就得从头改。后来我干脆写了一套可自定义训练集个数与数据格式的Matlab逻辑回归代码,核心函数全部手写,不依赖专门工具箱,替换数据文件就能跑。这篇文章就把这套实现完整拆开,从原理到代码到避坑,一步步说清楚。
1. 这个项目解决了什么问题
1.1 为什么是逻辑回归 + Matlab
逻辑回归虽然名字里带“回归”,但本质是二分类模型,在工业界用得非常多。用户流失预测、信用评分、医疗诊断,很多业务第一版模型就是逻辑回归。原因很简单:模型透明、可解释性强、训练快、对硬件要求低。它还能输出概率,而不是只给一个生硬的类别,这对下游风控规则、营销决策特别友好。
Matlab做这个任务最大的优势是矩阵运算方便,写代码不用像C++那样手撸循环,调试和可视化也省心。训练完直接画损失曲线、画决策边界,适合做算法验证和教学演示。有些人喜欢用Python调sklearn,这当然没问题,但如果你的数据需要和Simulink、既有Matlab工具箱打通,或者团队协作环境就是Matlab,那么直接用Matlab实现会省去很多工程对接成本。
有人会问:Matlab不是自带fitglm或fitclinear吗?为什么还要手写?因为自带函数封装得太黑盒,你想看损失曲线、想改迭代逻辑、想加入自定义预处理步骤,都很被动。而且自带函数对输入格式有严格要求,数据里一旦有缺失值或者文本标签,处理起来很麻烦。自己写的代码虽然看起来“笨”,但每一步在算什么一清二楚,对学习和调优都更有帮助。
1.2 “自定义训练集个数与数据格式”到底指什么
网上很多教程的代码是这样的:load('data.mat'),然后固定用前70%训练、后30%测试。一旦换自己的数据文件,就得从头改一遍。这套代码想解决两个痛点:
- 训练集个数可变。数据总样本可能几百到几万,代码里
trainNum就是一个参数。设成80,就是取前80条做训练,剩余做测试;设成总样本数,就是只训练不评估。这样调参和对比不同训练集规模对模型的影响非常方便。 - 数据格式自适应。支持
.csv、.xlsx、.txt,也支持带表头或不带表头;默认认为标签在最后一列,但如果你习惯把标签放在第一列,也可以通过labelCol参数改。
也就是说,换数据文件时基本不用动核心训练函数,只需要改主程序开头的配置参数。这套能力的价值在于,把“写代码”和“调数据”解耦,实际工作中能省掉大量重复劳动。
注意:代码默认要求数据全部是数值型。如果数据里有文本标签,比如“是/否”或“正常/异常”,需要先转换成0/1数字再喂给模型。这一步在后面的实际运行部分会详细说。
2. 逻辑回归核心原理与Matlab建模思路
2.1 从线性回归到分类:sigmoid映射
逻辑回归的思路非常直白:先用特征做线性组合,得到z = θᵀx,但这个值可以是任意大小,不能直接当概率。于是加一层sigmoid激活函数:
h(z) = 1 / (1 + exp(-z))
把z压缩到(0,1)区间。本质上就是“线性回归 + 概率压缩”。预测时如果h ≥ 0.5,判为正类,否则判为负类。因为最终的决策边界来自θᵀx = 0这条直线,所以逻辑回归天生是线性分类器。遇到非线性问题,需要先构造多项式特征或交互特征,这一点在调优时非常重要。
Matlab实现sigmoid只需要一行:
function y = sigmoid(z) y = 1 ./ (1 + exp(-z)); end这里有个Matlab新手特别容易踩的坑:当z是向量时,一定要用./和exp。如果用成/,那就是矩阵除法,结果是一个矩阵,后续损失函数直接算成NaN。我第一次写逻辑回归时就栽在这里,损失曲线直接画出了“瀑布”。
2.2 损失函数与梯度下降
为什么逻辑回归不用均方误差?因为sigmoid套在均方误差里会让损失函数变成非凸函数,梯度下降很容易卡在局部最优。逻辑回归用的是交叉熵损失:
J(θ) = -(1/m) * Σ [yᵢ * log(hᵢ) + (1-yᵢ) * log(1-hᵢ)]
这个函数是凸函数,只要学习率设置得当,梯度下降可以收敛到全局最优。对参数θ求偏导后,梯度表达式非常漂亮,和线性回归的梯度形式一样:
∂J/∂θ = (1/m) * Xᵀ(h - y)
也就是说,误差h-y被放大后沿特征方向反向传播。训练过程中每一次迭代都在做同一件事:计算当前预测和真实值的差距,再沿着梯度反方向调整参数。理解了这个,看代码就顺了。
2.3 Matlab向量化实现思路
写Matlab代码最忌讳用for循环逐条样本计算,数据量一大就慢到不行。正确做法是整体矩阵运算。假设X是m×n矩阵,先给X左边补一列全1作为偏置项θ₀,然后:
- z = X * theta,得到一个m×1向量
- h = sigmoid(z)
- error = h - y
- grad = (1/m) * X' * error
整个训练过程就是一个循环更新theta的过程,循环次数等于迭代次数,而不是样本个数。这样写出来的代码非常短,而且可以通过矩阵维度来检查逻辑:X' * error得到的一定是(n+1)×1向量,正好和theta维度匹配。我在实际项目中坚持这种向量化写法,哪怕样本量只有几百条,也保持了良好的可扩展性。
2.4 为什么用全量梯度下降而不是随机梯度下降
这套代码使用的是全量梯度下降,也就是每一步都拿整个训练集去计算梯度。优点是梯度方向稳定,损失曲线平滑,很容易判断收敛状态;缺点是如果训练集达到上百万条,每步计算会比较慢。但作为快速验证和教学实验,全量梯度下降完全够用,而且调试起来更简单。
如果以后要处理大规模数据,可以改成小批量梯度下降,或者用Matlab自带的fminunc做更高级的优化。基础版本先跑通,再考虑性能优化,这是我一直推荐的迭代方式。
3. 代码架构与关键模块实现
3.1 主程序:参数配置入口
我把所有配置项放在主程序开头,保持“参数与逻辑分离”。文件名就叫main_logistic.m,结构如下:
%% 清空环境 clear; close all; clc; %% 全局参数配置 params.dataFile = 'iris.csv'; % 数据文件路径,支持csv/xlsx/txt params.labelCol = 5; % 标签所在列,默认最后一列 params.trainNum = 100; % 训练集样本个数 params.learningRate = 0.1; % 学习率 params.iterations = 500; % 迭代次数 params.normalize = true; % 是否做特征归一化,建议true params.threshold = 0.5; % 分类阈值 %% 读取数据 data = loadData(params.dataFile); %% 分离特征和标签 y = data(:, params.labelCol); X = data; X(:, params.labelCol) = [];这里有一个顺序细节:必须先保存y,再从X里删除标签列。如果不小心先删列再取标签,取到的就是另一列数据了。另外,如果trainNum超过总样本数,后面拆分测试集时会出错,这一步我也在5.4专门做了防护。
3.2 数据读取与格式自适应
loadData函数根据文件后缀选择不同的读取方式。readmatrix是Matlab R2019a之后推荐的通用读取函数,可以解析.csv、.xlsx和.txt。如果版本较老,可以替换成csvread或xlsread。
function data = loadData(filePath) [~, ~, ext] = fileparts(filePath); switch lower(ext) case {'.csv', '.txt'} data = readmatrix(filePath); case '.xlsx' data = readmatrix(filePath); case '.mat' loaded = load(filePath); fields = fieldnames(loaded); data = loaded.(fields{1}); % 取.mat中的第一个变量 otherwise error('不支持的文件格式: %s', ext); end end带表头的数据在readmatrix下通常能自动跳过文本行,但如果表头里有中文、日期等特殊字符,可能会被读成NaN列。更稳妥的方式是先用readtable读取,再转成数组:
tbl = readtable(filePath); data = table2array(tbl);readtable能正确识别文本列和数值列,转数组后相当于自动把文本列丢掉了。如果你的数据里有一列“ID”或者“日期”,并且不想参与建模,这一步可以手动删掉对应列。
3.3 训练函数:梯度下降核心代码
读取完数据后,按照trainNum拆分训练集和测试集:
X_train = X(1:params.trainNum, :); y_train = y(1:params.trainNum); X_test = X(params.trainNum+1:end, :); y_test = y(params.trainNum+1:end);如果开启归一化,必须在拆分之后只用训练集计算均值和标准差。这个细节很关键:测试集必须模拟未来未知数据,不能用它的统计量去参与训练,否则会引入信息泄漏。很多初学者的归一化错误就发生在这一步。
if params.normalize mu = mean(X_train); sigma = std(X_train) + 1e-8; X_train = (X_train - mu) ./ sigma; X_test = (X_test - mu) ./ sigma; end核心训练函数如下:
function [theta, costHistory] = trainLogistic(X, y, alpha, numIter) [m, n] = size(X); X = [ones(m, 1), X]; theta = zeros(n + 1, 1); costHistory = zeros(numIter, 1); for iter = 1:numIter h = sigmoid(X * theta); grad = (1 / m) * (X' * (h - y)); theta = theta - alpha * grad; costHistory(iter) = computeCost(X, y, theta); end end损失函数:
function J = computeCost(X, y, theta) m = length(y); h = sigmoid(X * theta); J = -(1 / m) * sum(y .* log(h + 1e-8) + (1 - y) .* log(1 - h + 1e-8)); end这里给log加1e-8是为了避免h=0或h=1时出现log(0)无穷大,属于数值稳定处理。不要小看这行,真实数据里总会出现极端预测概率,不加防护就会得到NaN。
3.4 预测与评估指标
预测函数和训练函数有两点必须保持一致:一是加偏置列,二是沿用训练集的归一化参数。很多朋友训练时跑得很顺,一到测试或者新数据预测就崩,几乎都是这两个地方没对齐。我在主程序里把mu和sigma作为变量直接传递,就是为了避免这个问题。
function [pred, prob] = predictLogistic(theta, X, threshold) X = [ones(size(X, 1), 1), X]; prob = sigmoid(X * theta); pred = prob >= threshold; end评估阶段输出准确率和混淆矩阵:
[pred, prob] = predictLogistic(theta, X_test, params.threshold); accuracy = mean(pred == y_test); fprintf('测试集准确率: %.4f\n', accuracy); C = confusionmat(y_test, pred); disp(C);如果正负样本不平衡,光看准确率会骗人。比如95%是负样本,模型全预测负类也能拿到95%准确率,但毫无意义。所以我建议至少用混淆矩阵手算precision和recall:
TP = C(2,2); FP = C(1,2); FN = C(2,1); precision = TP / (TP + FP); recall = TP / (TP + FN); fprintf('Precision: %.4f, Recall: %.4f\n', precision, recall);4. 实际运行与结果分析
4.1 数据准备与调用示例
用经典iris数据集来演示。iris一共150条样本,4个特征,3个类别。二分类逻辑回归只取前两类setosa和versicolor,并把标签转换成0/1。假设iris.csv的最后一列是文本标签,预处理如下:
% 假设data已经读入,X是前4列,labels是第5列 X = data(:, 1:4); labels = data(:, 5); % 只保留两类 idx = ismember(labels, {'setosa', 'versicolor'}); X = X(idx, :); labels = labels(idx, :); % 生成二分类标签:setosa=0, versicolor=1 y = ismember(labels, 'versicolor'); % 拼回去,最后一列称为0/1 dataProcessed = [X, y];拼接完之后,再执行主程序的流程,params.labelCol设为5即可。这里再次强调:逻辑回归只认数值型标签,文本标签必须提前映射成0/1,否则后面矩阵运算直接报错或得到荒谬结果。
4.2 训练前的数据预处理
逻辑回归对特征尺度很敏感。比如年龄0-100,收入10000-50000,收入项对梯度的贡献天然被放大,会导致收敛变慢甚至震荡。标准做法是z-score归一化:
z = (x - μ) / σ
如果某个特征标准差接近0,也就是所有样本几乎同值,归一化会出现除0问题。我在代码里给std加了一个1e-8的微小常数,能有效避免这个问题。
缺失值同样要处理。readmatrix读到空值会变成NaN,训练时梯度直接变成NaN。最简单的办法是用fillmissing填充,比如线性插值或均值填充:
X = fillmissing(X, 'linear');如果缺失比例太高,填充只是权宜之计,更合理的做法是先做数据清洗,再进入建模流程。
4.3 结果解读与决策边界可视化
训练完成后,先画损失曲线判断收敛状态:
plot(costHistory, 'LineWidth', 1.5); xlabel('迭代次数'); ylabel('损失值'); title('训练集损失变化'); grid on;正常曲线应该单调下降并趋于平缓。如果曲线突然上扬、抖动很大,说明学习率太高;如果一路缓慢下降但迭代500次还没平稳,说明学习率太低。损失曲线是我调试模型时第一个看的图,比准确率更能暴露问题。
对于二维特征,可以画决策边界。生成网格点后,用训练好的模型预测每个点,并画等高线:
[x1g, x2g] = meshgrid(linspace(min(X_train(:,1)), max(X_train(:,1)), 200), ... linspace(min(X_train(:,2)), max(X_train(:,2)), 200)); Xgrid = [x1g(:), x2g(:)]; Xgrid = (Xgrid - mu) ./ sigma; predGrid = predictLogistic(theta, Xgrid, params.threshold); contourf(x1g, x2g, reshape(predGrid, size(x1g)), 1);画出来之后,如果两类数据本身交叉严重,边界就会歪歪扭扭,模型准确率自然上不去。这个可视化比单纯看数字直观得多。
4.4 一个完整的iris运行结果示例
我实际跑了一遍iris前两类数据,trainNum设为100,学习率0.1,迭代500次,开启归一化。损失曲线最终收敛到0.11左右,测试集50条样本准确率100%。这个结果有点好到不真实,但iris本来就是线性可分数据,所以只能说明代码流程是对的,不代表换到复杂数据也会这么高。真实业务数据通常有噪声、有重叠,准确率能到85%已经值得庆祝了。
5. 常见问题与排查技巧
5.1 矩阵维度对不上怎么办
这是Matlab里最常遇到的报错。出现维度不匹配时,不要急着翻代码,先检查各个变量的size:
size(X) % 应该是 m x n size(y) % 应该是 m x 1 size(theta) % 应该是 (n+1) x 1常见原因有:忘记加偏置列,导致theta维度是n×1,但X补了一列变成n+1维;或者y是行向量而不是列向量。我习惯在训练函数开头强制转换:
y = y(:);这样能提前暴露很多维度问题,也能避免后续矩阵乘法时静默出错。
5.2 梯度不下降或NaN的原因
损失值出现NaN,大概率是log里面出现了0。虽然代码里加了1e-8,但exp函数仍然可能溢出。sigmoid中z很负时,exp(-z)会变成Inf,1/(1+Inf)=0本身没问题;但Inf参与矩阵乘法后可能产生NaN。最有效的规避方法是特征归一化,把z控制在合理范围内;其次是减小学习率。
梯度不下降,除了学习率太小,还可能是数据里有冗余特征,或者损失函数本身已经收敛。我建议打印前几次迭代和最后一次迭代的损失,如果下降幅度在1e-5以内,基本就是收敛了,继续增加迭代次数没有太大意义。
5.3 分类效果差?先查特征缩放
如果跑完发现准确率只有60%,别急着换模型。逻辑回归效果差的很大一部分原因是特征问题。先画散点图,按标签着色,看两类数据是否重叠严重;再检查是否有无关列混入,比如样本编号、日期、文本ID。如果特征都是连续数值却分类不好,可以尝试加交互项:
X_new = [X, X(:,1).^2, X(:,2).^2, X(:,1).*X(:,2)];逻辑回归的决策边界是线性的,加多项式特征等于把原始空间做非线性映射,边界会变得弯曲,效果往往上一个台阶。但也别加太多,否则容易过拟合。
5.4 自定义数据格式时的易错点
带表头的数据用readmatrix一般没问题,但表头里一旦有中文或日期,可能被读成NaN列。稳妥做法是用readtable读取后再转数组。分隔符如果不是逗号,要显式指定:
data = readmatrix(filePath, 'Delimiter', '\t');标签不是0/1时,要提前映射。trainNum设置过大时,要加判断:
if params.trainNum >= size(X, 1) params.trainNum = size(X, 1); end否则X_test是空矩阵,测试阶段会报维度错误。这些坑看起来很基础,但在实际使用时出现频率极高,值得在代码里提前防御。
5.5 学习率怎么调
我常用的调参策略是:先设0.1,迭代100次,打印损失曲线。如果曲线下降很快但开始震荡,就把学习率降到0.01;如果曲线几乎不动,就升到0.3或0.5。逻辑回归的梯度大小跟特征尺度有关,归一化后学习率更容易调。也可以做学习率衰减,每迭代100次乘0.9,收敛会更稳。对于教学项目,固定学习率已经够用了,没必要上高级优化器。
6. 个人实操体会与扩展建议
6.1 从“能跑”到“好用”的三个细节
第一,参数分离。所有配置都放主程序开头,训练函数不写死任何参数,换数据集时只需要复制主程序改几行。第二,归一化统计量要留好。训练时算出的mu和sigma一定要保存下来,预测新样本时用同一套统计量,否则概率全乱。第三,输出要完整。准确率不能只看一个数字,至少输出混淆矩阵和precision/recall,才能发现类别不平衡问题。
我在实际项目里还会把训练好的theta、mu、sigma保存成.mat文件:
save('logisticModel.mat', 'theta', 'mu', 'sigma');生产环境只需要加载这个文件,对新样本做同样的归一化,然后计算sigmoid(X * theta)即可输出概率。这样就把训练和预测彻底解耦了。
6.2 还能怎么扩展
这套代码很容易扩展成多分类逻辑回归,常用“一对多”策略:每个类别训练一个二分类器,预测时选概率最大的那个。加正则化也很简单,在损失函数里加L2惩罚项,梯度对应调整。特征选择可以看theta绝对值大小,绝对值越大说明对应特征对决策影响越大。
如果数据非线性程度太高,可以把逻辑回归换成带核函数的SVM,或者做特征工程后继续用逻辑回归。我做这个项目时最深的体会是,别迷信高级模型。很多时候逻辑回归就能把业务基线拉起来,后续再上XGBoost才是正确的迭代路径。而一份“拿来就能改”的Matlab代码,正是快速验证自己数据集的最短路径。