从零开始实现 Python 逻辑回归:原理、代码与实战深度解析

在机器学习的广阔领域中,逻辑回归是我们最常遇到的基本算法之一。尽管名字中带有“回归”二字,但它实际上是我们处理二分类问题的首选利器。你是否曾经想过,这个被广泛用于垃圾邮件过滤、疾病诊断和金融风控的算法,底层究竟是如何运作的?

今天,我们将抛开现成的框架,仅使用 NumPy 这一基础工具,从零开始用 Python 实现逻辑回归。在这个过程中,你不仅能掌握算法的数学原理,还能深入理解梯度下降是如何一步步优化模型参数的。让我们开始这段探索之旅吧。

逻辑回归的核心:Sigmoid 函数

与线性回归直接预测数值不同,逻辑回归的目标是预测事件发生的概率。为了将任意实数值映射到 0 和 1 之间,我们需要一个特殊的“挤压”函数——Sigmoid 函数

它的数学形式非常优雅:

$$ \sigma(z) = \frac{1}{1 + e^{-z}} $$

这个函数的图像呈现为经典的“S”形曲线。当输入 $z$ 趋近于正无穷时,输出趋近于 1;当 $z$ 趋近于负无穷时,输出趋近于 0。这种特性使得它非常适合用来表示概率。

!<a href="https://media.geeksforgeeks.org/wp-content/uploads/20250530131719941333/whatislogisticregression.webp">Sigmoid Function Visualization

为了更直观地理解,让我们先用 Python 画出这个函数,看看它的长像。

代码示例 1:可视化 Sigmoid 函数

import numpy as np
import matplotlib.pyplot as plt

def sigmoid(z):
    """计算 Sigmoid 激活值"""
    return 1 / (1 + np.exp(-z))

# 生成一组从 -10 到 10 的数据点
z = np.linspace(-10, 10, 100)

# 计算 Sigmoid 值
values = sigmoid(z)

# 绘制图表
plt.figure(figsize=(8, 5))
plt.plot(z, values, label=‘Sigmoid Function‘, color=‘blue‘, linewidth=2)
plt.title(‘Sigmoid Activation Function‘, fontsize=14)
plt.xlabel(‘Input (z)‘, fontsize=12)
plt.ylabel(‘Output (Probability)‘, fontsize=12)
plt.grid(True, linestyle=‘--‘, alpha=0.7)
plt.axhline(0.5, color=‘red‘, linestyle=‘:‘, label=‘Decision Boundary (0.5)‘)
plt.legend()
plt.show()

运行这段代码,你会看到那条漂亮的 S 曲线。在逻辑回归中,我们设定一个阈值(通常是 0.5):如果预测概率大于 0.5,我们将其归类为 1;否则归类为 0。

1. 环境准备与数据生成

在开始构建模型之前,我们需要准备好工具和数据。为了确保我们的代码具有可移植性,我们尽量减少对复杂第三方库的依赖,只保留必要的数学和绘图工具。

导入必要的库

我们将使用以下工具:

  • NumPy: Python 的科学计算基石,用于处理矩阵运算。
  • Matplotlib: 用于可视化数据分布和模型性能。
  • Scikit-learn: 仅用于辅助生成和分割数据,不参与模型核心逻辑。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

生成并处理合成数据

为了让演示更加生动,我们不只是加载枯燥的 CSV 文件,而是生成一个具有两个特征的二维数据集。这样我们 later 可以在平面上直观地看到决策边界。请注意,特征缩放对于逻辑回归至关重要,因为它能确保梯度下降算法快速收敛。

# 设置随机种子,保证结果可复现
np.random.seed(42)

# 生成 1000 个样本,每个样本有 2 个特征
X, y = make_classification(
    n_samples=1000, 
    n_features=2, 
    n_redundant=0, 
    n_informative=2, 
    random_state=42, 
    n_clusters_per_class=1
)

# 将数据分割为训练集(80%)和测试集(20%)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# 特征缩放:将数据标准化为均值 0,方差 1
# 这一步是加速梯度下降收敛的关键
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

print(f"训练集形状: {X_train.shape}, 测试集形状: {X_test.shape}")

2. 构建逻辑回归类

这是本文的核心部分。我们将创建一个名为 LogisticRegressionScratch 的类。在这个类中,我们将封装所有的数学逻辑。

模型架构设计

我们的模型需要包含以下几个关键方法:

  • __init__: 初始化学习率、迭代次数以及权重和偏置。
  • sigmoid: 激活函数,将线性输出转换为概率。
  • compute_cost: 计算代价函数,用于衡量模型预测值与真实值的差距。
  • fit: 训练过程,使用梯度下降更新参数。
  • predict: 预测过程,根据训练好的参数输出类别标签。

