支持向量機SMO算法實現(注釋詳細)

一:SVM算法

(一)見西瓜書及筆記

(二)統計學習方法及筆記

(三)推文//zhuanlan.zhihu.com/p/34924821

(四)推文

支持向量機原理(一) 線性支持向量機

支持向量機原理(二) 線性支持向量機的軟間隔最大化模型

二:SMO算法

(一)見西瓜書及筆記

(二)統計學習方法及筆記

(三)見機器學習實戰及筆記

(四)推文

支持向量機原理(四)SMO算法原理

三:代碼實現(一)SMO中的輔助函數

(一)加載數據集

import numpy as np
import matplotlib.pyplot as plt

#一:SMO算法中的輔助函數
#加載數據集
def loadDataSet(filename):
    dataSet = np.loadtxt(filename)
    m,n = dataSet.shape
    data_X = dataSet[:,0:n-1]
    data_Y = dataSet[:,n-1]

    return data_X,data_Y

(二)隨機選取一個J值,作為α_2的下標索引

#隨機選取一個數J,為後面內循環選取α_2做輔助(如果α選取不滿足條件,就選擇這個方法隨機選取)
def selectJrand(i,m):   #主要就是根據α_1的索引i,從所有數據集索引中隨機選取一個作為α_2的索引
    j = i
    while j==i:
        j = np.int(np.random.uniform(0,m))  #從0~m中隨機選取一個數,是進行整數化的
    print("random choose index for α_2:%d"%(j))
    return j    #由於這裡返回隨機數,所以後面結果 可能導致不同

(三)根據關於α_1與α_2的優化問題對應的約束問題分析,對α進行截取約束

def clipAlpha(aj,H,L):  #根據我們的SVM算法中的約束條件的分析,我們對獲取的aj,進行了截取操作
    if aj > H:
        aj = H
    if aj < L:
        aj = L
    return aj

四:代碼實現(二)SMO中的支持函數

(一)定義一個數據結構,用於保存所有的重要值

#首先我們定義一個數據結構(類),來保存所有的重要值
class optStruct:
    def __init__(self,data_X,data_Y,C,toler):   #輸入參數分別是數據集、類別標籤、常數C用於軟間隔、和容錯率toler
        self.X = data_X
        self.label = data_Y
        self.C = C
        self.toler = toler  #就是軟間隔中的ε,調節最大間隔大小
        self.m = data_X.shape[0]
        self.alphas = np.zeros(self.m)    #存放每個樣本點的α值
        self.b = 0  #存放閾值
        self.eCache = np.zeros((self.m,2))  #用於緩存誤差,每個樣本點對應一個Ei值,第一列為標識符,標誌是否為有效值,第二列存放有效值

(二)計算每個樣本點k的Ek值,就是計算誤差值=預測值-標籤值

#計算每個樣本點k的Ek值,就是計算誤差值=預測值-標籤值
def calcEk(oS,k):
    # 根據西瓜書6.24,我們可以知道預測值如何使用α值進行求解
    fxk = np.multiply(oS.alphas,oS.label).T@([email protected][k,:])+oS.b #np.multiply之後還是(m,1),([email protected][k,:])之後是(m,1),通過轉置(1,m)@(m,1)-->實數後+b即可得到預測值fx
    #獲取誤差值Ek
    Ek = fxk - oS.label[k]
    return Ek

(三)重點:內循環的啟發式方法,獲取最大差值|Ei-Ej|對應的Ej的索引J

#內循環的啟發式方法,獲取最大差值|Ei-Ej|對應的Ej的索引J
def selectJ(i,oS,Ei):   #注意我們要傳入第一個α對應的索引i和誤差值Ei,後面會用到
    maxK = -1   #用於保存臨時最大索引
    maxDeltaE = 0   #用於保存臨時最大差值--->|Ei-Ej|
    Ej = 0  #保存我們需要的Ej誤差值

    #重點:這裡我們是把SMO最後一步(根據最新閾值b,來更新Ei)提到第一步來進行了,所以這一步是非常重要的
    oS.eCache[i] = [1,Ei]

    #開始獲取各個Ek值,比較|Ei-Ej|獲取Ej的所有
    #獲取所有有效的Ek值對應的索引
    validECacheList = np.where(oS.eCache[:,0]!=0)[0]    #根據誤差緩存中第一列非0,獲取對應的有效誤差值
    if len(validECacheList) > 1:   #如果有效誤差緩存長度大於1(因為包括Ei),則正常進行獲取j值,否則使用selectJradn方法選取一個隨機J值
        for k in validECacheList:
            if k == i:  #相同則不處理
                continue
            #開始計算Ek值,進行對比,獲取最大差值
            Ek = calcEk(oS,k)
            deltaE = abs(Ei - Ek)
            if deltaE > maxDeltaE:  #更新Ej及其索引位置
                maxK = k
                maxDeltaE = deltaE
                Ej = Ek
        return maxK,Ej  #返回我們找到的第二個變量α_2的位置
    else:   #沒有有效誤差緩存,則隨機選取一個索引,進行返回
        j = selectJrand(i,oS.m)
        Ej = calcEk(oS,j)
        return j,Ej

