機器學習實戰——6.支持向量機

目錄

6.1 基於最大間隔分隔數據

6.2 尋找最大間隔

6.2.1 分類器求解的優化問題 

6.2.2 SVM應用的一般框架

6.3 SMO高效優化算法

6.3.1 Platt的SMO算法

6.3.2  應用簡化版SMO算法處理小規模數據集

6.4利用完整Platt SMO算法加速優化

6.5 在複雜數據上應用核函數

6.5.1 利用核函數將數據映射到高維空間

6.5.2 徑向基核函數

6.5.3 在測試中使用核函數

本章小結


實現svm有一個很好用的工具包:libsvm,下載地址:https://www.csie.ntu.edu.tw/~cjlin/libsvm/index.html

6.1 基於最大間隔分隔數據

優點:泛化錯誤率低,計算開銷不大,結果易解釋。

缺點:對參數調節和核函數的選擇敏感,原始分類器不加修改僅適用於處理二類問題。

適用數據類型:數值型和標稱型數據。

 如上圖所示:當兩組數據分隔的足夠開,很容易就可以在圖上畫出一條直線將兩組數據點分開時,這組數據被稱爲線性可分數據,將數據集分隔開來的直線稱爲分隔超平面。數據點都在二維平面上時,分隔超平面就只是一條直線;數據集是三維時,用來分隔數據的就是一個平面;更高維的就叫超平面,也就是分類的決策邊界。分佈在超平面一側的所有數據都屬於某個類別,而分佈在另一側的所有數據則屬於另一個類別。

我們希望採用這種方式來構建分類器,即如果數據點離決策邊界越遠,那麼其最後的預測結果也就越可信。考慮上圖B、C、D中的三條直線,它們都能將數據分隔開,但是其中哪一條最好呢?我們採用的是找到離分隔超平面最近的點,確保它們離分隔面的距離儘可能遠。這裏點到分隔面的距離被稱爲間隔。我們希望間隔儘可能大,這是因爲如果我們犯錯或者在有限數據上訓練分類器的話,我們希望分類器儘可能健壯。

支持向量就是離分隔超平面最近的那些點,接下來要試着最大化支持向量到分隔面的距離,需要找到此問題的優化求解方法。

6.2 尋找最大間隔

如何求解數據集的最佳分隔直線?先來看下圖,分隔超平面的形式可以寫成\omega ^{T}x+b。要計算點A到分隔超平面的距離,就必須給出點到分隔面的法線或垂線的長度,該值爲\left | \omega ^{A+b} \right |/\left \| \omega \right \|。這裏的常數b類似於Logistic迴歸中的截距\omega _{0}。這裏的向量w和常數b一起描述了所給數據的分隔線或超平面。接下來討論分類器。

6.2.1 分類器求解的優化問題 

輸入數據給分類器會輸出一個類別標籤,這相當於一個類似於Sigmoid的函數在作用。下面將使用海維賽德階躍函數(即單位階躍)的函數對\omega ^{T}x+b作用得到f(\omega ^{T}x+b),其中當u<0時,f(u)輸出-1,反之則輸出+1.這和Logistic迴歸有所不同,那裏的類別標籤是0或1。

這裏的類別標籤爲什麼採用-1和+1,而不是0和1呢?這是由於-1和+1僅僅相差一個符號,方便數學上的處理。我們可以通過一個統一公式來表示間隔或者數據點到分隔超平面的距離,同時不必擔心數據到底是屬於-1還是+1類。

當計算數據點到分隔面的距離並確定分隔面的放置位置時,間隔通過label*(\omega ^{T}x+b)來計算,這時就能體現出-1和+1類的好處了。如果數據點處於正方向(即+1類)並且離分隔超平面很遠的位置時,\omega ^{T}x+b會是一個很大的正數,同時label*(\omega ^{T}x+b)也會是一個很大的正數。而如果數據點處於負方向(-1類)並且離分隔超平面很遠的位置時,此時由於類別標籤爲-1,則label*(\omega ^{T}x+b)仍然是一個很大的正數。

現在的目標就是找出分類器定義中的w和b。爲此,我們必須找到具有最小間隔的數據點,而這些數據點也就是前面提到的支持向量。一旦找到具有最小間隔的數據點,我們就需要對該間隔最大化。這就可以寫作:

argmax\left \{ min\left ( label(w^{T}+b))\frac{1}{\left \| w \right \|} \right ) \right \}

直接求解上述問題相當困難,所以我們將它轉換成爲另一種更容易求解的形式。首先考察一下上式中大括號內的部分。由於對乘積進行優化是一件很討厭的事情,因此我們要做的是固定其中一個因子而最大化其它因子。如果令所有支持向量的label*(\omega ^{T}x+b)都爲1,那麼就可以通過求\left \| w \right \|^{-1}的最大值來得到最終解。但是並非所有數據點的label*(\omega ^{T}x+b)都等於1,只有那些離分隔超平面最近的點得到的值採爲1.而離超平面越遠的數據點,其label*(\omega ^{T}x+b)的值也就越大。

在上述優化問題中,給定了一些約束條件然後求最優值,因此該問題是一個帶約束條件的優化問題。這裏的約束條件就是label*(\omega ^{T}x+b)\geqslant 1。對於這類優化問題,有一個非常著名的求解方法,即拉格朗日乘子法。通過引入拉格朗日乘子,我們就可以基於約束條件來表述原來的問題。由於這裏的約束條件都是基於數據點的,因此我們就可以將超平面寫成數據點的形式,於是優化目標函數最後可以寫成:

