之前分析了基于用户的CF的原理。今天尝试调试一下基于物品的CF算法。
再次声明,本文部分内容是参考:https://blog.csdn.net/wolvesqun/article/details/52757772
感谢大佬的整理为我指明方向。
基于物品的CF的原理和基于用户的CF类似,只是在计算邻居时采用物品本身,而不是从用户的角度,即基于用户对物品的偏好找到相似的物品,然后根据用户的历史偏好,推荐相似的物品给他。从计算的角度看,就是将所有用户对某个物品的偏好作为一个向量来计算物品之间的相似度,得到物品的相似物品后,根据用户历史的偏好预测当前用户还没有表示偏好的物品,计算得到一个排序的物品列表作为推荐。图3给出了一个例子,对于物品A,根据所有用户的历史偏好,喜欢物品A的用户都喜欢物品C,得出物品A和物品C比较相似,而用户C喜欢物品A,那么可以推断出用户C可能也喜欢物品C.
虽然都是基于用户偏好来做计算的算法,但是UserCF是基于用户的相似度,而ItemCF更多的是计算物品的相似度。
因为部分算法都是一样的,请允许我跳过读取数据的阶段,有需要可以看我的上一篇博客和上上篇博客。
我的演示是根据大佬的博客做的,代码如下:
public static void itemCF(DataModel dataModel) throws TasteException {
ItemSimilarity itemSimilarity = RecommendFactory.itemSimilarity(RecommendFactory.SIMILARITY.EUCLIDEAN, dataModel);
RecommenderBuilder recommenderBuilder = RecommendFactory.itemRecommender(itemSimilarity, true);
RecommendFactory.evaluate(RecommendFactory.EVALUATOR.AVERAGE_ABSOLUTE_DIFFERENCE, recommenderBuilder, null, dataModel, 0.7);
RecommendFactory.statsEvaluator(recommenderBuilder, null, dataModel, 2);
LongPrimitiveIterator iter = dataModel.getUserIDs();
while (iter.hasNext()) {
long uid = iter.nextLong();
List list = recommenderBuilder.buildRecommender(dataModel).recommend(uid, RECOMMENDER_NUM);
RecommendFactory.showItems(uid, list, true);
}
}
其实根据我们的传参可以看出我们还是要建一个欧几里得距离的相似度的对象,如果我们手动回忆一下上一篇UserCF的内容,你会发现这里生成的ItemSimilarity和上一篇里生成的UserSimilarity对象实际上都是一个EuclideanDistanceSimilarity对象,一毛一样。
所以请允许我郑重跳过这一步,各种校验和赋值,然后把参数放了进去。
实际上根据传入用参数,我们是创建了一个GenericItemBasedRecommender对象,新建这个对象的操作也和UserCF那个没有多大差别,看了源码,基本上就是现有的数据进行各种校验和赋值,EMM,赋值赋值赋值......如下三张图所示,没有任何计算和处理数据过程:
实际上,从UserCF的源码可以看出,从这里开始终于有开始计算的逻辑了,痛哭。终于可以让我认认真真的看看逻辑了。
和之前一样,我们建立了一个基于平均绝对距离的评价器:
包括初始化了一些属性,生成了一个专用的随机数生成器,然后过,用这个评价器进入评价环节。
评价方法用的和userCF是同一个,先是对数据集进行分组,将数据分为训练集和测试集,到生成训练模型这一步都是用的和UserCF一样的方法。
之后生成一个GenericItemBasedRecommender,并使用这个导购对象进行评价计算,也就是getEvaluation()方法。
计算评价分数的这一步同样是使用了多线程:
但是因为我们的Recommender类是GenericItemBasedRecommender,这个类的doEstimatePreference()方法和以用户为基础的推荐类完全不同了,他同样是判断了训练数据集中没有该用户对该物品的真实评分(也就是这个数据确实在测试集中而不是在训练集中,不然预测就没有意义),然后进行了评分预测。从这一步开始,计算的步骤会和UserCF有很大的差别。
从上图可以看出,我们这次不会再进行用户的相似度计算,而是计算物品的相似度,是继承让这个两个完全不同的方法可以在同一套流程中顺利进行。这种写法还真是教科书般的写法。
@Override
public final double itemSimilarity(long itemID1, long itemID2) throws TasteException {
DataModel dataModel = getDataModel();
PreferenceArray xPrefs = dataModel.getPreferencesForItem(itemID1);
PreferenceArray yPrefs = dataModel.getPreferencesForItem(itemID2);
int xLength = xPrefs.length();
int yLength = yPrefs.length();
if (xLength == 0 || yLength == 0) {
return Double.NaN;
}
long xIndex = xPrefs.getUserID(0);
long yIndex = yPrefs.getUserID(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;
// No, pref inferrers and transforms don't appy here. I think.
while (true) {
int compare = xIndex < yIndex ? -1 : xIndex > yIndex ? 1 : 0;
if (compare == 0) {
// Both users expressed a preference for the item
double x = xPrefs.getValue(xPrefIndex);
double y = 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) {
break;
}
xIndex = xPrefs.getUserID(xPrefIndex);
}
if (compare >= 0) {
if (++yPrefIndex == yLength) {
break;
}
yIndex = yPrefs.getUserID(yPrefIndex);
}
}
double result;
if (centerData) {
// See comments above on these computations
double n = (double) count;
double meanX = sumX / n;
double meanY = sumY / n;
// 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(itemID1, itemID2, result);
}
if (!Double.isNaN(result)) {
result = normalizeWeightResult(result, count, cachedNumUsers);
}
return result;
}
计算相似度的方法和UserCF非常接近(这句话我不知道说了多少次了),取出两个物品的评分矩阵,如果训练集中两个矩阵有一个为空矩阵,则直接返回Double.NaN。如果两个矩阵都不为空,则分别取出第一个user的id,如果id相同,则分别取出各自的评分算出各种值和差值,如果各自矩阵的userId没有取完,则分别取下一个;如果id不同,则保留较大的那个,较小的跳过取下一个userId,直到其中一个矩阵的userId被取空。相似度的计算公式为1/(1+sqrt((x1-y1)^2+(x2-y2)^2+...+(xn-yn)^2))/sqrt(n)。x y分别是不同用户的分值。其实sqrt(n)更像一个正则量,我在上一篇也说过。
欧几里得距离计算的代码截图我就不放出了。
最后根据和UserCF一样的公式出预测分数: 计假设用户对物品a的评分为x,a与该物品相似度为sa, 对物品b的评分为y,b与该物品相似度为sb,则用户对本物品的预测评分公式为, (x*sa+y*sb)/(sa+sb).停留的那一句代码,目的是为了控制住评分的范围,不会因为相似物品有很多就评分爆表。
之后做的事情就是拿实际评分和预测评分进行对比,计算差值的绝对值diff。
每个物品的预测过程都在多线程中进行,最后都会把diff汇总到一起,计算diff的平均值作为分数。
RecommendFactory.statsEvaluator(recommenderBuilder, null, dataModel, 2);
准确率和召回率的定义我们就不再重复了,下面直接开始截源代码图:
@Override
public IRStatistics evaluate(RecommenderBuilder recommenderBuilder,
DataModelBuilder dataModelBuilder,
DataModel dataModel,
IDRescorer rescorer,
int at,
double relevanceThreshold,
double evaluationPercentage) throws TasteException {
Preconditions.checkArgument(recommenderBuilder != null, "recommenderBuilder is null");
Preconditions.checkArgument(dataModel != null, "dataModel is null");
Preconditions.checkArgument(at >= 1, "at must be at least 1");
Preconditions.checkArgument(evaluationPercentage > 0.0 && evaluationPercentage <= 1.0,
"Invalid evaluationPercentage: %s", evaluationPercentage);
int numItems = dataModel.getNumItems();
RunningAverage precision = new FullRunningAverage();
RunningAverage recall = new FullRunningAverage();
RunningAverage fallOut = new FullRunningAverage();
RunningAverage nDCG = new FullRunningAverage();
int numUsersRecommendedFor = 0;
int numUsersWithRecommendations = 0;
LongPrimitiveIterator it = dataModel.getUserIDs();
while (it.hasNext()) {
long userID = it.nextLong();
if (random.nextDouble() >= evaluationPercentage) {
// Skipped
continue;
}
long start = System.currentTimeMillis();
PreferenceArray prefs = dataModel.getPreferencesFromUser(userID);
// List some most-preferred items that would count as (most) "relevant" results
double theRelevanceThreshold = Double.isNaN(relevanceThreshold) ? computeThreshold(prefs) : relevanceThreshold;
FastIDSet relevantItemIDs = dataSplitter.getRelevantItemsIDs(userID, at, theRelevanceThreshold, dataModel);
int numRelevantItems = relevantItemIDs.size();
if (numRelevantItems <= 0) {
continue;
}
FastByIDMap trainingUsers = new FastByIDMap(dataModel.getNumUsers());
LongPrimitiveIterator it2 = dataModel.getUserIDs();
while (it2.hasNext()) {
dataSplitter.processOtherUser(userID, relevantItemIDs, trainingUsers, it2.nextLong(), dataModel);
}
DataModel trainingModel = dataModelBuilder == null ? new GenericDataModel(trainingUsers)
: dataModelBuilder.buildDataModel(trainingUsers);
try {
trainingModel.getPreferencesFromUser(userID);
} catch (NoSuchUserException nsee) {
continue; // Oops we excluded all prefs for the user -- just move on
}
int size = relevantItemIDs.size() + trainingModel.getItemIDsFromUser(userID).size();
if (size < 2 * at) {
// Really not enough prefs to meaningfully evaluate this user
continue;
}
Recommender recommender = recommenderBuilder.buildRecommender(trainingModel);
int intersectionSize = 0;
List recommendedItems = recommender.recommend(userID, at, rescorer);
for (RecommendedItem recommendedItem : recommendedItems) {
if (relevantItemIDs.contains(recommendedItem.getItemID())) {
intersectionSize++;
}
}
int numRecommendedItems = recommendedItems.size();
// Precision
if (numRecommendedItems > 0) {
precision.addDatum((double) intersectionSize / (double) numRecommendedItems);
}
// Recall
recall.addDatum((double) intersectionSize / (double) numRelevantItems);
// Fall-out
if (numRelevantItems < size) {
fallOut.addDatum((double) (numRecommendedItems - intersectionSize)
/ (double) (numItems - numRelevantItems));
}
// nDCG
// In computing, assume relevant IDs have relevance 1 and others 0
double cumulativeGain = 0.0;
double idealizedGain = 0.0;
for (int i = 0; i < recommendedItems.size(); i++) {
RecommendedItem item = recommendedItems.get(i);
double discount = i == 0 ? 1.0 : 1.0 / log2(i + 1);
if (relevantItemIDs.contains(item.getItemID())) {
cumulativeGain += discount;
}
// otherwise we're multiplying discount by relevance 0 so it doesn't do anything
// Ideally results would be ordered with all relevant ones first, so this theoretical
// ideal list starts with number of relevant items equal to the total number of relevant items
if (i < relevantItemIDs.size()) {
idealizedGain += discount;
}
}
nDCG.addDatum(cumulativeGain / idealizedGain);
// Reach
numUsersRecommendedFor++;
if (numRecommendedItems > 0) {
numUsersWithRecommendations++;
}
long end = System.currentTimeMillis();
log.info("Evaluated with user {} in {}ms", userID, end - start);
log.info("Precision/recall/fall-out/nDCG: {} / {} / {} / {}", new Object[] {
precision.getAverage(), recall.getAverage(), fallOut.getAverage(), nDCG.getAverage()
});
}
double reach = (double) numUsersWithRecommendations / (double) numUsersRecommendedFor;
return new IRStatisticsImpl(
precision.getAverage(),
recall.getAverage(),
fallOut.getAverage(),
nDCG.getAverage(),
reach);
}
根据参数,我们可以看出,这次依然使用阈值的方式来判断是否是正相关,而在代码中依然可以看出我们依旧使用某种算法来计算每个用户的阈值。
计算阈值的方式依然是平均值+标准差。
用户对某物品的评分大于这个阈值的时候,说明这个物品和该用户正相关。
我们可以依次整理每个用户所有的真实评分,整理出与该用户正相关的物品到底是哪些,也就是下面这部分代码(上上图中包含):
/ List some most-preferred items that would count as (most) "relevant" results
double theRelevanceThreshold = Double.isNaN(relevanceThreshold) ? computeThreshold(prefs) : relevanceThreshold;
FastIDSet relevantItemIDs = dataSplitter.getRelevantItemsIDs(userID, at, theRelevanceThreshold, dataModel);
int numRelevantItems = relevantItemIDs.size();
if (numRelevantItems <= 0) {
continue;
}
然后我们在数据集中抽取了部分(大部分)作为训练集,具体算法是,把该用户以外所有用户的数据都一股脑塞进去当测试集,只有遇到自己的时候把正相关的物品id拿掉,也就是只放非正相关的数据。
@Override
public void processOtherUser(long userID,
FastIDSet relevantItemIDs,
FastByIDMap trainingUsers,
long otherUserID,
DataModel dataModel) throws TasteException {
PreferenceArray prefs2Array = dataModel.getPreferencesFromUser(otherUserID);
// If we're dealing with the very user that we're evaluating for precision/recall,
if (userID == otherUserID) {
// then must remove all the test IDs, the "relevant" item IDs
List prefs2 = Lists.newArrayListWithCapacity(prefs2Array.length());
for (Preference pref : prefs2Array) {
prefs2.add(pref);
}
for (Iterator iterator = prefs2.iterator(); iterator.hasNext(); ) {
Preference pref = iterator.next();
if (relevantItemIDs.contains(pref.getItemID())) {
iterator.remove();
}
}
if (!prefs2.isEmpty()) {
trainingUsers.put(otherUserID, new GenericUserPreferenceArray(prefs2));
}
} else {
// otherwise just add all those other user's prefs
trainingUsers.put(otherUserID, prefs2Array);
}
}
总结一下就是某个用户的训练集里只放其他用户的全部数据和该用户的非正相关数据。
然后如果我们要对某一用户推荐n件物品,那么这个算法要求该用户已有2*n个评分数据才可以。
然后我们根据训练集,计算所其他用户与该用户的相似度,然后根据相似度,训练集中除已有该用户评价的物品以外的物品的预测评分,返回前2(由我们传入参数决定).这部分代码和上面几乎一样,所以不重新说一遍了。
最后我们用实际正相关的物品和我们预测正相关的物品进行对比。根据公式计算准确率和召回率。
LongPrimitiveIterator iter = dataModel.getUserIDs();
while (iter.hasNext()) {
long uid = iter.nextLong();
List list = recommenderBuilder.buildRecommender(dataModel).recommend(uid, RECOMMENDER_NUM);
RecommendFactory.showItems(uid, list, true);
}
其实和上一篇一样,到了这一步以后,基本上所用的代码都在之前的模块里提到过了。
到这一步实际上预测流程和算准确率召回率中的推荐那一步一模一样,只是训练集不再是部分数据。而是把所有已知数据作为训练集,然后计算物品之间的相关度,对用户没评价过的物品挨个进行估分(有些能估出来,有些则不能),然后对估分进行排序,取出估分最高的3个(参数传入)作为推荐数据。