推荐算法之协同过滤及其改进与实现

  • 前言
  • 协同过滤算法
    • (1)基于用户的协同过滤算法
    • (2)基于物品的协同过滤算法
  • 改进的协同过滤算法
    • (1)用户相似度计算的改进
    • (2)加权系数惩罚热门物品
  • 具体实现
    • 1、请求入口
    • 2、推荐实现类(获取最近K邻居)
    • 3、推荐实现类(获取相应的推荐歌曲)
    • 4、 封装返回结果
  • 最后的总结和絮叨

前言

因为我的项目和简历中都提及了推荐算法,也有几次面试官问到,现在想想回答的虽然没有问题,但是总感觉表述不是很清晰。今天抽空把推荐算法理一理。

协同过滤算法

因为我使用的推荐算法是基于协同过滤,所以我们先聊聊协同过滤算法吧。

协同过滤(Collaborative Filtering,简称CF)是一种最经典的推荐算法,这个算法的出现对于推荐系统具有划时代的意义。1992年第一次提出协同过滤算法。

协同过滤算法主要通过分析用户的历史数据,用以构建用户模型并进行推荐。协同过滤算法主要分为以下两类:一类是基于用户的协同过滤算法(User-Based Collaborative Filtering,简称UserCF),另一类是基于物品的协同过滤算法(Item-Based Collaborative Filtering,简称ItemCF)。

(1)基于用户的协同过滤算法

UserCF算法的核心是用户,思想是“人以群分”。算法的基本思路是:首先找到与目标用户喜好相似的邻居用户集体,然后以此为基础计算出目标用户对未操作物品的喜好预测评分,根据评分的高低推荐前N个给目标用户。

如图3-3所示,形象的显示了基于用户的协同过滤算法的一般概念:假设用户C为目标用户,根据图3-3可知,因为用户A和用户C由相同评分物品C和D,可知用户C的相似用户为用户A;又因为用户A已评分的集合物品A是用户C未操作的,所以将物品A推荐给目标用户C。

(2)基于物品的协同过滤算法

ItemCF算法的核心是物品,思想是“物以类聚”。算法的基本思路是:首先计算物品之间的相似度,然后根据目标用户的历史行为,将与之相似度较高的物品推荐给目标用户。

基于物品的协同过滤算法的理论如图3-4所示,假设用户A为目标用户,根据图3-4可知,因为物品A和物品C同时被用户B和用户C评分,因此物品C的相似邻居为物品A,又因为目标用户A对物品C已评分,所以将物品A推荐给目标用户A。

改进的协同过滤算法

传统的协同过滤推荐算法的思想如下。
(1)使用评分计算其他用户和目标用户的相似度;
(2)找出K个和目标用户相似度最高的邻居用户;
(3)向目标用户推荐邻居喜欢的物品且目标用户没有操作过的物品。

  • 音乐推荐应用场景中,传统的协同过滤算法有如下几点可以改进。

(1)用户相似度计算的改进

传统的协同过滤算法在音乐推荐时实现的思想是,构建一个稀疏的用户-歌曲评分矩阵,根据用户对歌曲的评分(如收藏、分享、加心心等操作),计算出用户的相似度。

但对于音乐方面,我认为用户偏好并不是评分就能笼统表达的,仅利用评分的填充会使得填充后的矩阵出现一定偏差,比如两个用户都收藏过《海阔天空》,这并不能说明他们相似度高,因为听歌时还带有用户的情感,可能用户A感觉斗志昂扬,但用户B感觉情绪低落。如果两个用户的感受相似,则可以更好地说明他们的相似性。

因此,针对上述这个问题,考虑到用户听歌时带有情感性,引入情感相似度,以此来提高系统推荐的准确性,改善用户的体验。基于音乐互动模块对用户情感进行分析,将用户情感数值化,基于情感相似度结合协同过滤推荐算法找出与目标用户最相似的邻居用户。

此改进方法的具体思想如下。