(四)實現更新Ek操作

#實現更新Ek操作,因為除了最後我們需要更新Ei之外,我們在內循環中計算α_1與α_2時還是需要用到E1與E2,
#因為每次的E1與E2由於上一次循環中更新了α值,所以這一次也是需要更新E1與E2值,所以單獨實現一個更新Ek值的方法還是有必要的
def updateEk(oS,k):
    Ek = calcEk(oS,k)
    oS.eCache[k] = [1,Ek]   #第一列1,表示為有效標識

五:代碼實現(三)SMO中的內循環函數

外循環是要找違背KKT條件最嚴重的樣本點(每個樣本點對應一個α),這裡我們將外循環的該判別條件放入內循環中考慮。

(一)補充違背KKT條件選取

對於SVM中的KKT條件如下:

一般來說,我們首先選擇違反0<αi<Cyig(xi)=1這個條件的點。

如果這些支持向量都滿足KKT條件,再選擇違反αi=0yig(xi)1和 αi=Cyig(xi)1的點。

(二)分析0<αi<Cyig(xi)=1條件 

 對於上面違反KKT條件實際應用時的兩種情況(或狀態):

1.    0<αiyig(xi)>1違背KKT條件

之所以不考慮α<c的情況,因為當yig(xi)>1時,必然出現α≠c,又因為0<α<c,所以我們只用考慮0<α⇒yig(xi)>1即可。

2.    α<Cyig(xi)<1違背KKT條件

之所以不考慮α>0的情況,因為當yig(xi)<1時,必然出現α≠0,又因為0<α<c,所以我們只用考慮α<C⇒yig(xi)<1即可。

(三)軟間隔分析(同上)

相比較於硬間隔狀態,多了一個鬆弛變量,所以我們考慮的時候加上該鬆弛變量即可。

1.    0<αiyig(xi)>1+ξ違背KKT條件

2.    α<Cyig(xi)<1-ξ違背KKT條件

(四)代碼分析

    if ((oS.label[i]*Ei < -oS.toler) and (oS.alphas[i] < oS.C)) or\
       ((oS.label[i]*Ei > oS.toler) and (oS.alphas[i] > 0)):  #注意:對於硬間隔,我們直接和1對比,對於軟間隔,我們要和1 +或- ε對比

這裡的代碼和我們上面分析的違背KKT條件有所不同,所以下面進行推導:

主要看Ei的公式:Ei=g(xi)-yi

如(二)(三)分析可以知道,我們將進入優化的條件(即違背KKT條件)寫成代碼中形式:

(yiEi<-toler且α<C)或(yiEi>toler且α>C)

條件中yiEi=yi(g(xi)-yi)=yig(xi)-yi2

由於yi=±1,所以yi2=1

最後,我們就可以將代碼中的原條件化簡為:

(yig(xi)<1-toler且α<C)或(yig(xi)>1+toler且α>C)

即我們在(三)中的形式

(六)分析內循環中η值的性質

        #計算η值=k_11+k_22-2k_12
        eta = oS.X[i]@oS.X[i] + oS.X[j]@oS.X[j] - 2.0*oS.X[i]@oS.X[j]  #eta性質可以知道是>=0的,所以我們只需要判斷是否為0即可
        if eta <= 0:
            print("eta <= 0")
            return 0

由下述η化簡可以知道:

η的取值範圍必然是η>=0。

又因為我們在推導SVM算法中知道:

當η=0時,我們要求解的α無法更新,所以,我們只需要η>0即可。

所以,代碼中判斷η<=0時,不符合條件,退出即可。  

(五)代碼實現

