前言
本系列為機器學習算法的總結和歸納,目的為了清晰闡述算法原理,同時附帶上手代碼實例,便於理解。
目錄
組合算法(Ensemble Method)
機器學習算法總結
一、簡介
1 概述
支持向量機(Support Vector Machine,常簡稱為SVM)是一種監督式學習的方法,可廣泛地應用於統計分類以及回歸分析。支持向量機屬於一般化線性分類器,這族分類器的特點是他們能夠同時最小化經驗誤差與最大化幾何邊緣區,因此支持向量機也被稱為最大邊緣區分類器。


如上圖所示,SVM的目的是尋找一個最優分割面,使得分類分類間隔最大化。而最優分割面求解原則可以大致歸納如下:
最優決策面能夠容忍更多噪聲—>所有樣本與分割超平面的距離盡可能遠—>最差的樣本(離分割超平面最近的樣本)與分割超平面的距離要盡可能遠。
2 數學建模
以上簡單概括了SVM工作的大致思路,下面要進一步求解“決策面”,也就是最優化。在這個最優化問題中,目標函數對應的是“分類間隔”,而優化對象則是決策面。
(1)決策面方程(超平面方程)
將二維空間直線y = ax + b進行轉換和向量化,可得


其中向量w和x分別為:


向量化后的w和r幾何意義分別是原直線的法向量和截距。
將上式推廣到n維空間就變成了超平面方程(一個超平面,在二維空間的例子就是一個直線),而且公式沒有變,只是


(2)“分類間隔”方程


由圖可知間隔的大小實際上就是支持向量對應的樣本點到決策面的距離的二倍。而


公式中的直線方程為Ax0+By0+C=0,點P的坐標為(x0,y0)。
將直線方程擴展到多維,求得我們現在的超平面方程,對公式進行如下變形:


這個d就是"分類間隔"。其中||w||表示w的二范數,求所有元素的平方和,然后再開方。因此


目的是為了找出一個分類效果好的超平面作為分類器。分類器的好壞的評定依據是分類間隔W=2d的大小,即分類間隔w越大,我們認為這個超平面的分類效果越好。此時,求解超平面的問題就變成了求解分類間隔W最大化的為題。W的最大化也就是d最大化的。
(3)約束條件
獲得目標函數的數學形式之后,需要將約束條件用數學語言進行描述。即如何判斷超平面是否將樣本點正確分類?以及怎么在眾多的點中選出支持向量上的點?
首先如果完全正確分類,會滿足


如果我們目標是求解中軸線上的決策面,並且相應的支持向量對應的樣本點到決策面的距離為d,代入整理並簡化,最終可得SVM最優化問題的約束條件


(4)線性SVM優化問題基本描述
得到我們的目標函數,我們的目標是d最大化。



而支持向量上的樣本點滿足


因此可以將目標函數進一步簡化,

隨后我們求解d的最大化問題變成了||w||的最小化問題


上述等效是為了求導方便,並不影響求解過程。因此,將最終的目標函數和約束條件放在一起進行描述:


上述公式描述的是一個典型的不等式約束條件下的二次型函數優化問題,同時也是支持向量機的基本數學模型。
(5)求解准備
最優化前提是目標函數必須是凸函數,其幾何意義表示為函數任意兩點連線上的值大於對應自變量處的函數值。其中根據凸集L的定義域,可以分為局部凸和全局凸


而我們的目標函數是凸函數,因此可以采用下面幾種方法求解。
通常我們需要求解的最優化問題有如下幾類:
· 無約束優化問題,可以寫為:


· 有等式約束的優化問題,可以寫為:


· 有不等式約束的優化問題,可以寫為:


對於第(a)類的優化問題,嘗試使用的方法就是費馬大定理(Fermat),即使用求取函數f(x)的導數,然后令其為零,可以求得候選最優值,再在這些候選值中驗證;如果是凸函數,可以保證是最優解。這也就是我們高中經常使用的求函數的極值的方法。
對於第(b)類的優化問題,常常使用的方法就是拉格朗日乘子法(Lagrange Multiplier) ,即把等式約束h_i(x)用一個系數與f(x)寫為一個式子,稱為拉格朗日函數,而系數稱為拉格朗日乘子。通過拉格朗日函數對各個變量求導,令其為零,可以求得候選值集合,然后驗證求得最優值。
對於第(c)類的優化問題,常常使用的方法就是KKT條件。同樣地,我們把所有的等式、不等式約束與f(x)寫為一個式子,也叫拉格朗日函數,系數也稱拉格朗日乘子,通過一些條件,可以求出最優值的必要條件,這個條件稱為KKT條件。
因此,求解最優化問題前,還需要學習
拉格朗日函數和KKT條件。
(6)拉格朗日函數
使用拉格朗日方程的目的,它將約束條件放到目標函數中,從而將有約束優化問題轉換為無約束優化問題,並使用拉格朗日對偶優化求解過程。
公式變形如下:


問題變成了求解新目標函數的最小值


新目標函數,先求最大值,再求最小值。這樣的話,我們首先就要面對帶有需要求解的參數w和b的方程,而αi又是不等式約束,這個求解過程不好做。所以,我們需要使用拉格朗日函數對偶性,將最小和最大的位置交換一下,這樣就變成了:


而我們要的解是d = p,而滿足這個條件,首先必須是凸優化問題,同時要滿足KKT條件。
(7)KKT條件
KKT條件的全稱是Karush-Kuhn-Tucker條件,KKT條件是說最優值條件必須滿足以下條件:
條件一:經過拉格朗日函數處理之后的新目標函數L(w,b,α)對x求導為零:
條件二:h(x) = 0;
條件三:α*g(x) = 0;
可證以上條件均滿足,詳細證明過程見參考。現在,凸優化問題和KKT都滿足了,問題轉換成了對偶問題。而求解這個對偶學習問題,可以分為三個步驟:首先要讓L(w,b,α)關於w和b最小化,然后求對α的極大,最后利用SMO算法求解對偶問題中的拉格朗日乘子。
(8)SMO算法
SM表示序列最小化(Sequential Minimal Optimizaion),目標是將大優化問題分解為多個小優化問題來求解的。這些小優化問題往往很容易求解,並且對它們進行順序求解的結果與將它們作為整體來求解的結果完全一致的。在結果完全相同的同時,SMO算法的求解時間短很多。
SMO算法的目標是求出一系列alpha和b,一旦求出了這些alpha,就很容易計算出權重向量w並得到分隔超平面。
SMO算法的工作原理是:每次循環中選擇兩個alpha進行優化處理。一旦找到了一對合適的alpha,那么就增大其中一個同時減小另一個。這里所謂的"合適"就是指兩個alpha必須符合以下兩個條件,條件之一就是兩個alpha必須要在間隔邊界之外,而且第二個條件則是這兩個alpha還沒有進行過區間化處理或者不在邊界上。
3 非線性SVM
在線性不可分的情況下,SVM通過某種事先選擇的非線性映射(核函數)將輸入變量映到一個高維特征空間,將其變成在高維空間線性可分,在這個高維空間中構造最優分類超平面。
對於線性不可分,我們使用一個非線性映射,將數據映射到特征空間,在特征空間中使用線性學習器,分類函數變形如下:


建立非線性學習器分為兩步:首先使用一個非線性映射將數據變換到一個特征空間F;然后在特征空間使用線性學習器分類。
使用核函數后,可以在低維特征空間中直接計算內積 <ϕ(xi),ϕ(x)>,簡化求解過程。
徑向基核函數采用向量作為自變量的函數,能夠基於向量舉例運算輸出一個標量。徑向基核函數的高斯版本的公式如下:


σ是用戶自定義的用於確定到達率(reach)或者說函數值跌落到0的速度參數。通過調控參數σ,高斯核實際上具有相當高的靈活性,也是使用最廣泛的核函數之一。
二、代碼實戰
2.1 核函數非線性SVM實現


# -*-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()
執行結果:
2.2 Sklearn實現手寫數字識別
sklearn.svm模塊提供了很多模型供我們使用,本文使用的是svm.SVC,它是基於libsvm實現的。
SVC這個函數,一共有14個參數。具體每個參數代表內容參見官方材料
https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html
SVC很是強大,我們不用理解算法實現的具體細節,不用理解算法的優化方法。同時,它也滿足我們的多分類需求。

# -*- coding: UTF-8 -*- import numpy as np import operator from os import listdir from sklearn.svm import SVC def img2vector(filename): """ 將32x32的二進制圖像轉換為1x1024向量。 Parameters: filename - 文件名 Returns: returnVect - 返回的二進制圖像的1x1024向量 """ #創建1x1024零向量 returnVect = np.zeros((1, 1024)) #打開文件 fr = open(filename) #按行讀取 for i in range(32): #讀一行數據 lineStr = fr.readline() #每一行的前32個元素依次添加到returnVect中 for j in range(32): returnVect[0, 32*i+j] = int(lineStr[j]) #返回轉換后的1x1024向量 return returnVect def handwritingClassTest(): """ 手寫數字分類測試 Parameters: 無 Returns: 無 """ #測試集的Labels hwLabels = [] #返回trainingDigits目錄下的文件名 trainingFileList = listdir('trainingDigits') #返回文件夾下文件的個數 m = len(trainingFileList) #初始化訓練的Mat矩陣,測試集 trainingMat = np.zeros((m, 1024)) #從文件名中解析出訓練集的類別 for i in range(m): #獲得文件的名字 fileNameStr = trainingFileList[i] #獲得分類的數字 classNumber = int(fileNameStr.split('_')[0]) #將獲得的類別添加到hwLabels中 hwLabels.append(classNumber) #將每一個文件的1x1024數據存儲到trainingMat矩陣中 trainingMat[i,:] = img2vector('trainingDigits/%s' % (fileNameStr)) clf = SVC(C=200,kernel='rbf') clf.fit(trainingMat,hwLabels) #返回testDigits目錄下的文件列表 testFileList = listdir('testDigits') #錯誤檢測計數 errorCount = 0.0 #測試數據的數量 mTest = len(testFileList) #從文件中解析出測試集的類別並進行分類測試 for i in range(mTest): #獲得文件的名字 fileNameStr = testFileList[i] #獲得分類的數字 classNumber = int(fileNameStr.split('_')[0]) #獲得測試集的1x1024向量,用於訓練 vectorUnderTest = img2vector('testDigits/%s' % (fileNameStr)) #獲得預測結果 # classifierResult = classify0(vectorUnderTest, trainingMat, hwLabels, 3) classifierResult = clf.predict(vectorUnderTest) print("分類返回結果為%d\t真實結果為%d" % (classifierResult, classNumber)) if(classifierResult != classNumber): errorCount += 1.0 print("總共錯了%d個數據\n錯誤率為%f%%" % (errorCount, errorCount/mTest * 100)) if __name__ == '__main__': handwritingClassTest()
三、SVM的優缺點
3.1 優點
- 可用於線性/非線性分類,也可以用於回歸,泛化錯誤率低,也就是說具有良好的學習能力,且學到的結果具有很好的推廣性。
- 可以解決小樣本情況下的機器學習問題,可以解決高維問題,可以避免神經網絡結構選擇和局部極小點問題。
- SVM是最好的現成的分類器,現成是指不加修改可直接使用。並且能夠得到較低的錯誤率,SVM可以對訓練集之外的數據點做很好的分類決策。
3.2 缺點
- 對參數調節和和函數的選擇敏感。
參考: