可扩展的中小型推介系统实践

本文的目的是描述如何使用 Mahout 开发/部署一个可扩展的中小型推介系统

我会一步一步的描述如何用 Mahout 搭建一个这样的推介系统。本文假设读者对 Mahout 有所了解,熟悉 Mahout 中的协同过滤(Collaborative Filtering)推介算法。本文不会描述 CF 相关的具体算法。

场景描述

这是最近在做的一个推介系统的实际场景。垂直领域的视频播放网站,用户访问我们的网站观看视频,我们根据用户的观看记录向用户推介其可能感兴趣的视频。

  1. 视频数量:几千或几万
  2. 用户数量:几十万
  3. 用户每播放完一个视频后,在视频播放页面显示用户可能喜欢的其他视频
  4. 所有的播放请求中,20% 是登陆用户,80% 为未登陆的匿名用户(由于版权的原因,有些视频必须登陆付费之后才能观看,所以登陆用户的比例很高)

代码实现

我们采用推介系统中最常用的 item based recommendation。
用户 A 观看视频 1 和 2, 于是我们就有观看记录 A -> 1,2。我们把这样的观看记录存在 MySQL 数据库中。Mahout 提供了从 MySQL 读取观看记录的类 MySQLBooleanPrefJDBCDataModel(在 integration 包中)。

DataModel dataModel = new MySQLBooleanPrefJDBCDataModel(dataSource);
ItemSimilarity similarity = new MySQLJDBCInMemoryItemSimilarity(dataSource);
AllSimilarItemsCandidateItemsStrategy candidateStrategy = 
    new AllSimilarItemsCandidateItemsStrategy(similarity);
ItemBasedRecommender recommender = new GenericItemBasedRecommender(
    dataModel, similarity, candidateStrategy, candidateStrategy);

初始化好 recommender 之后,我们就可以根据用户 ID,推介其可能喜欢的视频

recommender.recommend(userId, howMany)

或者,我们可以根据视频 ID,推介和这个视频最为相似的视频

recommender.mostSimilarItems(itemId, howMany)
数据准备

关键的代码就是这么简单,读者可能依然会困惑的就是 MySQLBooleanPrefJDBCDataModelMySQLJDBCInMemoryItemSimilarity 这两个类。
要使用这两个类,我们必须先在 MySQL 中创建好对应的表格