#三:實現內循環函數,相比於外循環,這裡包含了主要的更新操作
def innerL(i,oS):   #由外循環提供i值(具體選取要違背kkT<這裡實現>,使用交替遍歷<外循環中實現>)---提供α_1的索引
    Ei = calcEk(oS,i)   #計算E1值,主要是為了下面KKT條件需要使用到

    #如果下面違背了KKT條件,則正常進行α、Ek、b的更新,重點:後面單獨說明下面是否滿足違反KKT條件
    if ((oS.label[i]*Ei < -oS.toler) and (oS.alphas[i] < oS.C)) or\
       ((oS.label[i]*Ei > oS.toler) and (oS.alphas[i] > 0)):  #注意:對於硬間隔,我們直接和1對比,對於軟間隔,我們要和1 +或- ε對比
        #開始在內循環中,選取差值最大的α_2下標索引
        j,Ej = selectJ(i,oS,Ei)
        #因為後面要修改α_1與α_2的值,但是後面修改閾值b的時候需要用到新舊兩個值,所以我們需要在更新α值之前進行保存舊值
        alphaIold = oS.alphas[i].copy()
        alphaJold = oS.alphas[j].copy()

        #分析約束條件(是對所有α都適用),一會對我們新的α_2進行截取糾正,注意:α_1是由α_2推出的,所以不需要進行驗證了。
        #如果y_1!=y_2異號時:
        if oS.label[i] != oS.label[j]:
            L = max(0,alphaJold-alphaIold)
            H = min(oS.C,oS.C+alphaJold-alphaIold)
        else:   #如果y_1==y_2同號時
            L = max(0,alphaJold+alphaIold-oS.C)
            H = min(oS.C,alphaJold+alphaIold)
        #上面就是將α_j調整到L,H之間
        if L==H:    #如果L==H,之間返回0,跳出這次循環,不進行改變(單值選擇,沒必要)
            return 0

        #計算η值=k_11+k_22-2k_12
        eta = oS.X[i]@oS.X[i] + oS.X[j]@oS.X[j] - 2.0*oS.X[i]@oS.X[j]  #eta性質可以知道是>=0的,所以我們只需要判斷是否為0即可
        if eta <= 0:
            print("eta <= 0")
            return 0

        #當上面所有條件都滿足以後,我們開始正式修改α_2值,並更新對應的Ek值
        oS.alphas[j] += oS.label[j]*(Ei-Ej)/eta
        oS.alphas[j] = clipAlpha(oS.alphas[j],H,L)
        updateEk(oS,j)

        #查看α_2是否有足夠的變化量,如果沒有足夠變化量,我們直接返回,不進行下面更新α_1,注意:因為α_2變化量較小,所以我們沒有必要非得把值變回原來的舊值
        if abs(oS.alphas[j] - alphaJold) < 0.00001:
            print("J not move enough")
            return 0

        #開始更新α_1值,和Ek值
        oS.alphas[i] += oS.label[i]*oS.label[j]*(alphaJold-oS.alphas[j])
        updateEk(oS,i)

        #開始更新閾值b,正好使用到了上面更新的Ek值
        b1 = oS.b - Ei - oS.label[i] * (oS.alphas[i] - alphaIold) * oS.X[i] @ oS.X[i] - oS.label[j] * (
                    oS.alphas[j] - alphaJold) * oS.X[i] @ oS.X[j]

        b2 = oS.b - Ej - oS.label[i] * (oS.alphas[i] - alphaIold) * oS.X[i] @ oS.X[j] - oS.label[j] * (
                    oS.alphas[j] - alphaJold) * oS.X[j] @ oS.X[j]

        #根據統計學習方法中閾值b在每一步中都會進行更新,
        #1.當新值alpha_1不在界上時(0<alpha_1<C),b_new的計算規則為:b_new=b1
        #2.當新值alpha_2不在界上時(0 < alpha_2 < C),b_new的計算規則為:b_new = b2
        #3.否則當alpha_1和alpha_2都不在界上時,b_new = 1/2(b1+b2)
        if oS.alphas[i] > 0 and oS.alphas[i] < oS.C:
            oS.b = b1
        elif oS.alphas[j] > 0 and oS.alphas[j] < oS.C:
            oS.b = b2
        else:
            oS.b = 1/2*(b1+b2)

        #注意:這裡我們應該根據b_new更新一次Ei,但是我們這裡沒有寫,因為我們將這一步提前到了最開始,即selectJ中

        #以上全部更新完畢,開始返回標識
        return 1
    return 0    #沒有違背KKT條件

六:代碼實現(四)SMO中的外循環函數

(一)交替遍歷

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

(二)代碼實現

