可扩展的中小型推介系统实践
本文的目的是描述如何使用 Mahout 开发/部署一个可扩展的中小型推介系统
我会一步一步的描述如何用 Mahout 搭建一个这样的推介系统。本文假设读者对 Mahout 有所了解,熟悉 Mahout 中的协同过滤(Collaborative Filtering)推介算法。本文不会描述 CF 相关的具体算法。
场景描述
这是最近在做的一个推介系统的实际场景。垂直领域的视频播放网站,用户访问我们的网站观看视频,我们根据用户的观看记录向用户推介其可能感兴趣的视频。
- 视频数量:几千或几万
- 用户数量:几十万
- 用户每播放完一个视频后,在视频播放页面显示用户可能喜欢的其他视频
- 所有的播放请求中,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)
数据准备
关键的代码就是这么简单,读者可能依然会困惑的就是 MySQLBooleanPrefJDBCDataModel
和 MySQLJDBCInMemoryItemSimilarity
这两个类。
要使用这两个类,我们必须先在 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); } } }
这里要注意的是
- 必须要先用
MySQLBooleanPrefJDBCDataModel.exportWithIDsOnly
一次性的读取所有的观看记录,然后再构造GenericBooleanPrefDataModel
不可以直接用MySQLBooleanPrefJDBCDataModel
去构造LogLikelihoodSimilarity
,否则每一次计算相似值都会去读取一次数据库,造成计算速度极慢,几千个视频的相似度可能需要好几个小时才能算完。 - 判断一个 double 是不是 NaN,应该用
Double.isNaN(num)
不能用num == Double.NaN
,这是一个坑,居然不小心踩中了 >_< - 我们过滤了相似度小于 0.01,并且只保存最相似的前 500 个视频
- 几千个视频的相似度计算大概几分钟就可以完成
- 视频之间的相似度应该每天计算一次
推介系统的部署
因为是在原有的系统上增加推介系统,为了尽量减少对原有系统的影响,推介系统将作为一个独立的系统运行,并提供 RESTfull web service 访问接口。系统的部署很简单,推介系统运行在 tomcat 里,前面放置一个 Nginx。
关于推介的实时性
当一个用户观看一个视频之后,如果该观看记录是实时的插入 taste_preferences
我们可以立刻根据该观看记录推介视频。
- 如果新注册的用户,在看完一个视频之后,我们会立即根据该用户的观看记录做出推介。
- 如果增加了新的视频,这个视频只有从第二天才会开始出现在推介结果中。因为视频之间的相似度我们是每天更新一次。
关于登陆用户和匿名用户
我们的部分视频是对匿名用户开放的,所以部分的观看可能是未登陆的用户。而我们的推介是只针对登陆用户进行推介。所以我们的建议是:
- 对于匿名用户,建议根据其观看的视频,推介与该视频最相似的几个视频。使用的接口应为
recommender.mostSimilarItems(itemId, howMany)
- 对于登陆用户,建议根据观看记录,推介可能喜欢的视频。如果观看的视频数量比较少,或者其观看的视频比较冷门,我们返回的结果可能也会相对的比较少。使用的接口应为
recommender.recommend(userId, howMany)
关于性能与可扩展性
在视频数量和视频访问数量增加的时候,我们可以做到的相应的扩展。
- 相似度的计算,个人觉得只要计算时间不超过一小时,我们都可以用 Java 直接算的方式,只有当视频数量大到一定程度的时候,我们可以考虑使用 Hadoop 计算相似度。
- 匿名用户的访问占了很大的比例,我们可以相应的结果做缓存。比如我们使用 google guava 的 LoadingCache 对 mostSimilarItems 结果进行了缓存。如果推介的实时性不高的话,我们还可以对 recommend 的结果进行缓存。
- 因为推介系统的算法是无状态的,我们可以同时部署好几个 tomcat,前端由 Nginx 做负载均衡。
- 当用户的访问量增加的很大的时候,我们还可以把数据的存储由 MySQL 换成内存数据库,比如 Redis
发表评论