max[\sum_{i=1}^{m}\alpha -\frac{1}{2}\sum_{i,j=1}^{m}label^{(i)}label^{(j) }a_{i}a_{j}\left \langle x^{(i)},x^{(j)} \right \rangle]

其約束條件爲:

\alpha \geqslant 0,\sum_{i=1}^{m}\alpha _{i}label^{(j)}=0

以上的描述都是基於一個假設:數據必須100%線性可分。但是幾乎所有數據都不是完全線性可分的,這時就可以通過引入所謂鬆弛變量,來允許有些數據點可以處於分隔面的錯誤一側。這樣我們的優化目標就能保持仍然不變,但是此時新的約束條件則變爲:

C\geqslant \alpha \geqslant 0,\sum_{i=1}^{m}\alpha _{i}label^{(j)}=0

這裏的常數C用於控制“最大化間隔”和“保證大部分點的函數間隔小於1.0”這兩個目標的權重。在優化算法的實現代碼中,常數C是一個參數,因此可以通過調節該參數得到不同的結果。一旦求出了所有的alpha,那麼分隔超平面就可以通過這些alpha來表達。這一結論十分直接,SVM的主要工作是求解這些alpha。

6.2.2 SVM應用的一般框架

SVM的一般流程:

(1)收集數據:可以使用任意方法。

(2)準備數據:需要數值型數據。

(3)分析數據:有助於可視化分隔超平面。

(4)訓練算法:SVM的大部分時間都源自訓練,該過程主要實現兩個參數的調優。

(5)測試算法:十分簡單的計算過程就可以實現。

(6)使用算法:幾乎所有分類問題都可以使用SVM,值得一提的是,SVM本身是一個二分類器,對多分類問題應用SVM需要對代碼做一些修改。

6.3 SMO高效優化算法

以前人們使用二次規劃求解工具求解最優化問題,這種工具是一種用於在線性約束下優化具有多個變量的二次目標函數的軟件。而這些二次規劃求解工具則需要強大的計算能力支撐,另外在實現上也十分複雜。所有需要做的圍繞優化的事情就是訓練分類器,一旦得到alpha的最優值,我們就得到了分隔超平面並能夠將之用於數據分類。

6.3.1 Platt的SMO算法

1996年,JohnPlatt發佈了一個稱爲SMO的強大算法,用於訓練SVM。SMO表示序列最小優化。Platt的SMO算法是將大優化問題分解爲多個小優化問題來求解的。這些小優化問題往往很容易求解,並且對他們進行序列求解的結果將它們作爲整體來求解的結果是完全一致的。在結果完全相同的同時,SMO算法的求解時間短很多。

SMO算法的目標是求出一系列alpha和b,一旦求出了這些alpha,就很容易計算出權重向量w並得到分隔超平面。

SMO算法的工作原理是:每次循環中選擇兩個alpha進行優化處理。一旦找到一對合適的alpha,那麼就增大其中一個同時較小另一個。這裏所謂的“合適”就是指兩個alpha必須要符合一定的條件,條件之一就是這兩個alpha必須要在間隔邊界之外,而其第二個條件則是這兩個alpha還沒有進行過區間化處理或者不在邊界上。

6.3.2  應用簡化版SMO算法處理小規模數據集

Platt SMO算法的完整實現需要大量代碼。接下來的第一個例子對算法進行了簡化處理,以便了解算法的基本工作思路。簡化版代碼雖然量少但執行速度慢。Platt SMO算法中的外循環確定要優化的最佳alpha對。而簡化版卻會跳過這一部分,首先在數據集上遍歷每一個alpha,然後在剩下的alpha集合中隨機選擇另一個alpha。之所以這樣做是因爲有一個約束條件:

\sum\alpha _{i}label^{(j)}=0

由於改變一個alpha可能會導致約束條件失效,因此總是同時改變兩個alpha。

爲此,將構建一個輔助函數,用於在某個區間範圍內隨機選擇一個整數。同時,也需要另一個輔助函數,用於在數值太大時對其進行調整。下面的程序給出了這兩個函數的實現,新建“svmMLiA.py”,寫入以下代碼:

from numpy import *
def loadDataSet(fileName):
    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 selectJrand(i,m):
    j=i
    while(j==i):
        j=int(random.uniform(0,m))
    return j

def clipAlpha(aj,H,L):
    if aj>H:
        aj=H
    if L>aj:
        ai=L
    return aj

製作分類數據集保存到testSet.txt文件中:

import random
for i in range(51):
    #x=random.uniform(-1,3)
    x=random.uniform(5, 10)
    y=random.uniform(-4,4)
    #print(x,'\t',y,'\t',-1)
    print(x,'\t',y,'\t',1)

在數據集上應用‘svmMLiA.py’中的各個函數,其中‘selctJrand()’中的i是第一個alpha的下標,m是所有alpha的數目。只要函數值不等於輸入i,函數就會進行隨機選擇。‘clipAlpha()’用於調整大於H或小於L的alpha值。

測試上述函數:

if __name__ == '__main__':
    dataArr,labelArr=loadDataSet('testSet.txt')
    print(labelArr)

輸出結果:

可以看得出來,這裏採用的類別標籤是-1和1,而不是0和1.

上述工作完成之後,就可以使用SMO算法的第一個版本了。

