核心思想

当我们的系统要向用户A进行推荐时,要先找出与用户A最相似的几个用户,再从这几个用户的点赞的帖子中预测出用户a最喜欢的几个帖子并且是用户a没有点赞过的帖子进行推荐。

接下来请跟着我的步骤来实现这个算法。

1. 相似性度量方法

首先我们要知道两个用户之间的相似度,那么这里就可以引入一个概念相似性度量方法

杰卡德(Jaccard)相似系数

这个是用来衡量两个集合相似度的一种指标,意思是两个用户A和用户B产生交集的物品数量占两个用户所有物品数量并集的一个比例。大白话就是两个用户喜爱物品的交集越多,说明两个人的兴趣爱好越趋同。

这里给一个公式:jaccard.png

余弦相似度

在几何学当中,衡量两个向量是否相似就是看两个向量之间的夹角,如果夹角越小就说明两个向量越接近,在这里它和Jaccard公式的不同之处就在于分母的计算方式不一样,Jaccard公式的分母是用户喜爱物品的并集,而余弦相似度就是物品数量的乘积,但是我们的数据一般都是结构化数据,都是采用二维表格进行展示的,而每行或每列都是一个向量,所以我使用如下公式来计算:

余弦.png

分子为两个向量的内积,分母是两个向量模长的乘积。

一般情况下我们的数据会非常多,会导致构造的矩阵会非常稀疏,即向量中的0会非常多,所以当数据量不多的时候可以采用这种方式。

皮尔逊相关系数

皮尔逊相关系数就是概率统计中学过的相关系数,取值为【-1,1】,越接近1越是正相关,越接近-1越是负相关,如果为0,则表明二者不相关。

皮尔逊相关系数的计算方法和余弦相似度的计算方式很相似,这里的公式如下:

根据综合考量,最终在我的系统中选择了Jaccard公式来计算相似度。

2. 找出与用户A最相似的用户

Jaccard公式中用户A与用户B之间的相似度,是用户A和用户B共同交互过的物品的数量除以根号下(用户A的喜爱过的物品总数*用户B喜爱过的物品总数)。

根据Jaccard公式,我们需要两个前置条件,分别是每2个用户之间共同交互过的物品数量,每个用户所交互过的物品总数量。

2.1 求每2个用户之间共同交互过的物品数量

正常的思路就是先得到每个用户交互过的物品,然后再建立倒排表,来表示每个物品被哪些用户交互过。

首先要得到第一个前置条件(每2个用户之间共同交互过的物品数量),我们首先计算出两个Map:

(1)Map<String,Set<String>>userToArticles = new HashMap<>();

这个Map存的是用户ID所对应的一个交互过的物品id的Set集合。

(2)Map<String, Integer> nums = new HashMap<>();

这个Map存的是每个用户ID对应的该用户交互过的物品总数。

以下给出实现代码:

Map<String, Set<String>> userToArticles = new HashMap<>();
Map<String, Integer> num = new HashMap<>();
for(UserInfo user : userInfoList) {
    Set<String> articleIds = new HashSet<>();
    List<String> objectIds = mapper.selectByUserId(user.getUserId());
    articleIds.addAll(objectIds);

    userToArticles.put(user.getUserId(),articleIds);
    num.put(user.getUserId(),articleIds.size());
}

然后我再根据第一个Map来建立倒排表,表示每个物品被哪些用户交互过。

具体逻辑就是,遍历userToArticles 这个Map的每个键值对,在嵌套内循环遍历每个键值对中的Set<String>,也就是用户交互过的物品集合,将每个遍历到的物品和当前对应的键(也就是当前遍历到的用户)存进倒排表,就是这样一个Map,表示每个物品所对应的交互过这个物品的用户的集合,Map<String, Set<String>> itemToUsers。

以下给出实现代码:

public static Map<String, Set<String>> getItemToUsers(Map<String, Set<String>> userToItems) {
    Map<String, Set<String>> ItemToUsers = new HashMap<>();

    for (Map.Entry<String, Set<String>> entry : userToItems.entrySet()) {
        String userId = entry.getKey();
        Set<String> articleIds = entry.getValue();

        for (String articleId : articleIds) {
            //如果当前文章对应的用户集合中没有用户,就新建一个Set再把当前用户加进去,如果有的话就之间加进去
            Set<String> users = ItemToUsers.getOrDefault(articleId, new HashSet<>());
            users.add(userId);
            ItemToUsers.put(articleId, users);
        }
    }
    return ItemToUsers;
}

求出倒排表之后,接下来我们就可以计算每2个用户之间共同交互过的物品数量(协同过滤矩阵),

Map<String, Map<String, Integer>> CFMatrix = new HashMap<>();
# String表示当前用户,对应的Map<String, Integer>表示其他各个用户以及与当前用户的共同交互过的物品的数量

具体步骤是:

(1)遍历这个倒排表,嵌套内循环遍历每个物品对应的用户集合的每个用户。

(2)对于遍历到的每个用户,通过遍历其他所有用户,将当前用户与其他用户的共同交互物品数加一。

以下给出实现代码:

public static Map<String, Map<String, Integer>> getCFMatrix( Map<String, Set<String>> itemToUsers) {
    Map<String, Map<String, Integer>> CFMatrix = new HashMap<>();

    logger.info("开始构建协同过滤矩阵....");

    // 遍历所有的物品,统计用户两两之间交互的物品数
    for (Map.Entry<String, Set<String>> entry : itemToUsers.entrySet()) {
        String item = entry.getKey();
        Set<String> users = entry.getValue();

        // 首先统计每个用户交互的物品个数
        for (String u : users) {//遍历所有该博客对应的交互过的用户

            // 统计每个用户与其它用户共同交互的物品个数
            if (!CFMatrix.containsKey(u)) {
                CFMatrix.put(u, new HashMap<>());
            }

            for (String v : users) {//再次遍历所有用户,对不是u的其他用户进行操作
                if (!v.equals(u)) {
                    if (!CFMatrix.get(u).containsKey(v)) {
                        CFMatrix.get(u).put(v, 0);
                    }
                    CFMatrix.get(u).put(v, CFMatrix.get(u).get(v) + 1);
                }
            }
        }
    }
    return CFMatrix;
}

我们现在就能得到协同过滤矩阵的Map。

2.2 求每个用户所交互过的物品总数量

这一步就需要我们上面求得协同过滤矩阵CFMatrix和每个用户所交互的物品总数求相似度,然后就要运用到我们最开始说的jaccard公式

以下给出实现代码:

public static Map<String, Map<String, Double>> getSimMatrix(Map<String, Map<String, Integer>> CFMatrix, Map<String, Integer> num) {
    Map<String, Map<String, Double>> sim =new HashMap<>();
    logger.info("构建用户相似度矩阵....");

    for (Map.Entry<String, Map<String, Integer>> entry : CFMatrix.entrySet()) {//遍历协同过滤矩阵,遍历每个键值对
        String u = entry.getKey();
        Map<String, Integer> otherUser = entry.getValue();

        for (Map.Entry<String, Integer> userScore : otherUser.entrySet()) {
            String v = userScore.getKey();
            int score = userScore.getValue();
            if(!sim.containsKey(u))
            {
                sim.put(u,new HashMap<>());
            }
            sim.get(u).put(v, CFMatrix.get(u).get(v) / Math.sqrt(num.get(u) * num.get(v)));
        }
    }
    return sim;
}

我们现在得到的Map(Map<String, Map<String, Double>> sim)结果是当前用户对应的其他用户以及其他用户与当前用户的相似度。

2.3 物品推荐

这一步可以根据你们系统实际出发,这里就以我实际应用的系统来举例。

在我的系统中,我只取前20个用户,然后遍历这些用户交互过的物品,但是被推荐用户还没有交互过的物品进行打分。分数的计算就是(用户相似度*物品分数),物品分数根据你们系统来设置权重,我这里设置交互过的物品计分为1。

这里每个物品的推荐度就是被交互过的用户相似度之和。

以下给出实现代码:

public static Set<String> recommendForUser(Map<String, Map<String, Double>> sim,
                                                Map<String, Set<String>> valUserItem,
                                                int K, int N, String targetUser) {
    logger.info("给用户进行推荐....");
    Map<String, Double> itemRank = new HashMap<>();

    if (valUserItem.containsKey(targetUser)) {

        // 按照相似度进行排序,然后取前 K 个
        List<Map.Entry<String, Double>> sortedSim = new ArrayList<>(sim.get(targetUser).entrySet());
        Collections.sort(sortedSim, new Comparator<Map.Entry<String, Double>>() {
            public int compare(Map.Entry<String, Double> o1, Map.Entry<String, Double> o2) {
                return Double.compare(o2.getValue(), o1.getValue());
            }
        });

        logger.info("检查对相似度矩阵排序后的矩阵");
        for (Map.Entry<String, Double> entry : sortedSim) {
            String item = entry.getKey();
            Double similarity = entry.getValue();
            logger.info("用户 " + item + ", 相似度: " + similarity);
        }

        for (int i = 0; i < K; i++) {
            //前k个相似度高的用户
            if (i >= sortedSim.size())
                break;

            String similarUser = sortedSim.get(i).getKey();
            double score = sortedSim.get(i).getValue();

            // 找出相似用户中有交互的物品,但当前用户并未交互过的物品进行推荐
            for (String item : valUserItem.get(similarUser)) {
                if (valUserItem.get(targetUser).contains(item))//如果用户已经对该物品交互过,就不用再推荐
                    continue;

                itemRank.put(item, itemRank.getOrDefault(item, 0.0) + score);
                //这里就得到的推荐候选的一个集合
            }
        }
    }

    // 根据评分进行排序,取排名靠前的 N 个物品作为推荐列表
    List<Map.Entry<String, Double>> topNItems = new ArrayList<>(itemRank.entrySet());
    Collections.sort(topNItems, new Comparator<Map.Entry<String, Double>>() {
        public int compare(Map.Entry<String, Double> o1, Map.Entry<String, Double> o2) {
            return Double.compare(o2.getValue(), o1.getValue());
        }
    });

    Set<String> recommendedItems = new HashSet<>();
    for (int i = 0; i < Math.min(N, topNItems.size()); i++) {
        recommendedItems.add(topNItems.get(i).getKey());
    }

    return recommendedItems;
}

到这里,我们就能得到被推荐物品的id集合,Set<String> articleIds。

结语

一入JAVA,深似海啊。

这个方法有个缺点,在项目上线初期即还未收集到足够的用户信息时,无法向用户提供准确的预测。对于新用户来说,因为新用户还未与系统中的物品进行过交互,所以页无法向新用户进行推荐。