前言

这篇将会是令人激动的一部分,因为我们将首次接触到最优化算法。在我们生活中也会遇到很多最优化问题,例如如何在最短时间内从 A 点到达 B 点?如何投入最少的工作获取最大化的收益等等。

如果你并不理解什么是回归,这并不重要,后面的文章将会详细说明回归。现在假设有一些数据点,我们用一条直线对这些点进行拟合(该线称为最佳拟合直线),这个拟合的过程就称作回归。

利用 Logistic 回归进行分类的主要思想是:根据现有数据对分类边界线建立回归公式,以此进行分类

前排提醒:本实例使用 Python 版本 3.X 理论上都可以运行,我本人使用的是 3.10 版本

基于 Logistic 回归和 Sigmoid 函数的分类

Logistic 回归

  • 优点:计算代价不高,易于理解和实现

  • 缺点:容易欠拟合,分类精度不高

  • 适用数据类型:数值型和标称型数据

我们希望的函数应该是:能接受所有的输入然后预测输出类别。例如,在二分类情况下,希望函数输出 0 或 1 。或许你之前接触过这种性质的函数,该函数称为**单位阶跃函数,又称为赫维赛德阶跃函数(Heaviside step function)**。然而,单位阶跃函数的问题在于:该函数在跳跃点上从 0 瞬间跳跃到 1,这个瞬间跳跃的过程有时很难处理。幸好,另一个函数也有类似的性质,且数学上更容易处理,这就是 Sigmoid 函数。Sigmoid 函数具体的计算公式:$\huge \sigma(z) = \frac{1}{1+e^{-z}}$ 。

关于单位阶跃函数,可以参考电路21:什么是单位就阶跃函数?什么是电路的单位阶跃响应?

如下图给出了 Sigmoid 函数在不同坐标尺度下的曲线图。当 $x = 0$ 的时候,Sigmoid 函数值为 0.5,随着 $x$ 的增大,对应的 Sigmoid 值将会逼近 1;同理,随着 $x$ 的值减少,其值也会逼近于 0。如果横坐标刻度够大,Sigmoid 函数看起来就很像是一个阶跃函数。

image-20230720192315491

图片由 R 语言生成,如果你感兴趣的话,相关代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
library(ggplot2)
library(cowplot)

z1 <- seq(-5, 5, by = 0.1)
z2 <- seq(-20, 20, by = 0.1)
output1 <- 1 / (1 + exp(-z1))
output2 <- 1 / (1 + exp(-z2))

data1 <- data.frame(z = z1, output = output1, func = "Function 1")
data2 <- data.frame(z = z2, output = output2, func = "Function 2")
data <- rbind(data1, data2)

plot1 <- ggplot(data1) +
geom_line(aes(x = z, y = output), color = "blue") +
xlab("z") +
ylab("Output") +
ggtitle("局部") +
theme_minimal()

plot2 <- ggplot(data2) +
geom_line(aes(x = z, y = output), color = "red") +
xlab("z") +
ylab("Output") +
ggtitle("整体") +
theme_minimal()

# 设置两个图的不同横坐标范围
plot1 <- plot1 + coord_cartesian(xlim = c(-5, 5))
plot2 <- plot2 + coord_cartesian(xlim = c(-20, 20))

# 绘制两个图的布局
plot_grid(plot1, plot2, nrow = 2)

因此,为了实现 Logistic 回归分类器,我们可以在每个特征上都乘以一个回归系数(weight),然后把所有的结果值都相加,将这个总和带入 Sigmoid 函数中,进而得到一个范围在 $0 \sim 1$ 的值。任何大于 0.5 的值被分为类 1,小于 0.5 的分为类 2。因此,Logistic回归可以看作是一种概率估计的方法。

确定了分类器的函数形式之后,现在的问题变成了:最佳回归系数(weight)是多少?如何确定它的大小?

基于最优化方法的最佳回归系数确定