該SMO函數的僞代碼大致如下:

創建一個alpha向量並將其初始化爲0向量
當迭代次數小於最大迭代次數時(外循環):
    對數據集中的每個數據向量(內循環):
        如果該數據向量可以被優化:
            隨機選擇另外一個數據向量
            同時優化這兩個向量
            如果兩個向量都不能被優化,退出內循環
     如果所有向量都沒被優化,增加迭代數目,繼續下一次循環

 打開svmMLiA.py後輸入以下程序代碼:

from numpy import *
def loadDataSet(fileName):
    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 selectJrand(i,m):
    j=i
    while(j==i):
        j=int(random.uniform(0,m))
    return j

def clipAlpha(aj,H,L):
    if aj>H:
        aj=H
    if L>aj:
        aj=L
    return aj

#簡化版SMO算法
def smoSimple(dataMatIn,classLabels,c,toler,maxIter):
    dataMatrix=mat(dataMatIn)
    labelMat=mat(classLabels).transpose()
    b=0
    m,n=shape(dataMatrix)
    alphas=mat(zeros((m,1)))
    iter=0
    while(iter<maxIter):
        alphaPairsChanged=0
        for i in range(m):
            fxi = (multiply(alphas, labelMat)).astype(float).T * \
                  (dataMatrix * dataMatrix[i,:].T) + b
            Ei=fxi-float(labelMat[i])
            if((labelMat[i]*Ei<-toler) and (alphas[i]<c)) or\
                    ((labelMat[i]*Ei>toler) and (alphas[i]>0)):
                j=selectJrand(i,m)
                fxj=(multiply(alphas,labelMat)).astype(float).T*\
                (dataMatrix*dataMatrix[j,:].T)+b
                Ej=fxj-float(labelMat[j])
                alphaIold=alphas[i].copy()
                alphaJold=alphas[j].copy()
                if(labelMat[i]!=labelMat[j]):
                    L=max(0,alphas[j]-alphas[i])
                    H=min(c,c+alphas[j]-alphas[i])
                else:
                    L = max(0, alphas[j] + alphas[i]-c)
                    H = min(c, alphas[j] +alphas[i])
                if L==H:
                    print('L=H')
                    continue
                eta=2*dataMatrix[i,:]*dataMatrix[j,:].T-\
                    dataMatrix[i,:]*dataMatrix[i,:].T-\
                    dataMatrix[j,:]*dataMatrix[j,:].T
                if eta>=0:
                    print('eta>=0')
                    continue
                alphas[j]-=labelMat[j]*(Ei-Ej)/eta
                alphas[j]=clipAlpha(alphas[j],H,L)
                if (abs(alphas[j]-alphaJold)<0.00001):
                    print('j not moving enough')
                    continue
                alphas[i]+=labelMat[j]*labelMat[i]*(alphaJold-alphas[j])
                b1=b-Ei-labelMat[i]*(alphas[i]-alphaIold)*dataMatrix[i,:]*dataMatrix[i,:].T-\
                   labelMat[j]*(alphas[j]-alphaJold)*dataMatrix[i,:]*dataMatrix[j,:].T
                b2 = b - Ej - labelMat[i] * (alphas[i] - alphaIold) * dataMatrix[i, :] * dataMatrix[j, :].T - \
                     labelMat[j] * (alphas[j] - alphaJold)*dataMatrix[j,:]*dataMatrix[j,:].T
                if(0<alphas[i]) and (c>alphas[i]):
                    b=b1
                elif(0<alphas[j]) and (c>alphas[j]):
                    b=b2
                else:
                    b=(b1+b2)/2
                alphaPairsChanged+=1
                print('iter:%d i:%d,pairs changed %d'%(iter,i,alphaPairsChanged))

        if (alphaPairsChanged==0):
            iter+=1
        else:
            iter=0
        print('iteration number: %d'%iter)
    return b,alphas

if __name__ == '__main__':
    dataArr,labelArr=loadDataSet('testSet.txt')
    #print(labelArr)
    b,alphas=smoSimple(dataArr,labelArr,0.6,0.001,40)
    print(b)

該函數有5個輸入參數,分別是:數據集、類別標籤、常數c、容錯率和取消前最大的循環次數。每次循環中,將alphaPairsChanged先設爲0,然後再對整個集合順序遍歷。變量alphaPairsChanged用於記錄alpha是否已經進行優化。首先,fix能夠計算出來,這是預測的類別。然後,基於這個實例的預測結果和真實結果的比對,就可以計算誤差Ei。如果誤差很大,那麼可以對該數據實例所對應的alpha值進行優化。在if語句中,不管是正間隔還是負間隔都會被測試。並且在if語句中,也要同時檢查alpha值,以保證其不能等於0或c。由於後面alpha小於0或大於c時將被調整爲0或c,所以一旦在該if語句中它們等於這兩個值的話,那麼它們就已經在“邊界”上了,因而不再能夠減小或增大,因此就不再對它們進行優化了。

接下來,利用輔助函數隨機選擇第二個alpha值,即alpha[j]。同樣,可以採用第一個alpha值(alpha[i])的誤差計算方法,來計算這個alpha值的誤差。這個過程可以通過copy()的方法來實現,因此稍後可以將新的alpha值與老的alpha值進行比較。python則會通過引用的方式傳遞所有列表,所以必須明確地告知python要爲alphaIold和alphaJold分配新的內存;否則的話,在對新值和舊值進行比較時,就看不到新舊值的變化。之後開始計算L和H,它們用於將alpha[j]調整到0到c之間。如果L和H相等,就不做任何改變,直接執行continue語句。這在python中,則意味着本次循環結束直接進行下一次for的循環。