CREATE TABLE `taste_preferences` (
  `user_id` bigint(20) NOT NULL,
  `item_id` bigint(20) NOT NULL,
  PRIMARY KEY (`user_id`,`item_id`),
  KEY `user_id` (`user_id`),
  KEY `item_id` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
CREATE TABLE `taste_item_similarity` (
  `item_id_a` bigint(20) NOT NULL,
  `item_id_b` bigint(20) NOT NULL,
  `similarity` double NOT NULL,
  PRIMARY KEY (`item_id_a`,`item_id_b`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

表格 taste_preferences 很简单,只有两个字段,分别是 user_id, item_id,我们只要把观看记录从播放系统里导入到这个表格即可。用户的每一次观看记录,我们都实时的插入该表格。
表格 taste_item_similarity 保存的是两个视频之间的相似度。
Mahout 提供了 ItemSimilarityJob,可以使用 Hadoop 预计算视频与视频之间的相似度。但是考虑到我们的观看记录数和视频的数量并没有那么大,没有必要使用 Hadoop 计算相似度。
我们采用的是最简单的方法:使用 LogLikelihoodSimilarity 手工计算两个视频之间的相似度。

MySQLBooleanPrefJDBCDataModel dataModelTemp = new MySQLBooleanPrefJDBCDataModel(dataSource);
FastByIDMap<FastIDSet> userData = dataModelTemp.exportWithIDsOnly();
DataModel dataModel = new GenericBooleanPrefDataModel(userData);
ItemSimilarity similarity = new LogLikelihoodSimilarity(dataModel);

准备好 similarity 之后,我们再从数据中读取所有的视频 ID,排序之后存入一个数组 videoArray

private void computeSimilarityMatrix(ItemSimilarity similarity, final long[] videoArray) {
  for (final long leftId : videoArray) {
    try {
      final List<Map.Entry<Long, Double>> similarityList = Lists.newArrayList();
      for (long rightId : videoArray) {
        if (leftId < rightId) {
          double sim = similarity.itemSimilarity(leftId, rightId);
          if (!Double.isNaN(sim) && sim > 0.01) {
            similarityList.add(Maps.immutableEntry(rightId, sim));
          }
        }
      }
      Collections.sort(similarityList, new Comparator<Map.Entry<Long, Double>>() {
        @Override
        public int compare(Entry<Long, Double> o1, Entry<Long, Double> o2) {
            return o1.getValue() < o2.getValue() ? 1 : 0;
        }
      });
      int maxSize = (similarityList.size() < 500) ? similarityList.size() : 500;
      tasteItemSimilarityDao.save(leftId, similarityList.subList(0, maxSize));
    } catch (TasteException e) {
      Throwables.propagate(e);
    }
  }
}

这里要注意的是

  1. 必须要先用 MySQLBooleanPrefJDBCDataModel.exportWithIDsOnly 一次性的读取所有的观看记录,然后再构造 GenericBooleanPrefDataModel 不可以直接用 MySQLBooleanPrefJDBCDataModel 去构造 LogLikelihoodSimilarity,否则每一次计算相似值都会去读取一次数据库,造成计算速度极慢,几千个视频的相似度可能需要好几个小时才能算完。
  2. 判断一个 double 是不是 NaN,应该用 Double.isNaN(num) 不能用 num == Double.NaN,这是一个坑,居然不小心踩中了 >_<
  3. 我们过滤了相似度小于 0.01,并且只保存最相似的前 500 个视频
  4. 几千个视频的相似度计算大概几分钟就可以完成
  5. 视频之间的相似度应该每天计算一次
推介系统的部署

因为是在原有的系统上增加推介系统,为了尽量减少对原有系统的影响,推介系统将作为一个独立的系统运行,并提供 RESTfull web service 访问接口。系统的部署很简单,推介系统运行在 tomcat 里,前面放置一个 Nginx。

关于推介的实时性

当一个用户观看一个视频之后,如果该观看记录是实时的插入 taste_preferences 我们可以立刻根据该观看记录推介视频。

  1. 如果新注册的用户,在看完一个视频之后,我们会立即根据该用户的观看记录做出推介。
  2. 如果增加了新的视频,这个视频只有从第二天才会开始出现在推介结果中。因为视频之间的相似度我们是每天更新一次。
关于登陆用户和匿名用户

我们的部分视频是对匿名用户开放的,所以部分的观看可能是未登陆的用户。而我们的推介是只针对登陆用户进行推介。所以我们的建议是:

  1. 对于匿名用户,建议根据其观看的视频,推介与该视频最相似的几个视频。使用的接口应为 recommender.mostSimilarItems(itemId, howMany)
  2. 对于登陆用户,建议根据观看记录,推介可能喜欢的视频。如果观看的视频数量比较少,或者其观看的视频比较冷门,我们返回的结果可能也会相对的比较少。使用的接口应为 recommender.recommend(userId, howMany)
关于性能与可扩展性

在视频数量和视频访问数量增加的时候,我们可以做到的相应的扩展。

  1. 相似度的计算,个人觉得只要计算时间不超过一小时,我们都可以用 Java 直接算的方式,只有当视频数量大到一定程度的时候,我们可以考虑使用 Hadoop 计算相似度。
  2. 匿名用户的访问占了很大的比例,我们可以相应的结果做缓存。比如我们使用 google guava 的 LoadingCache 对 mostSimilarItems 结果进行了缓存。如果推介的实时性不高的话,我们还可以对 recommend 的结果进行缓存。
  3. 因为推介系统的算法是无状态的,我们可以同时部署好几个 tomcat,前端由 Nginx 做负载均衡。
  4. 当用户的访问量增加的很大的时候,我们还可以把数据的存储由 MySQL 换成内存数据库,比如 Redis

发表评论

电子邮件地址不会被公开。 必填项已用 * 标注