從勾股定理到餘弦相似度-程式設計師的數學基礎

vivo網際網路技術發表於2020-11-03

大部分程式設計師由於理工科的背景,有一些高數、線性代數、機率論與數理統計的數學基礎。所以當機器學習的熱潮來臨的時候,都躍躍欲試,對機器學習的演算法以及背後的數學思想有比較強烈的探索慾望。

本文的作者就是其中的一位。然而實踐的過程中,又發現數學知識的理解深度有些欠缺,在理解一些公式背後的意義時,有些力不從心的感覺。因此梳理了一些數學上的知識盲點,理順自己的知識脈絡,順便分享給有需要的人。

本文主要講解餘弦相似度的相關知識點。相似度計算用途相當廣泛,是搜尋引擎、推薦引擎、分類聚類等業務場景的核心點。為了理解清楚餘弦相似度的來龍去脈,我將會從最簡單的初中數學入手,逐步推匯出餘弦公式。然後基於餘弦公式串講一些實踐的例子。

一、業務背景

通常我們日常開發中,可能會遇到如下的業務場景。

從勾股定理到餘弦相似度-程式設計師的數學基礎

精準營銷,影像處理,搜尋引擎 這三個看似風馬牛不相及的業務場景,其實面臨一個共同的問題就是相似度的計算。例如精準營銷中的人群擴量涉及使用者相似度的計算;影像分類問題涉及影像相似度的計算,搜尋引擎涉及查詢詞和文件的相似度計算。相似度計算中,可能由於《數學之美》的影響,大家最熟悉的應該是餘弦相似度。那麼餘弦相似度是怎麼推匯出來的呢?

二、數學基礎

理解餘弦相似度,要從理解金字塔開始。我們知道金字塔的底座是一個巨大的正方形。例如吉薩大金字塔的邊長超過230m。構造這樣一個巨大的正方形,如何保證構造出來的圖形不走樣呢?比如如何確保構造的結果不是菱形或者梯形。

從勾股定理到餘弦相似度-程式設計師的數學基礎

1、勾股定理

要保證構造出來的四邊形是正方形,需要保證兩個點:其一是四邊形的邊長相等;其二是四邊形的角是直角。四邊形的邊長相等很容易解決,在工程實踐中,取一根定長的繩子作為邊長就可以了。如何保障直角呢?古人是利用勾股定理解決的,更切確地說是勾股定理的逆定理。

構造一個三角形,保證三角形的三邊長分別是3,4,5。那麼邊長為5的邊對應的角為直角。中國有個成語“無規矩不成方圓”,其中的矩,就是直角的尺。

從勾股定理到餘弦相似度-程式設計師的數學基礎

勾股證明是初中數學的知識,理解很容易。證明也很簡單,據說愛因斯坦11歲就發現了一種證明方法。勾股定理的證明方法據統計有超過400種, 感興趣的同學可以自行了解。另勾股定理也是費馬大定理的靈感來源,費馬大定理困擾了世間智者300多年,也誕生了很多的逸聞趣事,這裡不贅述。

2、餘弦定理

勾股定理存在著一個很大的限制,就是要求三角形必須是直角三角形。那麼對於普通的三角形,三個邊存在什麼樣的關係呢?這就引出了餘弦定理。

從勾股定理到餘弦相似度-程式設計師的數學基礎

餘弦定理指出了任意三角形三邊的關係,也是初中就可以理解的數學知識,證明也比較簡單,這裡就略過了。

其實對於三角形,理解了勾股定理和餘弦定理。就已經掌握了三角形的很多特性和秘密了。比如根據等邊三角形,可以推匯出cos(60)=1/2。但是如果想理解幾何更多的秘密,就需要進入解析幾何的世界。這個數學知識也不算很高深,高中數學知識。

這裡我們理解最簡單就可以了,那就是三角形在直角座標系中的表示。所謂“橫看成嶺側成峰,遠近高低各不同”,我們可以理解為三角形的另一種表現形式。

從勾股定理到餘弦相似度-程式設計師的數學基礎

比如我們可以用a,b,c三個邊描述一個三角形;在平面直角座標系中,我們可以用兩個向量表示一個三角形。

3、餘弦相似度

當我們引入了直角座標系後,三角形的表示就進入了更靈活、更強大和更抽象的境界了。幾何圖形可以用代數的方法來計算,代數可以用幾何圖形形象化表示,大大降低理解難度。比如我們用一個向量來表示三角形的一個邊,就可以從二維空間直接擴充套件到高維空間。

從勾股定理到餘弦相似度-程式設計師的數學基礎