Sigmoid 函数的输入记为 $z$,公式得出:$\Large z = w_0x_0 + w_1x_1 + w_2x_2 + … +w_nx_n$ 。

如果采用向量的写法1,可以写成:$\Large z=w^Tx$ 。其中向量 $x$ 是分类器的输入数据,向量 $w$ 就是我们要找的最佳系数,从而使分类器尽可能的精确。

下面介绍梯度上升这一最优化方法。

梯度上升法

我们介绍的第一个最优化算法叫梯度上升法。梯度上升法基于的思想是:要找到某函数的最大值,最好的方法是沿着该函数的梯度方向探寻。如果梯度记为 $\triangledown$ ,则函数 $f(x,y)$ 的梯度由下面的公式表示:

$\huge \triangledown f(x,y) = \begin{pmatrix} \frac{\partial f(x,y)}{\partial x} \ \frac{\partial f(x,y)}{\partial y} \end{pmatrix}$

这是机器学习中最容易混淆的地方,但在数学上并不难,需要做的是牢记这些符号的意义。这个梯度意味着要沿着 x 的方向移动 $\Large \frac{\partial f(x,y)}{\partial x}$ ,沿 y 的方向移动 $\Large \frac{\partial f(x,y)}{\partial y}$ 。其中,函数 $f(x,y)$ 必须要在待计算的点上有定义并且可微。

这里提到的梯度上升,及其公式,如果你足够熟悉的话,会发现它是高等数学中的偏导,另外如果你感兴趣可以查看在《智能之门》一书中开篇有提到人工智能算法的基础,梯度下降,反向传播,损失函数,三者联合可以多次迭代求解最大值/最小值,当然有些笼统,因为可能会出现局部最大值或者局部最小值的问题。

image-20230721005339070

如上图,梯度上升算法沿着梯度方向移动过程(迭代过程)。可以看到梯度增长的方向总是指向函数值增长最快的方向。这里所说的是移动方向,而没提到移动量的大小。该量值称为步长,记作 $\alpha$ 。用向量来表示的话,梯度上升算法的迭代公式:$\huge w= w + \alpha \triangledown_w f(w)$ 。

梯度下降算法公式:$\huge w= w - \alpha \triangledown_w f(w)$

该公式将会一直被迭代进行,直到到达某个停止条件为止,比如迭代次数达到某个指定值或者算法达到某个可以允许的误差范围。

在了解了上面的内容后,现在来看看我们接下来要使用 Logistic 回归分类器的例子,数据可视化如下图所示,具体数据 点我下载。其中下图的数据为 testSet.txt 文件中。

image-20230721011706980

训练算法:使用梯度上升找到最佳参数

上图中共有 100 个样本点,每个点包含两个数值型特征:$X_1$ 和 $X_2$ 。在此数据集上,我们将通过使用梯度上升法找到最佳回归系数,也就是拟合出 Logistic 回归模型的最佳参数。梯度上升法的伪代码如下:

1
2
3
4
5
每个回归系数初始化为1
重复 R 次
计算整个数据集的梯度
使用 alpha × 梯度 更新回归系数的向量
返回回归系数

现在我们使用 PyCharm 创建名称为 logRegres.py 的文件,输入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from numpy import *

# 获取数据
def loadDataSet():
dataMat = []; labelMat = []
# 获取文件指针
fr = open('testSet.txt')
# 读取文件
for line in fr.readlines():
# 将数据按行读取拆分
lineArr = line.strip().split()
# 初始化数据列表,最佳系数=1,特征1,特征2
dataMat.append([1.0, float(lineArr[0]), float(lineArr[1])])
# 初始化目标值列表
labelMat.append(int(lineArr[2]))
# 返回处理好的数据和目标值列表
return dataMat,labelMat

# Sigmoid函数
def sigmoid(inX):
return 1.0/(1+exp(-inX))