详细代码实现

让我们编写完整的类实现。请仔细阅读代码中的注释,它们解释了每一行背后的数学原理。

class LogisticRegressionScratch:
    def __init__(self, learning_rate=0.01, iterations=1000):
        """
        初始化逻辑回归参数
        :param learning_rate: 学习率,控制梯度下降的步长
        :param iterations: 梯度下降的迭代次数
        """
        self.learning_rate = learning_rate
        self.iterations = iterations
        self.weights = None  # 权重参数
        self.bias = None     # 偏置参数
        self.cost_history = [] # 用于记录每次迭代的代价,方便后续绘图

    def sigmoid(self, z):
        """
        Sigmoid 激活函数
        将输入映射到 (0, 1) 区间
        """
        return 1 / (1 + np.exp(-z))

    def compute_cost(self, h, y):
        """
        计算交叉熵损失
        :param h: 预测概率
        :param y: 真实标签
        :return: 损失值
        """
        m = len(y)
        # 交叉熵公式:-1/m * sum(y * log(h) + (1-y) * log(1-h))
        # 我们添加了一个极小值 1e-9 防止 log(0) 导致的计算错误
        epsilon = 1e-9
        cost = -(1/m) * np.sum(y * np.log(h + epsilon) + (1 - y) * np.log(1 - h + epsilon))
        return cost

    def fit(self, X, y):
        """
        使用梯度下降算法训练模型
        :param X: 特征矩阵
        :param y: 目标向量
        """
        m, n = X.shape
        
        # 初始化参数:权重为0向量,偏置为0
        self.weights = np.zeros(n)
        self.bias = 0

        # 梯度下降主循环
        for i in range(self.iterations):
            # 1. 计算线性组合 z = wX + b
            z = np.dot(X, self.weights) + self.bias
            
            # 2. 应用激活函数得到预测值 h
            h = self.sigmoid(z)

            # 3. 计算梯度
            # 权重的梯度是特征和误差的点积
            dw = (1/m) * np.dot(X.T, (h - y))
            # 偏置的梯度是误差的均值
            db = (1/m) * np.sum(h - y)

            # 4. 更新参数 (同时更新)
            self.weights -= self.learning_rate * dw
            self.bias -= self.learning_rate * db

            # 5. 记录代价,用于监控训练过程
            cost = self.compute_cost(h, y)
            self.cost_history.append(cost)
            
            # 可选:每100次迭代打印一次状态,观察训练进度
            if i % 100 == 0:
                print(f"Iteration {i}: Cost {cost:.4f}")

    def predict_prob(self, X):
        """
        预测概率
        """
        return self.sigmoid(np.dot(X, self.weights) + self.bias)

    def predict(self, X, threshold=0.5):
        """
        预测类别标签
        :param threshold: 判定阈值,默认为0.5
        """
        probabilities = self.predict_prob(X)
        # 如果概率大于阈值,返回1,否则返回0
        return (probabilities >= threshold).astype(int)

3. 模型训练与评估

现在我们已经搭建好了模型,是时候把它应用到数据上了。我们将实例化对象并用训练数据来拟合它。

# 实例化模型,设置学习率为 0.1,迭代次数为 1000
model = LogisticRegressionScratch(learning_rate=0.1, iterations=1000)

