原文:http://burakkanber.com/blog/machine-learning-in-js-k-nearest-neighbor-part-1/
翻譯:王維強
我的目的是使用一門通用語言來教授機器學習,內容涵蓋基礎概念與高級應用。Javascript是一個非常好的選擇,最明顯的優點就是對運行環境沒有特殊要求。另外,因為該語言缺乏與機器學習相關的類庫,也能迫使我們從基礎編碼寫起。
先看一個實例。
今天我們將要開始近鄰(k-nearest-neighbor)算法的學習,該算法在文章中一律被簡寫作kNN。我非常喜歡這個算法,因為雖然它簡單的要命,卻能解決一些令人興奮的問題。其優點在於不依賴於復雜的數學或理論,但是在實際應用中卻能對不同規模的輸入參數進行優美的處理。
近鄰算法也給我提供了實現初衷的好途徑:該算法是介紹“監督學習”的最佳切入點。
那么就讓我們着手建立最佳鄰居算法吧,同時我們會讓結果以圖像的形式展示出來,我喜歡可視化。
監督學習
在機器學習領域中存在兩個巨大的類別:監督學習和非監督學習。 簡而言之,非監督學習很像數據挖據,翻檢一些數據嘗試得出什么有用的結論。通常情況下,在開始數據處理之前你沒有更多信息可供參考。我們會在下周關注一個叫“k-means”的非監督學習算法,我們會在下一篇文章中給予其更多的討論。
監督學習,也可以說成是開始於“訓練數據”的學習算法。監督學習很像我們小時候作為孩子了解周遭世界的過程。我們和媽媽在廚房里,媽媽拿着一個蘋果給我們看,同時說“蘋果”二字,我們看到的東西是媽媽為我們標注好的。
第二天,她給我們看一個小一點的蘋果,也不那么紅,形狀也稍有不同。但是媽媽給你看的同時說的仍是“蘋果”這兩個字。整個過程會重復幾周,每天媽媽都會給你看稍有不同的蘋果,並且告訴你它們是”蘋果。通過這個過程你理解並記住了什么是蘋果。
不是每個蘋果都一模一樣,一生中看到的每個蘋果,你可能都認為是獨一無二的,但是媽媽已經把你訓練得能夠識別蘋果的所有特征。現在你可以建立分類或標簽用來區分腦海中的各種事物。你一旦看到蘋果就能立刻將之歸為蘋果,因為你認識到所有的蘋果都共享一些相同的屬性,另外這些屬性可能也不必只屬於蘋果。
這個過程被稱為“泛化”,是機器學習算法中非常重要的概念。我們不會因為一個iphone佩戴了不同的外殼或者屏幕上布滿划痕就認不出來。
在建立特定類型的機器學習算法之前,我們需要了解泛化的含義。我們的算法應該能夠實現泛化,但不過度泛化(說起來容易作起來難啊)。
比如,我們想要的是:“這是紅色的、圓形的、光滑的,這一定是蘋果。”。
不期望出現的是:“這是紅色的、圓形的,這一定是個球;另外一個橙色的、圓形的,也一定是個球。”
這種過度泛化會是個問題,同時泛化不足也是問題。這就是機器學習當中的一個挑戰:夠找到泛化的最佳點。
你可以使用一些測試案例來檢測你的算法幫你找到這個最佳點,我們也會在將來的文章中談到更多的關於這方面的更高級的算法。
許多監督學習問題都是怎么“歸類”問題。
歸類問題的過程類似於,有一籃子蘋果、橙子和梨。除了一個水果外,其他每個水果都有個標簽告訴你這個水果是什么。你需要通過學習這些標注過的水果來指出未標注的一個是什么水果。
這種歸類問題對人類來說非常簡單,但是計算機不知道該怎么做。kNN就是眾多歸類算法中的一個。
特征
先介紹一下機器學習中一個非常重要的概念了:特征。
特征是從對象中提取出來的能夠放入機器學習過程中的信息。比如你正在甄別一個物體是蘋果還是橙子,你可能要觀察以下特征:形狀,大小,顏色,光滑度,表面紋理等等。很可能某些時候某個具體特征對甄別什么是蘋果或橙子並沒有什么幫助,蘋果和橙子在尺寸方面就差別不大,所以該特征可以被棄用以節約計算成本。在此例中,尺寸特征真的沒有什么幫助。
在設計機器學習算法時,能知道哪些特征需要考慮在內是非常重要的技能。有時你可能依靠直覺,但是大多時候你希望能利用另外一套算法來判斷哪些特征是重要的(關於這一點,會在未來的更多文章中進行討論)。
如你所想,特征並不一直是“顏色,尺寸,形狀”。 文檔處理就是一個例子,在某些情況下,文檔中的每個單詞都是一個特征,或者每個連詞也是一個特征。我們也會在將來的文章中討論有關文檔分類的話題。
問題
需要解決的問題來了,給出一個住宅的房屋數目和面積,判斷出該住宅是apartment,house 或者 flat。
和往常一樣,為了理解問題的根本,我們從最大可能性的地方入手。通過問題的描述我們獲得了需要關注的特征是:房間數目和面積。我們可以這樣考慮,既然這是一個監督學習問題,我們就能從案例數據中獲得幫助。
什么是 "k-nearest-neighbor" 測量
我認為教授kNN算法最好的方式就是簡單地闡釋“k-nearest-neighbor”的真正含義。
如下表所示,這里列出了針對該問題的案例數據:
Rooms | Area | Type |
---|---|---|
1 | 350 | apartment |
2 | 300 | apartment |
3 | 300 | apartment |
4 | 250 | apartment |
4 | 500 | apartment |
4 | 400 | apartment |
5 | 450 | apartment |
7 | 850 | house |
7 | 900 | house |
7 | 1200 | house |
8 | 1500 | house |
9 | 1300 | house |
8 | 1240 | house |
10 | 1700 | house |
9 | 1000 | house |
1 | 800 | flat |
3 | 900 | flat |
2 | 700 | flat |
1 | 900 | flat |
2 | 1150 | flat |
1 | 1000 | flat |
2 | 1200 | flat |
1 | 1300 | flat |
我們將把上面這些信息用坐標點的方式在圖像中呈現出來,以房間數目為橫坐標,以面積為縱坐標。
當我們不可避免的加入一個新的未標記的數據點(“迷點”)時,也把它畫在圖上。然后我們選擇一個數(稱之為k),並且找到圖中最接近“迷點”的k個數據點,如果這些點中主要是“flat",那么我們有理由猜測這個”迷點“所代表的住宅也是一個flat。
這就是近鄰算法的含義。
如果3個最近的鄰居點中有兩個是apartment,另一個是house,則我們就可以說“迷點”所代表的住宅是一個apartment。
其簡略過程如下:
- 把所有數據(包括迷點)放入圖中。
- 測量迷點和每個點的距離。
- 選取一個數值。對於小型數據集,3是不錯的選擇。
- 找出哪3(k)個點離“迷點”最近。
- 3個點中多數點所代表的,就是我們想要的答案。
代碼
現在開始搭建程序,在我們實施算法的過程中會蹦出來一些新的點子,所以請先仔細閱讀下文,如果跳過,你會丟掉很重要的概念。
針對這個算法我會創建兩個類,一個是 Node,一個是 NodeList。
Node 表示集合中的某個具體數據,可以是已經標記好的數據,也可以是迷點。
NodeList 管理所有 Node 並實現一些額外的功能,如畫圖。
Node 的構造函數不做任何事情,只是表達一個對象,其屬性有“type”,“area” 和“rooms”。
var Node = function(object) { for (var key in object) { this[key] = object[key]; } };
通常情況下,我在創建算法時會把特征更多地更抽像出來。本例中需要在某些地方硬編碼面積(area)和房間數目(rooms),但是我通常創建更通用的kNN算法以便能應付任意特征,而不僅僅限定於我們剛剛定義的內容。我會把這個作為練習留給你。
類似的,NodeList的構造器也很簡單:
var NodeList = function(k) { this.nodes = []; this.k = k; };
NodeList 構造器把 k,也就是kNN中的k作為唯一參數。
這里有個簡單的函數沒有寫出來,NodeList.prototype.add(node), 該函數只負責把 node 壓進 this.nodes 數組中。
接下來似乎我們就可以進入計算距離的議題了,但是我還是想偏離一下,先討論另一個問題。
特征歸一化
觀察上面表格中的數據,房間數目從1到10不等,面積從250到1700不等。如果我們直接拿這些數據在圖上標注會發生什么情況? 很顯然,數據點會幾乎列在一條縱線上,看起來丑陋而不易辯讀。
很不幸,這不僅是一個審美的問題,而會造成數據特征方面的巨大矛盾。
房間數從1到10對於確認該住所是 flat 還是 house 是非常巨大的跨度,但是相比面積數據的差異時,即使是房間數目的差異達到了9,對距離計算來說也相當於無。如果在計算 Node 間距離的過程中不調整這種矛盾,你會發現房間數目對計算結果的影響微乎其微,因為數據間在橫坐標方向彼此靠的實在太近了。
同樣是面積為700,房間數目分別為1和5的兩個住所,區別是什么?觀察上表中的數據,你會看到第一行是flat,第二行是apartment。但是,如果把它們標注在圖上並運行kNN算法,二者會被認為都是flat。
所以,我們使用把值歸一化到0到1的區間的方法,以取代直接使用這些數據。歸一化后,房間數目最小的1變為0,原本最大的10變為1。與之相似,最小面積為250的變為0,最大面積數1700變為1。這樣一來所有數據都處於同一級別,也就消除了比例矛盾的問題。這是一個制造差異化的簡單的方法。
提示: 你甚至不需要像我以上描述的那樣縮放某個數據(比如面積),如果面積比房間數目重要,你可以按照不同的比例縮放他們,這就是“權重”,給予某個特征更重要的地位。有一些算法可以判定什么樣的權重比較合適,以后我們會講到...
怎么開始做數據的歸一化呢? 我們應該給 NodeList 提供一個能找出每個特征數據的最大值和最小值的方法:
NodeList.prototype.calculateRanges = function() { this.areas = {min: 1000000, max: 0}; this.rooms = {min: 1000000, max: 0}; for (var i in this.nodes) { if (this.nodes[i].rooms < this.rooms.min) { this.rooms.min = this.nodes[i].rooms; } if (this.nodes[i].rooms > this.rooms.max) { this.rooms.max = this.nodes[i].rooms; } if (this.nodes[i].area < this.areas.min) { this.areas.min = this.nodes[i].area; } if (this.nodes[i].area > this.areas.max) { this.areas.max = this.nodes[i].area; } } };
我之前說過,最好的方法是抽象出特征量而不是硬編碼房間的數目或面積,但是,現在這樣做對我講解算法來說更清晰些。
現在有了最大和最小值,我們可以開始算法的核心功能了。
把所有的 Node 壓入 NodeList 之后:
NodeList.prototype.determineUnknown = function() { this.calculateRanges(); /* * Loop through our nodes and look for unknown types. */ for (var i in this.nodes) { if ( ! this.nodes[i].type) { /* * If the node is an unknown type, clone the nodes list and then measure distances. */ /* Clone nodes */ this.nodes[i].neighbors = []; for (var j in this.nodes) { if ( ! this.nodes[j].type) continue; this.nodes[i].neighbors.push( new Node(this.nodes[j]) ); } /* Measure distances */ this.nodes[i].measureDistances(this.areas, this.rooms); /* Sort by distance */ this.nodes[i].sortByDistance(); /* Guess type */ console.log(this.nodes[i].guessType(this.k)); } } };
作為入口,首先計算最大最小值的范圍。
然后循環整個 Node 集合尋找未知 Node(沒錯,未知Node可以不止一個)。
一旦發現某個未知 Node,就把所有已知的 Node 克隆出來作為該未知 Node 的鄰居序列。之所以這樣做是因為我們需要計算該未知 Node 和所有已知Node的距離。
最后,我們連續調用未知 Node 的三個方法: measureDistances, sortByDistance, 和 guessType.
Node.prototype.measureDistances = function(area_range_obj, rooms_range_obj) { var rooms_range = rooms_range_obj.max - rooms_range_obj.min; var area_range = area_range_obj.max - area_range_obj.min; for (var i in this.neighbors) { /* Just shortcut syntax */ var neighbor = this.neighbors[i]; var delta_rooms = neighbor.rooms - this.rooms; delta_rooms = (delta_rooms ) / rooms_range; var delta_area = neighbor.area - this.area; delta_area = (delta_area ) / area_range; neighbor.distance = Math.sqrt( delta_rooms*delta_rooms + delta_area*delta_area ); } };
measureDistances 方法兩個參數分別是面積和房間數的最大最小值區間,如果你已經把硬編碼的特征抽象出來了,那么該方法的參數就會變成了一數組,該數的每個元素是一個特征值區間,但是這里我還是使用硬編碼,以方便理解。
接下來快速計算房間數區間(值為9),面積區間(值為1450)。
然后循環處理未知 Node 的所有的鄰居,針對每個鄰居計算出來房間數目和面積大小的差異(delta_room,delta_area),然后做歸一化處理(用差異值除以區間值)。
比如,房間數目差值為3,范圍區間是9,那么delta_room的值為3/9等於0.333,該值應該永遠處於-1到+1之間。
最后,用畢達哥拉斯定理計算距離(譯者注:勾股定理)。需要注意的是,如果維度超過2,還是可以直接用這個定理,把所有的差值平方后相加,再開方即可:
Math.sqrt( a*a + b*b + c*c + d*d + ... + z*z );
到目前為止,人們對該算法的優勢的了解還是比較清楚的。人的腦力對5或10個特征之間的統計可能還應付的了,但是該算法卻可以處理成百上千的維度。
Node.prototype.sortByDistance = function() { this.neighbors.sort(function (a, b) { return a.distance - b.distance; }); };
sortByDistance 方法用距離排序所有鄰居。
Node.prototype.guessType = function(k) { var types = {}; for (var i in this.neighbors.slice(0, k)) { var neighbor = this.neighbors[i]; if ( ! types[neighbor.type] ) { types[neighbor.type] = 0; } types[neighbor.type] += 1; } var guess = {type: false, count: 0}; for (var type in types) { if (types[type] > guess.count) { guess.type = type; guess.count = types[type]; } } this.guess = guess; return types; };
最后一個算法是 guessType, 該方法需要一個參數 k,找出 k 個最接近該點的鄰居,然后按照這些鄰居的類型標記,找出占大多數的一類(flat,house,apartment),那么這個類型就是要返回的結果。
恭喜!算法結束了。現在就讓我們把它畫出來把。
利用Canvas畫出結果
畫出結果的方法還是很直截了當的:
- 用顏色標記類型:apartments = red, houses = green, flats = blue.
- 把圖形縮放在一個正方形之內(我們之前做歸一化的一個重要原因).
- 我們還需要設置些圖形的邊界空白,如果有些數據落在太靠邊的位置,看起來不是很好。
- 需要把kNN算法的結果以圓環的形式表示出來,圓環內就是囊括在內的k個最近鄰居,圓環需要用顯著的顏色表示出來。
NodeList.prototype.draw = function(canvas_id) { var rooms_range = this.rooms.max - this.rooms.min; var areas_range = this.areas.max - this.areas.min; var canvas = document.getElementById(canvas_id); var ctx = canvas.getContext("2d"); var width = 400; var height = 400; ctx.clearRect(0,0,width, height); for (var i in this.nodes) { ctx.save(); switch (this.nodes[i].type) { case 'apartment': ctx.fillStyle = 'red'; break; case 'house': ctx.fillStyle = 'green'; break; case 'flat': ctx.fillStyle = 'blue'; break; default: ctx.fillStyle = '#666666'; } var padding = 40; var x_shift_pct = (width - padding) / width; var y_shift_pct = (height - padding) / height; var x = (this.nodes[i].rooms - this.rooms.min) * (width / rooms_range) * x_shift_pct + (padding / 2); var y = (this.nodes[i].area - this.areas.min) * (height / areas_range) * y_shift_pct + (padding / 2); y = Math.abs(y - height); ctx.translate(x, y); ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI*2, true); ctx.fill(); ctx.closePath(); /* * Is this an unknown node? If so, draw the radius of influence */ if ( ! this.nodes[i].type ) { switch (this.nodes[i].guess.type) { case 'apartment': ctx.strokeStyle = 'red'; break; case 'house': ctx.strokeStyle = 'green'; break; case 'flat': ctx.strokeStyle = 'blue'; break; default: ctx.strokeStyle = '#666666'; } var radius = this.nodes[i].neighbors[this.k - 1].distance * width; radius *= x_shift_pct; ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI*2, true); ctx.stroke(); ctx.closePath(); } ctx.restore(); } };
上面的代碼,我不想給出詳細的說明了,但是有兩行你需要自己得出結論:
- "var x = "
- "var radius = "
過程可能令人費解沮喪,但這是很好的練習機會,不要放棄,直至理解他們。
最終,我們還要加入一些代碼,功能就是每隔五秒生成一個隨機點作為迷點數據:
var run = function() { nodes = new NodeList(3); for (var i in data) { nodes.add( new Node(data[i]) ); } var random_rooms = Math.round( Math.random() * 10 ); var random_area = Math.round( Math.random() * 2000 ); nodes.add( new Node({rooms: random_rooms, area: random_area, type: false}) ); nodes.determineUnknown(); nodes.draw("canvas"); }; window.onload = function() { setInterval(run, 5000); run(); };
如果你想對k值做些調整,可以在上述的 run() 方法中實施。
kNN算法不是一個十分復雜的分類器,但是有很多非常棒的應用,更令人興奮的地方在於你不必把它當作一個分類器,kNN背后的概念能十分靈活地解決非分類器方面的問題。對於解決下列問題,kNN可能依然是非常好的候選方案。
- 哪個顏色名稱(blue, red, green, yellow, grey, purple等等)與給定的 RGB 值接近? 這種算法在圖像搜索中非常有用,比如“搜索紫色的圖片”。
- 你正在創建一個約會網站,並且想為針對某個人的情況給出的匹配排序。特征可能包括未知,年齡,身高,用kNN算法找出20個最靠近的鄰居並排序。
- 快速找出與給定文檔相似的5個文檔。特征就是文檔中的單詞。解決這種問題並不需要十分復雜的算法。
- 給出以前電商顧客的數據,分析出當前正在瀏覽網站的潛在購買者會不會發生購買行為? 特征可能是一天中的時間段,瀏覽頁數,位置,鏈接來源等。
弱點及說明
kNN存在兩個問題。
一、如果數據訓練充滿整個空間,到處都是,那么該算法就會力不從心。也就是說,數據在某些特征上需要是可分割的或聚集的。圖像上的隨機斑點是沒有任何幫助的。只有非常少的機器學習算法可以從密集隨機數據中識別出模式。
二、如果數據集增加到數千的規模,將會出現計算效率問題,距離計算也會讓算法在效率上雪上加霜。一個可行的方法是首先濾掉在特征值區間意外的那些數據。例如,迷點數據的房間數是3,我們可能就沒有必要把那些房間數大於6的也考慮在內了。
下面是 JSFiddle 的鏈接,已知點已經用顏色標出,迷點為灰色,kNN的猜測結果是圍繞迷點的一個圓圈。半徑內囊括了3個最近的鄰居,5秒鍾之內會產生一個新的隨機迷點,並給出計算結果。