逻辑回归实战:特征映射与正则化破解非线性二分类 看到“Logistic回归ex2data2”这个命名我第一反应就是又有人在经典的机器学习练习里被非线性二分类卡住了。这个数据集结构很简单——两个数值型输入特征一组0/1标签总共118条样本但它有一个很扎心的特点两类样本在平面上呈弧形交错任何一条直线都切不干净。直接套一个普通逻辑回归训练精度只能勉强到六成。先别急着换SVM或者神经网络这个练习的设计意图非常明确让你亲手把特征映射和正则化这两件事做一遍用最简单的模型解决看起来不简单的分类问题。下面我把从读数据、写特征映射、推导正则化梯度到画决策边界的完整过程拆开讲适合正在学二分类、或者想把逻辑回归从“调包”进阶到“实装”的人。1. 动手前先读数据ex2data2里到底装了什么1.1 两个特征一个标签118条样本这个练习的数据文件是典型的文本表格格式逗号分隔没有表头一共三列。第一列是第一个测试得分第二列是第二个测试得分第三列是标签1表示合格0表示不合格。数据量不大118条特征只有两个属于那种一眼能看完、适合手工推演的小规模数据集。这种数据形态在真实工业场景里很常见比如产线上同时记录两个电性参数最终决定芯片通过与否。因为特征少可视化特别直观我们可以在动手建模前直接画个散点图用肉眼判断问题的难度。读取数据用一行搞定import numpy as np data np.loadtxt(ex2data2.txt, delimiter,) X_raw data[:, :2] y data[:, 2] print(X_raw.shape, y.shape) # (118, 2) (118,)如果你手头的数据文件是自己的版本比如命名成ex2data2.csv注意确认分隔符是不是逗号如果字段间是空格或制表符np.loadtxt的delimiter参数要对应改掉否则会把一整行当成一个字符串解析一上来就报错。1.2 散点图一画线性分类器就暴露了拿到数据第一件事永远是可视化。把标签为1的样本用加号画出标签为0的用圆圈画出import matplotlib.pyplot as plt pos (y 1) neg (y 0) plt.scatter(X_raw[pos, 0], X_raw[pos, 1], marker, labelAccepted) plt.scatter(X_raw[neg, 0], X_raw[neg, 1], markero, labelRejected) plt.xlabel(Test 1 score) plt.ylabel(Test 2 score) plt.legend() plt.show()画出来之后你会看到两类点像两个对扣着的香蕉左一片右一片中间还有交叉。这种分布最典型的特征就是你拿尺子在图上怎么比划都找不到一条直线能把两类完整切开。就算强行切也一定会把一大块另一类样本划进来。这直接决定了线性逻辑回归的结局。逻辑回归的决策边界是θ^T x 0在二维输入空间里就是一条直线。我实测用原始两个特征直接训练训练精度大概只有61%上下——比投硬币好点但离可用差得远。1.3 问题拆解非线性的锅得从特征上找补线性模型解决不了非线性的切分这是模型表达能力的限制不是优化算法不行。要破局有两条路换一个天生带非线性决策边界的模型比如SVM加核函数、神经网络或者保持逻辑回归这个线性分类器的骨架不变但把输入特征从二维扩成高维多项式——后者就是特征映射。特征映射的直觉很朴素既然在原来的坐标系里直线切不开那就把每个样本点“抬到”一个更高维的空间在高维空间里用一个平面去切。平面在高维空间切出来的痕投影回二维坐标系就是一条曲线正好能贴合弧形分布。理解了这一步后面的一切都顺理成章。2. 特征映射把一个点的二维坐标变成28维向量2.1 为什么高维空间能切开非线性数据拿一个最简单的例子类比二维平面上有一堆红点和蓝点红点围成一个圆环蓝点在圆心。你在平面上用直线无论如何切不开圆环。但如果你给每个点多加一个维度——比如用x1^2 x2^2作为第三维圆心点的第三维数值小圆环上的点第三维数值大两类点在这个新维度上直接被分开。此时用一个平面就能切投影回二维就是那个圆。特征映射干的就是这件事ex2data2不需要你去找一个这么巧的表达式而是用一堆多项式项把你可能需要的各种弯曲变形都放进特征空间让数据自己组合出合适的分布。2.2 映射到6次多项式特征数从2涨到28通用的做法是把原始特征x1, x2展开成所有满足i j degree的项x1^i * x2^j。degree取6时项包括常数项、一阶项、二阶项……一直到六阶项数量是组合总数 C(degree 2, 2) C(8, 2) 28也就是说原来每个样本是两个数做完特征映射后变成28个数。展开顺序一般约定为常数1然后x1、x2然后x1^2、x1*x2、x2^2依此类推。这个顺序本身没有严格要求但必须全局一致因为参数向量的第几个分量对应哪个特征是严格按照这个顺序来的。6次不是算出来的是经验值。对于这个数据集6次多项式基本能把决策边界的复杂度撑起来同时28个特征也不算多配合正则化可控。如果你把degree加到10、12特征数会成平方关系暴涨模型参数变多、训练变慢、过拟合风险上升收益却不明显。2.3 一个趁手的map_feature函数代码实现非常直白两个for循环嵌套就能生成全部组合def map_feature(x1, x2, degree6): m len(x1) out np.ones((m, 1)) for i in range(1, degree 1): for j in range(i 1): col (x1 ** (i - j)) * (x2 ** j) out np.column_stack((out, col)) return out外层循环控制总次数i内层循环控制x2的次数jx1的次数就是i - j。这样从i1, j0到i6, j6所有组合都被覆盖而且顺序和参数向量一一对应。我习惯让函数返回(m, n)的二维数组这样可以和后续的矩阵乘法直接对得上。这里有一个特别容易踩的坑训练的时候用这个函数生成特征矩阵画决策边界的时候也必须用同一个函数生成网格点的特征。如果你在别处“顺手”改了一个不同顺序的版本参数和特征的对应关系就乱了画出来的决策边界会是一堆毫无意义的乱线。2.4 特征变多参数膨胀过拟合随之而来28个特征意味着模型有28个自由度比原始2个特征时能折腾的空间大多了。如果没有约束优化器为了让训练集上的每个点都分类正确会把部分参数推到很大的绝对值决策边界在样本点附近疯狂扭曲形成那种“九曲十八弯”的过度拟合形状。这时候你去看训练精度可能会很高但泛化性能很差。解决办法就是加正则化在代价函数里对参数的大小施加惩罚让模型必须做到“能拟合、但不疯狂”。这个逻辑就是我们接下来要亲手实现的部分。3. 带正则化的Logistic回归公式与实现3.1 逻辑回归的代价函数为什么长那样逻辑回归先计算线性组合θ^T x再经过sigmoid函数压缩到0到1之间h 1 / (1 exp(-θ^T x))输出可以解释为“样本属于正类的概率”。代价函数用对数损失而非平方损失是因为如果用平方误差MSE代入sigmoid后整个代价函数对θ不再是一个凸函数梯度下降容易掉进局部极小而换成交叉熵形式的对数损失得到的代价函数是凸的优化起来稳定得多。无正则化的代价函数是J(θ) -1/m * Σ [ y^(i) * log(h(x^(i))) (1 - y^(i)) * log(1 - h(x^(i))) ]样本被分错时对应项的惩罚会非常大分对时趋向0。这套设计让模型不仅在“猜对/猜错”上有区分还给出了概率意义上的置信度惩罚。3.2 L2正则项让参数别太嚣张在代价函数后面加一项L2惩罚J_reg(θ) J(θ) λ / (2m) * Σ_{j1}^{n} θ_j^2这里有两个细节值得停下来想。第一惩罚项的分母是2m除以样本数m是为了让正则化力度不受样本量影响也方便后续推导梯度除以2则是为了在求导时把系数消掉公式更干净。第二求和是从j1开始的θ_0常数项对应的偏置不参与正则化。原因很直观θ_0只负责整体平移决策边界不决定边界的弯曲程度。你去惩罚它模型不会因为决策边界变直变平滑只会白白损失拟合能力属于有百害无一利。λ是正则化强度。λ太小惩罚约等于没有过拟合λ太大把所有参数都按到接近0决策边界退化欠拟合。这个权衡后面我会用实测对比给你看。3.3 梯度推导正则项求导后为什么会多出λ/m * θ代价函数对θ_j求导分两部分。第一部分来自原始代价函数和普通逻辑回归的梯度一模一样∂J / ∂θ_j 1/m * Σ (h^(i) - y^(i)) * x_j^(i)第二部分来自正则项。因为正则项里是θ_j的平方求导后变成2θ_j乘以系数λ/(2m)后恰好得到λ/m * θ_j。合并起来对于j≥1的梯度是∂J_reg / ∂θ_j 1/m * Σ (h^(i) - y^(i)) * x_j^(i) λ/m * θ_j而j0偏置不参与正则化梯度就是第一项不需要加额外项。这个“为什么是λ/m而不是λ”的问题我在学习时被问过很多次本质就是上面这个推导过程。对应代码实现def sigmoid(z): return 1 / (1 np.exp(-z)) def cost_function_reg(theta, X, y, lambda_reg): m len(y) h sigmoid(X theta) h np.clip(h, 1e-10, 1 - 1e-10) theta_reg theta.copy() theta_reg[0] 0 cost (-1 / m) * (y np.log(h) (1 - y) np.log(1 - h)) reg (lambda_reg / (2 * m)) * np.sum(theta_reg ** 2) return cost reg def gradient_reg(theta, X, y, lambda_reg): m len(y) h sigmoid(X theta) grad (1 / m) * (X.T (h - y)) theta_reg theta.copy() theta_reg[0] 0 grad (lambda_reg / m) * theta_reg return gradnp.clip(h)那行是我特意加的。h精确等于1的时候log(1-h)会变成负无穷h等于0的时候另一项也会炸。训练初期如果参数路径不好真会撞上这种极端值所以直接clip掉数值上更稳。3.4 优化器怎么选scipy真香这个小练习里我推荐直接用scipy.optimize.minimize算法选BFGS或者L-BFGS-B。它内部会自己做步长搜索不需要你去调学习率收敛效果比手写梯度下降稳定得多。调用方式就是把代价函数、初始参数、正则化参数、梯度函数都传进去from scipy.optimize import minimize res minimize( funcost_function_reg, x0initial_theta, args(X, y, lambda_reg), methodBFGS, jacgradient_reg, options{maxiter: 400} )注意jac这个参数一定要传自己写的梯度函数否则scipy会用数值微分去逼近精度差、速度慢还容易触发奇怪的数值警告。手写梯度下降当然也能跑通但你会多出一个学习率的超参数要试而且这个数据集的特征范围不大梯度下降的速度优势也体现不出来没必要给自己添麻烦。4. 完整实战读取数据、训练模型、画决策边界4.1 数据读取与散点图前面已经给出了数据读取和散点图代码这里直接进入特征构造。先确认一下数据形状然后调用map_feature把二维特征扩成28维X map_feature(X_raw[:, 0], X_raw[:, 1], degree6) m, n X.shape print(特征矩阵维度:, m, x, n) # (118, 28)这里X_raw[:, 0]和X_raw[:, 1]都是一维数组长度118所以map_feature返回的X是118行28列的矩阵。列数28刚好和前面公式算出来一致。如果你用degree6以外取值列数会变后面的参数向量长度也要跟着变这些是最基本的形状自检。4.2 训练参数并检查收敛初始化参数为全零λ按练习惯例取1initial_theta np.zeros(n) lambda_reg 1 res minimize( funcost_function_reg, x0initial_theta, args(X, y, lambda_reg), methodBFGS, jacgradient_reg, options{maxiter: 400} ) theta res.x print(优化完成:, res.success, 迭代轮数:, res.nit)res.success为True说明优化器正常收敛。如果你发现它为False先别急着改参数回头检查梯度函数是不是写错了大部分情况问题出在梯度公式而不是模型结构。4.3 评估训练精度预测规则很简单sigmoid输出大于等于0.5就判为正类否则判为负类。def predict(theta, X): return (sigmoid(X theta) 0.5).astype(int) pred predict(theta, X) train_acc np.mean(pred y) * 100 print(f训练集精度: {train_acc:.2f}%)我实跑下来λ1、degree6时这个数大约是83%。这个精度不是重点重点是你能感受到加了正则化之后决策边界在图上是一条平滑弯曲的线而不是一根乱窜的毛线。4.4 画出0.5概率决策边界决策边界的定义就是θ^T x 0对应概率刚好是0.5。要画这条线最稳妥的办法是在平面上生成网格对每个网格点做同样的特征映射计算预测值再画出值为0的等高线u np.linspace(-1, 1.5, 200) v np.linspace(-1, 1.5, 200) z np.zeros((len(u), len(v))) for i in range(len(u)): for j in range(len(v)): mapped map_feature(np.array([u[i]]), np.array([v[j]]), degree6) z[i, j] mapped theta plt.contour(u, v, z.T, levels[0], colorsgreen) plt.show()有两个细节容易出错。第一map_feature接收的输入必须是数组而不是标量这样返回的才是(1, 28)矩阵如果你传一个浮点数进去len(x1)会直接报错。第二z矩阵填完后画contour时要用z.T因为contour函数期望Z[i, j]对应的是(Y[i], X[j])这个点而我们填充时用的是u[i], v[j]的顺序转置一下才对应正确。这个细节很多人第一次都栽过。4.5 把散点图和边界叠加起来把上面散点图代码和等高线代码放在同一个画布上就能得到最经典的那张结果图绿色的平滑曲线把弧形区域切开正负样本大致分布在两侧。图上最直观的感受是λ1的边界既没有激进地把角落噪声点全包进来也没有退化成一条直线处在一种很舒服的中间状态。如果你想让图更好看可以在contour里通过levels[-0.001, 0.001]这样一个小范围画出边界线的宽度感但本质是一样的。网格密度200×200对这个小数据问题也够再密只是让曲线更光滑一点却会明显增加循环耗时。5. 踩坑实录这些错误我几乎每次都遇到5.1 正则化把θ0也惩罚了最经典的低级错误在代价函数里写np.sum(theta ** 2)没有先把theta[0]置0。表面上看只是多了一项实际后果是决策边界整体偏移训练精度可能掉好几个点而且不容易一下就察觉到是因为这个原因。推荐写法是复制一份再改theta_reg theta.copy() theta_reg[0] 0复制而不是原地修改太重要了。如果你在原数组上直接theta[0] 0下一次循环或者下一次计算时θ的真值就丢了梯度会一路算错下去排查起来非常头痛。5.2 梯度公式里漏掉了正则项第二个高频错误是梯度只算了无正则部分正则部分λ/m * θ没加上去。如果λ1漏掉这一项scipy优化时可能导致res.successFalse或者迭代过程中代价函数反复震荡最后得到一组完全不对的参数。判断梯度写没写对有个笨但有效的办法把λ设成0跑一遍训练看loss曲线是否单调下降、精度是否合理。λ0时正则项本来就不该生效如果此时行为依然异常说明问题在梯度主体如果λ0正常而λ1异常那基本就是正则梯度漏了。5.3 log函数被零触发训练过程出现NaNsigmoid输出无限接近1时np.log(1-h)会得到负无穷再和0相乘得到NaN后面所有梯度全部污染。这类问题在数据干净的时候不太常见但一旦出现排查起来很绝望。我的习惯是进入代价函数后立刻对h做一次cliph np.clip(h, 1e-10, 1 - 1e-10)这个上限下限取值不敏感1e-10足够小、足够安全又不会明显改变loss的数值。这比捕获警告、或者用bigfloat之类的方案简洁得多。5.4 λ怎么调0、1、10、100的对比我把不同λ的实际效果整理成下面这个表数据是我自己训练时的经验值趋势是稳定的λ取值决策边界形态训练精度经验值泛化倾向0扭曲严重试图包住每个样本点接近90%明显过拟合1平滑弯曲贴合两类区域约83%均衡10边界明显变平部分样本被吞掉约75%欠拟合倾向100接近直线60%上下严重欠拟合看这张表你会发现训练精度最高对应的λ未必是最好的那个。λ0在训练集上刷到快90%但那种边界换个样本很可能就翻车。实际项目中选λ要用验证集在这个练习里没有划分验证集全当理解现象就好。5.5 自己写梯度下降时的学习率陷阱如果你不用scipy非要手写梯度下降会遇到一个很典型的麻烦学习率太小收敛太慢学习率太大会震荡甚至发散。对于这个数据α从0.01起步比较稳但要跑到收敛可能需要上万次迭代α取1基本上前几步就会把代价函数推到NaN。我的建议是学习率宁小勿大并且每50轮打印一次代价函数的值观察它是否持续下降。如果出现先降后升立即停掉把α调小一个数量级。5.6 决策边界绘制的维度与转置问题最后一个高频bug集中在绘图环节。刚才提到map_feature必须接受一维数组返回(1, 28)形状否则mapped theta形状对不上。另外for循环里用zip(u, v)去同时遍历是不对的因为决策边界上每个u[i]要和所有v[j]组合必须双层循环。转置的坑再强调一遍plt.contour(u, v, z.T, levels[0])漏掉.T你画出来的等高线会旋转90度和散点图完全错位。我在这个细节上浪费过十几分钟检查了两遍代码才想起来是转置搞反了。我个人把degree从2一路试到8λ从0一路试到100把所有组合的决策边界都画出来贴在一张图上对比之后才真正理解了特征工程和正则化这对组合拳的分量。如果你也想把这套逻辑内化成自己的东西建议别只跑一遍标准答案多花半小时做这种网格化实验。只盯训练精度数字永远看不到决策边界在图上扭来扭去的那种直观冲击等你能解释出λ变化时边界形态为什么会那样变这个练习才算真正过关。