首先,通过音乐互动模块将用户互动所得的用户情感相似性数值化后,使用欧几里德距离公式计算用户间的情感相似度。欧几里德度量公式是一种简单易懂的用以计算相似度的方法。它是将用户共同参与的互动作为坐标轴,然后,将参与互动的用户放置在坐标系中,并计算两个用户之间的直线距离。在二维坐标中,两用户的欧几里德距离如图3-5所示。

由此可推导到i维坐标中,两用户之间的欧几里德距离如公式(4.1)所示。

上述欧几里德距离公式计算出来的是一个大于或等于0的数,使用公式(4.2)将其规范到(0, 1]之间,以便更直观地反映用户之间的相似度。

可以将数据库中的数据构建出一个用户-互动分值矩阵,如图3-6所示,通过矩阵的构建和计算可以找到u1的情感相似用户u3和u5。

用户u和用户v的情感相似度计算公式如(4.3)所示。

其中,N(u)表示用户u已经参与过的互动集合,N(v)表示用户v已经参与过的互动集合。u(i)表示用户u对互动i的偏好,v(i)表示用户v对互动i的偏好。n表示用户u和用户v共同参与过的互动数。

(2)加权系数惩罚热门物品

在音乐场景下经常产生热门歌曲,如果热门歌曲出现次数较多,就会影响实际相似度的计算结果,从而导致推荐的歌曲都是热门歌曲,无法满足用户的实际需求。

为了减小这种影响,可以考虑加入一个加权系数用以惩罚热门歌曲的影响,即惩罚因子。

因此本文对相似度的计算公式加以改进,将歌曲出现次数的倒数作为惩罚因子。歌曲出现的次数越多,即改歌曲越热门,同时,该歌曲对用户喜好相似度的贡献则越少。带有惩罚因子的公式可减弱热门歌曲造成的影响,改进后的公式如(4.4)所示。

其中,N(i)表示歌曲i出现的次数,可以看出,该公式加入歌曲出现次数的倒数计算用户u和用户v的共同爱好列表中的相似度,从而惩罚了热门歌曲的影响。

改进后的算法的具体流程,如图3-7所示。

  • 改进后的算法的具体步骤如下。
    (1)用户情感分析信息、用户评分信息。
    (2)在音乐互动分析的用户情感信息上,进行情感数值化构建用户情感矩阵,计算用户情感相似度。
    (3)根据用户情感相似度找出目标用户的K个最近邻居集,并按递减顺序将这些结果值排序。
    (4)根据用户听歌的历史行为,构建用户评分矩阵,并加入惩罚因子,计算用户评分相似度。
    (5)将最终评分值由高到低排序,并将排序结果推荐给目标用户。
    (6)输出目标用户的推荐用户和推荐歌曲集合。

具体实现

先贴下推荐算法时序图,如下。

如果熟悉的朋友可能看出来了,这是基于Mahout框架实现的。在架子里面实现自己逻辑即可。

这里主要贴出重点的代码块,详细点击文末阅读原文移步GitHub。

1、请求入口

请求入口和相应参数设置,很简单就不多解释了。

  • RecommendController
@Controller
@RequestMapping("/recommendAction")
public class RecommendController {private static final Logger log = LoggerFactory.getLogger(RecommendController.class);final static int NEIGHBORHOOD_NUM = 3;   //用户邻居数量final static int RECOMMENDER_NUM = 3;    //推荐结果个数static DataModel dataModel = null; //Mahout提供的数据模型(用于将数据库的数据转为带构建的数据模型)

如下代码构建推荐系统,并调用相关的方法。

        //基于用户的协同过滤算法,基于物品的协同过滤算法UserSimilarity user = new EuclideanDistanceSimilarity(dataModel);  //计算欧式距离,欧式距离来定义相似性,用s=1/(1+d)来表示,范围在[0,1]之间,值越大,表明d越小,距离越近,则表示相似性越大//指定用户邻居数量NearestNUserNeighborhood neighbor = new NearestNUserNeighborhood(NEIGHBORHOOD_NUM, user, dataModel);//构建基于用户的推荐系统Recommender r = new GenericUserBasedRecommender(dataModel, neighbor, user);//获取目标用户的K个最近邻居集long[] theNeighborhood = r.recommendUser(userID, RECOMMENDER_NUM);//获取最终推荐结果List<MyRec> myRecList = r.recommendSong(theNeighborhood, userID, RECOMMENDER_NUM);

2、推荐实现类(获取最近K邻居)