# 计算最佳回归系数,传入基本数据和目标值列表
def gradAscent(dataMatIn, classLabels):
# 将输入数据转换为矩阵
dataMatrix = mat(dataMatIn)
# 将目标值矩阵转置为了后面的矩阵乘法
labelMat = mat(classLabels).transpose()
# 获取输入数据的维度
m,n = shape(dataMatrix)
# 步长
alpha = 0.001
# 循环次数/迭代次数
maxCycles = 500
# 回归系数初始化
weights = ones((n,1))
# 重复 maxCycles 次
for k in range(maxCycles):
# 执行 Sigmoid 函数
h = sigmoid(dataMatrix*weights)
# 损失函数
error = (labelMat - h)
# ⭐计算新的回归系数
weights = weights + alpha * dataMatrix.transpose()* error
# 返回迭代完成的“最佳回归系数”
return weights

其中,上述注释中 ⭐ 的地方运用的公式:$\large 新回归系数=旧回归系数+学习率×输入数据转置矩阵×误差$

这个公式你也许会一头雾水,你不是一个人,它是根据前面的数学公式进行相关运算推理出来的公式,此处暂时省略推导过程,因为我目前还没有精力去学习并写一个推导的过程。

现在我们来使用如下代码,来测试一下上述代码的实际效果:

1
2
3
4
5
6
7
dataArr,labelMat = loadDataSet()
print(gradAscent(dataArr,labelMat))

# 输出
[[ 4.12414349]
[ 0.48007329]
[-0.6168482 ]]

分析数据:画出决策边界

上面的代码解出了最佳回归系数,它确定了不同类别数据之间的分割线。现在我们来将它可视化表达出来,在我们的 logRegres.py 文件中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 绘制决策边界
def plotBestFit(weights):
# 导入绘图包
import matplotlib.pyplot as plt
# 读取数据
dataMat,labelMat=loadDataSet()
dataArr = array(dataMat)
n = shape(dataArr)[0]
# 类别1的 x,y
xcord1 = []; ycord1 = []
# 类别2的x,y
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')
# 生成一个范围从 -3到 3 的步长为 0.1 的列表
x = 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()

对于上式中的 ⭐ 的地方,我知道你也一定好奇它是怎能来的,决策边界(拟合曲线)是如下的形式:$\Large z=w_0+w_1 \times x_1+ w_2 \times x_2$ ,其中,$w_0$,$w_1$,$w_2$ 是回归系数,$z$ 是模型的预测结果。

为了绘制决策边界,我们希望找到在模型中使得 $z=0$ 的点。当 $z=0$ 时,表示这个点处于决策边界上。

为了找到 $z=0$ 的点,我们将上面的方程改写为:$\Large 0=w_0+w_1 \times x_1+w_2 \times x_2$ 。接着,我们将 $x_2$ 表达式解出:$\Large x_2 = \frac {-(wo+w_ {1}\times x_ {1})}{w_ {2}} $ 。这就是公式 $\Large y = \frac {-(wo+w_ {1}\times x_ {1})}{w_ {2}} $。

现在我们运行如下代码,来绘制图形:

1
2
dataArr,labelMat = loadDataSet()
plotBestFit(stocGradAscent0(dataArr,labelMat).getA())
image-20230722233954799

可以看到,分类结果只分错了 2-4 个点。尽管例子直观数据相对很小,但是却要进行大量的计算,接下来,我们将会对这个算法进行改进,从而使得它可以用在真实的数据集上。

训练算法:随机梯度上升

梯度上升算法在每次更新回归系数时都需要遍历整个数据集,该方法在处理 100 个左右的数据集的时候尚可,但是如果存在数十亿的样本或者千万种特征,那么这种方法的计算复杂度太高了。一种改进方法是一次仅用一个样本来更新回归系数,该方法称为 随机梯度上升算法(Stochastic Gradient Ascent)。由于可以在新样本到来时对分类器进行增量式更新,因而随机梯度上升算法是一个在线学习算法。与“在线学习”相对应,一次处理所有数据被称作是“批处理”。代码示例:

