我最近在调整我们网站GuruDigger 的类似文章推荐功能。
原先的类似文章推荐是基于用户自己打的标签,而标签大家往往打得比较随意,推荐的内容比较少,
我需要采用更好的方式来处理这个问题。
整体思路 
推荐文章可以用协同过滤(Collaborative Filtering ),
不过要业务逻辑符合这个模式,以及数据量足够大才行,我还是用基于文章内容(Content Based)的算法来解决。
具体思路:
首先是分词。文章需要拆分成单元为词的串,然后才有办法处理。 
然后从词串中获取能够代表文章内容意思的关键词。 
然后基于文章的关键词,来选择类似的文章。 
 
这种方法,文章数据只被利用到了词和词出现频率的信息,
另外也可以通过基于语义的方式来分析出更多内容,
对应的学术领域叫做Topic Modeling ,
我现在这个思路算是一个很简单的方法。
分词 
采用mmseg 的分词方法。原理看起来比较简单,大家可以稍微了解一下。
具体实现采用的库是针对ruby的绑定rmmseg-cpp 。
1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
require  'rmmseg' 
RMMSeg :: Dictionary . load_dictionaries 
 def  cn_word_split  text 
  algor  =  RMMSeg :: Algorithm . new ( text ) 
   out  =  [] 
   while  tok  =  algor . next_token 
     out  <<  to_utf8 ( tok . text ) 
   end 
   out 
 end 
 def  to_utf8  text 
  String . new ( text ) . force_encoding ( 'utf-8' ) 
 end 
 
提取关键词 
分词完毕之后,还需要初步筛掉一些对了解文章内容不需要的词,这种词叫做停用词 ,
我随便在网络上面下载了一份,大致能用,在这里下载 。
1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
def  stop_words 
  File . read ( 'chinese_stop_words.txt' ) . split . sort 
 end 
 def  cn_filter_stop_words  words 
  ( words  -  stop_words ) . reject  do  | word | 
     stop_words . map  do  | sw | 
       word . include? ( sw ) 
     end . any? 
   end 
 end 
 
对了,分词之前,还需要对文章预处理一下,去掉一些比较杂的东西。
1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
def  cn_tokenize  text 
  out  =  text . downcase 
   # Strip all HTML 
   out  =  out . gsub ( /<[^<>]+>/ ,  ' ' ) 
   # Strip all number 
   out  =  out . gsub ( /[0-9]+/ ,  ' ' ) 
   # strip all url 
   out  =  out . gsub ( /(http|https):\/\/[^\s]*/ ,  ' ' ) 
 
   out  =  cn_word_split ( out ) 
   out  =  cn_filter_stop_words ( out ) 
   out . reject  do  | word | 
     word . length  <=  1 
   end 
 end 
 
我采用tf-idf 的方式给文章中所有词算好一个权重。
关于tf-idf的介绍,可见这里 。
我采用的是一个叫tf_idf 的Gem,
同时我把文章title,tag等也当做词加入进文章内容中。为了表示title和tag的重要性,
出现在title和tag中的词,相当于出现在文章中的次数为系数k(我选取的是10和5)。
1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
def  pre_token  item 
  tw  =  cn_tokenize ( item . title ) 
   dw  =  cn_tokenize ( item . desc ) 
   gw  =  item . taggings . map { | t |  t . tag . name } 
   tw  *  10  +  gw  *  5  +  dw 
 end 
 require  'tf_idf' 
 def  caculate  items 
  corpus  =  items . map { | item |  pre_token ( item )} 
 
   data  =  TfIdf . new ( corpus ) . tf_idf 
 end 
 
选择类似的文章 
把文章抽象成一个在关键词作为维度的多维空间的向量,然后2篇文章的相似度就是它们之间的夹角。
这种方法叫余弦相似度 。
按照公式 :
实现起来很简单:
1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
   def  similars  data ,  d1 
    data . each_with_index . map  do  | d2 ,  i | 
       same_keys  =  d1 . keys  &  d2 . keys 
       score  =  same_keys . map { | k |  d1 [ k ]  *  d2 [ k ] } . sum 
       score  /=  ( 
         Math . sqrt ( d1 . values . map { | v |  v ** 2 } . sum )  * 
         Math . sqrt ( d2 . values . map { | v |  v ** 2 } . sum ) 
         ) 
       [ score ,  i ] 
     end . sort . reverse 
   end 
 
 
上面函数返回的就是按照相似度排列的item列表了。
我考虑只展示前5个相似度比较高的,如果还是没有,选择相似度最高的2个。
1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
def  filter_similars  similars ,  i 
  selected  =  [] 
   similars . each  do  | score ,  j | 
     # escape same item 
     next  if  j  ==  i 
 
     # max similar 
     break  if  selected . length  >=  5 
 
     # max similar for score too low 
     break  if  selected . length  >=  2  and  score  <  0 . 1 
 
     selected  <<  [ score ,  j ] 
   end 
   selected 
 end 
 
结论 
稍微调整一下之后,列出来相似文章还是大致靠谱的。关于整体开发过程,还是有一些可以说的。
在调研上面花费了很多时间,走了弯路。这个领域我不熟悉,搜索资料,尝试各种其他方法上面,花费了不少的时间。
比如有一个python专门关于Topic Modeling的库叫gensim ,
我打算用它,不过现在项目主要用ruby,我尝试互相调用,最后还是失败了,还是走回全部ruby的方法。
现在全部流程是批次的,每次有新的文章进来,都需要重新算,一次计算需要消耗我自己rMBP时间100秒左右,
计算开销还是很大的。不过现在网站压力不大,放到后台慢慢算。
关于推荐准确率,瓶颈主要在关键词获取,现在的方案中,分数比较高的词很多还是会不准确,
如果要提升的话,可能要用更多数据输入过滤掉更多的词,或者通过语义分析只提取名词,
不过现在得到的结果还行,优化的问题等真正需要面对的时候再说,先去做其它一些更重要的事情吧。