在前面我们介绍了线性情况下的支持向量机,它是通过寻找一个线性的超平面来达到对数据进行分类的目的。只是,由于是线性方法,所以对非线性的数据就没有办法处理了。
比如图中的两类数据,分别分布为两个圆圈的形状,因为数据本身就是线性不可分的,所以不论是什么高级的分类器,仅仅要它是线性的,就没法处理,SVM 也不行。
我们可以看到该数据集就是用两个半径不同的圆圈加上了少量的噪音得到的。所以,一个理想的分界应该是一个“圆圈”而不是一条线(超平面)。假设用X1和X2来表示这个二维平面的两个坐标的话。我们知道一条二次曲线(圆圈是二次曲线的一种特殊情况)的方程能够写作这种形式:
注意上面的形式,假设我们构造另外一个五维的空间,当中五个坐标的值分别为
那么显然,上面的方程在新的坐标系下能够写作:
这个关于新的坐标Z1,Z2,Z3,Z4,Z5的方程,就是一个超平面方程,它的维度是5。也就是说,如果我们做一个映射 ϕ : 二维 → 五维,将 X1,X2按照上面的规则映射为 Z1,Z2,··· ,Z5,那么在新的空间中原来的数据将变成线性可分的,从而使用之前我们推导的线性分类算法就可以进行处理了。
再进一步描写叙述 Kernel 的细节之前,最好还是再来看看这个样例映射过后的直观样例。当然。我没有办法把 5 维空间画出来,只是因为我这里生成数据的时候就是用了特殊的情形,详细来说,我这里的超平面实际的方程是这个样子(圆心X2轴上的一个正圆):
因此我仅仅须要把它映射到这样一个三维空间中就可以,下图(这是一个 gif 动画)即是映射之后的结果。将坐标轴经过适当的旋转,就能够非常明显地看出,数据是能够通过一个平面来分开的:
上面的例子说明, 用线性分类方法求解非线性分类问题分为两步:首先使用一个变换将原空间的数据映射到新空间;然后在新空间里用线性分类学习方法从训练数据中学习分类模型。 核技巧就属于这样的方法。
下面给核函数一个正式定义,设为输入空间,
为特征空间,如果存在一个
到
的映射
,对所有的
,函数
满足
,则称
为输入空间到特征空间的映射函数,
为核函数。
核函数常用的技巧是不计算映射函数,因为特征空间
通常是高维的,甚至无穷维,所以
计算并不容易,而计算核函数
却相对简单。映射
取法多种多样,可以取不同的特征空间,即使在同一特征空间也可以取不同的映射。映射后的样本一般是线性可分带有异常值的,这时考虑 SVM 的优化目标:
由于在输入空间计算的是的内积,所以经过映射后分别为
与
,现在只需修改目标函数为
与
的内积即可,又由于
,所以不需要定义映射函数
,只需要定义核函数便可得到高维空间中内积的结果,而这便是 Kernel Trick。所以线性不可分的数据集的优化目标变为:
也就是说给定核函数,即可用求解线性 SVM 的方法来求解非线性问题,核技巧的好处在于不需要显式的定义特征空间与映射函数,只需要选择一个合适的核函数即可。综上核函数是用来免去显式计算高维变换的,直接用低维度的参数带入核函数来等价计算高维度的向量的内积。
什么样的函数可以作为一个有效核函数呢?答案是只要满足 Mercer 定理 即可,即如果函数
是
上的映射( 也就是两个n维向量映射到实数域 )。那么如果
是一个有效核函数(也称为Mercer核函数),那么当且仅当其训练样本
相应的核函数矩阵是对称半正定的,这里先解释一下正定矩阵:
首先来定义奇异矩阵,若 n 阶矩阵 A 为奇异阵,则其行列式为零,即。设 M 是 n 阶方阵,如果对任何非零向量 z ,都有
,其中
表示 z 的转置,就称 M 为正定矩阵。
正定矩阵性质如下:
1)正定矩阵一定是非奇异的。
2)正定矩阵的任一主子矩阵也是正定矩阵。
3)若 A 为 n 阶正定矩阵,则 A 为 n 阶可逆矩阵。
对于 N 个训练样本,每一个样本对应一个训练样例。那么,我们可以将任意两个
和
带入核函数中,计算
。这样可以把
表示为一个 m×m的 Gram 矩阵,只要 Gram 矩阵为对称半正定的,则 K(x,z) 即为一个有效的核函数,Gram 矩阵如下:
显然对于自己定义的核函数判定是否为正定核不太容易,所以在工业生产中一般使用一些常用的核函数,下面给出几个:
1)线性核:线性核其实就是不采用非线性分类器,认为样本是线性可分的;
2)多项式核:该核函数对应的是一个 p 次多项式的分类器,这时需要额外调节的参数为 c p ;
3)高斯核:或者叫做径向基核,该核函数甚至可以将特征空间映射为无穷维,这时需要额外调节的参数为,
如果选得很大的话,高次特征上的权重实际上衰减得非常快,所以实际上(数值上近似一下)相当于一个低维的子空间;反过来,如果
选得很小,则可以将任意的数据映射为线性可分,当然,这并不一定是好事,因为随之而来的可能是非常严重的过拟合问题。总的来说,通过调控参数
,高斯核实际上具有相当高的灵活性,也是使用最广泛的核函数之一。
综上,给出非线性可分支持向量机的学习算法:
接下来的这部分我觉得是最难理解的?而且计算也是最难得,就是SMO算法。
SMO算法就是帮助我们求解:
虽然这个优化问题只剩下了α这一个变量,但是别忘了α是一个向量,有m个αi等着我们去优化,所以还是很麻烦,所以大神提出了SMO算法来解决这个优化问题。
整个SMO算法包括两部分,求解两个变量的二次规划问题和选择这两个变量的启发式方法。上面那个悬而未决的优化问题无非就是要找到一组最优的α,SMO算法则是把对整个α的优化转化为对每一对αi的优化,如果我们把其他α先固定,仅仅优化某一对α,那么我们可以通过解析式(即通过确定的公式来计算)来优化α。而且此时KKT条件很重要,之前说过最优解是一定会满足KKT条件的,所以如果我们优化α到所有的α都满足了KKT条件,那么这样最优解就会找到。我觉得这就是SMO算法的基本思想吧。
前面求出了关于α的约束条件:
其中这个条件很关键。下面为了叙述方便,统一选择α1和α2要更新的那两个参数,其他参数先固定。如果我确定了α2的新值,那么根据
,因为其他m-2个参数都固定,所以
是个固定值,所以我可以根据这个计算出α1,所以每一次我选择两个变量进行更新。
因此现在的优化目标可以写为:
其中c是与α1和α2无关的部分,在本次优化中当做常数项处理,也为一个常数。
到这里就可以发现,现在的优化目标其实就是一个关于α1和α2的二次函数。二次函数我们就很熟悉了,求极值的话求导看定义域就好。而且根据α1和α2的约束关系,我们可以把α1消去,然后对α2求导,导数为0的点就是我们要找的极值点。
将优化目标中所有的α1都替换为用α2表示的形式,得到如下式子:
此时,优化目标中仅含有α2一个待优化变量了,我们现在将待优化函数对α2求偏导得到如下结果:
已知:
将以上三个条件带入偏导式子中,得到如下结果:
化简后:
记:
若n<=0则退出本次优化,若n>0则可得到α2的更新公式:
此时,我们已经得到了α2的更新公式。不过我们此时还需要考虑α2的取值范围问题。因为α2的取值范围应该是在0到C之间,但是在这里并不能简单地把取值范围限定在0至C之间,因为α2的取值不仅仅与其本身的范围有关,也与α1,y1和y2有关。设α1*y1+α2*y2=k,画出其约束,在这里要分两种情况,即y1是否等于y2。我们在这里先来考虑y1!=y2的情况:在这种情况下α1-α2=k:
可以看出此时α2的取值范围为:
当y1=y2时,α1+α2=k:
可以看出此时α2的取值范围为:
以上,可以总结出α2的取值上下界的规律:
故可得到α2的取值范围:
可得α1的更新公式:
接下来,需要确定常数b的更新公式,在这里首先需要根据“软间隔”下SVM优化目标函数的KKT条件推导出新的KKT条件,得到结果如下:
由于现在alpha的取值范围已经限定在0至C之间,也就是上面KKT条件的第三种情况。接下来我们将第三种KKT条件推广到任意核函数的情境下:
由此我们可以得到常数b的更新公式:
其中Ei是SVM的预测误差,计算式为:
以上就是SMO算法的大部分细节。接下来,我们可以根据这些推导对SMO算法进行实现,并且用我们的算法训练一个SVM分类器。
现在我们已经梳理完了SMO算法实现步骤,接下来按照这个思路编写代码,进行实战练习。
我们先使用简单的数据集进行测试。通过代码观察数据集的表现形式:
# -*- coding: utf-8 -*-
"""
Created on Sat Feb 2 14:12:12 2019
@author: jacksong1996
"""
import numpy as np
import matplotlib.pyplot as plt
"""
函数说明:加载数据
Parameters:filename-数据集文件名
Returns:
dataSet-训练集数据列表
labelMat-标签列表
Modify:2019/2/2
"""
def loadDataSet(filename):
dataSet = [];labelMat = []
fr = open(filename)
for line in fr.readlines():
lineArr = line.strip().split('\t')
dataSet.append([float(lineArr[0]),float(lineArr[1])])
labelMat.append(float(lineArr[2]))
return dataSet,labelMat
"""
函数说明:加载数据
Parameters:
dataSet-训练集数据列表
labelMat-标签列表
Returns:
无
Modify:2019/2/2
"""
def showDataSet(dataSet,labelMat):
data_plus = []
data_minus = []
for i in range(len(dataSet)):
if(labelMat[i]>0):
data_plus.append(dataSet[i])
else:
data_minus.append(dataSet[i])
data_plus_arr = np.array(data_plus)
data_minus_arr = np.array(data_minus)
plt.scatter(np.transpose(data_plus_arr)[0],np.transpose(data_plus_arr)[1])
plt.scatter(np.transpose(data_minus_arr)[0],np.transpose(data_minus_arr)[1])
plt.show()
if __name__=='__main__':
dataSet,labelMat = loadDataSet('testSetRBF.txt')
showDataSet(dataSet,labelMat)
运行结果:
可见,数据明显是线性不可分的。下面我们根据公式,编写核函数,并增加初始化参数kTup用于存储核函数有关的信息,同时我们只要将之前的内积运算变成核函数的运算即可。最后编写testRbf()函数,用于测试。创建svmMLiA.py文件,编写代码如下:
# -*- coding: utf-8 -*-
import matplotlib.pyplot as plt
import numpy as np
import random
class optStruct:
"""
数据结构,维护所有需要操作的值
Parameters:
dataMatIn - 数据矩阵
classLabels - 数据标签
C - 松弛变量
toler - 容错率
kTup - 包含核函数信息的元组,第一个参数存放核函数类别,第二个参数存放必要的核函数需要用到的参数
"""
def __init__(self, dataMatIn, classLabels, C, toler, kTup):
self.X = dataMatIn #数据矩阵
self.labelMat = classLabels #数据标签
self.C = C #松弛变量
self.tol = toler #容错率
self.m = np.shape(dataMatIn)[0] #数据矩阵行数
self.alphas = np.mat(np.zeros((self.m,1))) #根据矩阵行数初始化alpha参数为0
self.b = 0 #初始化b参数为0
self.eCache = np.mat(np.zeros((self.m,2))) #根据矩阵行数初始化虎误差缓存,第一列为是否有效的标志位,第二列为实际的误差E的值。
self.K = np.mat(np.zeros((self.m,self.m))) #初始化核K
for i in range(self.m): #计算所有数据的核K
self.K[:,i] = kernelTrans(self.X, self.X[i,:], kTup)
def kernelTrans(X, A, kTup):
"""
通过核函数将数据转换更高维的空间
Parameters:
X - 数据矩阵
A - 单个数据的向量
kTup - 包含核函数信息的元组
Returns:
K - 计算的核K
"""
m,n = np.shape(X)
K = np.mat(np.zeros((m,1)))
if kTup[0] == 'lin': K = X * A.T #线性核函数,只进行内积。
elif kTup[0] == 'rbf': #高斯核函数,根据高斯核函数公式进行计算
for j in range(m):
deltaRow = X[j,:] - A
K[j] = deltaRow*deltaRow.T
K = np.exp(K/(-1*kTup[1]**2)) #计算高斯核K
else: raise NameError('核函数无法识别')
return K #返回计算的核K
def loadDataSet(fileName):
"""
读取数据
Parameters:
fileName - 文件名
Returns:
dataMat - 数据矩阵
labelMat - 数据标签
"""
dataMat = []; labelMat = []
fr = open(fileName)
for line in fr.readlines(): #逐行读取,滤除空格等
lineArr = line.strip().split('\t')
dataMat.append([float(lineArr[0]), float(lineArr[1])]) #添加数据
labelMat.append(float(lineArr[2])) #添加标签
return dataMat,labelMat
def calcEk(oS, k):
"""
计算误差
Parameters:
oS - 数据结构
k - 标号为k的数据
Returns:
Ek - 标号为k的数据误差
"""
fXk = float(np.multiply(oS.alphas,oS.labelMat).T*oS.K[:,k] + oS.b)
Ek = fXk - float(oS.labelMat[k])
return Ek
def selectJrand(i, m):
"""
函数说明:随机选择alpha_j的索引值
Parameters:
i - alpha_i的索引值
m - alpha参数个数
Returns:
j - alpha_j的索引值
"""
j = i #选择一个不等于i的j
while (j == i):
j = int(random.uniform(0, m))
return j
def selectJ(i, oS, Ei):
"""
内循环启发方式2
Parameters:
i - 标号为i的数据的索引值
oS - 数据结构
Ei - 标号为i的数据误差
Returns:
j, maxK - 标号为j或maxK的数据的索引值
Ej - 标号为j的数据误差
"""
maxK = -1; maxDeltaE = 0; Ej = 0 #初始化
oS.eCache[i] = [1,Ei] #根据Ei更新误差缓存
validEcacheList = np.nonzero(oS.eCache[:,0].A)[0] #返回误差不为0的数据的索引值
if (len(validEcacheList)) > 1: #有不为0的误差
for k in validEcacheList: #遍历,找到最大的Ek
if k == i: continue #不计算i,浪费时间
Ek = calcEk(oS, k) #计算Ek
deltaE = abs(Ei - Ek) #计算|Ei-Ek|
if (deltaE > maxDeltaE): #找到maxDeltaE
maxK = k; maxDeltaE = deltaE; Ej = Ek
return maxK, Ej #返回maxK,Ej
else: #没有不为0的误差
j = selectJrand(i, oS.m) #随机选择alpha_j的索引值
Ej = calcEk(oS, j) #计算Ej
return j, Ej #j,Ej
def updateEk(oS, k):
"""
计算Ek,并更新误差缓存
Parameters:
oS - 数据结构
k - 标号为k的数据的索引值
Returns:
无
"""
Ek = calcEk(oS, k) #计算Ek
oS.eCache[k] = [1,Ek] #更新误差缓存
def clipAlpha(aj,H,L):
"""
修剪alpha_j
Parameters:
aj - alpha_j的值
H - alpha上限
L - alpha下限
Returns:
aj - 修剪后的alpah_j的值
"""
if aj > H:
aj = H
if L > aj:
aj = L
return aj
def innerL(i, oS):
"""
优化的SMO算法
Parameters:
i - 标号为i的数据的索引值
oS - 数据结构
Returns:
1 - 有任意一对alpha值发生变化
0 - 没有任意一对alpha值发生变化或变化太小
"""
#步骤1:计算误差Ei
Ei = calcEk(oS, i)
#优化alpha,设定一定的容错率。
if ((oS.labelMat[i] * Ei < -oS.tol) and (oS.alphas[i] < oS.C)) or ((oS.labelMat[i] * Ei > oS.tol) and (oS.alphas[i] > 0)):
#使用内循环启发方式2选择alpha_j,并计算Ej
j,Ej = selectJ(i, oS, Ei)
#保存更新前的aplpha值,使用深拷贝
alphaIold = oS.alphas[i].copy(); alphaJold = oS.alphas[j].copy();
#步骤2:计算上下界L和H
if (oS.labelMat[i] != oS.labelMat[j]):
L = max(0, oS.alphas[j] - oS.alphas[i])
H = min(oS.C, oS.C + oS.alphas[j] - oS.alphas[i])
else:
L = max(0, oS.alphas[j] + oS.alphas[i] - oS.C)
H = min(oS.C, oS.alphas[j] + oS.alphas[i])
if L == H:
print("L==H")
return 0
#步骤3:计算eta
eta = 2.0 * oS.K[i,j] - oS.K[i,i] - oS.K[j,j]
if eta >= 0:
print("eta>=0")
return 0
#步骤4:更新alpha_j
oS.alphas[j] -= oS.labelMat[j] * (Ei - Ej)/eta
#步骤5:修剪alpha_j
oS.alphas[j] = clipAlpha(oS.alphas[j],H,L)
#更新Ej至误差缓存
updateEk(oS, j)
if (abs(oS.alphas[j] - alphaJold) < 0.00001):
print("alpha_j变化太小")
return 0
#步骤6:更新alpha_i
oS.alphas[i] += oS.labelMat[j]*oS.labelMat[i]*(alphaJold - oS.alphas[j])
#更新Ei至误差缓存
updateEk(oS, i)
#步骤7:更新b_1和b_2
b1 = oS.b - Ei- oS.labelMat[i]*(oS.alphas[i]-alphaIold)*oS.K[i,i] - oS.labelMat[j]*(oS.alphas[j]-alphaJold)*oS.K[i,j]
b2 = oS.b - Ej- oS.labelMat[i]*(oS.alphas[i]-alphaIold)*oS.K[i,j]- oS.labelMat[j]*(oS.alphas[j]-alphaJold)*oS.K[j,j]
#步骤8:根据b_1和b_2更新b
if (0 < oS.alphas[i]) and (oS.C > oS.alphas[i]): oS.b = b1
elif (0 < oS.alphas[j]) and (oS.C > oS.alphas[j]): oS.b = b2
else: oS.b = (b1 + b2)/2.0
return 1
else:
return 0
def smoP(dataMatIn, classLabels, C, toler, maxIter, kTup = ('lin',0)):
"""
完整的线性SMO算法
Parameters:
dataMatIn - 数据矩阵
classLabels - 数据标签
C - 松弛变量
toler - 容错率
maxIter - 最大迭代次数
kTup - 包含核函数信息的元组
Returns:
oS.b - SMO算法计算的b
oS.alphas - SMO算法计算的alphas
"""
oS = optStruct(np.mat(dataMatIn), np.mat(classLabels).transpose(), C, toler, kTup) #初始化数据结构
iter = 0 #初始化当前迭代次数
entireSet = True; alphaPairsChanged = 0
while (iter < maxIter) and ((alphaPairsChanged > 0) or (entireSet)): #遍历整个数据集都alpha也没有更新或者超过最大迭代次数,则退出循环
alphaPairsChanged = 0
if entireSet: #遍历整个数据集
for i in range(oS.m):
alphaPairsChanged += innerL(i,oS) #使用优化的SMO算法
print("全样本遍历:第%d次迭代 样本:%d, alpha优化次数:%d" % (iter,i,alphaPairsChanged))
iter += 1
else: #遍历非边界值
nonBoundIs = np.nonzero((oS.alphas.A > 0) * (oS.alphas.A < C))[0] #遍历不在边界0和C的alpha
for i in nonBoundIs:
alphaPairsChanged += innerL(i,oS)
print("非边界遍历:第%d次迭代 样本:%d, alpha优化次数:%d" % (iter,i,alphaPairsChanged))
iter += 1
if entireSet: #遍历一次后改为非边界遍历
entireSet = False
elif (alphaPairsChanged == 0): #如果alpha没有更新,计算全样本遍历
entireSet = True
print("迭代次数: %d" % iter)
return oS.b,oS.alphas #返回SMO算法计算的b和alphas
def testRbf(k1 = 1.3):
"""
测试函数
Parameters:
k1 - 使用高斯核函数的时候表示到达率
Returns:
无
"""
dataArr,labelArr = loadDataSet('testSetRBF.txt') #加载训练集
b,alphas = smoP(dataArr, labelArr, 200, 0.0001, 100, ('rbf', k1)) #根据训练集计算b和alphas
datMat = np.mat(dataArr); labelMat = np.mat(labelArr).transpose()
svInd = np.nonzero(alphas.A > 0)[0] #获得支持向量
sVs = datMat[svInd]
labelSV = labelMat[svInd];
print("支持向量个数:%d" % np.shape(sVs)[0])
m,n = np.shape(datMat)
errorCount = 0
for i in range(m):
kernelEval = kernelTrans(sVs,datMat[i,:],('rbf', k1)) #计算各个点的核
predict = kernelEval.T * np.multiply(labelSV,alphas[svInd]) + b #根据支持向量的点,计算超平面,返回预测结果
if np.sign(predict) != np.sign(labelArr[i]): errorCount += 1 #返回数组中各元素的正负符号,用1和-1表示,并统计错误个数
print("训练集错误率: %.2f%%" % ((float(errorCount)/m)*100)) #打印错误率
dataArr,labelArr = loadDataSet('testSetRBF2.txt') #加载测试集
errorCount = 0
datMat = np.mat(dataArr); labelMat = np.mat(labelArr).transpose()
m,n = np.shape(datMat)
for i in range(m):
kernelEval = kernelTrans(sVs,datMat[i,:],('rbf', k1)) #计算各个点的核
predict=kernelEval.T * np.multiply(labelSV,alphas[svInd]) + b #根据支持向量的点,计算超平面,返回预测结果
if np.sign(predict) != np.sign(labelArr[i]): errorCount += 1 #返回数组中各元素的正负符号,用1和-1表示,并统计错误个数
print("测试集错误率: %.2f%%" % ((float(errorCount)/m)*100)) #打印错误率
def showDataSet(dataMat, labelMat):
"""
数据可视化
Parameters:
dataMat - 数据矩阵
labelMat - 数据标签
Returns:
无
"""
data_plus = [] #正样本
data_minus = [] #负样本
for i in range(len(dataMat)):
if labelMat[i] > 0:
data_plus.append(dataMat[i])
else:
data_minus.append(dataMat[i])
data_plus_np = np.array(data_plus) #转换为numpy矩阵
data_minus_np = np.array(data_minus) #转换为numpy矩阵
plt.scatter(np.transpose(data_plus_np)[0], np.transpose(data_plus_np)[1]) #正样本散点图
plt.scatter(np.transpose(data_minus_np)[0], np.transpose(data_minus_np)[1]) #负样本散点图
plt.show()
if __name__ == '__main__':
testRbf()
运行结果如下图所示:
可以看到,训练集错误率为4%,测试集错误率也是4%, 。可以尝试更换不同的K1参数以观察测试错误率、训练错误率、支持向量个数随k1的变化情况。你会发现K1过大,会出现过拟合的情况,即训练集错误率低,但是测试集错误率高。