逻辑回归LR(Logistic Regression)原理以及代码实践
简介
逻辑回归应该算得上是机器学习领域必须掌握的经典算法之一,并且由于其简单有效、可并行化、可解释性强等优点,至今仍然也是分类问题中最基础和最受欢迎的算法之一。尽管它的名字里面有"回归"两字,但是实际上它是用来做分类的,"逻辑"两字是其英文名字"Logistic"的音译,实际上是来自于该方法中使用的Logit函数。逻辑回归可以用于一些常见的分类问题,比如垃圾邮件过滤、网上虚假交易识别、肿瘤恶性或者良性的判断等。逻辑回归处理的问题可以简单地看成判断是"0"还是"1",其输出是一个介于0~1之间的值,表明预测结果属于0或者1的概率。
LR的类型
LR主要可以被分成以下3种:
二值逻辑回归
预测的目标只有2种可能的输出,比如肿瘤的恶性和良性、是否是垃圾邮件等。
多项式逻辑回归
预测的目标类别有三种及其以上,并且不考虑顺序。比如,预测那种食物更受欢迎(苹果,香蕉,橘子等)。
有序逻辑回归
预测的目标类别有三种及其以上,但是顺序很重要。比如,预测用户对电影的评分,从1到5,分值越大说明越受用户喜欢。
决策边界
LR主要是用来处理分类问题,其输出是一个介于0~1之间的概率值,为了预测一个数据究竟属于哪个类别,我们可以设置一个阈值。基于这个阈值,我们就可以来对数据进行分类。下面是逻辑回归过程中决策平面的变化过程:
决策边界
决策平面有助于将模型输出的预测概率分为正负两类,比如下面这个例子:
其中黑色直线的方程为x_1+x_2=3,我们此时就可以设置阈值为3。对于红色叉形的数据,其标签为1,将其带入方程有x_1+x_2 \geqslant 3;对于蓝色圆圈的数据,带入方程有x_1+x_2 \leqslant 3。因此我们就可以说黑色的直线是我们找到的一个合理的决策边界。
线性回归和逻辑回归的区别
线性回归的输出是输入的加权和。逻辑回归并没有直接输出输入的加权和,而是通过了一个激活函数将加权和映射到0~1之间。通过下面的图例来展示他们之间的区别:
线性回归和逻辑回归的区别
可以看到,LR就是在线性回归的基础上增加了一个激活函数。这个激活函数是Sigmoid函数,其表达式为:
Sigmoid表达式
图像如下:
Sigmoid函数
可以看到Sigmoid函数可以把任意的x值压缩到y \in [0,1]之间,而概率值本来就是处于[0,1]之间的,因此把LR的输出看成预测目标属于"0"或者"1"的概率值也是说得通的。
逻辑回归的数学定义
根据上节的内容,我们知道逻辑回归就是在线性回归模型的基础上增加了激活函数,因此我们先来看下线性回归模型的定义:
它其实就是对输入向量x的各个特征赋予相应的权重,来表示各个特征的重要程度,然后再进行加权求和,得到最后的输出。它的输出是一个数值y,因此它是用来解决回归问题的模型。为了表达和计算方便,我们也可以消掉后面的常数项b,可以给输入向量x前面添加一个常数项1,同时把b添加到权重矩阵w中去,即令x' = [1, x]^T, w' = [b, w]^T,那么线性回归的表达式就可以转化为:
为了表达方便,我们还是统一使用f(x) = w^Tx这个表达式,但是读者要清楚,完整的线性回归表达式中还有常数项b的存在。
上节已经介绍了Sigmoid激活函数,LR其实就是将线性回归的表达式再放进Sigmoid激活函数中,仅此而已。于是我们就可以得到LR的完整数学表达式:
逻辑回归表达式
对于标准的逻辑回归模型而言,要确定的参数就是特征向量对应的权重向量w。一旦我们得到了权重向量w,我们只需要把输入特征向量x带入方程,得到一个处于[0,1]之间的输出概率值,然后根据提前设置好的阈值来决定输入数据的类别。比如概率值大于0.5,我们认为预测目标类别为1,反之为0。
逻辑回归的损失函数
损失函数是用来衡量模型输出与真实输出之间的差异,它可以指导我们朝着将差异最小化的方向去优化我们的模型。逻辑回归处理的问题的标签就只有"0"和"1"两种,故我们可以令预测结果为正样本(类别为1)的概率为p,预测结果为负样本(类别为0)的概率为1-p,可有如下表达式:
其中p是通过上一节介绍的LR的表达式计算出来的:
我们将预测为"1"和"0"的概率综合起来,可以写成如下形式:
其实这个转变不涉及到什么数学公式,只是为了便于计算和表示简洁而已。它表达的意思跟上面式子是一样的。当y_i=1的时候,结果依旧为p;当y_i=0时,结果为1-p。
论数学的简洁性。😜
上述表达式可以表示单个样本x_i的标签为y_i的概率。假设我们的训练数据包含N个已经标记好的样本,设为x = \left{\left(\boldsymbol{x}{1}, y {1}\right),\left(\boldsymbol{x}{2}, y {2}\right),\left(\boldsymbol{x}{3}, y {3}\right) \ldots\left(\boldsymbol{x}{N}, y{N}\right)\right},这些都是已经发生过的事实,我们需要根据这些样本数据来估计出逻辑回归模型的参数。根据极大似然估计的原理,我们可以写出似然函数:
似然函数
这里的P_总代表的是这些事件同时发生的概率,我们的目的是让这个概率最大(因为这些事件都是已经发生的事实)。
似然函数连乘的形式不便于求导,我们对上式两侧取对数将其变成累加的形式,可以得到:
根据极大似然估计原理,我们需要F(w)越大越好,但是直觉上,这跟损失函数的定义有点违背,因为我们通常希望损失越小越好。处理方法也很简单,我们可以在前面乘以一个-1,这就将求最大值问题转换成了求最小值问题,我们再乘以1/N来计算所有样本数据的平均损失,故最终的损失函数形式为:
怎么来理解这个损失函数呢?下面给出一张图来说明:
当实际标签为1但是模型预测为0的时候,损失函数应该剧烈惩罚这种情况,反之亦然。正如从上图中看到的那样,先看蓝色曲线-log(h(x)),当h(x)接近1的时候,即模型预测为1的时候,损失接近于0;当h(x)接近0的时候,损失接近无穷大。同理,对于绿色曲线-log(1-h(x)),当实际标签为0,模型预测也为0的时候,损失为0;当模型预测为1的时候,损失趋于无穷大。总得来说,就是当模型预测值和实际值一样的时候,损失很小。否则,损失趋于无穷大。这样就可以在模型预测错误的时候,损失也很大,在误差反向传播的时候,计算出来的梯度也很大,可以使模型朝着损失减小的方向快速收敛。
注意,上图中的h(x)对应我们公式中的p,即模型的预测值。
逻辑回归的训练
得到了逻辑回归的目标函数之后,接下来我们需要求出F(w)的梯度,以便后续使用SGD等算法进行优化。在对F(w)求导之前,我们先做一些准备工作,即先对p求导,回想一下概率p的计算公式如下:
p是关于参数w的函数,通过链式求导法则,展开得:
知道了p'之后,也很容易得到(1-p)' = -p(1-p)x。
下面正式对F(w)求导,求导过程如下:
我们将上式中的p展开,则得到了最终的梯度表达式如下:
在得到了梯度之后,我们就可以使用SGD来对模型的参数进行更新。核心思想是先随机初始化一个w_0,然后给定一个步长\eta,通过不断地修改参数w,从而使得损失函数的值不断降低,直到达到指定的迭代次数,或者梯度等于0为止。参数更新公式如下:
关于梯度下降算法可以参考 深入浅出--梯度下降法及其实现- 简书。
两个问题
- 为什么不用线性回归来做分类?
假设我们有一个关于肿瘤的数据集,它包含一个特征,即肿瘤大小,标签是肿瘤是否是恶性,我们可以将其画出来,如下:
注意看,所有的数据要么是0要么是1。如果我们使用线性回归的话,我们需要找到一条直线来拟合这些数据,假如图中的蓝色直线就是我们使用线性回归得到的直线。我们可以设定一个阈值为0.5,这样就线性回归同样也可以用来做分类,如下图所示:
我们在y=0.5的位置画一条线,与线性回归拟合的直线相交,再做与x轴的垂线,交点为S,如上图中黄色所示。令所有所有处于S点左侧的点为负样本,令右侧的为正样本,看起来好像也可以对样本点进行很好的分类。但是考虑训练数据中可能会有一些异常值,这些值可能会影响到最终的预测结果。还是设置阈值为0.5,假如现在多加了一个异常样本点进去训练,那么情况可能会变成下图这样:
由于多了一个异常点数据,上图中的蓝色直线是重新进行线性回归之后拟合出来的直线。绿色的点线是我们设置的决策边界。可以看到此时如果还是设置阈值为0.5的话,会将一些恶性肿瘤分成良性肿瘤,真实的决策边界应该在黄色直线所处的位置。所以,如果使用线性回归来做分类问题,一个小小的异常值就会干扰整个线性回归预测结果。
- 为什么逻辑回归不用MSE作为损失函数?
我们知道在线性回归中,是使用的MSE(最小均方误差)来作为损失函数的。而在逻辑回归中却变成了对数损失函数,这是为什么呢?我们先写出它们各自的表达式:
对数损失函数
L(y - \hat y) = -\sum_{i=1}^ny_i log(\hat y_i)
MSE损失函数
L(y - \hat y) = -\sum_{i=1}^n (y_i - \hat y_i)^2
在上述式子中,y代表真实的标签,\hat y代表模型预测的标签,n代表标签的数量,我们假设n=2,即标签要么是"0",要么是"1"。
我们来计算一下,当真实的标签与模型预测的标签不一致时,这两种损失函数的损失值分别是多少,以及对数损失究竟比MSE好在哪里。
例子
假如我们有一个样本数据,其真实标签为"1",模型预测的标签为"0"。
使用MSE损失函数计算出来的损失值为:
(1-0)^2=1
使用对数损失函数计算出来的损失值为:
-(1log(0) + 0log(1)) = tends \ to \ infinity因为对数函数的曲线如下:
f ( x ) = l o g ( x ) f(x) = log(x) f(x)=log(x)
当x趋于0时,log(0)趋于负无穷。
综上所述,可以看到,MSE损失函数的值与对数损失函数值相比,不值一提。因此,当真实值与模型输出值不一致的时候,对数损失函数对逻辑回归模型预测错误的惩罚力度是非常大的。
当然,在模型预测值与真实值一致的情况下,这两个损失函数计算出来的损失值都是一样的,都是0。我们可以看出,MSE对于二分类问题并不是一个很好的选择,是因为在模型分错的情况下,损失很小,惩罚力度不够。同样,在多分类情况下,即标签是通过one-hot进行编码时,MSE仍然不是一个好的选择。
在分类场景下,我们经常使用基于梯度的方法(比如拟牛顿法,梯度下降等)来最小化损失函数,从而找到参数的最优解。然后,如果损失函数是非凸的,这类方法不能保证我们找到全局最优解,相反很可能陷入局部最小值。凸函数和非凸函数如下:
凸函数和非凸函数
上图中蓝色的点便是函数的极小值点,对于右边的非凸函数,可以找到多个极小值点,这并不是我们希望的。
注意,这里的凸函数和我们直观理解上的意义相反。
我们先来了解一下什么是凸函数。先给出维基百科上关于凸函数的定义:
凸函数是具有如下特性的一个定义在某个向量空间的凸子集C(区间)上的实值函数f:对其定义域C上的任意两点x_1,x_2,总有f({\frac {x_{1}+x_{2}}{2}})\leq {\frac {f(x_{1})+f(x_{2})}{2}}
如下图所示:
可以看到函数f介于[x_1,x_2]之间的函数值均处于红色直线的下方。如果f是二阶可微的,我们可有以下结论:
如果对于所有的x,均有f''(x) \ge 0,那么f就是凸函数。因此,如果我们能够证明我们的损失函数的二阶导数始终大于等于0,那么我们就可以证明它是一个凸函数,那就意味着一定有一个全局最小值。接下来,我们从数学的角度来证明一下,MSE损失函数是非凸函数的,而对数损失函数是凸函数。为了简化计算,我们假设样本数据只有一个特征x和一个二值标签。
MSE损失函数
由上述前提可得,MSE损失函数可写成:
令g(x)为f(x)的一阶导数,计算可得:
继续计算f(x)的二阶导数,如下:
其中x^2 \hat y(1-\hat y)始终大于等于0,故为了简化,先将它们去掉,令简化后的二阶导数为H(\hat y),如下:
我们知道y只有"0"和"1"两个取值,我们在这两种情况下检查一下MSE损失函数的二阶导数的正负情况。
当y=0时,
当\hat y\in [0, 2/3]的时候,二阶导数H(\hat y) \geqslant 0;当\hat \in [2/3,1]时,二阶导数H(\hat y) \leqslant 0,故此时MSE损失函数非凸。
当y=1时,
当\hat y\in [0, 1/3]的时候,二阶导数H(\hat y) \leqslant 0;当\hat \in [1/3,1]时,二阶导数H(\hat y) \geqslant 0,故此时MSE损失函数非凸。
综上所述,如果逻辑回归使用MSE损失函数的话,损失函数是非凸的,模型训练的时候可能陷入局部最优解,因此是不推荐的。
对数损失函数
根据上述前提,先写成其损失函数:
整理得:
对f(x)求一阶导数得:
继续求二阶导得:
image.png
由上式可知,对于任意的x,e^{-\theta x}总是大于0,故f''(x)均大于等于0,故对数损失函数是一个凸函数,一定存在全局最优解。故使用拟牛顿法或者SGD等方法,一定可以找到一个参数w,使得损失函数取全局最小值。
代码实践
以下代码是《机器学习实战》中的一个例子,使用逻辑回归来预测患有疝病的马的存活率问题。代码如下:
import numpy as np
import matplotlib.pyplot as plt
class LogisticRegression():
def init (self, filename, alpha=0.001, MaxCycle=500):
self.filename = filename
self.maxCycles = MaxCycle
self.alpha = alpha #learning rate
def load_data_set(self):
dataMat = []; labelMat = []
fr = open(self.filename, 'r')
for line in fr.readlines():
lineArr = line.strip().split()
dataMat.append([1.0, float(lineArr[0]), float(lineArr[1])])
labelMat.append(int(lineArr[2]))
return dataMat, labelMat
def sigmoid(self, inx):
return 1.0/(1+np.exp(-inx))
def gradientAscent(self, dataMatIn, classLabels):
dataMatrix = np.mat(dataMatIn)
labelMat = np.mat(classLabels).transpose()
m,n = np.shape(dataMatrix)
weights = np.ones((n, 1))
for k in range(self.maxCycles):
h = self.sigmoid(dataMatrix*weights)
error = labelMat - h #equals to Yi-w^TXi
#updata weights
weights = weights + self.alpha*dataMatrix.transpose()*error
return weights
def stoc_grad_ascent0(self, data_mat, class_labels):
"""
随机梯度上升,只使用一个样本点来更新回归系数
:param data_mat: 输入数据的数据特征(除去最后一列),ndarray
:param class_labels: 输入数据的类别标签(最后一列数据)
:return: 得到的最佳回归系数
:随机梯度上升的效果不怎么好,大概错分了三分之一的样本,但是迭代的次数与m一样,即100次,而梯度下降发迭代了500次,并且没有矩阵
:转置的运算,减少了计算量
"""
m, n = np.shape(data_mat)
alpha = 0.01
weights = np.ones(n)
for i in range(m):
# sum(data_mat[i]*weights)为了求 f(x)的值, f(x)=a1*x1+b2*x2+..+nn*xn,
# 此处求出的 h 是一个具体的数值,而不是一个矩阵
h = self.sigmoid(sum(data_mat[i] * weights))
error = class_labels[i] - h
# 还是和上面一样,这个先去看推导,再写程序
weights = weights + alpha * error * data_mat[i]
return weights
def stoc_grad_ascent1(self, data_mat, class_labels, num_iter=150):
"""
改进版的随机梯度上升,使用随机的一个样本来更新回归系数, 为了解决随机梯度上升算法中,由于数据集并非线性可分的问题,
在每次迭代的时候都会导致系数的剧烈变化的问题,我们期望算法能够避免来回波动,从而收敛到某个值,通过改变alpha学习率因子
:param data_mat: 输入数据的数据特征(除去最后一列),ndarray
:param class_labels: 输入数据的类别标签(最后一列数据
:param num_iter: 迭代次数
:return: 得到的最佳回归系数
"""
m, n = np.shape(data_mat)
weights = np.ones(n)
for j in range(num_iter):#默认迭代次数为150
# 这里必须要用list,不然后面的del没法使用
data_index = list(range(m))
for i in range(m):
# i和j的不断增大,导致alpha的值不断减少,但是不为0
alpha = 4 / (1.0 + j + i) + 0.01
# 随机产生一个 0~len()之间的一个值
# random.uniform(x, y) 方法将随机生成下一个实数,它在[x,y]范围内,x是这个范围内的最小值,y是这个范围内的最大值。
rand_index = int(np.random.uniform(0, len(data_index)))
h = self.sigmoid(np.sum(data_mat[data_index[rand_index]] * weights))
error = class_labels[data_index[rand_index]] - h
weights = weights + alpha * error * data_mat[data_index[rand_index]]
del (data_index[rand_index]) #每次随机选一个值,然后使用完之后删掉
return weights
def plot_best_fit(self, weights):
dataMat, labelMat = self.load_data_set()
dataArr = np.array(dataMat)
n = np.shape(dataArr)[0]
xcord1 = []; ycord1 = []
xcord2 = []; ycord2 = []
for i in range(n):
if int(labelMat[i]) == 1:
xcord1.append(dataArr[i,1])
ycord1.append(dataArr[i,2])
else:
xcord2.append(dataArr[i, 1])
ycord2.append(dataArr[i, 2])
fig = plt.figure()
ax = fig.add_subplot(111)
ax.scatter(xcord1, ycord1, s=30, c='red', marker='s')
ax.scatter(xcord2, ycord2, s=30, c='green')
x = np.arange(-3.0, 3.0, 0.1)
y = (-weights[0] - weights[1]*x)/weights[2]
ax.plot(x, y)
plt.xlabel('X1'); plt.ylabel('X2')
plt.show()
def colic_test(self):
"""
打开测试集和训练集,并对数据进行格式化处理,其实最主要的的部分,比如缺失值的补充(真的需要学会的),人家已经做了
:return:
"""
f_train = open('./5.Logistic/HorseColicTraining.txt', 'r')
f_test = open('./5.Logistic/HorseColicTest.txt', 'r')
training_set = []
training_labels = []
# 解析训练数据集中的数据特征和Labels
# trainingSet 中存储训练数据集的特征,trainingLabels 存储训练数据集的样本对应的分类标签
for line in f_train.readlines():
curr_line = line.strip().split('\t')
if len(curr_line) == 1:
continue # 这里如果就一个空的元素,则跳过本次循环
line_arr = [float(curr_line[i]) for i in range(21)] #每一行21个数据
training_set.append(line_arr)
training_labels.append(float(curr_line[21]))
# 使用 改进后的 随机梯度下降算法 求得在此数据集上的最佳回归系数 trainWeights
train_weights = self.stoc_grad_ascent1(np.array(training_set), training_labels, 500)
error_count = 0
num_test_vec = 0.0
# 读取 测试数据集 进行测试,计算分类错误的样本条数和最终的错误率
for line in f_test.readlines():
num_test_vec += 1
curr_line = line.strip().split('\t')
if len(curr_line) == 1:
continue # 这里如果就一个空的元素,则跳过本次循环
line_arr = [float(curr_line[i]) for i in range(21)]
if int(self.classify_vector(np.array(line_arr), train_weights)) != int(curr_line[21]):
error_count += 1
error_rate = error_count / num_test_vec
print('the error rate is {}'.format(error_rate))
return error_rate
def multi_test(self):
"""
调用 colicTest() 10次并求结果的平均值
:return: nothing
"""
num_tests = 10
error_sum = 0
for k in range(num_tests):
error_sum += self.colic_test()
print('after {} iteration the average error rate is {}'.format(num_tests, error_sum / num_tests))
# do the classification work
def classify_vector(self, inx, weights):
prob = self.sigmoid(np.sum(inx * weights))
if prob > 0.5:
return 1.0
return 0.0
def test(self, method=1):
data_arr, class_labels = self.load_data_set()
if method == 1:
# 注意,这里的grad_ascent返回的是一个 matrix, 所以要使用getA方法变成ndarray类型
weights = self.gradientAscent(data_arr, class_labels).getA()
elif method == 2:
weights = self.stoc_grad_ascent0(np.array(data_arr), class_labels)
else:
weights = self.stoc_grad_ascent1(np.array(data_arr), class_labels)
self.plot_best_fit(weights)
if name == 'main ':
lr = LogisticRegression('./5.Logistic/TestSet.txt');
lr.test(3)
lr.multi_test()
以下是程序拟合出来的决策平面:
这个是使用逻辑回归进行10次存活率预测,计算每次预测的错误率:
关于代码使用的疝病数据集,可以从这里下载。https://github.com/apachecn/AiLearning/blob/master/docs/ml/5.md
上述代码是手动实现了包括梯度下降等训练方法,实际上Sklearn库为我们高度封装了这些基础算法。下面以莺尾花分类例子来展示如何在Sklearn中使用逻辑回归模型。
首先导入必要的包
导入必要的几个包
import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
sklearn.datasets中的鸢尾花数据集一共包含4个特征变量,1个类别变量。共有150个样本,这里存储了其萼片和花瓣的长宽,共4个属性,鸢尾植物分三类,种类分别为山鸢尾、杂色鸢尾、维吉尼亚鸢尾。
莺尾花数据集信息
加载数据集并且进行划分:
载入数据集,Y的值有0,1,2三种情况,每种特征50个样本
iris = load_iris()
X = iris.data[:, :2] #获取花卉两列数据集
Y = iris.target
划分训练集和测试集
x_train, x_test, y_train, y_test = train_test_split(X,Y, test_size = 0.3, random_state = 0)
构造逻辑回归模型实例,并且进行训练:
#逻辑回归模型,C=1e5表示目标函数。
lr = LogisticRegression(C=1e5)
lr = lr.fit(X,Y)
对模型进行评估:
print("Logistic Regression模型训练集的准确率:%.3f" %lr.score(x_train, y_train))
print("Logistic Regression模型测试集的准确率:%.3f" %lr.score(x_test, y_test))
输出:
在测试集上进行测试:
from sklearn import metrics
y_hat = lr.predict(x_test)
accuracy = metrics.accuracy_score(y_test, y_hat) #错误率,也就是np.average(y_test==y_pred)
print("Logistic Regression模型正确率:%.3f" %accuracy)
结果:
对数据进行可视化:
Plot the decision boundary. For that, we will assign a color to each
point in the mesh [x_min, x_max]x[y_min, y_max].
x1_min, x1_max = X[:, 0].min() - .5, X[:, 0].max() + .5 # 第0列的范围
x2_min, x2_max = X[:, 1].min() - .5, X[:, 1].max() + .5 # 第1列的范围
h = .02
x1, x2 = np.meshgrid(np.arange(x1_min, x1_max, h), np.arange(x2_min, x2_max, h)) # 生成网格采样点
grid_test = np.stack((x1.flat, x2.flat), axis=1) # 测试点
grid_hat = lr.predict(grid_test) # 预测分类值
grid_hat = lr.predict(np.c_[x1.ravel(), x2.ravel()])
grid_hat = grid_hat.reshape(x1.shape)
plt.figure(1, figsize=(6, 5))
预测值的显示, 输出为三个颜色区块,分布表示分类的三类区域
plt.pcolormesh(x1, x2, grid_hat,cmap=plt.cm.Paired)
plt.scatter(X[:, 0], X[:, 1], c=Y,edgecolors='k', cmap=plt.cm.Paired)
plt.scatter(X[:50, 0], X[:50, 1], marker = '*', edgecolors='red', label='setosa')
plt.scatter(X[50:100, 0], X[50:100, 1], marker = '+', edgecolors='k', label='versicolor')
plt.scatter(X[100:150, 0], X[100:150, 1], marker = 'o', edgecolors='k', label='virginica')
plt.xlabel('Sepal length')
plt.ylabel('Sepal width')
plt.legend(loc = 2)
plt.xlim(x1.min(), x1.max())
plt.ylim(x2.min(), x2.max())
plt.title("Logistic Regression classification result", fontsize = 15)
plt.xticks(())
plt.yticks(())
plt.grid()
plt.show()
结果:
参考