面向開發人員的機器學習指南
現如今,大多數的開發人員都聽說過機器學習,但是當他們試圖尋找捷徑來學習這些技術時,卻有很多人都對機器學習中的一些抽象概念望而卻步,諸如 回歸 、 無監督學習 、 概率密度函數 和其他許多定義。如果訴諸于書本,代表著作有 《An Introduction to Statistical Learning with Applications in R》 與 《Machine Learning for Hackers》 ,其中的實例是用 R 語言實現的。
然而,R 實際上跟 Java、C#、Scala 等用于開發日常應用的編程語言不同。這也是本文采用 Smile 來介紹機器學習的原因,Smile 是關于機器學習的一個庫,它可以在 Java 與 Scala 中使用,而這兩種語言對大多數開發者來說至少是不陌生的。
第一部分“機器學習的整體框架”包含了學習下文應用實例要用到的所有重要概念。“應用實例”這一部分參考了 《Machine Learning for Hackers》 這本書的例子。另外, 《Machine Learning in Action》 這本書將用于驗證。
第二部分“應用實例”包含了各種機器學習 (ML) 應用實例,以 Smile 作為 ML 庫。
注意在本文中,“新”定義會添加超鏈接,以備讀者了解該主題的更多相關內容,但是完成這些實例,并不需要閱讀全部的內容。
最后,我想感謝以下這些人:
- Haifeng Li : 感謝他給予的支持,還寫了那么出色、且能免費使用的 Smile 庫。
- Erik Meijer : 感謝在我寫這篇博文的時候他給予的建議與指導。
- Richard van Heest 、 Lars Willems : 感謝他們審閱了這篇博文并做了反饋。
機器學習的整體框架
也許你曾聽說過機器學習這個概念。然而,如果要你向另一個人解釋什么是機器學習,你要怎么做呢?在繼續閱讀之前,請先考慮一下這個問題。
機器學習有很多不同的定義方式,其中一些更加精確,然而,在這些定義中也有許多不一致之處。有些定義認為機器學習就是根據歷史數據建立一個靜態的模型,然后可以用于預測未來的數據。另一些則認為隨著數據的增加,它是一個隨時間不斷變化的動態模型。
我是比較支持動態說的,但是由于某些限制,我們的實例只用來闡釋靜態的模型方法。不過,在動態機器學習這一節中,我們也對動態原則怎樣運作做了解釋。
接下來的這一部分給出了機器學習中常用的定義和概念。我們建議讀者在進入應用實例的學習之前先通讀這部分。
特征
一個特征就是用來訓練模型的一種性質。例如,基于文字“買”和“錢”出現的頻率可以把郵件分類為垃圾郵件和正常郵件。這些用來分類的單詞就是特征,如果把它們與其他單詞組合在一起,那它們就是特征的一部分。如果你想用機器學習來預測一個人是否是你的朋友,那么“共同的朋友”可以作為一個特征。注意,在這個領域中,特征有時也指 屬性 。
模型
一提到機器學習, 模型 是經常會碰到的一個術語。模型就是一種機器學習方法的結果以及該方法采用的算法。這個模型可以在監督學習中用來做預測,或者在無監督學習中用來檢索聚類。在這個領域中, 在線訓練 和 離線訓練 這兩個術語也有很大機會見到。在線訓練指的是往一個已經存在的模型中添加訓練數據,而離線訓練指的是從頭開始建立一個新模型。由于性能原因,在線訓練方法是最可取的。然而,對某些算法來說,也有例外。
學習方法
在機器學習領域中,有兩種前沿的的學習方式,也就是監督學習和無監督學習,簡要地介紹一下還是很有必要的,因為在機器學習的應用中,選擇合適的機器學習方法和算法是一個重要而有時又有點乏味的過程。
監督學習
在監督學習中,你可以明確地定義要使用的特征,以及你預期的輸出結果。例如,通過身高和體重預測性別,這是一個分類的問題。此外,你還可以通過回歸分析預測絕對值。用同樣的數據做回歸分析的一個例子是通過性別和體重預測一個人的身高。某些監督算法只能用來解決分類問題和回歸分析中的一種,例如 K-NN。不過也有一些算法如 Support Vector Machines 在兩種情況下都適用。
分類
在監督學習的范圍內,分類問題是相對簡單的。考慮一組標簽以及一些已經打上正確標簽的數據,我們想要做的就是為新數據預測標簽。然而,在把數據考慮為分類問題之前,你應該分析一下數據的特點。如果數據的結構明顯可以讓你輕松地畫出一條回歸線,那么應用回歸算法反而會更好。如果數據無法擬合出一條回歸線,或者當算法的性能不理想時,那么分類就是一個很好的選擇。
分類問題的一個例子是,根據郵件的內容把郵件分為正常或垃圾郵件。考慮一個訓練組,其中的郵件被標為正常或垃圾,可以應用一個分類算法來訓練模型。這個模型就可以用來預測未來的郵件是正常或是垃圾。分類算法的一個典型的例子是 K-NN 算法,分類問題的常用實例是將郵件分為垃圾郵件或正常郵件,這也是本文當中使用到的例子之一。
回歸
回歸要比分類要強大很多。這是因為,回歸分析預測的是實際值而不是標簽。一個簡單的例子可以說明這一點。考慮一個表格,其中包含體重、身高和性別等數據,當給定一個體重和身高數據時,你可以應用 K-NN 算法來預測某人的性別。對這個同樣的數據集使用回歸分析,如果是給定性別以及其他各個缺失的參數,反過來,你可以預測某人的體重或身高。
能力越大,責任就越大,所以在用回歸分析建立模型時必須格外小心。常見的陷阱是過擬合、欠擬合以及對模型如何控制 外推法 與 內插法 欠缺考慮。
無監督學習
相比監督學習,無監督學習不需要你事先確切地了解輸出結果。應用無監督學習的中心思想是發掘出一個數據集內在的結構。PCA 就是一個例子,它通過組合特征從而減小特征數。 組合過程基于這些特征之間可能隱含的關聯。另一個無監督學習的例子是 K-均值聚類。K-均值聚類就是要找出一個數據集中的分組,之后這些分組可以用于其他目的,例如用于監督學習中。
主成分分析(PCA)
主成分分析是統計學中的一種技術,它用于將一組相關列轉化為較小的一組無關列,以化簡一個問題的特征數。這組較小的列就叫做主成分。這種技術主要用于探索性數據分析中,因為它揭示了數據中的內部結構,這樣的結構無法直觀地看出來。
PCA 一個最大弱點就是數據中的異常值。這些異常值嚴重地影響了結果,所以,事先觀察數據,排除較大的異常值能夠極大地提高這種方法的性能。
為了清楚 PCA 到底是做什么用的,我們將一組二維點數據的圖與同樣數據經 PCA 處理后的圖作了對比。
原始數據表示在左圖中,其中每個顏色表示不同的類別。顯然,這些數據可以從二維約化到一維之后,仍然能夠恰當的分類。這就是 PCA 被提出的緣故了。根據每個數據點的原始維度,通過 PCA 可以計算出一個新的值來。
右圖是對這些數據點應用了 PCA 后的結果。注意,這些數據有一個y值,但這純粹是為了能夠將數作圖展示出來。所有這些數據點的Y值都是0,因為 PCA 算法只返回 X 值。同時注意,右圖數據點中的 X 值并不對應左圖中各點的X值,這表明 PCA 并不僅僅是“丟掉”一個維度。
驗證技術
在這部分中我們會介紹一些用于模型驗證的技術方法,以及一些與機器學習驗證方法相關的專業術語。
交叉驗證
交叉驗證法是機器學習領域中最常用的驗證方法之一。它的基本思想是,將原始數據分為訓練集和驗證集,先用訓練集對模型進行訓練,然后再用模型來預測驗證集的數據。將預測值與實際值進行對比,以此來評價模型的性能和訓練數據的質量。
這種交叉驗證法最重要的環節是分割數據。應用這種方法時,應該始終使用整個數據集。換言之,你不可以隨機選取 X 個數據點作為訓練集然后隨機選取X個數據點作為驗證集,這樣的話,在兩個數據集中可能就有些點是重復的,而另一些點又沒有被利用到。
2-折交叉驗證
在2-折交叉驗證中,每一“折”(所以要執行兩次)都要將數據分割為驗證集和訓練集,用訓練集訓練模型,再用驗證集做驗證。這樣做就可以在驗證模型時計算兩次誤差。這些誤差值不應該相差太大。萬一差太大,那么不是你的數據有問題,就是你選來建立模型的特征有問題。無論是哪種情況,你都應該再看看數據,找出具體的問題,因為以數據為基礎來訓練一個模型有可能因為錯誤數據而出現模型過度擬合的情況。
正則化
正則化的基本思想是,通過簡化一個模型而防止它過度擬合。假設你的數據滿足一個三次多項式函數,但是數據中有噪聲,這會使模型函數的次數加高一級。于是,盡管模型剛開始好像不錯,但碰到新的數據就表現不理想了。正則化通過一個特定的 λ 值來簡化模型,有利于防止這種情況的發生。然而,要找到一個合適的 λ 值卻不易,因為你不知道模型什么時候才會過度擬合。這也是交叉驗證法經常被用來尋找適合模型的最佳 λ 值的原因。
精確率
在計算機科學中,我們用精確率這個術語來描述相關的選中條目的數量。因此,當你計算一個文檔搜索算法的精確率時,那個算法的精確率就定義為在設定的結果中有多少個文檔是確實相關的。
這個值由下式算出:
掌握這個內容可能會有點難,所以我舉個例子:
假設有一個完備的文檔集 {aa,ab,bc,bd,ee},我們要查找名字帶有 a 的文檔。如果算法返回的文檔集是 {aa,ab},那么直覺告訴我們精確率是100%。我們可以代入公式驗證一下:
事實上就是100%。如果我們再查找一次,除了{aa,ab}這個結果,我們還得到{bc,de}這個結果,精確率就會受到影響如下:
這里,結果中包含了相關的結果,也包含了兩個不相關的結果,導致精確率降低了。然而,如果給這個例子計算召回率,那它將是100%,這就是精確率與召回率之間的不同之處。
召回率
召回率是指,給定查找條件和一個數據集,算法檢索到的相關條目的數量。因此,給定一組文檔以及能夠返回一個文檔子集的查找條件,召回率就表示相關的文檔中有多少被實際返回。召回率由下式計算:
我們舉個例子看看如何應用該公式:
假設有一個完備的文檔集 { aa, ab, bc, bd, ee },我們要查找名字帶有a的文檔。如果算法返回{aa,ab},那么召回率顯然就是100%。我們可以代入公式驗證一下:
事實上就是100%。下面我們看看如果算法只返回部分相關結果會怎么樣:
這里,結果只包含一半的相關結果,導致召回率降低了。然而,如果計算這種情況下的精確率,結果是會100%,因為所有返回結果都是相關的。
先驗
給定一個數據點,一個分類器的先驗值代表了這個數據點屬于該分類器的可能性大小。在實踐中,這意味著當你在一個數據點處得到一個預測值時,先驗值就表示模型對那個數據點的分類的確信度有多高。
均方根誤差(RMSE)
均方根誤差(RMSE 或 RMSD,D 代表 deviation,即偏差)是指對實際值與預測值之差先平方,再求均值,然后開方。我舉個例子來解釋一下好幫助理解。假設我們有以下數據:
模型的平方差的均值是 4.33333,該值的平方根是 2.081666. 因此,該模型的預測值的平均誤差為 2.08。RMSE 值越低,模型的預測效果越好。這就是為什么在選擇特征時,人們會分別計算包含和不包含某個特征的 RMSE 值,以判斷那個特征是如何影響模型的性能的。通過這些信息,人們就可以確定,和模型的效率提升比起來,由于該特征值增加的額外的計算時間是否值得。
此外,因為 RMSE 值是一個絕對值,所以它可以歸一化以進行模型之間的比較。這就是歸一化均方根誤差 (NRMSE)。然而,要計算這個值,首先要知道系統所包含的最小值與最大值。假設我們有一個最小值為 5 度、最大值為 25 度的溫度范圍,那么可用下式來計算 NRMSE 值:
若代入實際值,可得到如下結果:
那這個 10.4 表示什么呢?這是模型對數據點進行預測的平均誤差百分數。
最后,我們可以利用 RMSE 值來計算擬合度 (R Squared)。擬合度反映的是,與各個值的平均值作比較(不考慮模型的情況),模型的預測效果有多好。我們首先要計算出平均法的 RMSE 值。對上文的表格最后一列值的取平均,結果是 4.22222,其平方根是 2.054805。首先注意到這個值比模型的值要小。這不是個好現象,因為這表明模型的預測效果比單單取平均值要差。然而,我們主要是演示怎么計算擬合度,所以我們繼續計算過程。
現在,模型與平均法的 RSME 值都求出來了,接著用下式計算擬合度:
代入實際值得到以下結果:
那么,-1.307229 代表什么呢?它就是表示模型每次對一個值的預測效果比平均法差約 1.31%。換言之,在這個具體情況中,用平均法來做預測比用模型的效果要好。
常見陷阱
這部分要介紹的是在應用機器學習技術的過程中經常會碰到的問題,主要內容是向讀者解析這些陷阱以幫助讀者避開它們。
過度擬合
對數據進行擬合時,數據本身可能會包含噪聲(例如有測量誤差)。如果你精確地把每一個數據點都擬合進一個函數中,那你會把噪聲也耦合到模型中去。這雖然能使模型在預測測試數據時表現良好,但在預測新數據時會相對較差。
把數據點和擬合函數畫在圖表中,下列左圖反映過度擬合的情況,右圖表示一條穿過數據點的回歸曲線,它對數據點能夠 適當擬合 。
應用回歸分析時容易產生過度擬合,也很容易出現在樸素貝葉斯分類算法中。在回歸分析中,產生過度擬合的途徑有舍入操作、測量不良和噪聲數據。在樸素貝葉斯分類算法中,選定的特征可能導致過度擬合。例如,對垃圾和正常郵件的分類問題保留所有停用詞。
通過運用驗證技術、觀察數據的統計特征以及檢測和剔除異常值,可以檢測出過度擬合。
欠擬合
當你對數據進行建模時,把很多統計數據都遺漏掉了,這叫做欠擬合。有很多原因可以導致欠擬合,例如,對數據應用不合適的回歸類型。如果數據中包含了非線性結構,而你卻運用線性回歸,這就產生了一個欠擬合模型。下列左圖代表了一條欠擬合的回歸線,右圖右圖表示一條合適的回歸線。
為了防止欠擬合,你可以畫出數據點從而了解數據的內在結構,以及應用驗證技術,例如交叉驗證。
維度災難
對于已知的數據量存在一個最大的特征數(維數),當實際用于建立機器模型的特征數超過這個最大值時,就產生維度災難問題。矩陣秩虧就是這樣的一種問題。 普通最小二乘 (OLS) 算法 通過解一個線性系統來建立模型。然而,如果矩陣的列數多于行數,那這個系統不可能有唯一解。最好的解決辦法是獲取更多數據點或者減小特征數。
動態機器學習
在幾乎所有你能找到的機器學習文獻中,靜態模型就是首先通過建立、驗證流程,然后作預測或建議用途。然而在實踐中,僅僅這樣做還不足以應用好機器學習。所以,我們在這部分中將要介紹怎么樣把一個靜態模型改造成一個動態模型。因為(最佳的)實現是依賴于你所使用的算法的,所以我們將只作概念介紹而不給出實例了。鑒于文本解釋不夠清晰,我們首先用一個圖表展示整個體系,然后用這個圖表介紹機器學習以及如何做成一個動態系統。
機器學習的基本流程如下:
1、搜集數據
2、把數據分割為測試集和訓練集
3、訓練一個模型(應用某種機器學習算法)
4、驗證模型,驗證方法需要使用模型和測試數據
5、基于模型作出預測。
在該領域的實際應用中,以上的流程是不完整的,有些步驟并未包含進去。在我看來,這些步驟對于一個智能學習系統來說至關重要。
所謂的動態機器學習,其基本思路如下:模型作出預測后,將預測信息連同用戶反饋一起返回給系統,以改善數據集和模型。那么,這些用戶反饋是怎么獲得的呢?我們以為 非死book 的朋友推薦為例。用戶面臨兩種選擇:“添加朋友”或“移除”。基于用戶的決定,對于那個預測你就得到了用戶的直接反饋。
因此,假設你獲得了這些用戶反饋,那么你可以對模型應用機器學習來學習這些用戶反饋。聽起來可能有點奇怪,我們會更詳細地解釋這一過程。然而在那之前,我們要做一個免責聲明:我們關于臉書朋友推薦系統的解釋是一個100%的假說,并且絕對沒有經過臉書本身的證實。就我們所知,他們的系統對外是不公開的。
假設該系統基于以下特征進行預測:
1、共同朋友的數量
2、相同的戶籍
3、相同的年齡
然后你可以為臉書上的每一個人計算出一個先驗值,這個先驗值描述了他/她是你的朋友的概率有多大。假設你把一段時間內所有的預測信息都存儲下來,就可以用機器學習分析這些數據來改善你的系統。更詳細地說,假設大多數的“移除好友”推薦在特征 2 上具有較高評級,但在特征 1 上評級相對較低,那么我們可以給預測系統加入權重系數,讓特征 1 比特征 2 更重要。這樣就可以為我們改善推薦系統。
此外,數據集隨時間而增大,所以我們要不斷更新模型,加入新數據,使預測更準確。不過,在這個過程中,數據的量級及其突變率起著決定性作用。
實例
在這部分中,我們結合實際環境介紹了一些機器學習算法。這些實例主要為了方便讀者入門之用,因此我們不對其內在的算法作深入講解。討論的重點完全集中在這些算法的功能方面、如何驗證算法實現以及讓讀者了解常見的陷阱。
我們討論了如下例子:
基于下載/上傳速度的互聯網服務提供商標記法 (K-NN)
正常/垃圾郵件分類(樸素貝葉斯法)
基于內容的郵件排序(推薦系統)
基于身高預測體重(線性回歸:普通最小二乘法)
嘗試預測最暢銷書排行(文本回歸)
應用無監督學習合并特征(主成分分析)
應用支持向量機(支持向量機)
我們在這些實例中都使用了 Smile 機器學習 庫,包括 smile-core 和 smile-plot 這兩個庫。這些庫在 Maven , Gradle, Ivy, SBT 和 Leiningen 等工具上都是可用的。
所以,在開始做這些實例之前,我假定你在自己最喜歡的 IDE 上建立了一個新項目,并把 smile-core 庫和 smile-plot 庫添加到了你的項目中。其他需要用到的庫以及如何獲取實例的數據會在每個例子中分別予以說明。
基于下載/上傳速度的互聯網服務供應商標記法(使用 K-NN 算法、Smile 庫、Scala 語言)
這一部分的主要目標是運用 K 最近鄰算法,根據下載/上傳速度對將 互聯網服務供應商 (ISP ) 分為 Alpha 類(由 0 代表)或 Beta 類(由 1 代表)。K-NN 算法的思路如下:給定一組已經分好類的點,那么,對新點的分類可以通過判別它的 K 個最近鄰點的類別(K 是一個正整數)。K 個最近鄰點可以通過計算新點與其周圍點之間的歐氏距離來查找。找出這些鄰近點,你就得到了最具代表性的類別,并將新點分到這一類別中。
做這一案例需要先下載 示例數據 。此外,還要把代碼段中的路徑改為你存儲示例數據的地方。
首先要加載 CSV 數據文件。這沒什么難的,所以我直接給代碼,不做進一步解釋:
object KNNExample {
defmain(args: Array[String]): Unit = {
valbasePath = "/.../KNN_Example_1.csv"
valtestData = getDataFromCSV(new File(basePath))
}
defgetDataFromCSV(file: File): (Array[Array[Double]], Array[Int]) = {
valsource = scala.io.Source.fromFile(file)
valdata = source
.getLines()
.drop(1)
.map(x => getDataFromString(x))
.toArray
source.close()
valdataPoints = data.map(x => x._1)
valclassifierArray = data.map(x => x._2)
return (dataPoints, classifierArray)
}
defgetDataFromString(dataString: String): (Array[Double], Int) = {
//Split the comma separated value string into an array of strings
//把用逗號分隔的數值字符串分解為一個字符串數組
valdataArray: Array[String] = dataString.split(',')
//Extract the values from the strings
//從字符串中抽取數值
valxCoordinate: Double = dataArray(0).toDouble
valyCoordinate: Double = dataArray(1).toDouble
valclassifier: Int = dataArray(2).toInt
//And return the result in a format that can later
//easily be used to feed to Smile
//并以一定格式返回結果,使得該結果之后容易輸入到Smile中處理
return (Array(xCoordinate, yCoordinate), classifier)
}
}
你首先可能會奇怪 為什么數據要使用這種格式 。數據點與它們的標記值之間的間隔是為了更容易地分割測試數據和訓練數據,并且在執行 K-NN 算法以及給數據繪圖時,API 需要這種數據格式。其次,把數據點存儲為一個數組 (Array[Array[Double]]),能夠支持 2 維以上的數據點。
給出這些數據之后,接下來要做的就是將數據可視化。Smile 為這一目的提供了一個很好的繪圖庫。不過,要使用這一功能,應該把代碼轉換到 Swing 中去。此外還要把數據導入到繪圖庫中以得到帶有實際繪圖結果的 JPane 面板。代碼轉換之后如下:
object KNNExample extends SimpleSwingApplication {
deftop = new MainFrame {
title = "KNN Example"
valbasePath = "/.../KNN_Example_1.csv"
valtestData = getDataFromCSV(new File(basePath))
valplot = ScatterPlot.plot(testData._1,
testData._2,
'@',
Array(Color.red, Color.blue)
)
peer.setContentPane(plot)
size = new Dimension(400, 400)
}
...
將數據繪成圖是為了檢驗 K-NN 在這種具體情況中是否為合適的機器學習算法。繪圖結果如下:
在這個圖中可以看到,藍點和紅點在區域 3<x<5 和 5<y<7.5 是混合的。既然兩組點混合在一起,那 K-NN 算法就是個不錯的選擇,若是擬合一條決策邊界則會在混合區域造成很多誤分。
考慮到 K-NN 算法是這個問題的一個不錯的選擇,我們可以繼續機器學習的實踐。GUI 在這里實際上沒有用武之地,我們摒棄不用。回顧機器學習的全局體系這部分,其中提到機器學習的兩個關鍵部分:預測和驗證。首先我們進行驗證,不作任何驗證就把模型拿來使用并不是一個好主意。這里驗證模型的主要原因是為了防止過擬合。不過在做驗證之前,我們要選擇一個合適的 K 值。
這個方法的缺點就是不存在尋找最佳 K 值的黃金法則。然而,可以通過觀察數據來找出一個合理的 K 值,使大多數數據點可以被正確分類。此外,K 值的選取要小心,以防止算法引起的不可判定性。例如,假設 K=2,并且問題包含兩種標簽,那么,當有一個點落在兩種標簽之間時,算法會選擇哪一種標簽呢?有一條經驗法則是這樣的:K應該是特征數(維數)的平方根。那在我們的例子中就會有K=1,但這真不是一個好主意,因為它會在決策邊界導致更高的誤分率。考慮到我們有兩種標簽,讓 K=2 會導致錯誤,因此,目前來說,選擇 K=3 是比較合適。
在這個例子中,我們做 2 折交叉驗證。一般來講,2 折交叉驗證是一種相當弱的模型驗證方法,因為它將數據集分割為兩半并且只驗證兩次,仍有可能產生過擬合,不過由于這里的數據集只包含 100 個點,10 折驗證(一個較強的版本)發揮不了作用,因為這樣的話,將只有 10 個點用于測試,會導致誤差率傾斜。
defmain(args: Array[String]): Unit = {
valbasePath = "/.../KNN_Example_1.csv"
valtestData = getDataFromCSV(new File(basePath))
//Define the amount of rounds, in our case 2 and
//initialise the cross validation
//在這第二種情況中我們設置了交叉驗證的次數并初始化交叉驗證
valcv = new CrossValidation(testData._2.length, validationRounds)
valtestDataWithIndices = (testData
._1
.zipWithIndex,
testData
._2
.zipWithIndex)
valtrainingDPSets = cv.train
.map(indexList => indexList
.map(index => testDataWithIndices
._1.collectFirst { case (dp, `index`) => dp}.get))
valtrainingClassifierSets = cv.train
.map(indexList => indexList
.map(index => testDataWithIndices
._2.collectFirst { case (dp, `index`) => dp}.get))
valtestingDPSets = cv.test
.map(indexList => indexList
.map(index => testDataWithIndices
._1.collectFirst { case (dp, `index`) => dp}.get))
valtestingClassifierSets = cv.test
.map(indexList => indexList
.map(index => testDataWithIndices
._2.collectFirst { case (dp, `index`) => dp}.get))
valvalidationRoundRecords = trainingDPSets
.zipWithIndex.map(x => ( x._1,
trainingClassifierSets(x._2),
testingDPSets(x._2),
testingClassifierSets(x._2)
)
)
validationRoundRecords
.foreach { record =>
valknn = KNN.learn(record._1, record._2, 3)
//And for each test data point make a prediction with the model
//對每個測試數據點,用模型做一次預測
valpredictions = record
._3
.map(x => knn.predict(x))
.zipWithIndex
//Finally evaluate the predictions as correct or incorrect
//and count the amount of wrongly classified data points.
//最后檢驗預測結果正確與否,并記下被錯誤分類的數據點的個數
valerror = predictions
.map(x => if (x._1 != record._4(x._2)) 1 else 0)
.sum
println("False prediction rate: " + error / predictions.length * 100 + "%")
}
}
如果你多次執行上面這段代碼,你可能發現錯誤預測率會有一些波動。這是由于用來做訓練和測試的隨機樣本。如果不幸地取到不好的隨機樣本,誤差率會比較高,若取到好的隨機樣本,則誤差率會極低。
不幸的是,關于如何為模型選取最好的隨機樣本來訓練,我沒有這樣的黃金法則。也許有人會說,產生最小誤差率的模型總是最好的。不過,再回顧一下過擬合這個概念,選用這樣的特殊模型也可能會對新數據束手無策。這就是為什么獲得一個足夠大且具有代表性的數據集對一個成功的機器學習應用來說非常關鍵。然而,當遇到這種問題時,你可以用新的數據和已知的正確分類不斷更新模型。
我們概括一下目前為止的進展狀況。首先小心地選取訓練和測試數據;下一步,建立幾個模型并驗證,選出給出最好結果的模型。接下來我們到達最后一步,就是用這個模型做預測:
valknn = KNN.learn(record._1, record._2, 3)
valunknownDataPoint = Array(5.3, 4.3)
valresult = knn.predict(unknownDatapoint)
if (result == 0)
{
println("Internet Service Provider Alpha")
}
else if (result == 1)
{
println("Internet Service Provider Beta")
}
else
{
println("Unexpected prediction")
}
執行這段代碼之后,未標記點 (5.3, 4.3) 就被標記為 ISP Alpha。這個點是最容易分類的點之一,它明顯落在數據圖的 Alpha 區域中。如何做預測已經顯而易見,我不再列舉其他點,你可以隨意嘗試其它不同點,看看預測結果。
正常/垃圾郵件分類(樸素貝葉斯法)
在這個實例中,基于郵件內容,我們將用樸素貝葉斯算法把郵件分為正常郵件與垃圾郵件。樸素貝葉斯算法是計算一個目標在每個可能類別中的幾率,然后返回具有最高幾率的那個類別。要計算這種幾率,算法會使用特征。該算法被稱為樸素貝葉斯是由于它不考慮特征之間的任何相關性。換言之,每個特征都一樣重要。我舉個例子進一步解釋:
假設你正在征顏色、直徑和形狀這幾個特征將水果和蔬菜進行分類,現在有以下類別:蘋果、番茄和蔓越莓。
假設你現在要把一個具有如下特征值的目標分類:(紅色、4cm、圓形)。對我們來說,它顯然是一個番茄,因為對比蘋果它比較小,對比蔓越莓它太大了。然而,樸素貝葉斯算法會獨立地評估每個特征,它的分類過程如下:
蘋果 66.6% 可能性(基于顏色和形狀)
番茄 100.0% 可能性(基于顏色、形狀和大小)
蔓越莓 66.6% 可能性(基于顏色和形狀)
因此,盡管實際上很明顯它不可能是蔓越莓或蘋果,樸素貝葉斯仍會給出 66.6% 的機會是這兩種情況之一。所以即使它正確地把目標分類為番茄,在邊界情況(目標大小剛好超出訓練集的范圍)下,它也可能給出糟糕的結果。不過,在郵件分類中,樸素貝葉斯算法表現還是不錯的,這是由于郵件的好壞無法僅僅通過一個特征(單詞)來分類。
你現在應該大概了解樸素貝葉斯算法了,我們可以繼續做之前的實例了。在這個例子中,我們使用 Scala 語言,利用 Smile 庫中的樸素貝葉斯實現,將郵件按內容分為垃圾郵件和正常郵件。
在開始之前還需要你從 SpamAssasins 公共文庫上下載這個例子的 數據 。你所要用到的數據在 easy_ham 和 spam 文件里,但其余的文件在你需要做更多實驗時也會用到。把這些文件解壓之后,修改代碼段里的文件路徑以適應文件夾的位置。此外,在做篩選時,你還需要用到 停用詞文件 。
對于每一個機器學習實現,第一步是要加載訓練數據。不過在這個例子中,我們需要進一步深入機器學習。在 K-NN 實例中,我們用下載速度和上傳速度作為特征。我們并不指明它們是特征,因為它們就是唯一可用的屬性。對郵件分類這個例子來說,拿什么作為特征并非毫無意義。要分類出垃圾或正常郵件。你可以使用的特征有發送人、主題、郵件內容,甚至發送時間。
在這個例子中,我們選用郵件內容作為特征,也就是,我們要在訓練集中,從郵件正文中選出特征(此例中是單詞)。為了做到這一點,我們需要建立一個 詞匯文檔矩陣 (TDM )。
我們從編寫一個加載案例數據的函數開始。這個函數就是 getMessage 方法,在給定一個文件作為參數后,它從一個郵件中獲取過濾文本。
defgetMessage(file : File) : String =
{
//Note that the encoding of the example files is latin1,
// thus this should be passed to the fromFile method.
//注意案例文件采用latin1編碼,所以應該把它們傳遞給fromFile方法
valsource = scala.io.Source.fromFile(file)("latin1")
vallines = source.getLinesmkString "n"
source.close()
//Find the first line break in the email,
//as this indicates the message body
//在郵件中找出第一個換行符,因為這暗示信息的主體
valfirstLineBreak = lines.indexOf("nn")
//Return the message body filtered by only text from a-z and to lower case
//返回過濾后的信息主體,即只包含a-z并且為小寫的文本。
return lines
.substring(firstLineBreak)
.replace("n"," ")
.replaceAll("[^a-zA-Z ]","")
.toLowerCase()
}
到此,在我們提供的案例數據文件夾中,我們需要一個方法來獲取所有郵件的文件名。
defgetFilesFromDir(path: String):List[File] = {
val d = new File(path)
if (d.exists && d.isDirectory) {
//Remove the mac os basic storage file,
//and alternatively for unix systems "cmds"
//移除mac os的基本存儲文件或者unix系統的“cmd”文件
d .listFiles
.filter(x => x .isFile &&
!x .toString
.contains(".DS_Store") &&
!x .toString
.contains("cmds"))
.toList
}
else {
List[File]()
}
}
然后,我們要定義一組路徑,它們可以方便我們從案例數據中加載不同的數據集。與此同時,我們也直接定義一組大小為 500 的樣本,這是垃圾郵件訓練集的總數。為了讓訓練集在兩種分類上保持平衡,我們把正常郵件的樣本總數也定為 500。
defmain(args: Array[String]): Unit = {
valbasePath = "/Users/../Downloads/data"
valspamPath = basePath + "/spam"
valspam2Path = basePath + "/spam_2"
valeasyHamPath = basePath + "/easy_ham"
valeasyHam2Path = basePath + "/easy_ham_2"
valamountOfSamplesPerSet = 500
valamountOfFeaturesToTake = 100
//First get a subset of the filenames for the spam
// sample set (500 is the complete set in this case)
//首先取出一個包含文件名的子集作為垃圾郵件樣本集(在這個案例中,全集是500個文件名)
vallistOfSpamFiles = getFilesFromDir(spamPath)
.take(amountOfSamplesPerSet)
//Then get the messages that are contained in these files
//然后取得包含在這些文件中的信息
valspamMails = listOfSpamFiles.map(x => (x, getMessage(x)))
//Get a subset of the filenames from the ham sample set
//取出一個文件名子集作為正常郵件樣本集
// (note that in this case it is not necessary to randomly
// sample as the emails are already randomly ordered)
//(注意在本案例中沒有必要隨機取樣,因為這些郵件已經是隨機排序)
vallistOfHamFiles = getFilesFromDir(easyHamPath)
.take(amountOfSamplesPerSet)
//Get the messages that are contained in the ham files
//取得包含在正常郵件中的信息
valhamMails = listOfHamFiles
.map{x => (x,getMessage(x)) }
}
既然我們已經獲取了正常郵件和垃圾郵件的訓練數據,就可以開始建立兩個 TDM 了。不過在給出實現這個過程的代碼之前,我們首先簡短解釋下這么做的原因。TDM 包含了 所有 出現在訓練集正文中的單詞,以及詞頻。然而,詞頻可能不是最好的量度方法(比如,一封含有 1000000 個“cake”的郵件就能把整個表搞砸),因此我們也會計算 出現率 ,也就是,包含那個特定詞匯的文檔數量。現在我們開始生成兩個 TDM。
valspamTDM = spamMails
.flatMap(email => email
._2.split(" ")
.filter(word => word.nonEmpty)
.map(word => (email._1.getName,word)))
.groupBy(x => x._2)
.map(x => (x._1, x._2.groupBy(x => x._1)))
.map(x => (x._1, x._2.map( y => (y._1, y._2.length))))
.toList
//Sort the words by occurrence rate descending
//以出現率降序排列這些單詞
//(amount of times the word occurs among all documents)
//(該單詞在所有文檔中出現的總次數)
valsortedSpamTDM = spamTDM
.sortBy(x => - (x._2.size.toDouble / spamMails.length))
valhamTDM = hamMails
.flatMap(email => email
._2.split(" ")
.filter(word => word.nonEmpty)
.map(word => (email._1.getName,word)))
.groupBy(x => x._2)
.map(x => (x._1, x._2.groupBy(x => x._1)))
.map(x => (x._1, x._2.map( y => (y._1, y._2.length))))
.toList
//Sort the words by occurrence rate descending
//以出現率降序排列這些單詞
//(amount of times the word occurs among all documents)
//(該單詞在所有文檔中出現的總次數)
valsortedHamTDM = hamTDM
.sortBy(x => - (x._2.size.toDouble / spamMails.length))
給定了那些表格,為了更深入了解它們,我用 wordcloud 將它們生成圖片。這些圖片中反映了頻率最高的 50 個單詞 (top 50)的情況,我們觀察一下。注意紅色單詞來自垃圾郵件,綠色單詞來自正常郵件。此外,單詞的大小代表出現率。因此,單詞越大,至少出現一次該單詞的文檔越多。
你可以看到,停用詞大多出現在前面。這些停用詞是噪聲,在特征選擇過程中我們要盡可能避開它們。所以在選出特征之前,我們要從表格中剔除這些詞。案例數據集已經包含了一列停用詞,我們首先編寫代碼來獲取這些詞。
defgetStopWords() : List[String] =
{
valsource = scala.io.Source
.fromFile(new File("/Users/.../.../Example Data/stopwords.txt"))("latin1")
vallines = source.mkString.split("n")
source.close()
return lines.toList
}
現在我們可以擴展前文中的 TDM 生成代碼,剔除停用詞:
valstopWords = getStopWords
valspamTDM = spamMails
.flatMap(email => email
._2.split(" ")
.filter(word => word.nonEmpty && !stopWords.contains(word))
.map(word => (email._1.getName,word)))
.groupBy(x => x._2)
.map(x => (x._1, x._2.groupBy(x => x._1)))
.map(x => (x._1, x._2.map( y => (y._1, y._2.length))))
.toList
valhamTDM = hamMails
.flatMap(email => email
._2.split(" ")
.filter(word => word.nonEmpty && !stopWords.contains(word))
.map(word => (email._1.getName,word)))
.groupBy(x => x._2)
.map(x => (x._1, x._2.groupBy(x => x._1)))
.map(x => (x._1, x._2.map( y => (y._1, y._2.length))))
.toList
如果馬上觀察垃圾郵件和正常郵件的 top 50 單詞,就可以看到大多數停用詞已經消失了。我們可以再作調整,但現在就用這個結果吧。
在了解了什么是“垃圾單詞”和“正常單詞”之后,我們就可以決定建立一個特征集了,稍后我們會把它用在樸素貝葉斯算法中以創建一個分類器。注意:包含更多的特征總是更好的,然而,若把所有單詞都作為特征,則可能出現性能問題。這就是為什么在機器學習領域,很多開發者傾向于棄用沒有明顯影響的特征,純粹就是性能方面的原因。另外,機器學習過程可以通過在完整的 Hadoop 集群上運行來完成,但是闡明這方面的內容就超出了本文的范圍。
現在我們要選出出現率(而不是頻率)最高的 100 個“垃圾單詞”和 100 個“正常單詞”,并把它們組合成一個單詞集,它將輸入到貝葉斯算法。最后,我們要轉換這些訓練數據以適應貝葉斯算法的輸入格式。注意最終的特征集大小為 200(其中,#公共單詞×2)。請隨意用更大或更小的特征數做實驗。
//Add the code for getting the TDM data and combining it into a feature bag.
//添加生成TDM數據并將其合成一個特征包的代碼
valhamFeatures = hamTDM
.records
.take(amountOfFeaturesToTake)
.map(x => x.term)
valspamFeatures = spamTDM
.records
.take(amountOfFeaturesToTake)
.map(x => x.term)
//Now we have a set of ham and spam features,
//現在我們有了一套正常郵件和垃圾郵件特征,
// we group them and then remove the intersecting features, as these are noise.
//我們將它們組合在一起并將公共特征去除,因為它們是噪聲
var data = (hamFeatures ++ spamFeatures).toSet
hamFeatures
.intersect(spamFeatures)
.foreach(x => data = (data - x))
//Initialise a bag of words that takes the top x features
//from both spam and ham and combines them
//初始化一個單詞包,從垃圾特征集和正常特征集中取出最前的x個特征,將它們合并
var bag = new Bag[String] (data.toArray)
//Initialise the classifier array with first a set of 0(spam)
//and then a set of 1(ham) values that represent the emails
//將該分類器數組初始化,首先用一組數值0代替垃圾郵件,然后用一組數值1代替正常郵件
var classifiers = Array.fill[Int](amountOfSamplesPerSet)(0) ++
Array.fill[Int](amountOfSamplesPerSet)(1)
//Get the trainingData in the right format for the spam mails
//取得正確格式的垃圾郵件訓練數據
var spamData = spamMails
.map(x => bag.feature(x._2.split(" ")))
.toArray
//Get the trainingData in the right format for the ham mails
//取得正確格式的正常郵件訓練數據
var hamData = hamMails
.map(x => bag.feature(x._2.split(" ")))
.toArray
//Combine the training data from both categories
//將兩種訓練數據合并
var trainingData = spamData ++ hamData
給定了這個特征包以及一個訓練數據集,我們就可以開始訓練算法。為此,我們有幾個模型可以采用:General 模型、Multinomial 模型和Bernoulli 模型。General 模型需要一個定義好的分布,而這個分布我們事先并不知道,因此這個模型并不是個好的選擇。Multinomial 和Bernoulli 兩個模型之間的區別就是它們對單詞出現率的處理方式不同。Bernoulli模型僅僅是驗證一個特征是否存在(二元值 1 或 0),因此它忽略了出現率這個統計值。反之,Multinomial 模型結合了出現率(由數值表示)。所以,與 Multinomial 模型比較,Bernoulli 模型在長文檔中的表現較差。既然我們要對郵件排序,并且也要使用到出現率,因此我們主要討論 Multinomial 模型,但盡管試試 Bernoulli 模型。
//Create the bayes model as a multinomial with 2 classification
// groups and the amount of features passed in the constructor.
//建立一個多項式形式的貝葉斯模型,將類別數2以及特征總數傳遞給該構建函數
var bayes = new NaiveBayes(NaiveBayes.Model.MULTINOMIAL, 2, data.size)
//Now train the bayes instance with the training data,
// which is represented in a specific format due to the
//bag.feature method, and the known classifiers.
//現在可以用訓練數據和已知的分類器對貝葉斯模型進行訓練,
//訓練數據已經用bag.feature方法表示為特定的格式
bayes.learn(trainingData, classifiers)
現在我們有了一個訓練好的模型,可以再次進行驗證環節。不過呢,在案例數據中,我們已經將簡單的和復雜的垃圾郵件(正常郵件)分開來,因此我們就不使用交叉驗證了,而是用這些測試集來驗證模型。以垃圾郵件分類作為開始,為此,我們使用 spam2 文件夾中的 1397 封垃圾郵件。
vallistOfSpam2Files = getFilesFromDir(spam2Path)
valspam2Mails = listOfSpam2Files
.map{x => (x,getMessage(x)) }
valspam2FeatureVectors = spam2Mails
.map(x => bag.feature(x._2.split(" ")))
valspam2ClassificationResults = spam2FeatureVectors
.map(x => bayes.predict(x))
//Correct classifications are those who resulted in a spam classification (0)
//正確的分類是那些分出垃圾郵件(0)的結果
valcorrectClassifications = spam2ClassificationResults
.count( x=> x == 0)
println ( correctClassifications +
" of " +
listOfSpam2Files.length +
"were correctly classified"
)
println (( (correctClassifications.toDouble /
listOfSpam2Files.length) * 100) +
"% was correctly classified"
)
//In case the algorithm could not decide which category the email
//belongs to, it gives a -1 (unknown) rather than a 0 (spam) or 1 (ham)
//如果算法無法確定一封郵件屬于哪一類,
//它會給出-1(未知的)的結果而不是0(垃圾郵件)或1(正常郵件)
valunknownClassifications = spam2ClassificationResults
.count( x=> x == -1)
println( unknownClassifications +
" of " +
listOfSpam2Files.length +
"were unknowingly classified"
)
println( ( (unknownClassifications.toDouble /
listOfSpam2Files.length) * 100) +
% wasunknowinglyclassified"
)
如果以不同的特征數多次運行這段代碼,就可以得到下列結果:
注意,被標記為垃圾的郵件數量正是由模型所正確分類的。有趣的是,在只有 50 個特征的情況,這個算法分類垃圾郵件表現的最好。不過,考慮到在這50個最高頻特征詞中仍然有停用詞,這個結果就不難解釋了。若觀察被分類為垃圾郵件的數目隨特征數增加的變化(從 100 開始),可以看到,特征數越多,結果越大。注意還有一組被分為未知郵件。這些郵件在“正常”和“垃圾”兩個類別中的先驗值是相等的。這種情況也適用于那些其中未包含正常或垃圾郵件特征詞的郵件,因為這樣的話,算法會認為它有 50% 是正常郵件, 50% 是垃圾郵件。
現在我們對正常郵件進行相同的分類過程。通過將變量 listOfSpam2Files 的路徑改為 easyHam2Path,并重新運行該代碼,我們可以得到以下結果:
注意現在被正確分類的是那些被標記為“正常”的郵件。從這里可以看到,事實上當只用50個特征時,被正確分類為正常郵件的數目明顯低于使用100個特征時的情況。你應該注意到這點并且對所有類別驗證你的模型,就如在這個例子中,用垃圾郵件和正常郵件的測試數據對模型都做了驗證。
概括一下這個實例,我們演示了如何應用樸素貝葉斯算法來分類正常或垃圾郵件,并得到如下的結果:高達 87.26% 的垃圾郵件識別率和 97.79% 的正常郵件識別率。這表明樸素貝葉斯算法在識別正常或垃圾郵件時表現得確實相當好。
樸素貝葉斯算法實例到這里就結束了。如果你還想多研究一下這個算法和垃圾郵件分類,文庫里還有一組“困難級別的”正常郵件,你可以通過調整特征數、剔除更多停用詞來嘗試正確分類。
基于內容的郵件排序(推薦系統)
這個實例完全是關于建立你自己的推薦系統的。我們將基于如下特征對郵件進行排序:“發送人”、“主題”、“主題中的公共詞匯”和“郵件正文中的公共詞匯”。稍后我們會對實例中的這些特征一一做解釋。注意在設計你自己的推薦系統時,你要自己定義這些特征,而這正是最困難的環節之一。想出合適的特征來非常重要,而且就算最終選好了特征,已有的數據往往可能無法直接利用。
這個實例旨在教你如何選取特征以及解決在這個過程中使用你自己的數據時會遇到的問題。
我們將會使用郵件數據的一個子集,這些郵件數據已在實例“垃圾/正常郵件分類”中使用過了。此外,你還需要停用詞文件。注意這些數據是一組接收到的郵件,因此我們還缺少一半數據,也就是這個郵箱發送出去的郵件。然而,就算沒有這些信息,我們還是可以做一些相當漂亮的排序工作,待會兒就知道了。
在我們開始建立排序系統之前,我們首先需要從郵件數據集中抽取出盡可能多的數據來。因為這些數據本身有點冗長,我們給出處理這個過程的代碼。行內注釋對代碼的作用進行了解釋。注意,該程序一開始就是一個在 GUI 中的 swing 應用。之所以這樣做,是因為稍后我們要將數據繪成圖以洞悉其隱含的模式。同時注意,我們直接將數據分割為測試數據和訓練數據,為我們之后驗證模型做準備。
importjava.awt.{Rectangle}
importjava.io.File
importjava.text.SimpleDateFormat
importjava.util.Date
importsmile.plot.BarPlot
importscala.swing.{MainFrame, SimpleSwingApplication}
importscala.util.Try
object RecommendationSystem extends SimpleSwingApplication {
case class EmailData(emailDate : Date, sender : String, subject : String, body : String)
deftop = new MainFrame {
title = "Recommendation System Example"
valbasePath = "/Users/../data"
valeasyHamPath = basePath + "/easy_ham"
valmails = getFilesFromDir(easyHamPath).map(x => getFullEmail(x))
valtimeSortedMails = mails
.map (x => EmailData ( getDateFromEmail(x),
getSenderFromEmail(x),
getSubjectFromEmail(x),
getMessageBodyFromEmail(x)
)
)
.sortBy(x => x.emailDate)
val (trainingData, testingData) = timeSortedMails
.splitAt(timeSortedMails.length / 2)
}
defgetFilesFromDir(path: String): List[File] = {
val d = new File(path)
if (d.exists && d.isDirectory) {
//Remove the mac os basic storage file,
//and alternatively for unix systems "cmds"
//移除mac os的基本存儲文件或者unix系統的“cmds”文件
d.listFiles.filter(x => x.isFile &&
!x.toString.contains(".DS_Store") &&
!x.toString.contains("cmds")).toList
} else {
List[File]()
}
}
defgetFullEmail(file: File): String = {
//Note that the encoding of the example files is latin1,
//thus this should be passed to the from file method.
//注意案例文件采用latin1編碼,因此應該把它們傳遞給fronFile方法
valsource = scala.io.Source.fromFile(file)("latin1")
valfullEmail = source.getLinesmkString "n"
source.close()
fullEmail
}
defgetSubjectFromEmail(email: String): String = {
//Find the index of the end of the subject line
//找出主題行的結束標志
valsubjectIndex = email.indexOf("Subject:")
valendOfSubjectIndex = email
.substring(subjectIndex) .indexOf('n') + subjectIndex
//Extract the subject: start of subject + 7
// (length of Subject:) until the end of the line.
//抽取主題:主題開端+7(主題的長度),直到該行結束
valsubject = email
.substring(subjectIndex + 8, endOfSubjectIndex)
.trim
.toLowerCase
//Additionally, we check whether the email was a response and
//remove the 're: ' tag, to make grouping on topic easier:
//此外,我們檢查郵件是否是一封回復并刪除“re:”標簽,使對話題的分組更容易
subject.replace("re: ", "")
}
defgetMessageBodyFromEmail(email: String): String = {
valfirstLineBreak = email.indexOf("nn")
//Return the message body filtered by only text
//from a-z and to lower case
//返回過濾后的信息主體,即只包含a-z小寫形式的文本
email.substring(firstLineBreak)
.replace("n", " ")
.replaceAll("[^a-zA-Z ]", "")
.toLowerCase
}
defgetSenderFromEmail(email: String): String = {
//Find the index of the From: line
//找出帶有“From:”標志的行
valfromLineIndex = email
.indexOf("From:")
valendOfLine = email
.substring(fromLineIndex)
.indexOf('n') + fromLineIndex
//Search for the <> tags in this line, as if they are there,
// the email address is contained inside these tags
//在該行中搜索“<>”標簽,如果標簽存在,則郵件地址包含在其中
valmailAddressStartIndex = email
.substring(fromLineIndex, endOfLine)
.indexOf('<') + fromLineIndex + 1
valmailAddressEndIndex = email
.substring(fromLineIndex, endOfLine)
.indexOf('>') + fromLineIndex
if (mailAddressStartIndex > mailAddressEndIndex) {
//The email address was not embedded in <> tags,
// extract the substring without extra spacing and to lower case
//郵件地址不是括在<>標簽內,抽取子字符串并去除空格,轉為小寫形式
var emailString = email
.substring(fromLineIndex + 5, endOfLine)
.trim
.toLowerCase
//Remove a possible name embedded in () at the end of the line,
//for example in test@test.com (tester) the name would be removed here
//刪除行末可能包含在()內的名字,例如,在test@test.com(tester) 中,名字會被刪除掉
valadditionalNameStartIndex = emailString.indexOf('(')
if (additionalNameStartIndex == -1) {
emailString
.toLowerCase
}
else {
emailString
.substring(0, additionalNameStartIndex)
.trim
.toLowerCase
}
}
else {
//Extract the email address from the tags.
//抽取標簽中的郵件地址
//If these <> tags are there, there is no () with a name in
// the From: string in our data
//我們的數據中,如果“From:”字符串存在標簽<>,則不會有帶()的名字出現
email
.substring(mailAddressStartIndex, mailAddressEndIndex)
.trim
.toLowerCase
}
}
defgetDateFromEmail(email: String): Date = {
//Find the index of the Date: line in the complete email
//在整封郵件中找出日期行的標志
valdateLineIndex = email
.indexOf("Date:")
valendOfDateLine = email
.substring(dateLineIndex)
.indexOf('n') + dateLineIndex
//All possible date patterns in the emails.
//郵件中所有可能的日期格式
valdatePatterns = Array( "EEE MMM dd HH:mm:ss yyyy",
"EEE, dd MMM yyyy HH:mm",
"dd MMM yyyy HH:mm:ss",
"EEE MMM dd yyyy HH:mm")
datePatterns.foreach { x =>
//Try to directly return a date from the formatting.
//嘗試用一種日期格式直接返回一個日期
//when it fails on a pattern it continues with the next one
// until one works
//對于一種格式,當返回錯誤時,它接著嘗試下一種格式直到成功。
Try(return new SimpleDateFormat(x)
.parse(email
.substring(dateLineIndex + 5, endOfDateLine)
.trim.substring(0, x.length)))
}
//Finally, if all failed return null
//最后,如果都失敗了則返回null
//(this will not happen with our example data but without
//this return the code will not compile)
//(對于我們的案例數據,這不會發生,但是沒有這一返回,代碼就不會編譯)
null
}
}
這一數據預處理過程非常普遍,并且,一旦你的數據格式不標準,如帶有郵件的日期和發送人,那這個過程將會令人非常糾結。不過,給出了這段代碼之后,我們的實例數據現在就有了以下這些可用的屬性:完整郵件、接收日期、發送人、主題和正文。有了這些,我們得以確定推薦系統實際要用到的特征。
我們建議選取的第一個特征來源于郵件的發送人。那些你經常收到 Ta 郵件的人應該排在那些你很少收到 Ta 郵件的人的前面。這是個很有力的假設,但是你應該會本能地認同我們沒有考慮垃圾郵件這件事。我們來看看發送人在整個郵件集上的分布。
//Add to the top body:
//添加到主體的頂部
//First we group the emails by Sender, then we extract only the sender address
//and amount of emails, and finally we sort them on amounts ascending
//首先我們按發送人給郵件分組,然后只抽取發送人的地址以及郵件數,
//最后按郵件數給這些地址按升序排序
valmailsGroupedBySender = trainingData
.groupBy(x => x.sender)
.map(x => (x._1, x._2.length))
.toArray
.sortBy(x => x._2)
//In order to plot the data we split the values from the addresses as
//this is how the plotting library accepts the data.
//為了將數據繪圖,我們將數值與這些地址分離開,因為繪圖庫只接收這樣的數據
valsenderDescriptions = mailsGroupedBySender
.map(x => x._1)
valsenderValues = mailsGroupedBySender
.map(x => x._2.toDouble)
valbarPlot = BarPlot.plot("", senderValues, senderDescriptions)
//Rotate the email addresses by -80 degrees such that we can read them
//將郵件地址旋轉80度以方便閱讀
barPlot.getAxis(0).setRotation(-1.3962634)
barPlot.setAxisLabel(0, "")
barPlot.setAxisLabel(1, "Amount of emails received ")
peer.setContentPane(barPlot)
bounds = new Rectangle(800, 600)
這里可以看到,給你發送郵件最多的人發了45封,其后是37封,然后就迅速下降了。這些異常值的存在,會導致直接使用這些數據時,推薦系統僅將郵件發送最多的 1 到 2 位發送人列為重要級別,而剩下的則不考慮。為了防止出現這個問題,我們將通過 log1p 函數對數據進行重縮放。 log1p 函數是將數據加 1,然后再對其取對數 log。將數據加 1 的操作是考慮到發送人只發送一封郵件的情況。在對數據進行這樣一個取對數操作之后,其圖像是這樣的。
//Code changes:
//代碼更新
valmailsGroupedBySender = trainingData
.groupBy(x => x.sender)
.map(x => (x._1, Math.log1p(x._2.length)))
.toArray
.sortBy(x => x._2)
barPlot.setAxisLabel(1, "Amount of emails received on log Scale ")
事實上,這些數據仍是相同的,只不過用了不同的比例來展示。注意數據的數值范圍在 0.69 與 3.83 之間。這個范圍小了很多,異常值也不會太偏離其他數據。在機器學習領域,這種數據操作技巧是很常用的。找到合適的縮放比例需要某種洞察力。所以,在做重縮放時,應用 Smile 的繪圖庫畫出多幅不同縮放比例的圖,會給工作帶來很大的幫助。
下一個我們要分析的特征是主題出現的頻率和時段。如果一個主題經常出現,那它有可能更重要。此外,我們還考慮了一個郵件會話的持續時間。因此,一個主題的頻率可以用這個主題下郵件會話的持續時間來標準化。這樣,高度活躍的郵件會話會被排在最前面,再次強調,這也是我們的一個假設。
我們來看看這些主題和它們的出現次數:
//Add to 'def top'
//添加到‘def top’
valmailsGroupedByThread = trainingData
.groupBy(x => x.subject)
//Create a list of tuples with (subject, list of emails)
//創建一列元組(主題,郵件列表)
valthreadBarPlotData = mailsGroupedByThread
.map(x => (x._1, x._2.length))
.toArray
.sortBy(x => x._2)
valthreadDescriptions = threadBarPlotData
.map(x => x._1)
valthreadValues = threadBarPlotData
.map(x => x._2.toDouble)
//Code changes in 'def top'
//改變的‘def top’代碼
valbarPlot = BarPlot.plot(threadValues, threadDescriptions)
barPlot.setAxisLabel(1, "Amount of emails per subject")
可以看出這與發送人的情況有類似的分布,因此我們再一次應用 log1p 函數。
//Code change:
//代碼更新
valthreadBarPlotData = mailsGroupedByThread
.map(x => (x._1, Math.log1p(x._2.length)))
.toArray
.sortBy(x => x._2)
現在,每個主題的郵件數的取值范圍變為 0.69 到 3.41,對推薦系統來說,這比 1 到 29 的范圍要好。不過,我們還沒有把時間段考慮進去,因此我們回到標準頻率上來,著手對數據作變換。為此,我們首先要獲取每個郵件會話中,第一個郵件到最后一個郵件的時間間隔:
//Create a list of tuples with (subject, list of emails,
//time difference between first and last email)
//創建一列元組(主題,郵件列表,首末兩封郵件的時間間隔)
valmailGroupsWithMinMaxDates = mailsGroupedByThread
.map(x => (x._1, x._2,
(x._2
.maxBy(x => x.emailDate)
.emailDate.getTime -
x._2
.minBy(x => x.emailDate)
.emailDate.getTime
) / 1000
)
)
//turn into a list of tuples with (topic, list of emails,
// time difference, and weight) filtered that only threads occur
//轉換為一列過濾后的元組(話題,郵件列表,時間間隔,權重),其中只出現郵件對話
valthreadGroupedWithWeights = mailGroupsWithMinMaxDates
.filter(x => x._3 != 0)
.map(x => (x._1, x._2, x._3, 10 +
Math.log10(x._2.length.toDouble / x._3)))
.toArray
.sortBy(x => x._4)
valthreadGroupValues = threadGroupedWithWeights
.map(x => x._4)
valthreadGroupDescriptions = threadGroupedWithWeights
.map(x => x._1)
//Change the bar plot code to plot this data:
//改變條形圖代碼以將這些數據繪圖
valbarPlot = BarPlot.plot(threadGroupValues, threadGroupDescriptions)
barPlot.setAxisLabel(1, "Weighted amount of emails per subject")
注意代碼中我們是如何確定時間差的,并將其除以 1000。這是為了把時間單位從毫秒化成秒。此外,我們用一個主題的頻率除以時間差來計算權重。因為該值很小,我們對其取 log10 函數以使它放大一些。但這樣做會把數值變負數,因此,我們將每個數值都加上 10 使其為正數。這個加權過程的結果如下:
我們想要的數值大概落在 4.4 到 8.6 的范圍,這表明異常值對特征的影響已經很小。此外,我們觀察權重最高的 10 個主題和最低的 10 個主題,看看到底發生了什么。
權重最高的 10 個主題
權重最低的 10 個主題
可以看到,權重最高的是那些短時間內就收到回復的郵件,而權重最低的則是那些回復等待時間很長的郵件。這樣的話,即使是那些頻率很低的主題也可以根據其往來郵件之間時間間隔短而被排在重要的位置。因此,我們可以得到兩個特征:來自發送人的郵件數量 mailsGroupedBySender 和屬于一個已知郵件會話的郵件的權重 threadGroupedWithWeights。
如前所述,我們的排序系統是要基于盡可能多的特征的,我們繼續找下一個特征。這個特征是以我們剛剛計算出的權重值為基礎的。我們的想法是,郵箱會收到帶有不同主題的新郵件。不過,這些郵件的主題有可能含有類似于之前收到的重要郵件主題的關鍵詞。因此,在一個郵件會話(一個主題下的多封往來郵件)開始之前,我們就能夠將郵件排序。為此,我們把關鍵詞的權重指定為含有這些關鍵詞的主題的權重。如果多個主題都含有這些關鍵詞,我們就選擇權重最高的一個排在第一位。
這個特征有個問題,那就是停用詞。還好,目前我們有一個停用詞文件可以讓我們剔除(大部分)英語停用詞。然而,當你在設計自己的系統時,還應該考慮到可能會有多種語言出現,這時你就要剔除所有這些語言的停用詞了。此外,在不同的語言中,某些單詞會有多種不同的意思,因此,在這種情況下剔除停用詞就要小心了。就現在來說,我們繼續剔除英語停用詞。這個特征的代碼如下:
defgetStopWords: List[String] = {
valsource = scala.io.Source
.fromFile(new File("/Users/../stopwords.txt"))("latin1")
vallines = source.mkString.split("n")
source.close()
lines.toList
}
//Add to top:
//添加到頂部
valstopWords = getStopWords
valthreadTermWeights = threadGroupedWithWeights
.toArray
.sortBy(x => x._4)
.flatMap(x => x._1
.replaceAll("[^a-zA-Z ]", "")
.toLowerCase.split(" ")
.filter(_.nonEmpty)
.map(y => (y,x._4)))
valfilteredThreadTermWeights = threadTermWeights
.groupBy(x => x._1)
.map(x => (x._1, x._2.maxBy(y => y._2)._2))
.toArray.sortBy(x => x._1)
.filter(x => !stopWords.contains(x._1))
給定這段代碼,我們就得到了一張列表 filteredThreadTermWeights,該列表基于已有郵件會話中的權重列出了一些關鍵詞。這些權重可以用來計算新郵件的主題的權重,即使這封郵件不是對已有會話的回復。
對于第四個特征,我們打算合并考慮在所有郵件中出現頻率很高的詞匯的權重。為此,我們要創建一個 TDM ,不過這一次和前一個例子中的有點不同,這次,我們只將所有文檔中的詞匯頻率取對數。此外,我們還要對出現率取 log10 函數。這么做可以縮小詞匯頻率的比例,防止結果被可能的異常值影響。
valtdm = trainingData
.flatMap(x => x.body.split(" "))
.filter(x => x.nonEmpty && !stopWords.contains(x))
.groupBy(x => x)
.map(x => (x._1, Math.log10(x._2.length + 1)))
.filter(x => x._2 != 0)
這個 TDM 列表可以幫助我們根據歷史數據計算新郵件正文的權重,這個權重很重要。
準備了這 4 個特征之后,我們就可以對訓練數據做實際的排序計算。為此,我們要計算出每封郵件的 senderWeight (代表發送人的權重)、termWeight (代表主題詞匯的權重)、threadGroupWeight (代表郵件會話的權重)以及 commonTermsWeight (代表郵件正文的權重),并將它們相乘以得到最后的排序。由于我們是做乘法而不是加法,我們需要小心那些小于1的數值。例如,有人發送了一封郵件,那么其 sengerWeight 就是 0.69,若將其與那些還未發送過任何郵件的人作比較就會不公平,因為對方的 senderWeight 值是 1。因此,對于那些數值可能低于 1 的各個特征,我們取函數 Math.max(value,1)。我們來看看代碼:
valtrainingRanks = trainingData.map(mail => {
//Determine the weight of the sender, if it is lower than 1, pick 1 instead
//確定發送人的權重,如果小于1,則設為1
//This is done to prevent the feature from having a negative impact
//這是為了防止特征出現負面影響
valsenderWeight = mailsGroupedBySender
.collectFirst { case (mail.sender, x) => Math.max(x,1)}
.getOrElse(1.0)
//Determine the weight of the subject
//確定主題的權重
valtermsInSubject = mail.subject
.replaceAll("[^a-zA-Z ]", "")
.toLowerCase.split(" ")
.filter(x => x.nonEmpty &&
!stopWords.contains(x)
)
valtermWeight = if (termsInSubject.size > 0)
Math.max(termsInSubject
.map(x => {
tdm.collectFirst { case (y, z) if y == x => z}
.getOrElse(1.0)
})
.sum / termsInSubject.length,1)
else 1.0
//Determine if the email is from a thread,
//and if it is the weight from this thread:
//判斷郵件是否來自一個會話,如果是,其在該會話的權重:
valthreadGroupWeight: Double = threadGroupedWithWeights
.collectFirst { case (mail.subject, _, _, weight) => weight}
.getOrElse(1.0)
//Determine the commonly used terms in the email and the weight belonging to it:
//確定郵件中的常用詞匯及其權重:
valtermsInMailBody = mail.body
.replaceAll("[^a-zA-Z ]", "")
.toLowerCase.split(" ")
.filter(x => x.nonEmpty &&
!stopWords.contains(x)
)
valcommonTermsWeight = if (termsInMailBody.size > 0)
Math.max(termsInMailBody
.map(x => {
tdm.collectFirst { case (y, z) if y == x => z}
.getOrElse(1.0)
})
.sum / termsInMailBody.length,1)
else 1.0
valrank = termWeight *
threadGroupWeight *
commonTermsWeight *
senderWeight
(mail, rank)
})
valsortedTrainingRanks = trainingRanks
.sortBy(x => x._2)
valmedian = sortedTrainingRanks(sortedTrainingRanks.length / 2)._2
valmean = sortedTrainingRanks
.map(x => x._2).sum /
sortedTrainingRanks.length
我們計算了訓練集中所有郵件的排序,還將它們做了排序并取中位數和平均值。我們取中位數和平均值是為了確定一個 決策邊界 ,通過該邊界來評估一封郵件是優先級還是非優先級。在實踐中,這個辦法通常并不管用。實際上最好的辦法是讓用戶標記一組郵件作為優先級,與另一組郵件作為非優先級。然后就可以用這些郵件的排序來計算出決策邊界,此外還可以確認排序系統的特征是否選得正確。如果最終用戶標記為非優先級的郵件評級比標記為優先級的郵件還高,那你可能要重新評估你所選取的特征。
我們之所以提出這個決策邊界,而不是僅僅對用戶郵件進行排序,是考慮了時間這個因素。如果你純粹根據排序來整理郵件,那結果將會令人討厭,因為人們通常喜歡根據時間來整理郵件。然而,假設我們要把這一排序系統融合到一個郵件客戶端中,有了這個決策邊界之后,我們就可以標記優先級郵件,然后把它們單獨顯示在一個列表中。
我們來看看在訓練集中,有多少封郵件被標記為優先級。為此,我們首先需要添加下列代碼:
valtestingRanks = trainingData.map(mail => {
//mail contains (full content, date, sender, subject, body)
//包含(全部內容、日期、發送人、主題、正文)的郵件
//Determine the weight of the sender
//確定發送人的權重
valsenderWeight = mailsGroupedBySender
.collectFirst { case (mail.sender, x) => Math.max(x,1)}
.getOrElse(1.0)
//Determine the weight of the subject
//確定主題的權重
valtermsInSubject = mail.subject
.replaceAll("[^a-zA-Z ]", "")
.toLowerCase.split(" ")
.filter(x => x.nonEmpty &&
!stopWords.contains(x)
)
valtermWeight = if (termsInSubject.size > 0)
Math.max(termsInSubject
.map(x => {
tdm.collectFirst { case (y, z) if y == x => z}
.getOrElse(1.0)
})
.sum / termsInSubject.length,1)
else 1.0
//Determine if the email is from a thread,
//and if it is the weight from this thread:
//判斷一封郵件是否來自一個會話,如果是,其在該會話的權重:
valthreadGroupWeight: Double = threadGroupedWithWeights
.collectFirst { case (mail.subject, _, _, weight) => weight}
.getOrElse(1.0)
//Determine the commonly used terms in the email and the weight belonging to it:
//確定郵件中的常用詞匯及其權重:
valtermsInMailBody = mail.body
.replaceAll("[^a-zA-Z ]", "")
.toLowerCase.split(" ")
.filter(x => x.nonEmpty &&
!stopWords.contains(x)
)
valcommonTermsWeight = if (termsInMailBody.size > 0)
Math.max(termsInMailBody
.map(x => {
tdm.collectFirst { case (y, z) if y == x => z}
.getOrElse(1.0)
})
.sum / termsInMailBody.length,1)
else 1.0
valrank = termWeight *
threadGroupWeight *
commonTermsWeight *
senderWeight
(mail, rank)
})
valpriorityEmails = testingRanks
.filter(x => x._2 >= mean)
println(priorityEmails.length + " ranked as priority")
在實際執行了這個測試代碼以后,你將看到這個測試集被標記為優先級的郵件數量實際是 563,占到測試集郵件數的 45%。這是一個相當大的值,所以我們可以用決策邊界進行調節。不過,我們僅以此作為說明的目的,這個值并不應該拿去代表實際情況,所以我們就不再去糾結那個百分比。反之,我們來看看優先級郵件中評級最高的10封。
注意我已經將電子郵件地址的部分內容刪除了,以防止垃圾郵件程序抓取這些地址。從下表中可以看到,這10封優先級最高的郵件中,大部分郵件都來自不同的會話,這些會話的活動性很高。以評級最高的那封郵件為例,這封郵件是一封9分鐘前的郵件的回復。這就表明了這個郵件會話的重要性。
此外,我們還看到 tim.One… 在這個表中出現很多次。這反映出他的所有郵件都很重要,或者,他發送了這么多郵件以致排序系統自動將其評為優先。作為這個實例的最后一步,我們對這一點再做些討論:
valtimsEmails = testingRanks
.filter(x => x._1.sender == "tim.one@...")
.sortBy(x => -x._2)
timsEmails
.foreach(x => println( "| " +
x._1.emailDate +
" | " +
x._1.subject +
" | " +
df.format(x._2) +
" |")
)
運行這一段代碼后,一個包含 45 封郵件的列表就會被打印出來,評級最低的 10 封郵件如下:
我們知道決策邊界就是平均值,這里是 25.06,那么可以看出,Tim 只有一封郵件沒有被標記為優先級。這表明,一方面,我們的決策邊界太低了,而另一方面,Tim也許真的發送了很多重要郵件,否則很多郵件的評級就會低于決策邊界。不幸的是,我們無法為你提供確切的答案,因為我們不是這些測試數據的原主人。
當你手中的數據并非一手數據時,驗證一個像這樣的排序系統是相當困難的。驗證并改善這個系統最常用的方式是將它開放給客戶使用,讓他們標記、糾正錯誤。而這些糾錯就可以用來改善系統。
總的來說,我們介紹了如何從含有異常值的原始數據中獲取特征,以及如何把這些特征耦合到最終的排序值中。此外,我們還嘗試對這些特征進行驗證,但由于缺乏對數據集的確切把握,我們無法得出明確的結論。不過,如果你想使用自己的直接數據進行同樣的過程,那么這個實例就可以幫助你建立自己的排序系統。
根據身高預測體重(應用普通最小二乘法)
在這部分中,我們將介紹 普通最小二乘法 ,它是一種線性回歸方法。因為這種方法十分強大,所以開始實例之前,有必要先了解回歸與常見的陷阱。我們將在這部分介紹一部分這些問題,其他的相關問題我們已在欠擬合與過擬合這兩小節中介紹過了。
線性回歸的基本思想是用一條“最優的”回歸線來擬合數據點。注意這只對線性數據并且無過大異常值的情況才適用。如果你的數據不滿足這種條件,那你可以嘗試對數據進行操作,例如將數據取平方或取對數,直到滿足適用條件。
與往常一樣,在一項工程開始時,首先要導入一個數據集。為此,我們提供了如下 csv 文件 以及讀取文件用的代碼:
defgetDataFromCSV(file: File): (Array[Array[Double]], Array[Double]) = {
valsource = scala.io.Source.fromFile(file)
valdata = source.getLines().drop(1).map(x => getDataFromString(x)).toArray
source.close()
var inputData = data.map(x => x._1)
var resultData = data.map(x => x._2)
return (inputData,resultData)
}
defgetDataFromString(dataString: String): (Array[Double], Double) = {
//Split the comma separated value string into an array of strings
//把用逗號分隔的數值字符串分解為一個字符串數組
valdataArray: Array[String] = dataString.split(',')
var person = 1.0
if (dataArray(0) == ""Male"") {
person = 0.0
}
//Extract the values from the strings
//從字符串中抽取數值
//Since the data is in US metrics
//inch and pounds we will recalculate this to cm and kilo's
//因數據是采用美制單位英寸和磅,我們要將它們轉換為厘米和千克
valdata : Array[Double] = Array(person,dataArray(1).toDouble * 2.54)
valweight: Double = dataArray(2).toDouble * 0.45359237
//And return the result in a format that can later easily be used to feed to Smile
//并以一定格式返回結果,使得該結果之后容易輸入到Smile中處理
return (data, weight)
}
注意,該數據讀取器將數值從 英制單位 轉換為 公制單位 。這對 OLS 的應用沒有什么大影響,不過我們還是采用更為 常用的 公制單位。
這樣操作之后我們得到一個數組 Array[Array[Double]],該數組包含了數據點和 Array[Double] 值,該值代表男性或女性。這種格式既有利于將數據繪圖,也有利于將數據導入機器學習算法中。
我們首先看看數據是什么樣的。為此,用下列代碼將數據繪成圖。
object LinearRegressionExample extends SimpleSwingApplication {
deftop = new MainFrame {
title = "Linear Regression Example"
valbasePath = "/Users/.../OLS_Regression_Example_3.csv"
valtestData = getDataFromCSV(new File(basePath))
valplotData = (testData._1ziptestData._2).map(x => Array(x._1(1) ,x._2))
valmaleFemaleLabels = testData._1.map( x=> x(0).toInt)
valplot = ScatterPlot.plot( plotData,
maleFemaleLabels,
'@',
Array(Color.blue, Color.green)
)
plot.setTitle("Weight and heights for male and females")
plot.setAxisLabel(0,"Heights")
plot.setAxisLabel(1,"Weights")
peer.setContentPane(plot)
size = new Dimension(400, 400)
}
如果你執行上面這段代碼,就會彈出一個窗口顯示以下 右邊 那幅圖像。注意當代碼運行時,你可以滾動鼠標來放大和縮小圖像。
在這幅圖像中,綠色代表女性,藍色代表男性,可以看到,男女的身高和體重有很大部分是重疊的。因此,如果我們忽略男女性別,數據看上去依舊是呈線性的(如 左 圖所示)。然而,若不考慮男女性別差異,模型就不夠精確。
在本例中,找出這種區別(將數據依性別分組)是小事一樁,然而,你可能會碰到一些其中的數據區分不那么明顯的數據集。意識到這種可能性對數據分組是有幫助的,從而有助于改善機器學習應用程序的性能。
既然我們已經考察過數據,也知道我們確實可以建立一條回歸線來擬合數據,現在就該訓練模型了。Smile 庫提供了 普通最小二乘算法 ,我們可以用如下代碼輕松調用:
val olsModel = new OLS(testData._1,testData._2)
有了這個 OLS 模型,我們現在可以根據某人的身高和性別預測其體重了:
println("Prediction for Male of 1.7M: " +olsModel.predict(Array(0.0,170.0)))
println("Prediction for Female of 1.7M:" + olsModel.predict(Array(1.0,170.0)))
println("Model Error:" + olsModel.error())
結果如下:
Predictionfor Maleof 1.7M: 79.14538559840447
Predictionfor Femaleof 1.7M:70.35580395758966
ModelError:4.5423150758157185
回顧前文的分類算法,它有一個能夠反映模型性能的先驗值。回歸分析是一種更強大的統計方法,它可以給出一個實際誤差。這個值反映了偏離擬合回歸線的平均程度,因此可以說,在這個模型中,一個身高1.70米的男性的預測體重是 79.15kg ± 4.54kg,4.54 為誤差值。注意,如果不考慮數據的男女差異,這一誤差會增加到 5.5428。換言之,考慮了數據的男女差異后,模型在預測時,精確度提高了 ±1kg
最后一點,Smile 庫也提供了一些關于模型的統計信息。R平方值是模型的均方根誤差(RMSE)與平均函數的 RMSE 之比。這個值介于 0 與 1 之間。假如你的模型能夠準確的預測每一個數據點,R平方值就是 1,如果模型的預測效果比平均函數差,則該值為 0。在機器學習領域中,通常將該值乘以 100,代表模型的精確度。它是一個歸一化值,所以可以用來比較不同模型的性能。
本部分總結了線性回歸分析的過程,如果你還想了解如何將回歸分析應用于非線性數據,請隨時學習下一個實例“應用文本回歸嘗試暢銷書排行預測”。
應用文本回歸嘗試預測最暢銷書排行
在實例“根據身高預測體重”中,我們介紹了線性回歸的概念。然而,有時候需要將回歸分析應用到像文本這類的非數字數據中去。
在本例中,我們將通過嘗試預測最暢銷的 100 本 O’Reilly 公司出版的圖書,說明如何應用文本回歸。此外,我們還介紹在本例的特殊情況下應用文本回歸無法解決問題。原因僅僅是這些數據中不含有可以被我們的測試數據利用的信號。即使如此,本例也并非一無是處,因為在實踐中,數據可能會含有實際信號,該信號可以被這里要介紹的文本回歸檢測到。
除了 Smile 庫,本例也會使用 Scala-csv 庫 ,因為 csv 中包含帶逗號的字符串。我們從獲取需要的數據開始:
object TextRegression {
defmain(args: Array[String]): Unit = {
//Get the example data
//獲取案例數據
valbasePath = "/users/.../TextRegression_Example_4.csv"
valtestData = getDataFromCSV(new File(basePath))
}
defgetDataFromCSV(file: File) : List[(String,Int,String)]= {
valreader = CSVReader.open(file)
valdata = reader.all()
valdocuments = data.drop(1).map(x => (x(1),x(3)toInt,x(4)))
return documents
}
}
現在我們得到了 O’Reilly 出版社最暢銷100部圖書的書名、排序和詳細說明。然而,當涉及某種回歸分析時,我們需要數字數據。這就是問什么我們要建立一個 文檔詞匯矩陣 (DTM )。注意這個 DTM 與我們在垃圾郵件分類實例中建立的詞匯文檔矩陣 (TDM) 是類似的。區別在于,DTM 存儲的是文檔記錄,包含文檔中的詞匯,相反,TDM 存儲的是詞匯記錄,包含這些詞匯所在的一系列文檔。
我們自己用如下代碼生成 DTM:
importjava.io.File
importscala.collection.mutable
class DTM {
var records: List[DTMRecord] = List[DTMRecord]()
var wordList: List[String] = List[String]()
defaddDocumentToRecords(documentName: String, rank: Int, documentContent: String) = {
//Find a record for the document
//找出一條文檔記錄
valrecord = records.find(x => x.document == documentName)
if (record.nonEmpty) {
throw new Exception("Document already exists in the records")
}
var wordRecords = mutable.HashMap[String, Int]()
valindividualWords = documentContent.toLowerCase.split(" ")
individualWords.foreach { x =>
valwordRecord = wordRecords.find(y => y._1 == x)
if (wordRecord.nonEmpty) {
wordRecords += x -> (wordRecord.get._2 + 1)
}
else {
wordRecords += x -> 1
wordList = x :: wordList
}
}
records = new DTMRecord(documentName, rank, wordRecords) :: records
}
defgetStopWords(): List[String] = {
valsource = scala.io.Source.fromFile(new File("/Users/.../stopwords.txt"))("latin1")
vallines = source.mkString.split("n")
source.close()
return lines.toList
}
defgetNumericRepresentationForRecords(): (Array[Array[Double]], Array[Double]) = {
//First filter out all stop words:
//首先過濾出所有停用詞
valStopWords = getStopWords()
wordList = wordList.filter(x => !StopWords.contains(x))
var dtmNumeric = Array[Array[Double]]()
var ranks = Array[Double]()
records.foreach { x =>
//Add the rank to the array of ranks
//將評級添加到排序數組中
ranks = ranks :+ x.rank.toDouble
//And create an array representing all words and their occurrences
//for this document:
//為該文檔創建一個數組,表示所有單詞及其出現率
var dtmNumericRecord: Array[Double] = Array()
wordList.foreach { y =>
valtermRecord = x.occurrences.find(z => z._1 == y)
if (termRecord.nonEmpty) {
dtmNumericRecord = dtmNumericRecord :+ termRecord.get._2.toDouble
}
else {
dtmNumericRecord = dtmNumericRecord :+ 0.0
}
}
dtmNumeric = dtmNumeric :+ dtmNumericRecord
}
return (dtmNumeric, ranks)
}
}
class DTMRecord(valdocument : String,
valrank : Int,
var occurrences : mutable.HashMap[String,Int]
)
觀察這段代碼,注意到這里面有一個方法 def getNumericRepresentationForRecords(): (Array[Array[Double]], Array[Double])。這一方法返回一個元組,該元組以一個矩陣作為第一個參數,該矩陣中每一行代表一個文檔,每一列代表來自 DTM 文檔的完備詞匯集中的詞匯。注意第一個列表中的浮點數表示詞匯出現的次數。
第二個參數是一個數組,包含第一個列表中所有記錄的排序值。
現在我們可以按如下方式擴展主程序,這樣就可以得到所有文檔的數值表示:
valdocumentTermMatrix = new DTM()
testData.foreach(x => documentTermMatrix.addDocumentToRecords(x._1,x._2,x._3))
有了這個從文本到數值的轉換,現在我們可以利用回歸分析工具箱了。我們在“基于身高預測體重”的實例中應用了 普通最小二乘法 (OLS ),不過這次我們要應用 “最小絕對收縮與選擇算子”(Lasso ) 回歸。這是因為我們可以給這種回歸方法提供某個 λ 值,它代表一個懲罰值。該懲罰值可以幫助 LASSO 算法選擇相關的特征(單詞)而丟棄其他一些特征(單詞)。
LASSO 執行的這一特征選擇功能非常有用,因為在本例中,文檔說明包含了大量的單詞。LASSO 會設法找出那些單詞的一個合適的子集作為特征,而要是應用 OLS,則所有單詞都會被使用,那么運行時間將會變得極其漫長。此外,OLS 算法實現會檢測非滿秩。這是維數災難的一種情形。
無論如何,我們需要找出一個最佳的 λ 值,因此,我們應該用交叉驗證法嘗試幾個 λ 值,操作過程如下:
for (i <- 0 untilcv.k) {
//Split off the training datapoints and classifiers from the dataset
//從數據集中將用于訓練的數據點與分類器分離出來
valdpForTraining = numericDTM
._1
.zipWithIndex
.filter(x => cv
.test(i)
.toList
.contains(x._2)
)
.map(y => y._1)
valclassifiersForTraining = numericDTM
._2
.zipWithIndex
.filter(x => cv
.test(i)
.toList
.contains(x._2)
)
.map(y => y._1)
//And the corresponding subset of data points and their classifiers for testing
//以及對應的用于測試的數據點子集及其分類器
valdpForTesting = numericDTM
._1
.zipWithIndex
.filter(x => !cv
.test(i)
.contains(x._2)
)
.map(y => y._1)
valclassifiersForTesting = numericDTM
._2
.zipWithIndex
.filter(x => !cv
.test(i)
.contains(x._2)
)
.map(y => y._1)
//These are the lambda values we will verify against
//這些是我們將要驗證的λ值
vallambdas: Array[Double] = Array(0.1, 0.25, 0.5, 1.0, 2.0, 5.0)
lambdas.foreach { x =>
//Define a new model based on the training data and one of the lambda's
//定義一個基于訓練數據和其中一個λ值的新模型
valmodel = new LASSO(dpForTraining, classifiersForTraining, x)
//Compute the RMSE for this model with this lambda
//計算該模型的RMSE值
valresults = dpForTesting.map(y => model.predict(y)) zipclassifiersForTesting
valRMSE = Math
.sqrt(results
.map(x => Math.pow(x._1 - x._2, 2)).sum /
results.length
)
println("Lambda: " + x + " RMSE: " + RMSE)
}
}
多次運行這段代碼會給出一個在 36 和 51 之間變化的 RMSE 值。這表示我們排序的預測值會偏離至少 36 位。鑒于我們要嘗試預測最高的 100 位,結果表明這個模型的效果非常差。在本例中,λ 值變化對模型的影響并不明顯。然而,在實踐中應用這種算法時,要小心地選取 λ 值: λ 值選得越大,算法選取的特征數就越少。 所以,交叉驗證法對分析不同 λ 值對算法的影響很重要。
引述 John Tukey 的一句話來總結這個實例:
“數據中未必隱含答案。某些數據和對答案的迫切渴求的結合,無法保證人們從一堆給定數據中提取出一個合理的答案。”
應用無監督學習合并特征(PCA)
主成分分析 (PCA) 的基本思路是減少一個問題的維數。這是一個很好的方法,它可以避免維災難,也可以幫助合并數據,避開無關數據的干擾,使其中的趨勢更明顯。
在本例中,我們打算應用 PCA 把 2002-2012 年這段時間內 24 只股票的股價合并為一只股票的股價。這個隨時間變化的值就代表一個基于這 24 只股票數據的股票市場指數。把這24種股票價格合并為一種,明顯地減少了處理過程中的數據量,并減少了數據維數,對于之后應用其他機器學習算法作預測,如回歸分析來說,有很大的好處。為了看出特征數從 24 減少為 1 之后的效果,我們會將結果與同一時期的道瓊斯指數 (DJI) 作比較。
隨著工程的開始,下一步要做的是加載數據。為此,我們提供了兩個文件: Data file 1 和 Data file 2 .
object PCA extends SimpleSwingApplication{
deftop = new MainFrame {
title = "PCA Example"
//Get the example data
//獲取案例數據
valbasePath = "/users/.../Example Data/"
valexampleDataPath = basePath + "PCA_Example_1.csv"
valtrainData = getStockDataFromCSV(new File(exampleDataPath))
}
defgetStockDataFromCSV(file: File): (Array[Date],Array[Array[Double]]) = {
valsource = scala.io.Source.fromFile(file)
//Get all the records (minus the header)
//獲取所有記錄(減去標頭)
valdata = source
.getLines()
.drop(1)
.map(x => getStockDataFromString(x))
.toArray
source.close()
//group all records by date, and sort the groups on date ascending
//按日期將所有記錄分組,并按日期將組升序排列
valgroupedByDate = data.groupBy(x => x._1).toArray.sortBy(x => x._1)
//extract the values from the 3-tuple and turn them into
// an array of tuples: Array[(Date, Array[Double)]
//抽取這些3元組的值并將它們轉換為一個元組數組:Array[(Date,Array[Double])]
valdateArrayTuples = groupedByDate
.map(x => (x._1, x
._2
.sortBy(x => x._2)
.map(y => y._3)
)
)
//turn the tuples into two separate arrays for easier use later on
//將這些元組分隔為兩個數組以方便之后使用
valdateArray = dateArrayTuples.map(x => x._1).toArray
valdoubleArray = dateArrayTuples.map(x => x._2).toArray
(dateArray,doubleArray)
}
defgetStockDataFromString(dataString: String): (Date,String,Double) = {
//Split the comma separated value string into an array of strings
//把用逗號分隔的數值字符串分解為一個字符串數組
valdataArray: Array[String] = dataString.split(',')
valformat = new SimpleDateFormat("yyyy-MM-dd")
//Extract the values from the strings
//從字符串中抽取數值
valdate = format.parse(dataArray(0))
valstock: String = dataArray(1)
valclose: Double = dataArray(2).toDouble
//And return the result in a format that can later
//easily be used to feed to Smile
//并以一定格式返回結果,使得該結果之后容易輸入到Smile中處理
(date,stock,close)
}
}
有了訓練數據,并且我們已經知道要將24個特征合并為一個單獨的特征,現在我們可以進行主成分分析,并按如下方式為數據點檢索數據。
//Add to `def top`
//添加到‘def top’中
valpca = new PCA(trainData._2)
pca.setProjection(1)
valpoints = pca.project(trainData._2)
valplotData = points
.zipWithIndex
.map(x => Array(x._2.toDouble, -x._1(0) ))
valcanvas: PlotCanvas = LinePlot.plot("Merged Features Index",
plotData,
Line.Style.DASH,
Color.RED);
peer.setContentPane(canvas)
size = new Dimension(400, 400)
這段代碼不僅執行了 PCA,還將結果繪成圖像,y 軸表示特征值,x 軸表示每日。
為了能看出 PCA 合并的效果,我們現在通過如下方式調整代碼將道瓊斯指數加入到圖像中:
首先把下列代碼添加到 def top 方法中:
//Verification against DJI
//用道瓊斯指數驗證
valverificationDataPath = basePath + "PCA_Example_2.csv"
valverificationData = getDJIFromFile(new File(verificationDataPath))
valDJIIndex = getDJIFromFile(new File(verificationDataPath))
canvas.line("Dow Jones Index", DJIIndex._2, Line.Style.DOT_DASH, Color.BLUE)
然后我們需要引入下列兩個方法:
defgetDJIRecordFromString(dataString: String): (Date,Double) = {
//Split the comma separated value string into an array of strings
//把用逗號分隔的數值字符串分解為一個字符串數組
valdataArray: Array[String] = dataString.split(',')
valformat = new SimpleDateFormat("yyyy-MM-dd")
//Extract the values from the strings
//從字符串中抽取數值
valdate = format.parse(dataArray(0))
valclose: Double = dataArray(4).toDouble
//And return the result in a format that can later
//easily be used to feed to Smile
//并以一定格式返回結果,使得該結果之后容易輸入到Smile中處理
(date,close)
}
defgetDJIFromFile(file: File): (Array[Date],Array[Double]) = {
valsource = scala.io.Source.fromFile(file)
//Get all the records (minus the header)
//獲取所有記錄(減去標頭)
valdata = source
.getLines()
.drop(1)
.map(x => getDJIRecordFromString(x)).toArray
source.close()
//turn the tuples into two separate arrays for easier use later on
//將這些元組分隔為兩個數組以方便之后使用
valsortedData = data.sortBy(x => x._1)
valdates = sortedData.map(x => x._1)
valdoubles = sortedData.map(x => x._2 )
(dates, doubles)
}
這段代碼加載了 DJI 數據,并把它繪成圖線添加到我們自己的股票指數圖中。然而,當我們執行這段代碼時,效果圖有點無用。
如你所見,DJI 的取值范圍與我們的計算特征的取值范圍偏離很遠。因此,現在我們要將數據標準化。辦法就是根據數據的取值范圍將數據進行縮放,這樣,兩個數據集就會落在同樣的比例中。
用下列代碼替換 getDJIFromFile 方法:
defgetDJIFromFile(file: File): (Array[Date],Array[Double]) = {
valsource = scala.io.Source.fromFile(file)
//Get all the records (minus the header)
//獲取所有記錄(減去標頭)
valdata = source
.getLines()
.drop(1)
.map(x => getDJIRecordFromString(x))
.toArray
source.close()
//turn the tuples into two separate arrays for easier use later on
//將這些元組分隔為兩個數組以方便之后使用
valsortedData = data.sortBy(x => x._1)
valdates = sortedData.map(x => x._1)
valmaxDouble = sortedData.maxBy(x => x._2)._2
valminDouble = sortedData.minBy(x => x._2)._2
valrangeValue = maxDouble - minDouble
valdoubles = sortedData.map(x => x._2 / rangeValue )
(dates, doubles)
}
用下列代碼替換 def top 方法中 plotData 的定義:
val maxDataValue = points.maxBy(x => x(0))
val minDataValue = points.minBy(x => x(0))
val rangeValue = maxDataValue(0) - minDataValue(0)
val plotData = points
.zipWithIndex
.map(x => Array(x._2.toDouble, -x._1(0) / rangeValue))
現在我們看到,雖然 DJI 的取值范圍落在 0.8 與 1.8 之間,而我們的新特征的取值范圍落在 -0.5 與 0.5 之間,但兩條曲線的趨勢符合得很好。學完這個實例,加上段落中對 PCA 的說明,現在你應該學會了 PCA 并能把它應用到你自己的數據中。
應用支持向量機(SVM)
在我們實際開始應用支持向量機 (SVM) 之前,我會稍微介紹一下 SVM。基本的 SVM 是一個二元分類器,它通過挑選出一個代表數據點之間最大距離的超平面,將數據集分為兩部分。一個 SVM 就帶有一個所謂的“校正率”值。如果不存在理想分割,則該校正率提供了一個誤差范圍,允許人們在該范圍內找出一個仍盡可能合理分割的超平面。因此,即使仍存在一些令人不快的點,在校正率規定的誤差范圍內,超平面也是合適的。這意味著,我們無法為每種情形提出一個“標準的”校正率。不過,如果數據中沒有重疊部分,則較低的校正率要優于較高的校正率。
我剛剛說明了作為一個二元分類器的基本 SVM,但是這些原理也適用于具有更多類別的情形。然而,現在我們要繼續完成具有 2 種類別的實例,因為僅說明這種情況已經足夠了。
在本例中,我們將完成幾個小案例,其中,支持向量機 (SVM) 的表現都勝過其他分離算法如 KNN。這種方法與前幾例中的不同,但它能幫你更容易學會怎么使用以及何時使用 SVM。
對于每個小案例,我們會提供代碼、圖像、不同參數時的 SVM 運行測試以及對測試結果的分析。這應該使你對輸入 SVM 算法的參數有所了解。
在第一個小案例中,我們將應用高斯核函數,不過在 Smile 庫中還有其他核函數。緊接著高斯核函數,我們將講述多項式核函數,因為這個核函數與前者有很大的不同。
我們會在每個小案例中用到下列的基本代碼,其中只有構造函數 filePaths 和 svm 隨每個小案例而改變。
object SupportVectorMachine extends SimpleSwingApplication {
deftop = new MainFrame {
title = "SVM Examples"
//File path (this changes per example)
//文件路徑(隨案例而改變)
valtrainingPath = "/users/.../Example Data/SVM_Example_1.csv"
valtestingPath = "/users/.../Example Data/SVM_Example_1.csv"
//Loading of the test data and plot generation stays the same
//加載測試數據,繪圖生成代碼保持相同
valtrainingData = getDataFromCSV(new File(path))
valtestingData = getDataFromCSV(new File(path))
valplot = ScatterPlot.plot( trainingData._1,
trainingData._2,
'@',
Array(Color.blue, Color.green)
)
peer.setContentPane(plot)
//Here we do our SVM fine tuning with possibly different kernels
//此處,我們用可能的不同核函數對SVM進行微調
valsvm = new SVM[Array[Double]](new GaussianKernel(0.01), 1.0,2)
svm.learn(trainingData._1, trainingData._2)
svm.finish()
//Calculate how well the SVM predicts on the training set
//計算SVM對測試集的預測效果
valpredictions = testingData
._1
.map(x => svm.predict(x))
.zip(testingData._2)
valfalsePredictions = predictions
.map(x => if (x._1 == x._2) 0 else 1 )
println(falsePredictions.sum.toDouble / predictions.length
* 100 + " % false predicted")
size = new Dimension(400, 400)
}
defgetDataFromCSV(file: File): (Array[Array[Double]], Array[Int]) = {
valsource = scala.io.Source.fromFile(file)
valdata = source
.getLines()
.drop(1)
.map(x => getDataFromString(x))
.toArray
source.close()
valdataPoints = data.map(x => x._1)
valclassifierArray = data.map(x => x._2)
return (dataPoints, classifierArray)
}
defgetDataFromString(dataString: String): (Array[Double], Int) = {
//Split the comma separated value string into an array of strings
//把用逗號分隔的數值字符串分解為一個字符串數組
valdataArray: Array[String] = dataString.split(',')
//Extract the values from the strings
//從字符串中抽取數值
valcoordinates = Array( dataArray(0).toDouble, dataArray(1).toDouble)
valclassifier: Int = dataArray(2).toInt
//And return the result in a format that can later
//easily be used to feed to Smile
//并以一定格式返回結果,使得該結果之后容易輸入到Smile中處理
return (coordinates, classifier)
}
案例1(高斯核函數)
在本案例中,我們介紹了最常用的 SVM 核函數,即高斯核函數。我們的想法是幫助讀者尋找該核函數的最佳輸入參數。本例中用到的數據可以在 這里 下載。
從該圖中可以清楚看出,線性回歸線在這里起不了作用。我們要使用一個 SVM 來作預測。在給出的第一段代碼中,高斯核函數的 sigma 值為 0.01,邊距懲罰系數為 1.0,類別總數為 2,并將其傳遞給了 SVM。那么,這些都代表什么意思呢?
我們從高斯核函數說起。這個核函數反映了 SVM 如何計算系統中成對數據的相似度。對于高斯核函數,用到了歐氏距離中的方差。我們特意挑選高斯核函數的原因是,數據中并不含有明顯的結構如線性函數、多項式函數或者雙曲線函數。相反地,數據聚集成了3組。
我們傳遞到高斯核中構造函數的參數是 sigma。這個 sigma 值反映了核函數的平滑程度。我們會演示改變這一取值如何影響預測效果。我們將邊距懲罰系數取 1。這一參數定義了系統中向量的邊距,因此,這一值越小,約束向量就越多。我們會執行一組運行測試,通過結果向讀者說明這個參數在實踐中的作用。注意其中 s: 代表 sigma,c: 代表校正懲罰系數。百分數表示預測效果的誤差率, 它只不過是訓練之后,對相同數據集的錯誤預測的百分數。
不幸的是,并不存在為每個數據集尋找正確 sigma 的黃金法則。不過,可能最好的方法就是計算數據的 sigma 值,即 √(variance),然后在這個值附近取值看看哪一個 sigma 值效果最好。因為本例數據的方差在 0.2 與 0.5 之間,我們把這區間作為中心并在中心的兩邊都選取一些值,以比較我們的案例中使用高斯核的 SVM 的表現。
看看表格中的結果和錯誤預測的百分比,它表明產生最佳效果的參數組合是一個非常低的 sigma (0.001) 和一個 1.0 及以上的校正率。不過,如果把這個模型應用到實際中的新數據上,可能會產生過擬合。因此,在用模型本身的訓練數據測試模型時,你應該保持謹慎。一個更好的方法是使用交叉驗證,或用新數據驗證。
案例2(多項式核函數)
高斯核并不總是最佳選擇,盡管在應用 SVM 時,它是最常用的核函數。因此,在本例中,我們將演示一個多項式核函數勝過高斯核函數的案例。注意,雖然本案例中的示例數據是構建好的,但在本領域內相似的數據(帶有一點噪聲)是可以找到的。本案例中的訓練數據可以在 這里 下載,測試數據在 這里 下載。
對于本例數據,我們用一個三次多項式創建了兩個類別,并生成了一個測試數據文件和一個訓練數據文件。訓練數據包含x軸上的前500個點,而測試數據則包含x軸上500到1000這些點。為了分析多項式核函數的工作原理,我們將數據匯成圖。左圖是訓練數據的,右圖是測試數據的。
考慮到本實例開頭給出的基本代碼,我們作如下的替換:
val trainingPath = "/users/.../Example Data/SVM_Example_2.csv"
val testingPath = "/users/.../Example Data/SVM_Example_2_Test_data.csv"
然后,如果我們使用高斯核并且運行代碼,就可以得到如下結果:
可以看到,即使是最佳情況,仍然有 27.4% 的測試數據被錯誤分類。這很有趣,因為當我們觀察圖像時,可以看到兩個類別之間有一個很明顯的區分。我們可以對 sigma 和校正率進行微調,但是當預測點很遠時(例如 x 是 100000),sigma 和校正率就會一直太高而使模型表現不佳(時間方面與預測效果方面)。
因此,我們將高斯核替換為多項式核,代碼如下:
val svm = new SVM[Array[Double]](new PolynomialKernel(2), 1.0,2)
注意我們給多項式核的構造函數傳遞 2 的方式。這個 2 代表它要擬合的函數的次數。如果我們不單考慮次數為 2 的情況,我們還考慮次數為2、3、4、5的情況,并且讓校正率再一次在 0.001 到 100 之間變化,則得到如下結果:
從中我們可以看到,次數為 3 和 5 的情況得到了100%的準確率,這兩種情況中測試數據與訓練數據之間沒有一個點是重疊的。與高斯核的最佳情況 27.4% 的錯誤率相比,這種表現令人驚喜。確實要注意本例這些數據是構建好的,因此沒有什么噪聲數據。所以才能出現所有的“校正率”都為 0% 錯誤率。如果添加了噪聲,則需要對校正率進行微調。
以上就是對支持向量機這一部分的總結。
結論
在了解了機器學習的整體思想之后,你應該可以辨別出哪些情況分別屬于分類問題、回歸問題或是維數約化問題。此外,你應該理解機器學習的基本概念,什么是模型,并且知道機器學習中的一些常見陷阱。
在學完本文中的實例之后,你應該學會應用 K-NN、樸素貝葉斯算法以及線性回歸分析了。此外,你也能夠應用文本回歸、使用 PCA 合并特征以及應用支持向量機。還有非常重要的一點,就是能夠建立你自己的推薦系統。
來自:http://blog.jobbole.com/109702/