在幾百個點組成的小規模數據集上,簡化版SMO算法的運行是沒有什麼問題的,但是在更大的數據集上的運行速度就會變慢。剛才已經討論了簡化版SMO算法,下面我們就討論完整版的Platt 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值。
在講述改進後的代碼之前,我們必須要對上節的代碼進行清理。下面的程序清單中包含1個用於清理代碼的數據結構和3個用於對E
進行緩存的輔助函數。我們可以打開一個文本編輯器,輸入如下代碼:
程序清單6-3 完整版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 = float(multiply(oS.alphas,oS.labelMat).T*(oS.X*oS.X[k,:].T)) + 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)
#❸(以下兩行)選擇具有最大步長的j
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
類。該方法可以實現其成員變量的填充。除了增加了一個m×2的矩陣成員變量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函數nonzero
返回了一個列表,而這個列表中包含以輸入列表為目錄的列表值,當然讀者可以猜得到,這裡的值並非零。nonzero
語句返回的是非零E
值所對應的alpha值,而不是E
值本身。程序會在所有的值上進行循環並選擇其中使得改變最大的那個值❸。如果這是第一次循環的話,那麼就隨機選擇一個alpha值。當然,也存在有許多更複雜的方式來處理第一次循環的情況,而上述做法就能夠滿足我們的目的。
程序清單6-3的最後一個輔助函數是updateEk
,它會計算誤差值並存入緩存當中。在對alpha值進行優化之後會用到這個值。
程序清單6-3中的代碼本身的作用並不大,但是當和優化過程及外循環組合在一起時,就能組成強大的SMO算法。
接下來將簡單介紹一下用於尋找決策邊界的優化例程。打開文本編輯器,添加下列清單中的代碼。在前面,讀者已經看到過下列代碼的另外一種形式。
程序清單6-4 完整Platt 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)):
#❶ 第二個alpha選擇中的啟髮式方法
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.0 * 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)
#❷ 更新錯誤差值緩存
updateEk(oS, j)
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.0
return 1
else: return 0
程序清單6-4中的代碼幾乎和程序清單6-2中給出的smoSimple
函數一模一樣,但是這裡的代碼已經使用了自己的數據結構。該結構在參數oS
中傳遞。第二個重要的修改就是使用程序清單6-3中的selectJ
而不是selectJrand
來選擇第二個alpha的值❶。最後,在alpha值改變時更新Ecache
❷。程序清單6-5將給出把上述過程打包在一起的代碼片段。這就是選擇第一個alpha值的外循環。打開文本編輯器將下列代碼加入到svmMLiA.py
文件中。
程序清單6-5 完整版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
程序清單6-5給出的是完整版的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值。
為觀察上述執行效果,在Python提示符下輸入如下命令:
>>> dataArr,labelArr = svmMLiA.loadDataSet(\'testSet.txt\')
>>> b,alphas = svmMLiA.smoP(dataArr, labelArr, 0.6, 0.001, 40)
non-bound, iter: 2 i:54, pairs changed 0
non-bound, iter: 2 i:55, pairs changed 0
iteration number: 3
fullSet, iter: 3 i:0, pairs changed 0
fullSet, iter: 3 i:1, pairs changed 0
fullSet, iter: 3 i:2, pairs changed 0
類似地,讀者也可以檢查b
和多個alpha的值。那麼,相對於簡化版SMO算法,上述方法是否更快?基於前面給出的設置在我自己簡陋的筆記本上運行10次算法,然後求平均值,最後得到的結果是0.78秒。而在同樣的數據集上,smoSimple
函數平均需要14.5秒。在更大規模的數據集上結果可能更好,另外也存在很多方法可以進一步提升其運行速度。
如果修改容錯值結果會怎樣?如果改變C
的值又如何呢?在6.2節末尾曾經粗略地提到,常數C
給出的是不同優化問題的權重。常數C
一方面要保障所有樣例的間隔不小於1.0,另一方面又要使得分類間隔要盡可能大,並且要在這兩方面之間平衡。如果C
很大,那麼分類器將力圖通過分隔超平面對所有的樣例都正確分類。這種優化的運行結果如圖6-5所示。與圖6-4相比,會發現圖6-5中的支持向量更多。如果回想一下,就會記得圖6-4實際來自於簡化版算法,該算法是通過隨機的方式選擇alpha對的。這種簡單的方式也可以工作,但是效果卻不如完整版本好,後者覆蓋了整個數據集。讀者可能還認為選出的支持向量應該始終最接近分隔超平面。給定C
的設置,圖中畫圈的支持向量就給出了滿足算法的一種解。如果數據集非線性可分,就會發現支持向量會在超平面附近聚集成團。
圖6-5 在數據集上運行完整版SMO算法之後得到的支持向量,其結果與圖6-4稍有不同
讀者可能會想,剛才我們花了大量時間來計算那些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
計算毫無作用,所以數據集的其他數據點也就會很容易地被捨棄。
為了使用前面給出的函數,輸入如下命令:
>>> ws=svmMLiA.calcWs(alphas,dataArr,labelArr)
>>> ws
array([[ 0.65307162],
[-0.17196128]])
現在對數據進行分類處理,比如對說第一個數據點分類,可以這樣輸入:
>>> datMat=mat(dataArr)
>>> datMat[0]*mat(ws)+b
matrix([[-0.92555695]])
如果該值大於0,那麼其屬於1類;如果該值小於0,那麼則屬於-1類。對於數據點0,應該得到的類別標籤是-1,可以通過如下的命令來確認分類結果的正確性:
>>> labelArr[0]
還可以繼續檢查其他數據分類結果的正確性:
>>> datMat[2]*mat(ws)+b
matrix([[ 2.30436336]])
>>> labelArr[2]
1.0
>>> datMat[1]*mat(ws)+b
matrix([[-1.36706674]])
>>> labelArr[1]
-1.0
讀者可將該結果與圖6-5進行比較以確認其有效性。
我們現在可以成功訓練出分類器了,我想指出的就是,這裡兩個類中的數據點分佈在一條直線的兩邊。看一下圖6-1,大概就可以得到兩類的分隔線形狀。但是,倘若兩類數據點分別分佈在一個圓的內部和外部,那麼會得到什麼樣的分類面呢?下一節將會介紹一種方法對分類器進行修改,以說明類別區域形狀不同情況下的數據集分隔問題。