這裡,向量的定義跟點是一樣的;向量的乘法也只是各個維度值相乘累加;向量的長度看似是新的東西,其實繞了一個圈,本質上還是勾股定理,只是勾股定理從二維空間擴充套件到了N維空間而已。而且向量長度又是兩個相同向量乘法的特例。數學的嚴謹性在這裡體現得淋漓盡致。

結合勾股定理,餘弦定理,直角座標系,向量。我們就可以很自然地推匯出餘弦公式了,這裡唯一的理解難點就是勾股定理和餘弦定理都是用向量來表示。

從勾股定理到餘弦相似度-程式設計師的數學基礎

得到了餘弦公式後,我們該怎麼理解餘弦公式呢?

從勾股定理到餘弦相似度-程式設計師的數學基礎

極端情況下,兩個向量重合了,就代表兩個向量完全相似。然而這裡的完全相似,其實是指向量的方向。向量有方向和長度兩個要素,這裡只使用方向這一個要素,在實踐中就埋下了隱患。但是畢竟一個數學模型建立起來了。我們可以用這個模型解決一些實際中的問題了。

所謂數學模型,有可能並不需要高深的數學知識,對外的表現也僅僅是一個數學公式。比如餘弦定理這個數學模型,高中數學知識就足夠理解了。而且關於模型,有這樣一個很有意思的論述:“所有的數學模型都是錯的,但是有些是有用的”。這裡我們更多關注其有用的一面。

理解了向量夾角,那麼該怎麼理解向量呢?它僅僅是三角形的一條邊嗎? 

從勾股定理到餘弦相似度-程式設計師的數學基礎

 

人生有幾何,萬物皆向量。向量在數學上是簡單的抽象,這個抽象我們可以用太多實際的場景來使它落地。比如用向量來指代使用者標籤,用向量來指代顏色,用向量來指代搜尋引擎的邏輯...

三、業務實踐

理解了餘弦定理,理解了數學建模的方式。接下來我們就可以做一些有意思的事情了。比如前面提到的三個業務場景,我們可以看看如何用餘弦相似度來解決。當然實際問題肯定遠遠要複雜得多,但是核心的思想都是類似的。

案例1:精準營銷

假設一次運營計劃,比如我們圈定了1w的使用者,如何擴充套件到10萬人呢?

從勾股定理到餘弦相似度-程式設計師的數學基礎

利用餘弦相似度,我們這裡其實最核心的問題就是:如何將使用者向量化?

將每個使用者視為一個向量,使用者的每個標籤值視為向量的一個維度。當然這裡實際工程中還有特徵歸一化,特徵加權等細節。我們這裡僅作為演示,不陷入到細節中。

對於人群,我們可以取人群中,所有使用者維度值的平均值,作為人群向量。這樣處理後,就可以使用餘弦公式計算使用者的相似度了。

從勾股定理到餘弦相似度-程式設計師的數學基礎 

我們透過計算大盤使用者中每個使用者跟圈定人群的相似度,取topN即可實現人群的擴量。

直接“show me the code”吧! 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# -*- coding: utf- 8  -*-
import  numpy as np
import  numpy.linalg as linalg
  
  
def cos_similarity(v1, v2):
     num =  float (np.dot(v1.T, v2))  # 若為行向量則 A.T * B
     denom = linalg.norm(v1) * linalg.norm(v2)
     if  denom >  0 :
         cos = num / denom  # 餘弦值
         sim =  0.5  0.5  * cos  # 歸一化
         return  sim
     return  0
  
  
if  __name__ ==  '__main__' :
  
     u_tag_list = [
         [ "女" "26" "是" "白領" ],
         [ "女" "35" "是" "白領" ],
         [ "女" "30" "是" "白領" ],
         [ "女" "22" "是" "白領" ],
         [ "女" "20" "是" "白領" ]
     ]
     new_user = [ "女" "20" "是" "白領" ]
  
     u_tag_vector = np.array([
         [ 1 26 1 1 ],
         [ 1 35 1 1 ],
         [ 1 30 1 1 ],
         [ 1 22 1 1 ],
         [ 1 20 1 1 ]
     ])
  
     c1 = u_tag_vector[ 0 ]
     c1 += u_tag_vector[ 1 ]
     c1 += u_tag_vector[ 2 ]
     c1 += u_tag_vector[ 3 ]
     c1 += u_tag_vector[ 4 ]
     c1 = c1/ 5
      
     new_user_v1 = np.array([ 1 36 1 1 ])
     new_user_v2 = np.array([- 1 20 0 1 ])
     print( "vector-u1: " , list(map(lambda x:  '%.2f'  % x, new_user_v1.tolist()[ 0 : 10 ])))
     print( "vector-u2: " , list(map(lambda x:  '%.2f'  % x, new_user_v2.tolist()[ 0 : 10 ])))
     print( "vector-c1: " , list(map(lambda x:  '%.2f'  % x, c1.tolist()[ 0 : 10 ])))
     print( "sim<u1,c1>: " , cos_similarity(c1, new_user_v1))
     print( "sim<u2,c1>: " , cos_similarity(c1, new_user_v2))

案例2:影像分類

有兩類圖片,美食和萌寵。對於新的圖片,如何自動分類呢? 

從勾股定理到餘弦相似度-程式設計師的數學基礎 

這裡我們的核心問題是:圖片如何向量化?

圖片由畫素構成,每個畫素有RGB三個通道。由於畫素粒度太細,將圖片分割成大小相對的格子,每個格子定義3個維度,維度值取格子內畫素均值。

從勾股定理到餘弦相似度-程式設計師的數學基礎

參考部落格:  影像基礎7 影像分類——餘弦相似度

下面也是給出樣例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# -*- coding: utf- 8  -*-
import  numpy as np
import  numpy.linalg as linalg
import  cv2
  
  
def cos_similarity(v1, v2):
     num =  float (np.dot(v1.T, v2))  # 若為行向量則 A.T * B
     denom = linalg.norm(v1) * linalg.norm(v2)
     if  denom >  0 :
         cos = num / denom  # 餘弦值
         sim =  0.5  0.5  * cos  # 歸一化
         return  sim
     return  0
  
  
def build_image_vector(im):
     "" "
  
     :param im:
     : return :
     "" "
     im_vector = []
  
     im2 = cv2.resize(im, ( 500 300 ))
     w = im2.shape[ 1 ]
     h = im2.shape[ 0 ]
     h_step =  30
     w_step =  50
  
     for  i in range( 0 , w, w_step):
         for  j in range( 0 , h, h_step):
             each = im2[j:j+h_step, i:i+w_step]
             b, g, r = each[:, :,  0 ], each[:, :,  1 ], each[:, :,  2 ]
             im_vector.append(np.mean(b))
             im_vector.append(np.mean(g))
             im_vector.append(np.mean(r))
     return  np.array(im_vector)
  
  
def show(imm):
     imm2 = cv2.resize(imm, ( 510 300 ))
     print(imm2.shape)
     imm3 = imm2[ 0 : 50 0 : 30 ]
     cv2.imshow( "aa" , imm3)
  
     cv2.waitKey()
     cv2.destroyAllWindows()
     imm4 = imm2[ 51 : 100 0 : 30 ]
     cv2.imshow( "bb" , imm4)
     cv2.waitKey()
     cv2.destroyAllWindows()
     imm2.fill( 0 )
  
  
def build_image_collection_vector(p_name):
     path =  "D:\\python-workspace\\cos-similarity\\images\\"
  
     c1_vector = np.zeros( 300 )
     for  pic in p_name:
         imm = cv2.imread(path + pic)
         each_v = build_image_vector(imm)
         a=list(map(lambda x: '%.2f'  % x, each_v.tolist()[ 0 : 10 ]))
         print( "p1: " , a)
         c1_vector += each_v
     return  c1_vector/len(p_name)
  
  
if  __name__ ==  '__main__' :
  
     v1 = build_image_collection_vector([ "food1.jpg" "food2.jpg" "food3.jpg" ])
     v2 = build_image_collection_vector([ "pet1.jpg" "pet2.jpg" "pet3.jpg" ])
  
     im = cv2.imread( "D:\\python-workspace\\cos-similarity\\images\\pet4.jpg" )
     v3 = build_image_vector(im)
     print( "v1,v3:" , cos_similarity(v1,v3))
     print( "v2,v3:" , cos_similarity(v2,v3))
     a = list(map(lambda x:  '%.2f'  % x, v3.tolist()[ 0 : 10 ]))
     print( "p1: " , a)
     im2 = cv2.imread( "D:\\python-workspace\\cos-similarity\\images\\food4.jpg" )
     v4 = build_image_vector(im2)
  
     print( "v1,v4:" , cos_similarity(v1, v4))
     print( "v2,v4:" , cos_similarity(v2, v4))

至於程式碼中用到的圖片,使用者可以自行收集即可。筆者也是直接從搜尋引擎中擷取的。程式計算的結果也是很直觀的,V2(萌寵)跟影像D1的相似度為0.956626,比V1(美食)跟影像D1的相似度0.942010更高,所以結果也是很明確的。

從勾股定理到餘弦相似度-程式設計師的數學基礎

案例3:文字檢索