  • GenericUserBasedRecommender
    /*** 推荐方法:获取目标用户的邻居用户id* @param userID 需要推荐的目标用户id* @param howMany 推荐结果个数* @return 邻居用户id数组* @throws TasteException*/@Overridepublic long[] recommendUser(long userID, int howMany) throws TasteException {Preconditions.checkArgument(howMany >= 1, "howMany must be at least 1");log.debug("Recommending items for user ID '{}'", userID);long[] theNeighborhood = neighborhood.getUserNeighborhood(userID);System.out.println(userID+"'s theNeighborhood:"+Arrays.toString(theNeighborhood));return theNeighborhood;}/*** 推荐方法:根据邻居用户id推荐最喜欢的歌曲* @param theNeighborhood* @param userID* @param howMany* @return 推荐歌曲集合* @throws TasteException*/@Overridepublic List<MyRec> recommendSong(long[] theNeighborhood, long userID, int howMany) throws TasteException {List<MyRec> myRecList = new ArrayList<>();for (long oneNeighborhood : theNeighborhood) {FastIDSet theItemIDs = getTheItems(oneNeighborhood, userID);TopItems.Estimator<Long> estimator = new Estimator(userID, null , oneNeighborhood);List<RecommendedItem> topItems = TopItems.getTopSongs(howMany, theItemIDs.iterator(),null, estimator);log.debug("Recommendations are: {}", topItems);System.out.println("Recommendations: userId:"+oneNeighborhood + " ,songs:"+topItems);User user = new User();user.setId((int)oneNeighborhood);List<Song> songList = new ArrayList<>();for (int i = 0; i<topItems.size(); i++){Song song = new Song();song.setId((int)topItems.get(i).getItemID());songList.add(song);}MyRec myRec = new MyRec();myRec.setUser(user);myRec.setSongList(songList);myRecList.add(myRec);System.out.println("myRecList:"+ myRecList);}return myRecList;}
  • NearestNUserNeighborhood
    /*** 获取目标用户的邻居用户* @param userID*          ID of user for which a neighborhood will be computed* @return* @throws TasteException*/@Overridepublic long[] getUserNeighborhood(long userID) throws TasteException {DataModel dataModel = getDataModel();UserSimilarity userSimilarityImpl = getUserSimilarity();TopItems.Estimator<Long> estimator = new Estimator(userSimilarityImpl, userID, minSimilarity);LongPrimitiveIterator userIDs = SamplingLongPrimitiveIterator.maybeWrapIterator(dataModel.getUserIDs(),getSamplingRate());return TopItems.getTopUsers(n, userIDs, null, estimator);}
  • TopItems.getTopUsers:这里就是通过优先队列获得K个最近邻居集(不懂的朋友可以参看我之前文章写的 Top K问题
/*** 根据相似度排序邻居用户* @param howMany* @param allUserIDs* @param rescorer* @param estimator* @return* @throws TasteException*/public static long[] getTopUsers(int howMany,LongPrimitiveIterator allUserIDs,IDRescorer rescorer,Estimator<Long> estimator) throws TasteException {Queue<SimilarUser> topUsers = new PriorityQueue<SimilarUser>(howMany + 1, Collections.reverseOrder());boolean full = false;double lowestTopValue = Double.NEGATIVE_INFINITY;while (allUserIDs.hasNext()) {long userID = allUserIDs.next();if (rescorer != null && rescorer.isFiltered(userID)) {continue;}double similarity;try {similarity = estimator.estimate(userID);} catch (NoSuchUserException nsue) {continue;}double rescoredSimilarity = rescorer == null ? similarity : rescorer.rescore(userID, similarity);if (!Double.isNaN(rescoredSimilarity) && (!full || rescoredSimilarity > lowestTopValue)) {topUsers.add(new SimilarUser(userID, rescoredSimilarity));if (full) {topUsers.poll();} else if (topUsers.size() > howMany) {full = true;topUsers.poll();}lowestTopValue = topUsers.peek().getSimilarity();}}int size = topUsers.size();if (size == 0) {return NO_IDS;}List<SimilarUser> sorted = Lists.newArrayListWithCapacity(size);sorted.addAll(topUsers);Collections.sort(sorted);long[] result = new long[size];int i = 0;for (SimilarUser similarUser : sorted) {result[i++] = similarUser.getUserID();}return result;}
  • AbstractSimilarity. userSimilarity 市重点!这里就是将数据模型的数据提取出来并进行相关计算。
/*** 估算目标用户和其他用户的相似度!!!* @param userID1* @param userID2* @return* @throws TasteException*/@Overridepublic double userSimilarity(long userID1, long userID2) throws TasteException {DataModel dataModel = getDataModel();PreferenceArray xPrefs = dataModel.getPreferencesFromUser(userID1);PreferenceArray yPrefs = dataModel.getPreferencesFromUser(userID2);int xLength = xPrefs.length();int yLength = yPrefs.length();if (xLength == 0 || yLength == 0) {return Double.NaN;}long xIndex = xPrefs.getItemID(0);long yIndex = yPrefs.getItemID(0);int xPrefIndex = 0;int yPrefIndex = 0;double sumX = 0.0;double sumX2 = 0.0;double sumY = 0.0;double sumY2 = 0.0;double sumXY = 0.0;double sumXYdiff2 = 0.0;int count = 0;boolean hasInferrer = inferrer != null;boolean hasPrefTransform = prefTransform != null;while (true) {int compare = xIndex < yIndex ? -1 : xIndex > yIndex ? 1 : 0;if (hasInferrer || compare == 0) {double x;double y;if (xIndex == yIndex) {// Both users expressed a preference for the itemif (hasPrefTransform) {x = prefTransform.getTransformedValue(xPrefs.get(xPrefIndex));y = prefTransform.getTransformedValue(yPrefs.get(yPrefIndex));} else {x = xPrefs.getValue(xPrefIndex);y = yPrefs.getValue(yPrefIndex);}} else {// Only one user expressed a preference, but infer the other one's preference and tally// as if the other user expressed that preferenceif (compare < 0) {// X has a value; infer Y'sx = hasPrefTransform? prefTransform.getTransformedValue(xPrefs.get(xPrefIndex)): xPrefs.getValue(xPrefIndex);y = inferrer.inferPreference(userID2, xIndex);} else {// compare > 0// Y has a value; infer X'sx = inferrer.inferPreference(userID1, yIndex);y = hasPrefTransform? prefTransform.getTransformedValue(yPrefs.get(yPrefIndex)): yPrefs.getValue(yPrefIndex);}}sumXY += x * y;sumX += x;sumX2 += x * x;sumY += y;sumY2 += y * y;double diff = x - y;sumXYdiff2 += diff * diff;count++;}if (compare <= 0) {if (++xPrefIndex >= xLength) {if (hasInferrer) {// Must count other Ys; pretend next X is far awayif (yIndex == Long.MAX_VALUE) {// ... but stop if both are done!break;}xIndex = Long.MAX_VALUE;} else {break;}} else {xIndex = xPrefs.getItemID(xPrefIndex);}}if (compare >= 0) {if (++yPrefIndex >= yLength) {if (hasInferrer) {// Must count other Xs; pretend next Y is far awayif (xIndex == Long.MAX_VALUE) {// ... but stop if both are done!break;}yIndex = Long.MAX_VALUE;} else {break;}} else {yIndex = yPrefs.getItemID(yPrefIndex);}}}// "Center" the data. If my math is correct, this'll do it.double result;if (centerData) {double meanX = sumX / count;double meanY = sumY / count;// double centeredSumXY = sumXY - meanY * sumX - meanX * sumY + n * meanX * meanY;double centeredSumXY = sumXY - meanY * sumX;// double centeredSumX2 = sumX2 - 2.0 * meanX * sumX + n * meanX * meanX;double centeredSumX2 = sumX2 - meanX * sumX;// double centeredSumY2 = sumY2 - 2.0 * meanY * sumY + n * meanY * meanY;double centeredSumY2 = sumY2 - meanY * sumY;result = computeResult(count, centeredSumXY, centeredSumX2, centeredSumY2, sumXYdiff2);} else {result = computeResult(count, sumXY, sumX2, sumY2, sumXYdiff2);}if (similarityTransform != null) {result = similarityTransform.transformSimilarity(userID1, userID2, result);}if (!Double.isNaN(result)) {result = normalizeWeightResult(result, count, cachedNumItems);}return result;}
  • EuclideanDistanceSimilarity:最终调用到这里的欧几里德距离计算公式
    @Overridedouble computeResult(int n, double sumXY, double sumX2, double sumY2, double sumXYdiff2) {return 1.0 / (1.0 + Math.sqrt(sumXYdiff2) / Math.sqrt(n));}

以上就是获取K个最近邻居的整体流程。
注:传统的协同过滤推荐算法并不会直接返回最近K邻居,而是根据用户对歌曲的评分矩阵,返回推荐的歌曲。
而我因为业务需要,需要返回最近K邻居,并且在通过最近K邻居和歌曲评分矩阵,返回相应的推荐歌曲。

3、推荐实现类(获取相应的推荐歌曲)

  • GenericUserBasedRecommender
/*** 推荐方法:根据邻居用户id推荐最喜欢的歌曲* @param theNeighborhood* @param userID* @param howMany* @return 推荐歌曲集合* @throws TasteException*/@Overridepublic List<MyRec> recommendSong(long[] theNeighborhood, long userID, int howMany) throws TasteException {List<MyRec> myRecList = new ArrayList<>();for (long oneNeighborhood : theNeighborhood) {FastIDSet theItemIDs = getTheItems(oneNeighborhood, userID);TopItems.Estimator<Long> estimator = new Estimator(userID, null , oneNeighborhood);List<RecommendedItem> topItems = TopItems.getTopSongs(howMany, theItemIDs.iterator(),null, estimator);log.debug("Recommendations are: {}", topItems);System.out.println("Recommendations: userId:"+oneNeighborhood + " ,songs:"+topItems);User user = new User();user.setId((int)oneNeighborhood);List<Song> songList = new ArrayList<>();for (int i = 0; i<topItems.size(); i++){Song song = new Song();song.setId((int)topItems.get(i).getItemID());songList.add(song);}MyRec myRec = new MyRec();myRec.setUser(user);myRec.setSongList(songList);myRecList.add(myRec);System.out.println("myRecList:"+ myRecList);}return myRecList;}
  • GenericUserBasedRecommender:注:这里有和传统的不同,我没有去除目标用户已接触过的物品
    private FastIDSet getTheItems(long oneNeighborhood, long theUserID) throws TasteException {DataModel dataModel = getDataModel();FastIDSet possibleItemIDs = new FastIDSet();
//        添加所有邻居用户的物品possibleItemIDs.addAll(dataModel.getItemIDsFromUser(oneNeighborhood));
//        去除目标用户已接触过的物品
//        possibleItemIDs.removeAll(dataModel.getItemIDsFromUser(theUserID));return possibleItemIDs;}
  • TopItems:同理获取推荐歌曲
    public static List<RecommendedItem> getTopSongs(int howMany,LongPrimitiveIterator possibleItemIDs,IDRescorer rescorer,Estimator<Long> estimator) throws TasteException {Preconditions.checkArgument(possibleItemIDs != null, "argument is null");Preconditions.checkArgument(estimator != null, "argument is null");Queue<RecommendedItem> topItems = new PriorityQueue<RecommendedItem>(howMany + 1,Collections.reverseOrder(ByValueRecommendedItemComparator.getInstance()));boolean full = false;double lowestTopValue = Double.NEGATIVE_INFINITY;while (possibleItemIDs.hasNext()) {long itemID = possibleItemIDs.next();if (rescorer == null || !rescorer.isFiltered(itemID)) {double preference;try {preference = estimator.estimateSong(itemID);} catch (NoSuchItemException nsie) {continue;}double rescoredPref = rescorer == null ? preference : rescorer.rescore(itemID, preference);
//                if (Double.isNaN(rescoredPref)) {//                    rescoredPref = 0;
//                }if (!Double.isNaN(rescoredPref) && (!full || rescoredPref > lowestTopValue)) {topItems.add(new GenericRecommendedItem(itemID, (float) rescoredPref));if (full) {topItems.poll();} else if (topItems.size() > howMany) {full = true;topItems.poll();}lowestTopValue = topItems.peek().getValue();}}}int size = topItems.size();if (size == 0) {return Collections.emptyList();}List<RecommendedItem> result = Lists.newArrayListWithCapacity(size);result.addAll(topItems);Collections.sort(result, ByValueRecommendedItemComparator.getInstance());return result;}
  • GenericUserBasedRecommender.estimateSong:
        @Overridepublic double estimateSong(Long itemID) throws TasteException {return doEstimatePreferenceSong(theUserID, oneNeighborhood, itemID);}
  • GenericUserBasedRecommender.doEstimatePreferenceSong:评估歌曲(注:这里我也有所改动)
    protected float doEstimatePreferenceSong(long theUserID, long oneNeighborhood, long itemID) throws TasteException {DataModel dataModel = getDataModel();double preference = 0.0;double totalSimilarity = 0.0;int count = 0;if (oneNeighborhood != theUserID) {// See GenericItemBasedRecommender.doEstimatePreference() tooFloat pref = dataModel.getPreferenceValue(oneNeighborhood, itemID);if (pref != null) {double theSimilarity = similarity.userSimilarity(theUserID, oneNeighborhood);if (!Double.isNaN(theSimilarity)) {preference += theSimilarity * pref;totalSimilarity += theSimilarity;count++;}else {//                        preference += pref;
//                        totalSimilarity += theSimilarity;
//                        count++;return pref;}}}
//        if (count <= 1) {//            return Float.NaN;
//        }float estimate = (float) (preference / totalSimilarity);if (capper != null) {estimate = capper.capEstimate(estimate);}return estimate;}

4、 封装返回结果

  • RecommendController:在controller层封装结果返回给前端
        jsonResult jr = new jsonResult();List<MyRec> myRecInfoList = getRecInfo(myRecList);jr.add(myRecInfoList);System.out.println("myRecommend json---:"+jr);return jr;
  • RecommendController:调用方法
    public List<MyRec> getRecInfo(List<MyRec> myRecList) {UserServiceDao userService = new UserServiceDao();SongServiceDao songService = new SongServiceDao();System.out.println("get myRecList:" + myRecList);// 循环遍历到数据库中查询详细信息List<MyRec> myRecListInfo = new ArrayList<>();for (MyRec myRec : myRecList) {System.out.println("myRec.getUser().getUserId():" + myRec.getUser().getId());// 获取用户idInteger userId = myRec.getUser().getId();
//            查询用户详情信息User user = userService.getUserInfoById(userId);System.out.println("user:" + user);
//            设置更新后的用户myRec.setUser(user);
//            myRecListInfo.add(myRec);System.out.println("myRec.getSongList():" + myRec.getSongList());List<Song> songList = new ArrayList<>();for (Song song : myRec.getSongList()) {//                System.out.println("song.getSongId():"+song.getSongId());Integer songId = song.getId();// 获取歌曲详细信息song = songService.getSongInfoById(songId);
//                System.out.println("song:"+song);// 如果本地曲库有这首歌则添加。否则加的是null影响体验if (song.getId() != null){songList.add(song);}}myRec.setSongList(songList);
//            添加myRec到数组中myRecListInfo.add(myRec);}System.out.println("myRecListInfo:" + myRecListInfo);return myRecListInfo;}

以上。大致就是推荐功能的重点代码块(注:惩罚因子目前并没实现)。如需完整代码,欢迎前往全球最大同性交友网站GayHub 哦不GitHub查看(由于时间和经验的限制,代码写得可能不是很好,但整体还是可供参考的)