Eta是alpha[j]的最優修改量,如果eta爲0,那就是說需要退出for循環的當前迭代過程。該過程對真實SMO算法進行了簡化處理。如果eta爲0,那麼計算新的alpha[j]就比較麻煩了,現實中,這一情況並不常發生。於是,可以計算出一個新的alpha[j],然後利用輔助函數以及L與H值對其進行調整。

然後,就是需要檢查alpha[j]是否有輕微改變。如果是的話,就退出for循環。然後,alpha[i]和alpha[j]同樣進行改變,雖然改變的大小一樣,但是改變的方向正好相反(即如果一個增加,那麼另外一個減少)。在對alpha[i]和alpha[j]進行優化之後,給這兩個alpha值設置一個常數項b。

最後,在優化過程結束的同時,必須確保在合適的時機結束循環。如果程序執行到for循環的最後一行都不執行continue語句,那麼就已經成功地改變了一對alpha,同時可以增加alphaPairsChanged的值。在for循環之外,需要檢查alpha值是否做了更新,如果有更新則將iter設爲0後繼續運行程序。只有在所有數據集上遍歷maxIter次,且不再發生任何alpha修改之後,程序纔會停止並退出while循環。

運行上述代碼,迭代40次後得到b的值:

爲了得到支持向量的個數,輸入:

print(shape(alphas[alphas>0]))

運行結果:(1,4)

爲了解哪些數據點是支持向量,輸入:

for i in range(100):
    if alphas[i]>0:
       print(dataArr[i],labelArr[i])

運行結果:

[2.7842487287183553, -0.05754641163606955] -1.0
[2.7744955793870116, -1.8904062216981163] -1.0
[2.7849801075744813, -0.3151586065544034] -1.0
[5.123533544031859, -0.29524533476324244] 1.0

在原始數據集上對這些支持向量畫圈,代碼如下所示:

from numpy import *
import matplotlib.pyplot as plt
import svmMLiA
def loadDataSet(fileName):
    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 draw_svm():
    dataMat,labelMat=loadDataSet('testSet.txt')
    dataArr=array(dataMat)
    n=shape(dataArr)[0]
    xcord1=[]
    ycord1=[]
    xcord2=[]
    ycord2=[]
    for i in range(n):
        if int(labelMat[i])==1:
            xcord1.append(dataArr[i,0])
            ycord1.append(dataArr[i,1])
        else:
            xcord2.append(dataArr[i,0])
            ycord2.append(dataArr[i,1])
    return xcord1,xcord2,ycord1,ycord2
if __name__ == '__main__':
    dataArr,labelArr=loadDataSet('testSet.txt')
    b,alphas=svmMLiA.smoSimple(dataArr,labelArr,0.6,0.001,40)
    svmx=[]
    svmy=[]
    for i in range(102):
        if alphas[i]>0:
            svmx.append(dataArr[i][0])
            svmy.append(dataArr[i][1])
    xcord1, xcord2, ycord1, ycord2=draw_svm()
    fig=plt.figure()
    ax=fig.add_subplot(111)
    ax.scatter(xcord1,ycord1,s=30,color='red',marker='s')
    ax.scatter(xcord2, ycord2, s=30, color='green')
    ax.scatter(svmx, svmy, s=100, facecolor='none', edgecolors='b')
    plt.show()

 運行後得到帶圓圈標記的支持向量:

6.4利用完整Platt SMO算法加速優化

在幾百個點組成的小規模數據集上,簡化版SMO算法的運行是沒有問題的,但是在更大的數據集上的運行速度就會變慢。完整版的Platt SMO算法和簡化版的SMO算法實現alpha的更改和代數運算的優化環節一模一樣。在優化過程中,唯一的不同就是選擇alpha的方式。完整版的Platt SMO算法應用了一些能夠提速的啓發方法。

Platt SMO算法是通過一個外循環來選擇第一個alpha值的,並且其選擇過程會在兩種方式之間進行交替:一種方式是在所有數據集上進行單遍掃描,另一種方式則是在非邊界alpha中實現單遍掃描。而所謂非邊界alpha指的就是那些不等於邊界0或c的alpha值。對整個數據集的掃描相當容易,而實現非邊界alpha值的掃描時,首先需要建立這些alpha值的列表,然後再對這個表進行遍歷。同時,該步驟會跳過那些已知的不會改變的alpha值。

在選擇第一個alpha值後,算法會通過一個內循環來選擇第二個alpha值。在優化過程中,會通過最大化步長的方式來獲得第二個alpha值。在簡化版SMO算法中,在選擇j之後就計算了錯誤率Ej。這裏會建立一個全局的緩存用於 保存誤差值,並從中選擇使得步長或者說Ei-Ej最大的alpha值。

#完整版Platt SMO的支持函數
class  optStruct:
    def __init__(self,dataMatIn,classLabels,c,toler):
        self.X=dataMatIn
        self.labelMat=classLabels
        self.c=c
        self.tol=toler
        self.m=shape(dataMatIn)[0]
        self.alphas=mat(zeros((self.m,1)))
        self.b=0
        self.eCache=mat(zeros((self.m,2)))