假設有三個文件,描述的內容如下。一個是疫情背景下,蘋果公司的資訊,另外兩個是水果相關的資訊。輸入搜尋詞“蘋果是我最喜歡的水果”,  該怎麼找到最相關的文件?

從勾股定理到餘弦相似度-程式設計師的數學基礎

這裡的核心問題也是文字和搜尋詞如何向量化?

這裡其實可以把搜尋詞也視為文件,這樣問題就簡化成:文件如何向量化?

出於簡化問題的角度,我們可以給出最簡單的答案:文件由片語成,每個詞作為一個維度;文件中詞出現的頻率作為維度值。

當然,實際操作時我們維度值的計算會更復雜一些,比如用TF-IDF。這裡用詞頻(TF)並不影響演示效果,所以我們從簡。

將文字向量化後,剩下也是依樣畫葫蘆,用餘弦公式計算相似度, 流程如下:

從勾股定理到餘弦相似度-程式設計師的數學基礎

最後,給出程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# -*- coding: utf- 8  -*-
import  numpy as np
import  numpy.linalg as linalg
import  jieba
  
  
def cos_similarity(v1, v2):
     num =  float (np.dot(v1.T, v2))  # 若為行向量則 A.T * B
     denom = linalg.norm(v1) * linalg.norm(v2)
     if  denom >  0 :
         cos = num / denom  # 餘弦值
         sim =  0.5  0.5  * cos  # 歸一化
         return  sim
     return  0
  
  
def build_doc_tf_vector(doc_list):
     num =  0
     doc_seg_list = []
     word_dic = {}
     for  d in doc_list:
         seg_list = jieba.cut(d, cut_all=False)
         seg_filterd = filter(lambda x: len(x)> 1 , seg_list)
  
         w_list = []
         for  w in seg_filterd:
             w_list.append(w)
             if  w not in word_dic:
                 word_dic[w] = num
                 num+= 1
  
         doc_seg_list.append(w_list)
  
     print(word_dic)
  
     doc_vec = []
  
     for  d in doc_seg_list:
         vi = [ 0 ] * len(word_dic)
         for  w in d:
            vi[word_dic[w]] +=  1
         doc_vec.append(np.array(vi))
         print(vi[ 0 : 40 ])
     return  doc_vec, word_dic
  
  
def build_query_tf_vector(query, word_dic):
     seg_list = jieba.cut(query, cut_all=False)
     vi = [ 0 ] * len(word_dic)
     for  w in seg_list:
         if  w in word_dic:
             vi[word_dic[w]] +=  1
     return  vi
  
  
if  __name__ ==  '__main__' :
     doc_list = [
         "" "
          受全球疫情影響, 3 月蘋果宣佈關閉除大中華區之外數百家全球門店,其龐大的供應鏈體系也受到衝擊,
          儘管目前富士康等代工廠已經開足馬力恢復生產,但相比之前產能依然受限。中國是iPhone生產的大本營,
          為了轉移風險,iPhone零部件能否實現印度製造?實現印度生產的最大難點就是,相對中國,印度製造業仍然欠發達
         "" ",
         "" "
         蘋果是一種低熱量的水果,每 100 克產生大約 60 千卡左右的熱量。蘋果中營養成分可溶性大,容易被人體吸收,故有“活水”之稱。
         它有利於溶解硫元素,使皮膚潤滑柔嫩。
         "" ",
         "" "
         在生活當中,香蕉是一種很常見的水果,一年四季都能吃得著,因其肉質香甜軟糯,且營養價值高,所以深受老百姓的喜愛。
         那麼香蕉有什麼具體的功效,你瞭解嗎?
         "" "
     ]
  
     query =  "蘋果是我喜歡的水果"
  
     doc_vector, word_dic = build_doc_tf_vector(doc_list)
  
     query_vector = build_query_tf_vector(query, word_dic)
  
     print(query_vector[ 0 : 35 ])
  
     for  i, doc in enumerate(doc_vector):
         si = cos_similarity(doc, query_vector)
         print( "doc" , i,  ":" , si)

我們檢索排序的結果如下:

從勾股定理到餘弦相似度-程式設計師的數學基礎

文件D2是相似度最高的,符合我們的預期。這裡我們用最簡單的方法,實現了一個搜尋打分排序的樣例,雖然它並沒有實用價值,但是演示出了搜尋引擎的工作原理。

四、超越餘弦

前面透過簡單的3個案例,演示了餘弦定理的用法,但是沒有完全釋放出餘弦定理的洪荒之力。接下來展示一下工業級的系統中是如何使用餘弦定理的。這裡選取了開源搜尋引擎資料庫ES的核心Lucene作為研究物件。研究的問題是:Lucene是如何使用餘弦相似度進行文件相似度打分?

