看到“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='+', label='Accepted') plt.scatter(X_raw[neg, 0], X_raw[neg, 1], marker='o', label='Rejected') 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, degree=6): 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的次数j,x1的次数就是i - j。这样从i=1, j=0到i=6, j=6,所有组合都被覆盖,而且顺序和参数向量一一对应。我习惯让函数返回(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) * Σ_{j=1}^{n} θ_j^2
这里有两个细节值得停下来想。
第一,惩罚项的分母是2m,除以样本数m是为了让正则化力度不受样本量影响,也方便后续推导梯度;除以2则是为了在求导时把系数消掉,公式更干净。
第二,求和是从j=1开始的,θ_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
而j=0(偏置)不参与正则化,梯度就是第一项,不需要加额外项。这个“为什么是λ/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( fun=cost_function_reg, x0=initial_theta, args=(X, y, lambda_reg), method='BFGS', jac=gradient_reg, options={'maxiter': 400} )注意jac这个参数一定要传自己写的梯度函数,否则scipy会用数值微分去逼近,精度差、速度慢,还容易触发奇怪的数值警告。手写梯度下降当然也能跑通,但你会多出一个学习率的超参数要试,而且这个数据集的特征范围不大,梯度下降的速度优势也体现不出来,没必要给自己添麻烦。
4. 完整实战:读取数据、训练模型、画决策边界
4.1 数据读取与散点图
前面已经给出了数据读取和散点图代码,这里直接进入特征构造。先确认一下数据形状,然后调用map_feature把二维特征扩成28维:
X = map_feature(X_raw[:, 0], X_raw[:, 1], degree=6) m, n = X.shape print('特征矩阵维度:', m, 'x', n) # (118, 28)这里X_raw[:, 0]和X_raw[:, 1]都是一维数组,长度118,所以map_feature返回的X是118行28列的矩阵。列数28刚好和前面公式算出来一致。如果你用degree=6以外取值,列数会变,后面的参数向量长度也要跟着变,这些是最基本的形状自检。
4.2 训练参数并检查收敛
初始化参数为全零,λ按练习惯例取1:
initial_theta = np.zeros(n) lambda_reg = 1 res = minimize( fun=cost_function_reg, x0=initial_theta, args=(X, y, lambda_reg), method='BFGS', jac=gradient_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、degree=6时,这个数大约是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]]), degree=6) z[i, j] = mapped @ theta plt.contour(u, v, z.T, levels=[0], colors='green') 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.success=False,或者迭代过程中代价函数反复震荡,最后得到一组完全不对的参数。
判断梯度写没写对,有个笨但有效的办法:把λ设成0,跑一遍训练,看loss曲线是否单调下降、精度是否合理。λ=0时正则项本来就不该生效,如果此时行为依然异常,说明问题在梯度主体;如果λ=0正常而λ=1异常,那基本就是正则梯度漏了。
5.3 log函数被零触发,训练过程出现NaN
sigmoid输出无限接近1时,np.log(1-h)会得到负无穷,再和0相乘得到NaN,后面所有梯度全部污染。这类问题在数据干净的时候不太常见,但一旦出现,排查起来很绝望。
我的习惯是进入代价函数后立刻对h做一次clip:
h = 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,把所有组合的决策边界都画出来贴在一张图上对比之后,才真正理解了特征工程和正则化这对组合拳的分量。如果你也想把这套逻辑内化成自己的东西,建议别只跑一遍标准答案,多花半小时做这种网格化实验。只盯训练精度数字,永远看不到决策边界在图上扭来扭去的那种直观冲击;等你能解释出λ变化时边界形态为什么会那样变,这个练习才算真正过关。