def calcEk(oS,k):
    fxk=(multiply(oS.alphas,oS.labelMat).T*(oS.X*oS.X[k,:].T)).astype(float)+oS.b
    Ek=fxk-float(oS.labelMat[k])
    return Ek

def selectJ(i,oS,Ei):
    maxK=-1
    maxDeltaE=0
    Ej=0
    oS.eCache[i]=[1,Ei]
    validEcacheList=nonzero(oS.eCache[:,0].A)[0]
    if (len(validEcacheList))>1:
        for k in validEcacheList:
            if k==i:
                continue
            Ek=calcEk(oS,k)
            deltaE=abs(Ei-Ek)
            if (deltaE>maxDeltaE):
                maxK=k
                maxDeltaE=deltaE
                Ej=Ek
        return maxK,Ej
    else:
        j=selectJrand(i.oS.m)
        Ej=calcEk(oS,j)
    return j,Ej

def updateEk(oS,k):
    Ek=calcEk(oS,k)
    oS.eCache[k]=[1,Ek]

首要的事情就是建立一個數據結構來保存所有的重要值,而這個過程可以通過一個對象來完成。這裏使用對象的目的並不是爲了面向對象的編程,而只是作爲一個數據結構來使用對象。在將值傳給函數時,可以通過將所有數據移到一個結構中實現,這樣就可以省掉手工輸入的麻煩了。而此時,數據就可以通過一個對象來進行傳遞。實際上,當完成其實現時,可以很容易通過python的字典來完成。但是在訪問對象成員變量時,這樣做會有更多的手工輸入操作,對比一下myObject.X和myObject['X']就可以知道這一點。爲達到這個目的,需要構建一個僅包含init方法的optStruct類。該方法可以實現其成員變量的填充。除了增加一個mx2的矩陣成員變量eCache之外,這些做法和簡化版SMO一模一樣。eCache的第一列給出的是eCache是否有效的標誌位,而第二列給出的是實際的E值。

對於給定的alpha值,第一個輔助函數calcEk()能夠計算E值並返回。以前,該過程是採用內嵌的方式來完成的,但是由於該過程在這個版本的SMO算法中出現頻繁,這裏必須將其單獨拎出來。

下一個函數selectJ()用於選擇第二個alpha或者說內循環的alpha值。這裏的目標是選擇合適的第二個alpha值以保證在每次優化中採用最大步長。該函數的誤差值與第一個alpha值Ei和下標i有關。首先將輸入值Ei在緩存中設置成爲有效的。這裏的有效(valid)意味着它已經計算好了。在eCache中,代碼nonzero(oS.eCache[:,0].A)[0]構建出了一個非零表。NumPy函數nonzreo()返回了一個列表,而這個列表中包含以輸入列表爲目錄的列表值,這裏的值並非0。nonzero()語句返回的是非零E值所對應的alpha值,而不是E值本身。程序會在所有的值上進行循環並選擇其中使得改變最大的那個值。如果這是第一次循環的話,那麼就隨機選擇一個alpha值。

最後一個輔助函數updataEk(),它會計算誤差值並存入緩存當中。在對alpha值進行優化之後會用到這個值。

上述代碼本身的作用並不大,但是當和優化過程及外循環組合在一起時,就能組成強大的SMO算法。

接下來將簡單介紹一下用於尋找決策邊界的優化例程。打開文本編輯器,添加以下代碼:

def innerL(i,oS):
    Ei=calcEk(oS,i)
    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)):
        j,Ej=selectJ(i,oS,Ei)
        alphaIold=oS.alphas[i].copy()
        alphaJold=oS.alphas[j].copy()
        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
        eta=2*oS.X[i,:]*oS.X[j,:].T- \
            oS.X[i,:]*oS.X[i,:].T- \
            oS.X[j,:]*oS.X[j,:].T
        if eta>=0:
            print('eta>=0')
            return 0
        oS.alphas[j]-=oS.labelMat[j]*(Ei-Ej)/eta
        oS.alphas[j]=clipAlpha(oS.alphas[j],H,L)
        if (abs(oS.alphas[j]-alphaJold)<0.00001):
            print('j not moving enough')
            return 0
        oS.alphas[i]+=oS.labelMat[j]*oS.labelMat[i]*(alphaJold-oS.alphas[j])
        updateEk(oS,i)
        b1=oS.b-Ei-oS.labelMat[i]*(oS.alphas[i]-alphaIold)*oS.X[i,:]*oS.X[i,:].T- \
           oS.labelMat[j]*(oS.alphas[j]-alphaJold)*oS.X[i,:]*oS.X[j,:].T
        b2 = oS.b - Ej- oS.labelMat[i] * (oS.alphas[i] - alphaIold) * oS.X[i, :] * oS.X[j, :].T - \
             oS.labelMat[j] * (oS.alphas[j] - alphaJold) * oS.X[j, :] * oS.X[j, :].T
        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
        return 1
    else:
        return 0

上述代碼和smoSimple()函數幾乎一模一樣,但是這裏的代碼已經使用了自己的數據結構。該結構在參數oS中傳遞。第二個重要的修改就是使用SelectJ()而不是selectJrand()來選擇第二個alpha值。最後,在alpha值改變時更新Ecache。將上述過程打包在一起(即選擇第一個alpha值的外循環):

