大量短文本聚類效果優化:余弦相似度,Spark,Mini Batch Kmeans

1. 背景

1.1 問題概述

有10萬+條短文本,均是用戶反饋的問題(每條文本長度大概在200字左右),需要對這些文本進行主題聚類,看下用戶反饋的問題都集中在哪些方面。

1.2 工作

先采用Spark MLlib自帶的Kmeans聚類算法對文本進行聚類,因為其訓練速度很快。這里我采用TF-IDF作為特征提取方法,Spark ML Kmeans中的距離計算方法為歐式距離。
運行之后發現效果不太好:某個類的數據量達到了70%,也就是大量的文章都被劃分到了同一組,其他類的數量都較少。

如下圖所示,對歐式距離的Kmeans和其他距離方法進行了對比,實驗中表明歐式距離的結果中,本應屬于不同組的大量文章被劃分成了一組,與我這邊的效果一致,因此接下來可以試著采用余弦相似度作為距離算法。

歐式距離和余弦相似度的實驗效果,轉載(http://xueshu.baidu.com/usercenter/paper/show?paperid=a3195f1409270d32f304145ce00e967e&site=xueshu_se)

2. 第一步優化:歐式距離改為余弦相似度


2.1 歐式距離和余弦相似度對比

定義兩個n維向量:X(x1,x2,...,xn)和Y(y1,y2,...,yn)
歐式距離計算公式:


歐式距離

余弦相似度計算公式:


余弦相似度

歐式距離主要是衡量空間中兩個點的絕對距離,而余弦相似度注重兩個樣本之間在方向上的差異而非距離上的差異,主要是衡量兩個個體之間的相似性,值越大,說明差異越少,與歐式距離相反(距離越小,差異越小)。


歐式距離和余弦相似度對比

從上圖可以看出,歐氏距離衡量的是空間各點的絕對距離;而余弦距離衡量的是空間向量的夾角,更加體現在方向上的差異,而不是位置。如果保持 A 點位置不變,B 點朝原方向遠離坐標軸原點,那么這個時候余弦距離是保持不變的(因為夾角沒有發生變化),而 A 與 B 兩點的距離顯然在發生變化,這就是歐式距離與余弦相似度的不同之處。

2.2 開發基于Spark和余弦相似度的Kmeans聚類

由于Spark ML中的Kmeans不提供對距離函數進行更新和選擇的接口,因此只能按照Kmeans的原理開發,和在GitHub上借鑒已有的代碼修改開發。
這里有個小技巧,由于余弦相似度越大,兩個體之間的差異越少,所以為了保證在計算每個樣本所屬的最近的中心點的時候與歐式距離一致,這里在計算兩個體之間距離的時候采用如下方法計算:
(代碼框架參考基于歐式距離的Scala實現的Kmeans,基于該代碼修改為Spark和余弦相似度距離。https://blog.csdn.net/u014135021/article/details/53668634

 /**求兩個向量的余弦,1-相似度,結果越大 差異越大,越小差異越小 */
  def cos_distance(that: Point) = {
    val cos = 1- innerProduct(this.px, that.px) / (module(this.px) * module(that.px))
    cos
  }
 /** 求兩個向量的內積*/
  def innerProduct(v1: Vector[Double], v2: Vector[Double]) = {
    val listBuffer = ListBuffer[Double]()    
    for (i <- 0 until v1.length; j <- 0 to v2.length; if i == j) {
      if (i == j) listBuffer.append(v1(i) * v2(j))
    }
    listBuffer.sum
  }
  

3. 第二步優化:Scala代碼改成Spark


如下為Spark的Kmeans主題的聚類迭代部分
其中初始化隨機中心點的方法為:

takeSample(withReplacement: Boolean,num: Int,seed: Long = Utils.random.nextLong): Array[T]

其中參數:

  1. withReplacement:是否是有放回的抽樣
  2. num:返回的樣本的大小
  3. seed:隨機數生成器的種子
  //kmeans函數運行主體
  def run(sc:SparkContext)
  {
  
    var k=0 //當前迭代次數
    var f=true //是否還需要接著迭代
    val st=System.nanoTime()
    //設置隨機種子
    val seed = 10000l
    val random = new java.util.Random()
    random.setSeed(seed)
    InitCenterRandom(random)//隨機初始化中心點
    while(k<MaxIterations && f)
    {
      val st1 = System.currentTimeMillis()
      k+=1
      //計算每個點屬于哪個中心點所在的類,并且記錄每個類中點的數量,與該類中所有向量的和        
      val data_with_center = data.map(x => {
        var cid = FastSearch2(x._2).center_id
        (x._1,x._2,cid)
      })
      data_with_center.cache()
      //按照類別ID分組
      val result_groupby:RDD[(Int, Iterable[(X,Point,Int)])] = data_with_center.groupBy(_._3) 
      result_groupby.cache()
      result = result_groupby.map(x => {
        val center_datas = x._2.map(_._1).toList
        (x._1,center_datas)
      })
      //按照中心點相同groupby
      val newPoints = result_groupby.map(x => {
        val cid = x._1
        val center_datas = x._2
        val center_data_size:Int = center_datas.seq.size.toInt
        //計算該中心點下所有數據的Point向量和
        val totalPoint:Point = center_datas.map(_._2).reduce((x,y) => (x+y))
        //新的中心點為該類別下樣本向量和的平均值
        val newPoint:Point = totalPoint./(center_data_size)
        (cid, newPoint)
      })
      newPoints.cache()
      val newPoints2 = newPoints.collect()
      result_groupby.unpersist()
      data_with_center.unpersist()
      var i = -1
      //如果當前中心點中,存在比上一次迭代的中心點的距離大于閾值的情況,還需要接著迭代。
      f = CenterPoint.map(x => {
        i = i+1
        (i, x)
      }).zip(newPoints2).map(f=>f._1._2.cos_distance(f._2._2)).exists {_>threshold}
      
      //更新中心點
      if(f)
      {
        newPoints2.map(x=> {
          if(x._2 != null) {
            CenterPoint(x._1) = x._2
          }
        })
      }
      val et1 = System.currentTimeMillis()
      println("第"+k+"次聚類,sse=" + getSSE(sc,data) + ",time=" + (et1-st1)/1000+"s")
      newPoints.unpersist()
      System.gc()
      
    }
    val ed=System.nanoTime()
    //data.unpersist()
    println("Kmeans聚類時間為:"+(ed-st))

  }

/*根據隨機種子對象,初始化中心*/
def InitCenterRandom(random:java.util.Random) {
    val st=System.nanoTime()    
    val random_seed = random.nextLong()
    CenterPoint = data.takeSample(false, numClusters, random_seed).map(_._2)
    val ed=System.nanoTime()
    println("隨機中心點生成時間為:"+(ed-st))
  }

4. 第三步優化:大數據量的Mini Batch Kmeans


上述的Kmeans算法在大數據量的情況下,運算依然很慢,因此采用KMeans的變種:Mini Batch Kmeans算法,當數據量超過1萬的時候就可以使用該方法。該方法不僅處理速度快,準確度也很高。其實現原理是每次迭代的時候,選取部分樣本來更新當前迭代的中心點。這種分批處理的思路同樣也被應用在了梯度下降等算法中。

如下圖為摘自鏈接:https://blog.csdn.net/cht5600/article/details/76014573
將Kmeans算法與Mini Batch Kmeans算法的聚類結果對比,第三幅圖代表兩種方式分類差異的樣本:

Kmeans & MiniBatchKMeans

從圖中可以知道,針對同樣數量的文本分別采用Kmeans和Mini Batch Kmeans訓練,其時間差別較大,且inertia相差較少。

inertia:樣本離最近聚類中心的總和,其是K均值模型對象的屬性,表示樣本距離最近的聚類中心的總和,它是作為在沒有真實分類標簽下的非監督式評估指標,該值越小越好,值越小證明樣本在類間的分布越集中,即類內的距離越小。

因此,該方法在盡量保持準確度的情況下,大大減少了聚類時間。在本次實驗中采用10萬樣本,選取1000維特征,每次選取1000個樣本迭代,每次迭代僅需要1分鐘。
如下為Mini Batch Kmeans的迭代部分,大部分邏輯與上面的Kmeans一致,只有在每次迭代的選取的樣本不同:

//kmeans函數運行主體
  def runBatch(sc:SparkContext)
  {
    
    var k=0
    var f=true
    val st=System.nanoTime()
    //設置隨機種子
    val seed = 10000l
    val random = new java.util.Random()
    random.setSeed(seed)
    InitCenterRandom(random)//隨機初始化中心點

    while(k<MaxIterations && f)
    {
      val st1 = System.currentTimeMillis()
      k+=1
      //堆積選取MiniBatchSize個樣本 轉成RDD
      val data_batch = sc.parallelize(data.takeSample(false,ConfigUtil.MiniBatchSize,random.nextLong()))
      data_batch.cache()
      val data_with_center = data_batch.map(x => {
        var cid = FastSearch2(x._2).center_id
        (x._1,x._2,cid)
      })
      data_with_center.cache()
     val result_groupby:RDD[(Int, Iterable[(GovComment,Point,Int)])] = data_with_center.groupBy(_._3)
      result_groupby.cache()
      result = result_groupby.map(x => {
        val center_datas = x._2.map(_._1).toList
        (x._1,center_datas)
      })
      //按照中心點相同groupby
      val newPoints = result_groupby.map(x => {
        val cid = x._1
        val center_datas = x._2
        val center_data_size:Int = center_datas.seq.size.toInt
        //計算該中心點下所有數據的Point向量和
        val totalPoint:Point = center_datas.map(_._2).reduce((x,y) => (x+y))
        val newPoint:Point = totalPoint./(center_data_size)
        (cid, newPoint)
      })
      newPoints.cache()
      val newPoints2 = newPoints.collect()
      result_groupby.unpersist()
      data_with_center.unpersist()
      var i = -1
      f = CenterPoint.map(x => {
        i = i+1
        (i, x)
      }).zip(newPoints2).map(f=>f._1._2.cos_distance(f._2._2)).exists {_>threshold}

      if(f)
      {
        newPoints2.map(x=> {
          if(x._2 != null) {
            CenterPoint(x._1) = x._2
          }
        })
      }
      println("第"+k+"次聚類,sse=" + getSSE(sc,data_batch) + ",time=" +(System.currentTimeMillis()-st1)/1000+"s")
      newPoints.unpersist()
      System.gc()
      data_batch.unpersist()
    }
    val ed=System.nanoTime()
    //data.unpersist()
    println("Kmeans聚類時間為:"+(ed-st))

  }

5. 總結

  1. 針對短文本聚類,可以在條件允許的情況下提高特征維度;
  2. Spark Accumulator累加器的使用注意;
  3. 采用Mini Batch Kmeans可以盡量維持聚類準確度;
  4. 文本上面相似,余弦相似度效果相對歐式距離好些。

6. 參考

  1. https://blog.csdn.net/huangfei711/article/details/78469614
  2. http://xueshu.baidu.com/usercenter/paper/show?paperid=a3195f1409270d32f304145ce00e967e&site=xueshu_se
  3. https://blog.csdn.net/linvo/article/details/9333019
  4. https://blog.csdn.net/u014135021/article/details/53668634
  5. https://blog.csdn.net/cht5600/article/details/76014573
  6. https://blog.csdn.net/weixin_37536446/article/details/81326932
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容