文章目录
应用场景
在本人负责的一个交友类项目中,实现了一个向用户推荐可能感兴趣的其他用户的功能。推荐的目标是使推荐的用户尽可能相似。系统要求用户设置自己的兴趣爱好标签,因此推荐策略基于这些标签的相似度来进行
兴趣爱好相似度的计算方法
这里主要列举出几种基于距离的方法。
1. Jaccard 相似度
衡量两个集合相似度的指标。它的定义是两个集合交集的大小与并集的大小的比值。Jaccard相似度的值范围在0到1之间,值越接近1表示两个集合越相似。
公式:
J a c c a r d = ∣ A ∩ B ∣ ∣ A ∪ B ∣ Jaccard = \frac{|A\cap B|}{|A \cup B|} Jaccard=∣A∪B∣∣A∩B∣
使用步骤:
- 将目标用户的兴趣爱好放到一个集合里
- 将另一个用户的兴趣爱好放到另一个集合里
- 通过公式计算两者的 Jaccard 相似度
- 重复上述步骤,计算目标用户跟其他所有用户的Jaccard距离,根据距离降序排序,返回最匹配的若干个用户。
评价
实现简单直观,用户之间的共性越多,差异越小,则越相似。但这种方法没有考虑爱好之间的潜在关联。
2. 余弦相似度
余弦相似度是一种衡量两个非零向量之间角度的度量。它通过计算两个向量之间的夹角的余弦值来评估它们之间的相似性。如果两个向量在多维空间中指向相同的方向,则它们的余弦相似度接近1;如果方向相反,则接近-1;如果正交,则余弦相似度为0。
公式:
c
o
s
i
n
e
_
s
i
m
=
A
⋅
B
∣
∣
A
∣
∣
∣
∣
B
∣
∣
cosine\_sim = \frac{A·B}{||A|| ||B||}
cosine_sim=∣∣A∣∣∣∣B∣∣A⋅B
A
A
A 和
B
B
B 为两个向量,做内积在除以二者的模的乘积就是余弦相似度的值。
使用步骤:
- 将用户的兴趣爱好转成向量
- 计算目标用户与其他用户之间的余弦相似度
- 返回余弦相似度最大的用户作为最匹配的结果
评价
这是一种较为适合的方法,爱好在被编码后映射到了高维空间中,相似类型的爱好聚集在一起。余弦值越大,代表两个向量的方向越一致,爱好就越相似,用户之间也就越相似。然而,将爱好编码成向量比较麻烦,编码的质量决定了推荐的质量。
3. 欧式距离
欧式距离是一种常用的度量两个点在多维空间中的直线距离的方法。它基于笛卡尔坐标系中的两点之间的距离公式,适用于多维空间的任务。
公式:
d
i
s
t
a
n
c
e
(
A
,
B
)
=
∑
i
=
1
n
a
i
−
b
i
2
distance(A, B) = \sqrt{\sum_{i=1}^{n}{a_i - b_i}^2}
distance(A,B)=i=1∑nai−bi2
评价
衡量两个向量的直线距离,距离越大则越不相似,反之越相似。虽然可用,但在多维空间上,向量的相似度通常用夹角来衡量更好。同样需要将爱好编码成向量。
爱好标签转向量的方法
1. 独热编码
将每个爱好标签转换为一个维度,如果用户拥有这个标签,则对应维度的值为 1,否则为 0。
例如,如果有 30 个爱好标签,则一个用户的爱好向量就是 30 维,如果用户有某个爱好,那么那一维上就设置成 1。
这种编码方式的爱好数量和顺序需要预先设定好,不支持用户自定义标签。适合爱好数量较少的情况,数量太多会导致向量非常稀疏,效果不佳。
2. 连续编码
将若干个爱好标签直接编码成数字(如 1、2、3…)。
这种编码方式简单直接,但可能会引入不必要的顺序关系,即数值相近的标签被假定为相似。
3. 机器学习编码
使用 Word2Vec 或 GloVe 这样的模型来获取每个标签的词向量。这些模型通常已经有训练好的权重模型,可以直接使用。
这种方式能够捕捉到标签间的语义关系,但需要额外的数据处理和模型训练工作。
Jaccard 相似度代码实现
项目中最终选择了 Jaccard 算法实现,毕竟比较简单快速。
我的兴趣爱好是两级的,所以对一级和二级标签分别计算相似度,然后加权。
public class JaccardSimilarityUtil {
// 计算两个集合的 jaccard 相似度
public static double jaccardSimilarity(Set<Integer> user_one, Set<Integer> user_two){
Set<Integer> intersection = new HashSet<>(user_one);
// 获取两个集合的交集
intersection.retainAll(user_two);
// 获取两个集合的并集
Set<Integer> union = new HashSet<>(user_one);
union.addAll(user_two);
return (double) intersection.size() / union.size();
}
// 计算一级标签的 jaccard 相似度
public static double firstLevelJaccardSimilarity(Map<Integer, Set<Integer>> user1Interests, Map<Integer, Set<Integer>> user2Interests) {
Set<Integer> firstLevel1 = user1Interests.keySet();
Set<Integer> firstLevel2 = user2Interests.keySet();
return jaccardSimilarity(firstLevel1, firstLevel2);
}
// 计算二级标签的Jaccard相似度
public static double secondLevelJaccardSimilarity(Map<Integer, Set<Integer>> user1Interests, Map<Integer, Set<Integer>> user2Interests) {
Set<Integer> combinedFirstLevels = new HashSet<>(user1Interests.keySet());
combinedFirstLevels.addAll(user2Interests.keySet());
Set<Integer> secondLevel1 = new HashSet<>();
Set<Integer> secondLevel2 = new HashSet<>();
for (Integer firstLevel : combinedFirstLevels) {
secondLevel1.addAll(user1Interests.getOrDefault(firstLevel, Collections.emptySet()));
secondLevel2.addAll(user2Interests.getOrDefault(firstLevel, Collections.emptySet()));
}
return jaccardSimilarity(secondLevel1, secondLevel2);
}
// 计算综合Jaccard相似度
public static double combinedJaccardSimilarity(Map<Integer, Set<Integer>> user1Interests, Map<Integer, Set<Integer>> user2Interests) {
double firstLevelSimilarity = firstLevelJaccardSimilarity(user1Interests, user2Interests);
double secondLevelSimilarity = secondLevelJaccardSimilarity(user1Interests, user2Interests);
// 可以根据需要调整权重
return 0.5 * firstLevelSimilarity + 0.5 * secondLevelSimilarity;
}
// 计算一个用户与其他多个用户的Jaccard相似度
public static Map<Long, Double> calculateJaccardSimilarities(
Map<Integer, Set<Integer>> targetUserInterests,
Map<Long, Map<Integer, Set<Integer>>> otherUsersInterests) {
Map<Long, Double> similarities = new HashMap<>();
for (Map.Entry<Long, Map<Integer, Set<Integer>>> entry : otherUsersInterests.entrySet()) {
Long userId = entry.getKey();
Map<Integer, Set<Integer>> userInterests = entry.getValue();
// 计算综合Jaccard相似度
double similarity = combinedJaccardSimilarity(targetUserInterests, userInterests);
similarities.put(userId, similarity);
}
return similarities;
}
public static void main(String[] args) {
// key:value -> 一级爱好:二级爱好
Map<Integer, Set<Integer>> user1Interests = new HashMap<>();
user1Interests.put(1, new HashSet<>(Arrays.asList(1, 2, 3)));
user1Interests.put(2, new HashSet<>(Arrays.asList(4, 5)));
Map<Integer, Set<Integer>> user2Interests = new HashMap<>();
user2Interests.put(1, new HashSet<>(Arrays.asList(2, 3, 8)));
user2Interests.put(2, new HashSet<>(Arrays.asList(4, 5)));
user2Interests.put(3, new HashSet<>(Arrays.asList(6, 7)));
double firstLevelSimilarity = firstLevelJaccardSimilarity(user1Interests, user2Interests);
double secondLevelSimilarity = secondLevelJaccardSimilarity(user1Interests, user2Interests);
double combinedSimilarity = combinedJaccardSimilarity(user1Interests, user2Interests);
System.out.println("First Level Jaccard Similarity: " + firstLevelSimilarity);
System.out.println("Second Level Jaccard Similarity: " + secondLevelSimilarity);
System.out.println("Combined Jaccard Similarity: " + combinedSimilarity);
}
}
提升系统响应的优化
在计算相似度的时候需要遍历用户计算,这是比较耗费时间的一个过程,尤其是用户数量很大的情况下,以下是本人尝试的一些小优化。
1. 设置过滤条件减少匹配数量
可以在前端预先设置筛选条件(如地域、年龄、性别等),让用户先做一个预筛选,这样后端就可以减少查找的用户数量,从而减少计算相似度的时间。
2. 添加适当索引和缓存结果
- 在数据库中创建索引来加速查询速度,特别是对于频繁使用的查询字段。
- 使用缓存来存储最近的匹配结果,当用户离开后再次进入该页面时使用缓存数据而不是重新计算。
3. 预计算
在低峰时段(如凌晨三点)设置定时任务预先计算出潜在的匹配对象列表,并将结果存储到缓存中,在用户请求时直接返回。
总结
本文探讨了如何在交友应用中利用兴趣爱好标签来推荐相似用户。我们讨论了几种相似度计算方法,包括 Jaccard 相似度、余弦相似度和欧式距离,并详细介绍了每种方法的应用步骤及优缺点。此外,还介绍了几种将爱好标签转化为向量的方法,如独热编码、连续编码以及使用机器学习模型进行编码。
在实际应用中,我们选择了 Jaccard 相似度算法来实现这一功能,因为它简单且易于实现。为了进一步提升用户体验,我们还实施了一些性能优化措施,包括设置过滤条件减少匹配数量、添加索引和缓存结果以及预计算等。
当然,这只是在个人项目中的一些简单尝试,推荐算法博大精深,以上只是讨论了比较简单的基于距离的方法。而且使用的数据也只有用户的兴趣爱好,用户的其他行为信息也是可以用来推荐的,还有很大的优化空间。