#完整版Platt SMO的外循環代碼
def smoP(dataMatIn,classLabels,c,toler,maxIter,kTup=('lin',0)):
    oS=optStruct(mat(dataMatIn),mat(classLabels).transpose(),c,toler)
    iter=0
    entireSet=True
    alphaPairsChanged=0
    while(iter<maxIter) and ((alphaPairsChanged>0) or(entireSet)):
        alphaPairsChanged=0
        if entireSet:
            for i in range(oS.m):
                alphaPairsChanged+=innerL(i,oS)
                print('fullSet,iter:%d i:%d,pairs changed %d'%(iter,i,alphaPairsChanged))
            iter+=1
        else:
            nonBoundIs=nonzero((oS.alphas.A>0)*(oS.alphas.A<c))[0]
            for i in nonBoundIs:
                alphaPairsChanged+=innerL(i,oS)
                print('non-bound,iter:%d,i:%d,pairs changed %d'%(iter,i,alphaPairsChanged))
            iter+=1
        if entireSet:
            entireSet=False
        elif(alphaPairsChanged==0):
            entireSet=True
        print('iteration number:%d'%iter)
    return oS.b,oS.alphas

上述完整版的Platt SMO算法,其輸入和函數smoSimple()完全一樣。函數一開始構建一個數據結構來容納所有的數據,然後需要對控制函數退出的一些變量進行初始化。整個代碼的主體是while循環,這與smoSimple()有些類似,但是這裏的循環退出條件更多一些。當迭代次數超過指定的最大值,或者遍歷整個集合都未對任意alpha對進行修改時,就退出循環。這裏的maxIter變量和函數smoSimple()中的作用有一點不同,後者當沒有任何alpha發生變化時會將整個集合的一次遍歷過程計成一次迭代,而這裏的一次迭代定義爲一次循環過程,而不管該循環具體做了什麼事。此時,如果在優化過程中存在波動就會停止,因此這裏的做法優於smoSimple()函數中的計數方法。

while循環的內部與smoSimple()中有所不同,一開始的for循環在數據集上遍歷任意可能的alpha。通過調用innerL()來選擇第二個alpha,並在可能時對其進行優化處理。如果有任意一對alpha值發生改變,那麼會返回1.第二個for循環遍歷所有的非邊界alpha值,也就是不在邊界0或c上的值。

接下來,對for循環在非邊界循環和完整遍歷之間進行切換,並打印出迭代次數。最後程序將會返回常數b和alpha值。

運行程序:

if __name__ == '__main__':
    dataArr,labelArr=loadDataSet('testSet.txt')
    b, alphas = smoP(dataArr, labelArr, 0.6, 0.001, 40)

運行效果:

代碼中常數c給出的是不同優化問題的權重。常數c一方面要保障所有樣例的間隔不小於1.0,另一方面又要使得分類間隔要儘可能大,並且要在這兩方面之間平衡。如果c很大,那麼分類器將力圖通過分隔超平面對所有的樣例都正確分類。

利用alpha值進行分類時,首先必須基於alpha值得到超平面,這也包括了w的計算。下面列出的一個小函數可以用於實現上述任務:

def calcWs(alphas,dataArr,classLabels):
    X=mat(dataArr)
    labelMat=mat(classLabels).transpose()
    m,n=shape(X)
    w=zeros((n,1))
    for i in range(m):
        w+=multiply(alphas[i]*labelMat[i],X[i,:].T)
    return w

上述代碼中最重要的部分是for循環,雖然在循環中實現的僅僅是多個數的乘積。看一下前面計算出的任何一個alpha,就不會忘記大部分alpha值爲0.而非零alpha所對應的也就是支持向量。雖然上述for循環遍歷了數據集中的所有數據,但是最終起作用只有支持向量。由於對w計算毫無作用,所以數據集的其它數據點也就會很容易地捨棄。

if __name__ == '__main__':
    dataArr,labelArr=loadDataSet('testSet.txt')
    b, alphas = smoP(dataArr, labelArr, 0.6, 0.001, 40)
    ws=calcWs(alphas,dataArr,labelArr)
    print(ws)

得到參數:[[ 0.44897833]
 [-0.12557949]]

現對數據進行分類處理,比如說對第一個數據點分類,可以輸入:

if __name__ == '__main__':
    dataArr,labelArr=loadDataSet('testSet.txt')
    b, alphas = smoP(dataArr, labelArr, 0.6, 0.001, 40)
    ws=calcWs(alphas,dataArr,labelArr)
    datMat=mat(dataArr)
    res=datMat[0]*mat(ws)+b
    print(res)

運行結果:[[-0.86866025]]

如果該值大於0,那麼其屬於1類;如果該值小於0,那麼則屬於-1類。對於數據點0,應該得到的類別標籤是-1,可以通過如下的命令來確認分類結果的正確性:

 print(labelArr[0])

運行結果:-1.0

至此,已經可以成功訓練出分類器了。

6.5 在複雜數據上應用核函數

先製作數據集(將生成的數據點保存在“complex_testSet.txt”文件中):

from sklearn.datasets import make_circles

import matplotlib.pyplot as plt

from pandas import DataFrame

# generate 2d classification dataset

X, y = make_circles(n_samples=200, noise=0.05)

# scatter plot, dots colored by class value

df = DataFrame(dict(x=X[:,0], y=X[:,1], label=y))
for i in range(200):
    print(X[:,0][i],X[:,1][i],y[i])

colors = {0:'red', 1:'blue'}

fig, ax = plt.subplots()

grouped = df.groupby('label')

for key, group in grouped:

    group.plot(ax=ax, kind='scatter', x='x', y='y', label=key, color=colors[key])