#四:開始外循環,由於我們在內循環中實現了KKT條件的判斷,所以這裡我們只需要進行交替遍歷即可
#交替遍歷一種方式是在所有的數據集上進行單遍掃描,另一種是在非邊界上(不在邊界0或C上的值)進行單遍掃描
# 交替遍歷:
# 交替是通過一個外循環來選擇第一個alpha值的,並且其選擇過程會在兩種方式之間交替:
# 一種方式是在所有數據集上進行單遍掃描,
# 另一種方式則是在非邊界alpha中實現單遍掃描,所謂非邊界alpha指的是那些不等於邊界0或C的alpha值。
# 對整個數據集的掃描相當容易,
# 而實現非邊界alpha值的掃描時,首先需要建立這些alpha值的列表,然後對這個表進行遍歷。
# 同時,該步驟會跳過那些已知不變的alpha值。
def smoP(data_X,data_Y,C,toler,maxIter):
    oS = optStruct(data_X,data_Y,C,toler)
    iter = 0
    entireSet = True    #標誌是否應該遍歷整個數據集
    alphaPairsChanged = 0   #標誌一次循環中α更新的次數
    #開始進行迭代
    #當iter >= maxIter或者((alphaPairsChanged == 0) and not entireSet)退出循環
    #前半個判斷條件很好理解,後面的判斷條件中,表示上一次循環中,是在整個數據集中遍歷,並且沒有α值更新過,則退出
    while iter < maxIter and ((alphaPairsChanged > 0) or entireSet):
        alphaPairsChanged = 0
        if entireSet:   #entireSet是true,則在整個數據集上進行遍歷
            for i in range(oS.m):
                alphaPairsChanged += innerL(i,oS)   #調用內循環
            print("full dataset, iter: %d i:%d,pairs changed:%d"%(iter,i,alphaPairsChanged))
            iter += 1   #無論是否更新過,我們都計算迭代一次
        else:   #遍歷非邊界值
            nonBounds = np.where((oS.alphas>0) & (oS.alphas<C))[0]  #獲取非邊界值中的索引
            for i in nonBounds: #開始遍歷
                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還是False,下一次還是在非邊界上進行遍歷。可以認為這裡是傾向於非邊界遍歷,因為非邊界遍歷的樣本更符合內循環中的違反KKT條件
            entireSet = True

        print("iteration number: %d"%iter)

    return oS.b,oS.alphas

七:根據α實現求解權重W值

(一)公式

根據西瓜書中6.37:

 

求解權重向量

(二)代碼實現

def calcWs(alphas,data_X,data_Y):
    #根據西瓜書6.37求W
    m,n = data_X.shape
    w = np.zeros(n)
    for i in range(m):
        w += alphas[i]*data_Y[i]*data_X[i].T

    return w

八:測試SMO算法的實現

data_X,data_Y = loadDataSet("testSet.txt")
C = 0.6
toler = 0.001
maxIter = 40

b,alphas = smoP(data_X,data_Y,C,toler,maxIter)

ws = calcWs(alphas,data_X,data_Y)   #含有隨機操作,所以有多種可能性結果
print(ws)
test = data_X[0]@ws+b
print(test)
test = data_X[2]@ws+b
print(test)
test = data_X[1]@ws+b
print(test)

九:繪製圖像和支持向量

(一)代碼實現

#繪製圖像
def plotFigure(weights, b,toler,data_X,data_Y):
    m,n = data_X.shape
    # 進行數據集分類操作
    cls_1x = data_X[np.where(data_Y==1)]
    cls_1y = data_Y[np.where(data_Y==1)]
    cls_2x = data_X[np.where(data_Y!=1)]
    cls_2y = data_Y[np.where(data_Y!=1)]

    plt.scatter(cls_1x[:,0].flatten(), cls_1x[:,1].flatten(), s=30, c='r', marker='s')
    plt.scatter(cls_2x[:,0].flatten(), cls_2x[:,1].flatten(), s=30, c='g')

    # 畫出 SVM 分類直線
    xx = np.arange(0, 10, 0.1)
    # 由分類直線 weights[0] * xx + weights[1] * yy1 + b = 0 易得下式
    yy1 = (-weights[0] * xx - b) / weights[1]
    # 由分類直線 weights[0] * xx + weights[1] * yy2 + b + 1 = 0 易得下式
    yy2 = (-weights[0] * xx - b - 1 - toler) / weights[1]
    # 由分類直線 weights[0] * xx + weights[1] * yy3 + b - 1 = 0 易得下式
    yy3 = (-weights[0] * xx - b + 1 + toler) / weights[1]
    plt.plot(xx, yy1.T)
    plt.plot(xx, yy2.T)
    plt.plot(xx, yy3.T)

    # 畫出支持向量點
    for i in range(m):
        if alphas[i] > 0.0:
            plt.scatter(data_X[i, 0], data_X[i, 1], s=150, c='none', alpha=0.7, linewidth=1.5, edgecolor='red')

    plt.xlim((-2, 12))
    plt.ylim((-8, 6))
    plt.show()

plotFigure(ws,b,toler,data_X,data_Y)

(二)圖像顯示