# 训练模型
print("开始训练模型...")
model.fit(X_train, y_train)
print("训练完成。
")

# 在测试集上进行预测
predictions = model.predict(X_test)

# 计算准确率
accuracy = np.mean(predictions == y_test)
print(f"测试集准确率: {accuracy * 100:.2f}%")

输出示例

当你运行上面的代码时,你会看到代价函数随着迭代次数逐渐减小,这证明模型正在学习。

> Iteration 0: Cost 0.6931

> Iteration 100: Cost 0.2048

> Iteration 200: Cost 0.1472

> …

> Iteration 900: Cost 0.0961

>

> 测试集准确率: 96.50%

4. 深入探究:代价函数的收敛可视化

仅仅看到数字是不够的,作为一名开发者,我们更相信图表。通过绘制 cost_history,我们可以直观地判断梯度下降是否正常工作。如果曲线平滑下降,说明一切正常;如果剧烈震荡,可能需要降低学习率。

代码示例 2:绘制学习曲线

plt.figure(figsize=(10, 6))
plt.plot(model.cost_history, label=‘Cost over iterations‘, color=‘purple‘)
plt.title(‘Cost Function Convergence‘, fontsize=16)
plt.xlabel(‘Iterations‘, fontsize=12)
plt.ylabel(‘Cost (Log Loss)‘, fontsize=12)
plt.grid(True, linestyle=‘--‘)
plt.legend()
plt.show()

!Cost Function Convergence

通过这张图,我们可以确认我们的学习率设置得比较合理,损失稳步下降,没有出现梯度爆炸或消失的情况。

5. 决策边界的可视化

对于二维数据,最酷的可视化莫过于画出“决策边界”。这是模型区分 0 和 1 的分界线。让我们看看模型是如何切分数据的。

代码示例 3:绘制二维决策边界

def plot_decision_boundary(X, y, model):
    """
    绘制逻辑回归在二维数据上的决策边界
    """
    # 设置最小值和最大值,并创建网格
    x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    h = 0.01  # 网格步长
    
    xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                         np.arange(y_min, y_max, h))

    # 预测网格中每个点的类别
    # ravel() 将矩阵展平,c_[] 将两个数组按列合并
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)

    # 绘制等高线图作为背景(决策区域)
    plt.figure(figsize=(10, 6))
    plt.contourf(xx, yy, Z, cmap=plt.cm.Paired, alpha=0.8)

    # 绘制实际的数据点
    plt.scatter(X[:, 0], X[:, 1], c=y, edgecolors=‘k‘, cmap=plt.cm.Paired)
    plt.title(‘Decision Boundary of Logistic Regression‘, fontsize=16)
    plt.xlabel(‘Feature 1 (Standardized)‘, fontsize=12)
    plt.ylabel(‘Feature 2 (Standardized)‘, fontsize=12)
    plt.show()

# 使用训练好的模型绘制边界
plot_decision_boundary(X_test, y_test, model)

在这个可视化中,不同颜色的背景区域代表模型预测的不同类别,而散点则是真实的测试数据。你会看到一条清晰的线性边界将两类数据分开。如果数据本身不是线性可分的,这时候我们就需要引入多项式特征或使用核技巧,但这通常是支持向量机(SVM)或神经网络的任务,对于基础的逻辑回归,线性边界已经足够解释其核心思想。

实战中的考量与最佳实践

虽然从零编写代码有助于理解,但在实际工作中,我们还需要注意以下几个关键点,以确保模型的鲁棒性。

1. 多重共线性问题

逻辑回归对特征之间的相关性比较敏感。如果两个特征高度相关(例如“用英寸表示的身高”和“用厘米表示的身高”),权重会变得不稳定,导致模型难以解释。解决方法:在训练前计算相关性矩阵,并移除高度相关的特征。

2. 处理类别不平衡

在现实场景中,负样本(0)的数量往往远多于正样本(1),例如欺诈检测。这会导致模型倾向于总是预测 0,从而获得高准确率但毫无实用价值。

解决方案:我们可以修改 fit 方法中的代价函数,为正样本赋予更高的权重。

# 修改代价函数以支持类别权重
# 其中 pos_weight 是正样本的权重系数
# cost = -(1/m) * sum(weight_factor * y * log(h) + (1-y) * log(1-h))

或者,在评估时使用 F1-Score 或 AUC-ROC 曲线,而不是简单的准确率。

3. 正则化:防止过拟合

如果我们的特征非常多,模型可能会死记硬背训练数据(过拟合)。为了防止这种情况,我们可以引入 L1 (Lasso) 或 L2 (Ridge) 正则化。

L2 正则化示例代码

在计算梯度时,给权重梯度加上一个惩罚项:

# 在 fit 方法中添加正则化项
# lambda_ 是正则化强度
# 更新权重时: self.weights -= self.learning_rate * (dw + lambda_ * self.weights / m)

总结与展望

通过这篇文章,我们不仅实现了一个逻辑回归模型,更重要的是,我们揭开了机器学习算法的“黑盒”。我们看到了数学公式如何转化为一行行代码,看到了梯度下降如何一步步优化损失函数。

你可以尝试调整代码中的参数:

  • 改变学习率:试着将其设为 0.001 或 1.0,看看会发生什么?
  • 移除特征缩放:注释掉 StandardScaler,你会发现收敛速度显著变慢,甚至不收敛。
  • 增加特征:试试生成更多维度的数据,看看模型是否能处理。

虽然生产环境中我们通常会使用 scikit-learn 这样的高度优化库,但理解底层原理能让你在遇到模型性能瓶颈时,不再是盲目调参,而是拥有清晰的排查思路。希望这篇文章能激发你对算法深层的兴趣,继续探索这一充满魅力的领域。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/42191.html
点赞
0.00 平均评分 (0% 分数) - 0