plt.show()

對於上圖給出的數據,也可以像線性情況一樣,利用強大的工具來捕捉數據中的這種模式。接下來,使用一種稱爲核函數(kernel)的工具將數據轉換成易於分類器理解的形式。

6.5.1 利用核函數將數據映射到高維空間

上圖中,數據點處於一個圓中,人類的大腦能夠意識到這一點。然而,對於分類器而言,它只能識別分類器的結果是大於0還是小於0.如果只在x和y軸構成的座標系中插入直線進行分類的話,並不會得到理想的結果。要對圓中的數據進行某種形式的轉換,從而得到某些新的變量來表示數據。在這種表示情況下,更容易得到大於0或者小於0的測試結果。

在這個例子中,將數據從一個特徵空間轉換到另一個特徵空間,在新空間下,可以很容易利用已有的工具對數據進行處理。數學家們喜歡將這個過程稱之爲從一個特徵空間到另一個特徵空間的映射。在通常情況下,這種映射會將低維特徵空間映射到高維空間。

這種從某個特徵空間到另一個特徵空間的映射是通過核函數實現的。可以把核函數想象成一個包裝器(wrapper)或者是接口(interface),它能把數據從某個很難處理的形式轉換成爲另一個較容易處理的形式。也可以將它想象成爲另外一種距離計算的方法,距離計算的方法有很多種,核函數一樣具有多種類型。經過空間轉換之後,可以在高維空間中解決線性問題,這就等價於在低維空間中解決非線性問題。

SVM優化中一個特別好的地方就是,所有的運算都可以寫成內積(inner product,也稱點積)的形式。向量的內積指的是兩個向量相乘,之後得到單個標量或者數值。可以把內積運算替換成核函數,而不必做簡化處理。將內積替換成核函數的方式被稱爲核技巧(kernel trick)或者核“變電”(kernel substation)。

核函數並不僅僅應用於支持向量機,很多其他的機器學習算法也都用到核函數。

6.5.2 徑向基核函數

徑向基函數是SVM中常用的一個核函數。徑向基函數是一個採用向量作爲自變量的函數,能夠基於向量距離運算輸出一個標量。這個距離可以是從<0,0>向量或者其它向量開始計算的距離。徑向基函數的高斯版本公式:

k\left ( x,y \right )=exp\left ( \frac{-\left \| x-y \right \|^{2}}{2\sigma ^{2}} \right )

其中,\sigma是用戶定義的用於確定到達率(reach)或者說函數值跌落到0的速度參數。

上述高斯核函數將數據從其特徵空間映射到更高維的空間,具體來說這裏是映射到一個無窮維的空間。在上面的例子中,數據點基本上都在一個圓內,對於這個例子,可以直接檢查原始數據,並意識到只要度量數據點到圓心的距離即可。然而,如果碰到了一個不是這種形式的新數據集,就會陷入困境。在該數據集上,使用高斯核函數可以得到很好的結果。當然,該函數也可以用於許多其他的數據集,並且也能得到低錯誤率的結果。

如果在svmMLiA.py文件中添加一個函數並稍做修改,那麼就能夠在已有代碼中使用核函數。首先,打開svmMLiA.py代碼文件並輸入kernelTrans()。然後,對optStruct()類進行修改:

##轉換核函數
def kernelTrans(X,A,kTup):
    m,n=shape(X)
    K=mat(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=exp(K/(-1*kTup[1]**2))
    else:
        raise NameError('Houston We Have a Problem--That Kernel is not recognized')
    return K
class  optStruct:
    def __init__(self,dataMatIn,classLabels,c,toler,kTup):
        self.X=dataMatIn
        self.labelMat=classLabels
        self.c=c
        self.tol=toler
        self.m=shape(dataMatIn)[0]
        self.alphas=mat(zeros((self.m,1)))
        self.b=0
        self.eCache=mat(zeros((self.m,2)))
        self.K=mat(zeros((self.m,self.m)))
        for i in range(self.m):
            self.K[:,i]=kernelTrans(self.X,self.X[i,:],kTup)

在optStruct類的新版本中,除了引入了一個新變量kTup之外,和原來的版本一模一樣。kTup是一個包含核函數信息的元組,在初始化方法結束時,矩陣K先被構建,然後再通過調用函數kernelTrans()進行填充。全局的K值只需計算一次。然後,當想要使用核函數時,就可以對它進行調用,這也省去了很多冗餘的計算開銷。

當計算矩陣K時,該過程多次調用了函數kernelTrans()。該函數有3個輸入參數:2個數值型變量和1個元組。元組kTup給出的是核函數的信息。元組的第一個參數是描述所用核函數類型的一個字符串,其它2個參數則都是核函數可能需要的可選參數。該函數首先構建出了一個列向量,然後檢查元組以確定核函數的類型。這裏只給出了2種選擇,但是依然可以很容易地通過添加elif語句來擴展到更多選項。

在線性核函數的情況下,內積計算在“所有數據集”和“數據集中的一行”這兩個輸入之間展開。在徑向基核函數的情況下,在for循環中對於矩陣的每個元素計算高斯函數的值。而在for循環結束之後,將計算過程應用到整個向量上去。值得一提的是,在numpy矩陣中,除法符號意味着對矩陣元素展開計算而不像在MATLAB中一樣計算矩陣的逆。

最後,如果遇到一個無法識別的元組,程序就會拋出異常,因爲在這種情況下不希望程序再繼續運行,這一點相當重要。

爲了使用核函數,先期的兩個函數innerL()和calcEk()的代碼需要做些修改:

def innerL(i,oS):
    Ei=calcEk(oS,i)
    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)):
        j,Ej=selectJ(i,oS,Ei)
        alphaIold=oS.alphas[i].copy()
        alphaJold=oS.alphas[j].copy()
        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
        eta=2*oS.K[i,j]- oS.K[i,i]-oS.K[j,j]
        if eta>=0:
            print('eta>=0')
            return 0
        oS.alphas[j]-=oS.labelMat[j]*(Ei-Ej)/eta
        oS.alphas[j]=clipAlpha(oS.alphas[j],H,L)
        if (abs(oS.alphas[j]-alphaJold)<0.00001):
            print('j not moving enough')
            return 0
        oS.alphas[i]+=oS.labelMat[j]*oS.labelMat[i]*(alphaJold-oS.alphas[j])
        updateEk(oS,i)
        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]
        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
        return 1
    else:
        return 0

def calcEk(oS,k):
    fxk=((multiply(oS.alphas,oS.labelMat).T*oS.K[:,k])+oS.b).astype(float)
    Ek=fxk-float(oS.labelMat[k])
    return Ek

6.5.3 在測試中使用核函數

接下來構建一個對圓形數據點進行有效分類的分類器,該分類器使用了徑向基函數。前面提到的徑向基函數有一個用戶定義的輸入\sigma,首先,需要確定它的大小,然後利用該核函數構建出一個分類器,代碼如下所示:

#利用核函數進行分類的徑向基測試函數
def testRbf(k1=1.3):
    dataArr,labelArr=loadDataSet('complex_testSet.txt')
    b,alphas=smoP(dataArr,labelArr,200,0.0001,10000,('rbf',k1))
    datMat=mat(dataArr)
    labelMat=mat(labelArr).transpose()
    svInd=nonzero(alphas.A>0)[0]
    sVs=datMat[svInd]
    labelSV=labelMat[svInd]
    print('there are %d Support Vecture'%shape(sVs)[0])
    m,n=shape(datMat)
    errorCount=0
    for i in range(m):
        kernelEval=kernelTrans(sVs,datMat[i,:],('rbf',k1))
        predict=kernelEval.T*multiply(labelSV,alphas[svInd])+b
        if sign(predict)!=sign(labelArr[i]):
            errorCount+=1
        print('the training error rate is:%f'%(float(errorCount)/m))

上述代碼只有一個可選的輸入參數,該輸入參數是高斯徑向基函數中的一個用戶定義變量。整個代碼主要是由以前定義的函數集合構成的。首先,程序從文件中讀入數據集,然後在該數據集上運行Platt SMO算法,其中核函數的類型爲‘rbf’。

優化過程結束後,在後面的矩陣數學運算中建立了數據的矩陣副本,並且找出那些非零的alpha值,從而得到所需要的支持向量;同時,也就得到了這些支持向量和alpha的類別標籤值。這些值僅僅是需要分類的值。

整個代碼中最重要的是for循環開始的那兩行,它們給出瞭如何利用核函數進行分類。首先利用結構初始化方法中使用過的kernelTrans()函數,得到轉換後的數據。然後,再用其與前面的alpha及類別標籤值求積。其中需要特別注意的另一件事是,在這幾行代碼中,是如何做到只需要支持向量數據就可以進行分類的。除此之外,其他數據都可以直接捨棄。

測試上述代碼:

if __name__=='__main__':
    testRbf()

可以更換不同的k1參數以觀測錯誤率、訓練錯誤率、支持向量個數隨k1的變化情況。

支持向量的數目存在一個最優值。SVM的優點在於它能對數據進行高效分類。如果支持向量太少,就可能會得到一個很差的決策邊界;如果支持向量太多,就相當於每次都利用這個數據集進行分類,這種分類方法稱爲k近鄰。

本章小結

支持向量機是一種分類器。之所以成爲“機”是因爲它會產生一個二值決策結果,即它是一種決策“機”。支持向量機的泛化錯誤率較低,也就是說它具有良好的學習能力,且學到的結果具有很好的推廣性。這些優點使得支持向量機十分流行,有些人認爲它是監督學習中最好的定式算法。

支持向量機試圖通過求解一個二次優化問題來最大化分類間隔。在過去,訓練支持向量機常採用非常複雜並且低效的二次規劃求解方法。John Platt引入了SMO算法,此算法可以通過每次只優化2個alpha值來加快SVM的訓練速度。本章首先討論了一個簡化版所實現的SMO優化過程,接着給出了完整的Platt SMO算法。相對於簡化版而言,完整版算法不僅大大地提高了優化的速度,還使其存在一些進一步提高運行速度的空間。有關這方面的工作,一個經常被引用的參考文獻就是“Improvements to Platt's SMO Algorithm for SVM Classifier Design”。

核方法或者說核技巧會將數據(有時是非線性數據)從一個低維空間映射到一個高維空間,可以將一個在低維空間中的非線性問題轉換成高維空間下的線性問題來求解。該方法不止在SVM中適用,還可以用於其他算法中。而其中的徑向基函數是一個常用的度量兩個向量距離的核函數。

支持向量機是一個二分類器。當用其解決多類問題時,則需要額外的方法對其進行擴展。SVM的效果也對優化參數和所用核函數中的參數敏感。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章