當然,對於Lucene的實現,它有另一個名字:向量空間模型。即許多向量化的文件集合形成了向量空間。我們首先直接看公式:

從勾股定理到餘弦相似度-程式設計師的數學基礎

很明顯,實際公式跟理論公式長相差異很大。那麼我們怎麼理解呢?換言之,我們怎麼基於理論公式推匯出實際公式呢?

首先需要注意的是,在Lucene中,文件向量的特徵不再是我們案例3中展示的,用的詞頻,而是TF-IDF。關於TF-IDF相關的知識,比較簡單,主要的思路在於:

如何量化一個詞在文件中的關鍵程度?  TF-IDF給出的答案是綜合考慮詞頻(詞在當前文件中出現的次數)以及逆文件頻率(詞出現的文件個數)兩個因素。

  1. 詞在當前文件中出現次數(TF)越多,  詞越重要
  2. 詞在其他文件出現的次數(IDF)越少,詞越獨特

感興趣的話,可以自行參考其他資料,這裡不展開說明。

回到我們的核心問題: 我們怎麼基於理論公式推匯出實際公式呢?

四步走就可以了,如下圖:

從勾股定理到餘弦相似度-程式設計師的數學基礎

第一步:計算向量乘法

向量乘法就是套用數學公式了。這裡需要注意的是,這裡有兩個簡化的思想:

  1. 查詢語句中不存在的詞tf(t,q)=0
  2. 查詢語句基本沒有重複的詞tf(t,q)=1

所以我們比較簡單完成了第一步推導:

從勾股定理到餘弦相似度-程式設計師的數學基礎

第二步: 計算查詢語句向量長度|V(q)|

計算向量長度,其實就是勾股定理的使用了。只不過這裡是多維空間的勾股定理。

從勾股定理到餘弦相似度-程式設計師的數學基礎

這裡取名queryNorm, 表示這個操作是對向量的歸一化。這個其實是當向量乘以queryNorm後,就變成了單位向量。單位向量的長度為1,所以稱為歸一化,也就是取名norm。理解了這一層,看lucene原始碼的時候,就比較容易理解了。這正如琅琊榜的臺詞一樣:問題出自朝堂,答案卻在江湖。這裡是問題出自Lucene原始碼,答案卻在數學。

第三步:計算文件向量長度|V(d)|

這裡其實是不能沿用第二步的做法的。前面已經提到,向量有兩大要素:方向和長度。餘弦公式只考慮了方向因素。這樣在實際應用中,餘弦相似度就是向量長度無關的了。

從勾股定理到餘弦相似度-程式設計師的數學基礎

這在搜尋引擎中,如果查詢語句命中了長文件和短文件,按照餘弦公式TF-IDF特徵,偏向於對短小的文件打較高的分數。對長文件不公平,所以需要最佳化一下。

從勾股定理到餘弦相似度-程式設計師的數學基礎

這裡的最佳化思路就是採用文件詞個數累積,從而降低長文件和短文件之間的差距。當然這裡的業務訴求可能比較多樣,所以在原始碼實現的時候,開放了介面允許使用者自定義。藉以提升靈活度。

第四步:混合使用者權重和打分因子

所謂使用者權重,就是指使用者指定查詢詞的權重。例如典型地競價排名就是人為提升某些查詢詞的權重。所謂打分因子,即如果一個文件中相比其它的文件出現了更多的查詢關鍵詞,那麼其值越大。綜合考慮了多詞查詢的場景。經過4步,我們再看推匯出來的公式和實際公式,發現相似度非常高。

從勾股定理到餘弦相似度-程式設計師的數學基礎

推導公式和官方公式基本就一致了。

五、總結

本文簡單介紹了餘弦相似度的數學背景。從埃及金字塔的建設問題出發,引出了勾股定理,進而引出了餘弦定理。並基於向量推匯出來了餘弦公式。

接下來透過三個業務場景的例子,介紹餘弦公式的應用,即數學模型如何落地到業務場景中。這三個簡單的例子程式碼不過百行,能夠幫助讀者更好地理解餘弦相似度。

最後介紹了一個工業級的樣例。基於Lucene構建的ES是當前最火熱的搜尋引擎解決方案。學習餘弦公式在Lucene中落地,有助於理解業界的真實玩法。進一步提升對餘弦公式的理解。

六、參考文獻

  1. 書籍《數學之美》 作者:吳軍

  2. 影像基礎7 影像分類——餘弦相似度


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912579/viewspace-2731806/,如需轉載,請註明出處,否則將追究法律責任。

相關文章