1
2
3
4
5
6
7
8
9
10
11
# 随机梯度上升算法
def stocGradAscent0(dataMatrix, classLabels):
m,n = shape(dataMatrix)
alpha = 0.01
weights = ones(n)
# 每次选取一个样本
for i in range(m):
h = sigmoid(sum(dataMatrix[i]*weights))
error = classLabels[i] - h
weights = weights + alpha * error * dataMatrix[i]
return weights

可以看到随机梯度上升算法与梯度上升算法在代码上很相似,但是也有一些区别。现在使用如下代码验证我们的算法:

1
2
dataArr,labelMat = loadDataSet()
plotBestFit(stocGradAscent0(array(dataArr),labelMat))
image-20230723000906845

可以看到,拟合出来的直线效果还不错,但是并不像之前迭代的那样完美,这里的分类器分错了三分之一的样本。

现在我们来改进随机梯度上升算法来更加拟合前面 500 次迭代的效果,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 改进随机梯度上升算法
def stocGradAscent1(dataMatrix, classLabels, numIter=150):
m,n = shape(dataMatrix)
weights = ones(n)
# numIter表示迭代次数
for j in range(numIter):
dataIndex = list(range(m))
for i in range(m):
# 在每次迭代中,引入动态学习率 alpha 的概念,它随着迭代次数 j 和样本索引 i 的变化而动态调整
# alpha 的值随着迭代次数增加而逐渐减小,使得前期的更新步长较大,后期的更新步长较小,有助于算法更好地收敛到最优解
alpha = 4/(1.0+j+i)+0.0001
# 在每次迭代时,随机选择一个样本来更新回归系数,依次使用打乱后的样本
randIndex = dataIndex[i]
h = sigmoid(sum(dataMatrix[randIndex]*weights))
error = classLabels[randIndex] - h
weights = weights + alpha * error * dataMatrix[randIndex]
return weights

现在我们使用如下代码测试分类效果:

1
2
dataArr,labelMat = loadDataSet()
plotBestFit(stocGradAscent1(array(dataArr),labelMat))
image-20230723002129914

默认迭代次数是 150 次,可以看到拟合直线已经和之前 500 次迭代的效果差不多了,但是计算量更少。

示例:从疝气病症预测病马的死亡率

示例所用的数据集点我下载;密码:fgi0

疝气病是描述马肠胃痛的术语,然而这种病不一定源于马的肠胃问题,其他问题也可能引起马的疝气病。该数据集中包含了医院检测疝气病的一些指标。

另外,需要说明的是,该数据存在一个问题,数据集中有 30% 的值是缺失的。

准备数据:处理数据中的缺失值

数据中的缺失值是个非常棘手的问题,有很多文献都致力于解决这个问题。假设有 100 个样本和 20 个特征,这些数据都是机器收集回来的,若机器上的某个传感器损坏导致一个特征无效时怎么办?是否要扔掉整个数据?另外 19 个特征怎么办?它们是否还可用?答案是肯定的。因为有时候数据很珍贵,扔掉和重新获取都是不可取的,所以必须要采用一些方法来解决这个问题。

下面给出了一些可选的做法:

  • 使用可用特征的均值来填补缺失值
  • 使用特殊值来填补缺失值,例如 $-1$
  • 忽略有缺失值的样本
  • 使用显示样本的均值来填补缺失值
  • 使用另外的机器学习算法预测缺失值

现在我们要对我们的数据集做一些预处理,我们需要用一个实数值来替换缺失值,因为我们使用的 NumPy 数据类型不允许包含缺失值,这里选择实数 0 来替换所有缺失值。

另外,如果在测试数据集的时候发现了一条数据的类别标签已经丢失,那么最简单的做法就是将该数据丢弃。类别标签不同于特征标签,它很难用某个合适的值替换。

现在我们有了一个“干净”的数据集和一个不错的算法,接下来训练一个分类器来解决病马的生死问题。

测试算法:用 Logistic 回归进行分类

使用数据集的时候,请确保其文件在 Python 脚本同目录下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def classifyVector(inX, weights):
prob = sigmoid(sum(inX*weights))
if prob > 0.5: return 1.0
else: return 0.0

def colicTest():
frTrain = open('horseColicTraining.txt'); frTest = open('horseColicTest.txt')
trainingSet = []; trainingLabels = []
for line in frTrain.readlines():
currLine = line.strip().split('\t')
lineArr =[]
for i in range(21):
lineArr.append(float(currLine[i]))
trainingSet.append(lineArr)
trainingLabels.append(float(currLine[21]))
trainWeights = stocGradAscent1(array(trainingSet), trainingLabels, 1000)
errorCount = 0; numTestVec = 0.0
for line in frTest.readlines():
numTestVec += 1.0
currLine = line.strip().split('\t')
lineArr =[]
for i in range(21):
lineArr.append(float(currLine[i]))
if int(classifyVector(array(lineArr), trainWeights))!= int(currLine[21]):
errorCount += 1
errorRate = (float(errorCount)/numTestVec)
print("该测试的错误率为: %f" % errorRate)
return errorRate

def multiTest():
numTests = 10; errorSum=0.0
for k in range(numTests):
errorSum += colicTest()
print("经过 %d 次数的迭代后的平均错误率为: %f" % (numTests, errorSum/float(numTests)))

我们使用如下代码进行测试分类器效果:

1
multiTest()

运行后可能会报错 RuntimeWarning: overflow encountered in exp 。这说明在计算 Sigmoid 函数的时候遇到了数值溢出的问题。现在我们对 Sigmoid 函数做小小修改,如下所示:

1
2
3
4
5
6
7
# Sigmoid函数
def sigmoid(inX):
# 使用数值稳定性技巧,避免指数溢出
if inX >= 0:
return 1.0 / (1 + exp(-inX))
else:
return exp(inX) / (1 + exp(inX))

现在再次测试,其输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
# 输出
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
该测试的错误率为: 0.328358
经过 10 次数的迭代后的平均错误率为: 0.328358

可以看到 10 次迭代后的平均错误率在 35 左右,事实上在 30% 的缺失数据情况下这个训练结果并不差。当然,如果我们调整迭代次数和步长相关参数,平均错误率可以降到 20% 左右。

相关函数

strip()

strip() 是一个字符串方法,它通常用于移除字符串两端的空白字符(例如空格、制表符、换行符等)。代码示例:

1
2
3
4
5
text = "   Hello, World!   "
stripped_text = text.strip()

print(stripped_text)
# 输出: "Hello, World!"

mat()

mat() 不是内置函数,它是 NumPy 库中的函数,numpy.mat() 函数用于将输入数据转换为矩阵对象。它接受列表或字符串的输入,并返回一个NumPy矩阵。这个函数在处理线性代数和矩阵运算时特别有用。代码示例:

1
2
3
4
5
6
7
8
9
10
import numpy as np

# 创建一个矩阵
data = [[1, 2], [3, 4]]
matrix = np.mat(data)

print(matrix)
# 输出:
# [[1 2]
# [3 4]]

transpose()

在NumPy库中,可以使用 numpy.transpose() 函数来进行矩阵的转置操作transpose() 函数将矩阵的行和列进行交换,返回转置后的新矩阵。

不了解什么是转置的请查看线性代数,矩阵的转置

End

学不动,真的学不动,我感觉要先把《智能之门》中的反向传播,梯度下降,损失函数先写出来。

image-20230723005339614

  1. 1.线性代数的乘法运算,不了解的可以查看 线性代数:矩阵运算之乘法?