ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零实现逻辑回归:水果分类实战与梯度下降详解

从零实现逻辑回归:水果分类实战与梯度下降详解 1. 项目缘起从“水果分类”到“逻辑回归”的实战切入点最近在整理一些数学建模的入门案例发现很多朋友对逻辑回归这个经典算法的理解还停留在“调用sklearn的LogisticRegression然后.fit()一下”的层面。这当然没错但对于想真正搞懂模型背后原理甚至想在比赛中灵活运用的人来说这远远不够。恰好我手头有一个非常经典的“二分类水果数据”集它结构简单、特征明确但麻雀虽小五脏俱全非常适合用来手撕一遍逻辑回归。这个数据集常出现在“清风数学建模”这类教程里作为逻辑回归的入门练手项目。逻辑回归名字里带个“回归”干的却是“分类”的活儿这一点常常让初学者困惑。它本质上是在用线性回归的框架去解决概率预测问题再通过一个Sigmoid函数把线性结果“压”到0和1之间解释为属于某一类的概率。这次我们不依赖任何高级库的“黑箱”实现就用最基础的Python和NumPy从零推导损失函数手动实现梯度下降完整走一遍模型训练、预测和评估的全流程。你会发现抛开框架的封装算法的核心思想其实非常清晰和优雅。通过这个“水果分类”的小项目我们不仅能掌握逻辑回归的代码实现更能深入理解其数学本质、训练过程以及如何解读结果。这对于后续学习更复杂的模型如神经网络其神经元激活函数常使用Sigmoid或处理实际的二分类问题如用户流失预测、邮件垃圾识别都大有裨益。2. 数据理解与预处理构建模型的基石我们使用的“二分类水果数据”通常包含若干样本每个样本有多个特征如水果的重量、宽度、颜色评分等和一个标签比如0代表苹果1代表橙子。在动手写模型之前彻底理解并处理好数据是至关重要的一步这直接决定了模型能否学到有效的规律。2.1 数据加载与初步观察首先我们需要加载数据。假设数据保存在一个CSV文件fruits_data.csv中。import pandas as pd import numpy as np # 加载数据 df pd.read_csv(fruits_data.csv) print(数据形状:, df.shape) print(\n前5行数据:) print(df.head()) print(\n数据基本信息:) print(df.info()) print(\n标签分布:) print(df[label].value_counts())这一步的输出会告诉我们数据有多少行样本数、多少列特征数标签以及各列的数据类型。查看标签分布是为了确保两类样本的数量没有严重失衡例如99%都是苹果严重的类别不平衡需要特殊处理如过采样、欠采样或调整损失函数权重好在我们这个教学数据集通常是均衡的。2.2 特征与标签分离及特征缩放逻辑回归的模型表达式是z w1*x1 w2*x2 ... wn*xn b。我们需要将特征矩阵X和标签向量y分开。# 假设最后一列是标签‘label’其余是特征 X df.iloc[:, :-1].values # 获取特征矩阵.values转换为NumPy数组 y df.iloc[:, -1].values # 获取标签向量 print(特征矩阵 X 形状:, X.shape) print(标签向量 y 形状:, y.shape)接下来是一个关键步骤特征缩放Feature Scaling。由于逻辑回归的损失函数交叉熵损失以及我们即将使用的梯度下降优化算法其收敛速度和效果受特征尺度影响很大。如果特征A的范围是0-1000特征B的范围是0-1那么特征A对权重w的更新影响会大得多导致模型训练缓慢且可能无法找到最优解。最常用的方法是标准化Standardization。from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled scaler.fit_transform(X) # 拟合缩放器并转换数据 # 查看缩放后的前两个样本均值和标准差应分别接近0和1 print(缩放后前两个样本:\n, X_scaled[:2]) print(缩放后特征均值:, X_scaled.mean(axis0).round(2)) print(缩放后特征标准差:, X_scaled.std(axis0).round(2))注意fit_transform只在训练集上使用。如果我们有独立的测试集应该用训练集拟合好的scaler去transform测试集避免数据泄露。这里是全量数据演示实际建模时应先划分训练集和测试集。2.3 添加偏置项与数据划分为了将偏置b也纳入权重向量w统一处理我们给特征矩阵X添加一列全为1的特征。这样模型就变成了z w.T * X其中w的第一个元素就是偏置b。# 添加偏置项一列1 m X_scaled.shape[0] # 样本数 X_b np.c_[np.ones((m, 1)), X_scaled] # 水平拼接一列1 print(添加偏置项后 X_b 形状:, X_b.shape)然后我们将数据随机划分为训练集和测试集以评估模型的泛化能力。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split(X_b, y, test_size0.2, random_state42) print(f训练集大小: {X_train.shape}, 测试集大小: {X_test.shape})至此数据准备工作全部完成。我们得到了标准化并添加了偏置项的训练特征矩阵X_train、训练标签y_train以及对应的测试集X_test和y_test。3. 逻辑回归核心原理与手动实现现在进入核心环节不借助sklearn我们如何实现逻辑回归关键在于理解并实现三个部分Sigmoid函数、损失函数交叉熵损失以及用梯度下降法来最小化这个损失。3.1 Sigmoid函数将线性输出转化为概率Sigmoid函数公式为σ(z) 1 / (1 e^(-z))。它将任意实数z映射到 (0, 1) 区间输出值可以解释为样本属于正类标签为1的概率。def sigmoid(z): 计算Sigmoid函数值。 参数: z: 标量或NumPy数组。 返回: 与z形状相同的Sigmoid值。 # 为了防止计算e^(-z)时溢出当z为很大的负数使用数值稳定的写法 # 当z 0时用 1 / (1 exp(-z)) # 当z 0时用 exp(z) / (1 exp(z))数学上等价但数值更稳定 return np.where(z 0, 1 / (1 np.exp(-z)), np.exp(z) / (1 np.exp(z))) # 测试Sigmoid函数 z_test np.array([-10, -1, 0, 1, 10]) print(Sigmoid输出:, sigmoid(z_test)) # 输出应接近 [4.53978687e-05, 2.68941421e-01, 5.00000000e-01, 7.31058579e-01, 9.99954602e-01]3.2 损失函数交叉熵损失Log Loss对于二分类我们使用二元交叉熵损失。对于一个样本其损失为L(y, y_hat) -[y * log(y_hat) (1-y) * log(1-y_hat)]其中y是真实标签0或1y_hat是模型预测为正类的概率σ(z)。对于所有训练样本代价函数Cost Function是平均损失J(w) -(1/m) * Σ [y_i * log(σ(z_i)) (1-y_i) * log(1-σ(z_i))]这里m是样本数z_i w.T * x_i。def compute_cost(w, X, y): 计算逻辑回归的交叉熵损失代价。 参数: w: 权重向量含偏置形状 (n_features, 1) 或 (n_features,) X: 特征矩阵已含偏置项形状 (m_samples, n_features) y: 真实标签形状 (m_samples,) 返回: cost: 标量平均交叉熵损失。 m X.shape[0] # 确保y是列向量便于后续计算 y y.reshape(-1, 1) if len(y.shape) 1 else y w w.reshape(-1, 1) if len(w.shape) 1 else w # 计算线性输出和预测概率 z X.dot(w) # 形状 (m, 1) y_hat sigmoid(z) # 形状 (m, 1) # 计算损失。为了防止log(0)导致-inf给y_hat一个极小的偏移量epsilon epsilon 1e-15 y_hat np.clip(y_hat, epsilon, 1 - epsilon) # 交叉熵损失公式 cost -np.mean(y * np.log(y_hat) (1 - y) * np.log(1 - y_hat)) return cost # 初始化权重并计算初始损失 n_features X_train.shape[1] w_initial np.zeros((n_features, 1)) # 初始权重全为0 initial_cost compute_cost(w_initial, X_train, y_train) print(f初始权重下的训练集损失: {initial_cost:.4f})3.3 梯度下降更新权重的引擎我们的目标是找到一组权重w使得代价函数J(w)最小。梯度下降通过计算损失函数关于每个权重的梯度偏导数然后沿着梯度反方向即下降最快的方向更新权重。梯度公式推导过程略是求导结果为∂J/∂w (1/m) * X.T * (y_hat - y)def compute_gradient(w, X, y): 计算损失函数关于权重w的梯度。 参数: w, X, y: 同compute_cost函数。 返回: grad: 梯度向量形状与w相同。 m X.shape[0] y y.reshape(-1, 1) if len(y.shape) 1 else y w w.reshape(-1, 1) if len(w.shape) 1 else w z X.dot(w) y_hat sigmoid(z) # 梯度计算公式 grad (1/m) * X.T.dot(y_hat - y) return grad def gradient_descent(X, y, w_init, learning_rate, n_iters): 执行批量梯度下降。 参数: X, y: 训练数据和标签。 w_init: 初始权重向量。 learning_rate: 学习率控制更新步长。 n_iters: 迭代次数。 返回: w: 优化后的权重向量。 cost_history: 每次迭代的损失记录用于可视化。 w w_init.copy() cost_history [] for i in range(n_iters): # 计算梯度 grad compute_gradient(w, X, y) # 更新权重 w w - learning_rate * grad # 记录当前损失 cost compute_cost(w, X, y) cost_history.append(cost) # 每1000次迭代打印一次进度可选 if i % 1000 0: print(f迭代 {i:5d} 损失: {cost:.6f}) return w, np.array(cost_history)3.4 训练模型与超参数选择现在我们可以用准备好的数据、损失函数和优化算法来训练模型了。这里有两个关键超参数需要设定学习率learning_rate和迭代次数n_iters。# 设置超参数 learning_rate 0.1 # 常见初始尝试值如0.01, 0.1, 0.5 n_iters 5000 # 迭代次数 # 执行梯度下降 w_trained, cost_history gradient_descent(X_train, y_train, w_initial, learning_rate, n_iters) print(f\n训练完成。最终权重向量含偏置:\n{w_trained.flatten()}) print(f最终训练集损失: {cost_history[-1]:.6f})实操心得学习率的“炼丹”艺术学习率是梯度下降中最重要的超参数。太小如0.001收敛速度极慢可能需要上万次迭代太大如1.0可能导致损失值震荡甚至发散NaN。一个实用的技巧是绘制损失曲线。如果曲线平滑下降至平稳说明学习率合适如果震荡调小学习率如果下降极其缓慢调大学习率。可以从0.1开始尝试然后以3倍或10倍为因子进行调整如0.03 0.3。import matplotlib.pyplot as plt plt.figure(figsize(10, 6)) plt.plot(range(len(cost_history)), cost_history, b-, linewidth2) plt.xlabel(迭代次数) plt.ylabel(损失 (Cost)) plt.title(梯度下降损失下降曲线) plt.grid(True) plt.show()4. 模型预测、评估与结果解读模型训练好后我们需要用它来对测试集或新数据进行预测并评估其性能。4.1 预测函数与决策边界预测分为两步1) 计算概率y_hat σ(X * w)2) 根据设定的阈值默认为0.5将概率转化为类别标签。def predict(X, w, threshold0.5): 使用训练好的逻辑回归模型进行预测。 参数: X: 特征矩阵已含偏置项。 w: 训练好的权重向量。 threshold: 分类阈值默认0.5。 返回: y_pred: 预测的类别标签 (0或1)。 y_prob: 预测为正类的概率。 z X.dot(w) y_prob sigmoid(z) y_pred (y_prob threshold).astype(int) return y_pred.flatten(), y_prob.flatten() # 在测试集上进行预测 y_test_pred, y_test_prob predict(X_test, w_trained) print(测试集前10个样本的预测概率:, y_test_prob[:10]) print(测试集前10个样本的预测标签:, y_test_pred[:10]) print(测试集前10个样本的真实标签:, y_test[:10])决策边界是特征空间中一个将两类样本分开的曲面在二维特征下是一条线。其方程来源于σ(z)0.5即z w0 w1*x1 w2*x2 ... 0。对于两个特征的情况决策边界是一条直线x2 -(w0/w2) - (w1/w2)*x1。4.2 二分类性能评估指标仅仅看准确率Accuracy是不够的尤其是在类别不平衡时。我们需要一套更细致的评估体系。from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix, roc_auc_score, roc_curve # 计算各项指标 accuracy accuracy_score(y_test, y_test_pred) precision precision_score(y_test, y_test_pred) # 查准率预测为正的样本中实际为正的比例 recall recall_score(y_test, y_test_pred) # 查全率召回率实际为正的样本中被预测为正的比例 f1 f1_score(y_test, y_test_pred) # F1分数精确率和召回率的调和平均 auc roc_auc_score(y_test, y_test_prob) # AUCROC曲线下的面积 conf_matrix confusion_matrix(y_test, y_test_pred) print( 模型在测试集上的性能 ) print(f准确率 (Accuracy): {accuracy:.4f}) print(f精确率 (Precision): {precision:.4f}) print(f召回率 (Recall): {recall:.4f}) print(fF1分数: {f1:.4f}) print(fAUC值: {auc:.4f}) print(\n混淆矩阵:) print(conf_matrix) # 混淆矩阵结构: [[TN, FP], # [FN, TP]]指标解读与选择准确率整体分类正确的比例。在数据平衡时是个好指标。精确率关注“预测结果”。当误报FP成本很高时例如垃圾邮件过滤把正常邮件判为垃圾邮件很糟糕需要高精确率。召回率关注“真实情况”。当漏报FN成本很高时例如疾病检测漏掉一个病人很严重需要高召回率。F1分数是精确率和召回率的调和平均数在两者需要权衡时使用。AUC衡量模型整体排序能力的指标与阈值无关。AUC越接近1模型区分能力越好。0.5相当于随机猜测。对于我们的水果分类问题如果两类水果价值相当准确率和F1分数是主要参考。如果“把橙子误认为苹果”和“把苹果误认为橙子”造成的损失不同则需要调整阈值来优化对应的精确率或召回率。4.3 调整分类阈值与ROC曲线默认阈值0.5不一定是最优的。我们可以通过绘制ROC曲线并找到最佳阈值如最靠近左上角的点或根据业务需求。# 计算ROC曲线 fpr, tpr, thresholds roc_curve(y_test, y_test_prob) # 绘制ROC曲线 plt.figure(figsize(10, 8)) plt.plot(fpr, tpr, colordarkorange, lw2, labelfROC curve (AUC {auc:.4f})) plt.plot([0, 1], [0, 1], colornavy, lw2, linestyle--, labelRandom Guess) plt.xlim([0.0, 1.0]) plt.ylim([0.0, 1.05]) plt.xlabel(False Positive Rate (1 - Specificity)) plt.ylabel(True Positive Rate (Recall/Sensitivity)) plt.title(Receiver Operating Characteristic (ROC) Curve) plt.legend(loclower right) plt.grid(True) plt.show() # 寻找最佳阈值这里采用Youdens J统计量J TPR - FPR J tpr - fpr ix np.argmax(J) best_threshold thresholds[ix] print(f\n根据Youdens J统计量找到的最佳阈值为: {best_threshold:.4f}) print(f在该阈值下TPR{tpr[ix]:.4f}, FPR{fpr[ix]:.4f}) # 使用最佳阈值重新预测 y_test_pred_best, _ predict(X_test, w_trained, thresholdbest_threshold) print(f\n使用最佳阈值后的准确率: {accuracy_score(y_test, y_test_pred_best):.4f})5. 与Scikit-learn实现对比及进阶思考为了验证我们手动实现的正确性以及了解工业级库的便捷性我们用Scikit-learn的LogisticRegression在相同数据上跑一遍。5.1 Scikit-learn实现from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 注意sklearn的LogisticRegression默认会添加截距偏置项所以我们用未添加偏置的X_scaled X_train_sk, X_test_sk, y_train_sk, y_test_sk train_test_split(X_scaled, y, test_size0.2, random_state42) # 创建模型实例。注意参数solver指定优化算法C是正则化强度的倒数默认L2正则化 # 为了对比我们关闭正则化penaltynone但新版sklearn中需设置C为一个极大值如1e10并使用相似的优化器。 # ‘liblinear’或‘lbfgs’是常用求解器。 lr_sk LogisticRegression(penaltyl2, C1e10, solverlbfgs, max_iter5000) lr_sk.fit(X_train_sk, y_train_sk) # 查看sklearn得到的权重和偏置 print(Sklearn模型系数 (w1, w2, ...):, lr_sk.coef_) print(Sklearn模型截距 (偏置b):, lr_sk.intercept_) # 预测与评估 y_pred_sk lr_sk.predict(X_test_sk) y_prob_sk lr_sk.predict_proba(X_test_sk)[:, 1] # 取正类概率 print(f\nSklearn模型测试集准确率: {accuracy_score(y_test_sk, y_pred_sk):.4f}) print(fSklearn模型测试集AUC: {roc_auc_score(y_test_sk, y_prob_sk):.4f}) # 对比权重注意我们手动实现时第一个权重是偏置sklearn是分开的 w_manual w_trained.flatten() w_sk np.concatenate([lr_sk.intercept_, lr_sk.coef_.flatten()]) print(f\n手动实现权重: {w_manual}) print(fSklearn实现权重: {w_sk}) print(f权重差异的均方误差: {np.mean((w_manual - w_sk)**2):.10f})如果我们的手动实现正确两个模型的权重应该非常接近性能指标也相似。微小的差异可能源于优化算法sklearn的lbfgs是更高级的拟牛顿法、迭代停止条件或随机数种子。5.2 正则化防止过拟合的利器我们之前实现的是最基本的逻辑回归没有考虑正则化。正则化如L1或L2通过在损失函数中添加一个惩罚项与权重的绝对值或平方成正比来约束权重的大小防止模型过于复杂而拟合训练数据中的噪声过拟合。L2正则化岭回归的损失函数变为J(w) 原交叉熵损失 (λ/2m) * Σ w_j^2其中λ是正则化强度超参数sklearn中用C 1/λ。在我们的手动实现中加入L2正则化只需要修改梯度计算和损失计算def compute_cost_with_reg(w, X, y, lambda_): 计算带L2正则化的损失 m X.shape[0] y y.reshape(-1, 1) w w.reshape(-1, 1) z X.dot(w) y_hat sigmoid(z) epsilon 1e-15 y_hat np.clip(y_hat, epsilon, 1 - epsilon) # 交叉熵损失部分 cross_entropy_loss -np.mean(y * np.log(y_hat) (1 - y) * np.log(1 - y_hat)) # L2正则化项注意通常不惩罚偏置项w[0]这里为简化一起惩罚了 l2_penalty (lambda_ / (2 * m)) * np.sum(w[1:]**2) # 从w[1]开始不惩罚偏置 cost cross_entropy_loss l2_penalty return cost def compute_gradient_with_reg(w, X, y, lambda_): 计算带L2正则化的梯度 m X.shape[0] y y.reshape(-1, 1) w w.reshape(-1, 1) z X.dot(w) y_hat sigmoid(z) # 原始梯度 grad (1/m) * X.T.dot(y_hat - y) # 加上正则化项的梯度对w_j的导数是 (λ/m)*w_j同样不惩罚偏置 grad[1:] grad[1:] (lambda_ / m) * w[1:] return grad在实际应用中尤其是特征较多或数据量较少时开启正则化并调参C或lambda_是标准流程可以通过交叉验证来选择最佳的正则化强度。5.3 多分类扩展与项目总结逻辑回归本质上是二分类器但可以通过“一对多”One-vs-Rest, OvR或“一对一”One-vs-One, OvO策略扩展到多分类问题。Sklearn的LogisticRegression默认使用OvR。对于这个“水果分类”项目我们从数据预处理、原理推导、代码实现、训练优化到评估调参完整地走了一遍逻辑回归的建模流程。手动实现的意义在于透彻理解而Sklearn则提供了高效可靠的生产力工具。在实际工作中我们通常会使用成熟的库但对原理的深刻理解能帮助我们在模型出问题时快速定位是数据问题、特征问题还是算法参数问题并在需要定制化改造时游刃有余。最后可以尝试用这个手动实现的模型去预测几个新的水果数据记得进行同样的标准化处理看看它是否能正确区分苹果和橙子。这个过程就是机器学习从理论到实践最朴素的乐趣所在。
返回列表