在机器学习的广阔领域中,逻辑回归是我们最常遇到的基本算法之一。尽管名字中带有“回归”二字,但它实际上是我们处理二分类问题的首选利器。你是否曾经想过,这个被广泛用于垃圾邮件过滤、疾病诊断和金融风控的算法,底层究竟是如何运作的?
今天,我们将抛开现成的框架,仅使用 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()
通过这张图,我们可以确认我们的学习率设置得比较合理,损失稳步下降,没有出现梯度爆炸或消失的情况。
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 这样的高度优化库,但理解底层原理能让你在遇到模型性能瓶颈时,不再是盲目调参,而是拥有清晰的排查思路。希望这篇文章能激发你对算法深层的兴趣,继续探索这一充满魅力的领域。