mahout之推荐系统源码笔记(3) ---执行推荐之RecommenderJob

mahout之推荐系统源码笔记(3) —执行推荐之RecommenderJob

本笔记承接笔记二。

在笔记2中我们通过RowSimilarityJob获取了所有物品之间的相似度矩阵,通过这个矩阵,接下来我们就可以开始推荐了~

首先我们回到RecommenderJob。RecommenderJob在执行RowSimilarityJob之后执行了下面这个job:

    Job partialMultiply = new Job(getConf(), "partialMultiply");
    Configuration partialMultiplyConf = partialMultiply.getConfiguration();
    //两个map
    MultipleInputs.addInputPath(partialMultiply, similarityMatrixPath, SequenceFileInputFormat.class,
            SimilarityMatrixRowWrapperMapper.class);
    MultipleInputs.addInputPath(partialMultiply, new Path(prepPath, PreparePreferenceMatrixJob.USER_VECTORS),
            SequenceFileInputFormat.class, UserVectorSplitterMapper.class);
    partialMultiply.setJarByClass(ToVectorAndPrefReducer.class);
    partialMultiply.setMapOutputKeyClass(VarIntWritable.class);
    partialMultiply.setMapOutputValueClass(VectorOrPrefWritable.class);
    //两个map在这里reduce
    partialMultiply.setReducerClass(ToVectorAndPrefReducer.class);
    partialMultiply.setOutputFormatClass(SequenceFileOutputFormat.class);
    partialMultiply.setOutputKeyClass(VarIntWritable.class);
    partialMultiply.setOutputValueClass(VectorAndPrefsWritable.class);
    partialMultiplyConf.setBoolean("mapred.compress.map.output", true);
    partialMultiplyConf.set("mapred.output.dir", partialMultiplyPath.toString());

我们看到这个job有点奇怪,为什么呢?因为他有两个inputPath的设置,那么我们就可以知道它是两个map然后将数据总和reduce。
两个map分别是:SimilarityMatrixRowWrapperMapper、UserVectorSplitterMapper
通过之上的代码可以看到,SimilarityMatrixRowWrapperMapper的输入数据是之前我们RecommenderJob得到的相似矩阵,而UserVectorSplitterMapper的输入数据是我们再笔记1中的预备job中得到的user-item,pref矩阵。
首先我们看SimilarityMatrixRowWrapperMapper的代码:

public final class SimilarityMatrixRowWrapperMapper extends Mapper<IntWritable,VectorWritable,VarIntWritable,VectorOrPrefWritable> {

  private final VarIntWritable index = new VarIntWritable();
  private final VectorOrPrefWritable vectorOrPref = new VectorOrPrefWritable();

  @Override
  protected void map(IntWritable key,
                     VectorWritable value,
                     Context context) throws IOException, InterruptedException {
    Vector similarityMatrixRow = value.get();
    /* remove self similarity */
    //这里它将item关于自己的相似度设为NaN,其实并不是这一步remove
    //而是通过设置自己的相似度为最大在之后的计算中不在考虑,通过这种间接的方法remove
    similarityMatrixRow.set(key.get(), Double.NaN);

    index.set(key.get());
    vectorOrPref.set(similarityMatrixRow);
    context.write(index, vectorOrPref);
  }

这里有一个小细节,我们可以看到,vectorOrPref的类型已经不是普通的Vector了,而是变为了VectorOrPrefWritable,那么这个VectorOrPrefWritable是什么呢?我们跟进代码可以看到:

public final class VectorOrPrefWritable implements Writable {

  private Vector vector;
  private long userID;
  private float value;

  ...
  void set(Vector vector) {
    this.vector = vector;
    this.userID = Long.MIN_VALUE;
    this.value = Float.NaN;
  }

可以看到这个类型拥有三个成员,分别是一个Vector,一个long存放userID,一个float存放Value,而我们之上的map中的vectorOrPref对Vector进行赋值,其他两个变量的值根据set函数可以看到设置成了极小。接下来我们看另一个map,UserVectorSplitterMapper:

public final class UserVectorSplitterMapper extends Mapper<VarLongWritable,VectorWritable, VarIntWritable,VectorOrPrefWritable> {

  ...

  @Override
  protected void map(VarLongWritable key,
                     VectorWritable value,
                     Context context) throws IOException, InterruptedException {
    long userID = key.get();

    log.info("UserID = {}", userID);

    if (usersToRecommendFor != null && !usersToRecommendFor.contains(userID)) {
      return;
    }

    //通过跟进函数可以知道这个函数是一个小型的剪枝函数,减去我们考虑值范围之外的小的偏好值
    Vector userVector = maybePruneUserVector(value.get());

    //翻转为item-user并且放在vectorOrPref中,这个我们之后会说。
    for (Element e : userVector.nonZeroes()) {
      itemIndexWritable.set(e.index());
      vectorOrPref.set(userID, (float) e.get());
      context.write(itemIndexWritable, vectorOrPref);
    }
  }

  private Vector maybePruneUserVector(Vector userVector) {
    if (userVector.getNumNondefaultElements() <= maxPrefsPerUserConsidered) {
      return userVector;
    }

    float smallestLargeValue = findSmallestLargeValue(userVector);

    // "Blank out" small-sized prefs to reduce the amount of partial products
    // generated later. They're not zeroed, but NaN-ed, so they come through
    // and can be used to exclude these items from prefs.
    //可以看到他将用户定义或者默认的最大关注值之外的的所有偏小的偏好评价设置为NaN
    //之前说过,设置为NaN的条目我们就会不会考虑所以通过这种方式达到剪枝的目的
    for (Element e : userVector.nonZeroes()) {
      float absValue = Math.abs((float) e.get());
      if (absValue < smallestLargeValue) {
        e.set(Float.NaN);
      }
    }

    return userVector;
  }

  private float findSmallestLargeValue(Vector userVector) {

    PriorityQueue<Float> topPrefValues = new PriorityQueue<Float>(maxPrefsPerUserConsidered) {
      @Override
      protected boolean lessThan(Float f1, Float f2) {
        return f1 < f2;
      }
    };

    //找到在数量maxPrefsPerUserConsidered外的偏好条目偏好最小的最大值
    for (Element e : userVector.nonZeroes()) {
      float absValue = Math.abs((float) e.get());
      topPrefValues.insertWithOverflow(absValue);
    }
    return topPrefValues.top();
  }

}

可以看到这里也出现了vectorOrPref,找到声明 :
private final VectorOrPrefWritable vectorOrPref = new VectorOrPrefWritable();”
可以看到它的类型也是VectorOrPrefWritable ,这里的set代码如下:

public void set(long userID, float value) {
    this.vector = null;
    this.userID = userID;
    this.value = value;
  }

可以看到它将Vector设置为null。为什么要使用这种结构?其实就是为了使两个map不同的结果进行合并,通过一个reduce得到合并的结果,如果模糊我们接下来看这两个map的reduce就明白了。
在此之前我们引入另一个mahout自定义的类VectorAndPrefsWritable,可以看到类声明如下:

public final class VectorAndPrefsWritable implements Writable {

  private Vector vector;
  private List<Long> userIDs;
  private List<Float> values;

  ...
  public void set(Vector vector, List<Long> userIDs, List<Float> values) {
    this.vector = vector;
    this.userIDs = userIDs;
    this.values = values;
  }
  ...

可以清晰得看到这个结构,接下来我们看reduce:ToVectorAndPrefReducer的代码:

public final class ToVectorAndPrefReducer extends Reducer<VarIntWritable,VectorOrPrefWritable,VarIntWritable,VectorAndPrefsWritable> {

  private final VectorAndPrefsWritable vectorAndPrefs = new VectorAndPrefsWritable();

  @Override
  protected void reduce(VarIntWritable key,
                        Iterable<VectorOrPrefWritable> values,
                        Context context) throws IOException, InterruptedException {

    //使用List容器存放userID和pref
    List<Long> userIDs = new ArrayList<>();
    List<Float> prefValues = new ArrayList<>();
    Vector similarityMatrixColumn = null;
    for (VectorOrPrefWritable value : values) {
      if (value.getVector() == null) {
        // Then this is a user-pref value
        userIDs.add(value.getUserID());
        prefValues.add(value.getValue());
      } else {
        // Then this is the column vector
        if (similarityMatrixColumn != null) {
          throw new IllegalStateException("Found two similarity-matrix columns for item index " + key.get());
        }
        similarityMatrixColumn = value.getVector();
      }
    }

    if (similarityMatrixColumn == null) {
      return;
    }

    vectorAndPrefs.set(similarityMatrixColumn, userIDs, prefValues);
    context.write(key, vectorAndPrefs);
  }

}

这个reduce并不难理解,其实就是通过对VectorOrPrefWritable类型的三个成员判断来对两个map的结果进行融合合并。具体步骤可以表达如下:

map1:
补全[itemA ,Vector<itemB,sim>]中关于自己的所有相似度,并置为NaN: [itemA ,Vector<itemA,NaN>]
-> 构建VectorOrPrefWritable格式,将Vector<itemA,NaN>添加进VectorOrPrefWritable.vector
-> 整合得到[itemA , VectorOrPrefWritable(Vector<itemB,sim>,NaN,NaN)]

map2:
[userID,Vector<itemID,Pref>] 
-> 构建VectorOrPrefWritable格式,将userID和Pref添加进VectorOrPrefWritable
-> [itemID , VectorOrPrefWritable(null , userID, Pref)]

reduce:

->[itemA , VectorAndPrefsWritable (Vector<itemB,Pref> , List<userID>, List<Pref>)]

解释一下,这里的userList存放着所有与itemA相关的用户,prefList存放着所有相关的偏好。而vector存放关于itemA的相似度向量。

我们得到了关于每个item的相似矩阵、相关用户、偏好值,接下来我们就可以开始推荐了,RecommenderJob中下一个job代码如下:

Job aggregateAndRecommend = prepareJob(
            new Path(aggregateAndRecommendInput), outputPath, SequenceFileInputFormat.class,
            PartialMultiplyMapper.class, VarLongWritable.class, PrefAndSimilarityColumnWritable.class,
            AggregateAndRecommendReducer.class, VarLongWritable.class, RecommendedItemsWritable.class,
            outputFormat);
    Configuration aggregateAndRecommendConf = aggregateAndRecommend.getConfiguration();

job的mapreduce分别为(PartialMultiplyMapper,AggregateAndRecommendReducer)
首先我们看到这里的输入路径是aggregateAndRecommendInput,根据 String aggregateAndRecommendInput = partialMultiplyPath.toString();我们知道其实就是partialMultiplyPath,而partialMultiplyPath正是上一个mapreduce的输出,也就是我们刚才得到的那个关于item的信息集合。
然后我们跟进PartialMultiplyMapper可以看到代码如下:

public final class PartialMultiplyMapper extends Mapper<VarIntWritable,VectorAndPrefsWritable,VarLongWritable,PrefAndSimilarityColumnWritable> {

  private final VarLongWritable userIDWritable = new VarLongWritable();
  private final PrefAndSimilarityColumnWritable prefAndSimilarityColumn = new PrefAndSimilarityColumnWritable();

  @Override
  protected void map(VarIntWritable key,
                     VectorAndPrefsWritable vectorAndPrefsWritable,
                     Context context) throws IOException, InterruptedException {

    Vector similarityMatrixColumn = vectorAndPrefsWritable.getVector();
    List<Long> userIDs = vectorAndPrefsWritable.getUserIDs();
    List<Float> prefValues = vectorAndPrefsWritable.getValues();

    //通过对下面for循环分析可以知道
    //其将输入格式变为了[userid , <pref , Vector< itemB , sim> >]
    //即基于用户操作记录的所有item的相似度以及其偏好。
    for (int i = 0; i < userIDs.size(); i++) {
      long userID = userIDs.get(i);
      float prefValue = prefValues.get(i);
      if (!Float.isNaN(prefValue)) {
        prefAndSimilarityColumn.set(prefValue, similarityMatrixColumn);
        userIDWritable.set(userID);
        context.write(userIDWritable, prefAndSimilarityColumn);
      }
    }
  }

得到基于用户操作记录的所有item的相似度以及其偏好之后我们接下来跟进AggregateAndRecommendReducer。
由于AggregateAndRecommendReducer代码比较长,我们分阶段步骤进行分析,首先我们来看AggregateAndRecommendReducer的reduce代码:

  protected void reduce(VarLongWritable userID,
                        Iterable<PrefAndSimilarityColumnWritable> values,
                        Context context) throws IOException, InterruptedException {
    //判断是否具有偏好值
    if (booleanData) {
      reduceBooleanData(userID, values, context);
    } else {
      reduceNonBooleanData(userID, values, context);
    }
  }

可以看到这个reduce极其简单,根据是否具有偏好值将处理函数分为reduceBooleanData和reduceNonBooleanData进行处理,传入reduce的key和value,接下来我们先来看reduceBooleanData,也就是不具有偏好划分的数据,mahout是怎么处理的,跟进reduceBooleanData:

private void reduceBooleanData(VarLongWritable userID,
                                 Iterable<PrefAndSimilarityColumnWritable> values,
                                 Context context) throws IOException, InterruptedException {
    //输入格式:[userID , Iterable< pref , Vector<itemID , sim> >]

    /* having boolean data, each estimated preference can only be 1, * however we can't use this to rank the recommended items, * so we use the sum of similarities for that. */
    //这里英文注释已经说得很清楚了,什么意思呢?
    //就是说因为所有偏好评分我们都设置成了1
    //所以通过正常的根据相似度*偏好评分/相似度的加权平均的方式求得的值全部为1
    //这样就没有排名性了,怎么解决呢?
    //mahout对于这个问题,他通过将所有的相似度相加得到总的相似度来作为预测分数。
    //进而通过预测分数进行排名
    //这样做的从推荐系统的角度来看,他其实是根据两个item基于user的操作来划分预测排名的
    Iterator<PrefAndSimilarityColumnWritable> columns = values.iterator();
    Vector predictions = columns.next().getSimilarityColumn();
    while (columns.hasNext()) {
      //这里用到一个assign函数,函数实现比较复杂,篇幅限制就不再一一跟进
      //他是什么作用呢?其实从他的参数可以看出来
      //通过传进去的Functions对this(当前predictions)的vector和第一个参数Vector进行向量和
      //也就是对当前的predictions指向的vector和getSimilarityColumn得到的vector
      //根据相同的itemIndex将sim相似度值进行加和得到终的结果
      predictions.assign(columns.next().getSimilarityColumn(), Functions.PLUS);
    }

    //以上mahout通过对于每一个user,将与其相关的item的相似度加和,得到一个最终的预测
    //输出格式 [userID,Vector<itemID,prediction>]
    writeRecommendedItems(userID, predictions, context);
  }

总结一下,这个reduce输入格式为:
[userID , Iterable< 1 , Vector< itemID , sim> >] (因为是BooleanData , 所以pref是1)
然后我们将所有项根据userID 与 itemID加和,得到最后输出的相似度。

接下来我们看具有偏好值得reduceNonBooleanData,代码:

private void reduceNonBooleanData(VarLongWritable userID,
                                    Iterable<PrefAndSimilarityColumnWritable> values,
                                    Context context) throws IOException, InterruptedException {

    //输入格式:[userID , Iterable< pref , Vector<itemID , sim> >]
    /* each entry here is the sum in the numerator of the prediction formula */
    //用来做加权平均的分子向量
    Vector numerators = null;
    /* each entry here is the sum in the denominator of the prediction formula */
    //用来做加权平均的分母向量
    Vector denominators = null;
    /* each entry here is the number of similar items used in the prediction formula */
    //统计在同一个user相关的所有物品出现的次数。
    Vector numberOfSimilarItemsUsed = new RandomAccessSparseVector(Integer.MAX_VALUE, 100);

    //两个for循环,其实就是对value的iterator中的每一个的偏好向量中的每一维(每一行)进行计算
    for (PrefAndSimilarityColumnWritable prefAndSimilarityColumn : values) {
      Vector simColumn = prefAndSimilarityColumn.getSimilarityColumn();
      float prefValue = prefAndSimilarityColumn.getPrefValue();
      /* count the number of items used for each prediction */
      for (Element e : simColumn.nonZeroes()) {
        int itemIDIndex = e.index();
        numberOfSimilarItemsUsed.setQuick(itemIDIndex, numberOfSimilarItemsUsed.getQuick(itemIDIndex) + 1);
      }

      if (denominators == null) {
        denominators = simColumn.clone();
      } else {
        //这里计算关于本userID的所有itemID相同的所有item相似度和的绝对值,作为加权平均的分母
        denominators.assign(simColumn, Functions.PLUS_ABS);
      }

      //这里计算关于本userID的所有itemID相同的所有item相似度*prefvalue和的绝对值
      //作为加权平均的分子
      //这里的prefvalue是什么呢?其实就是上一个mapreduce变换以后省略的itemA的用户偏好值
      if (numerators == null) {
        numerators = simColumn.clone();
        if (prefValue != BOOLEAN_PREF_VALUE) {
          numerators.assign(Functions.MULT, prefValue);
        }
      } else {
        if (prefValue != BOOLEAN_PREF_VALUE) {
          simColumn.assign(Functions.MULT, prefValue);
        }
        numerators.assign(simColumn, Functions.PLUS);
      }

    }

    if (numerators == null) {
      return;
    }

    Vector recommendationVector = new RandomAccessSparseVector(Integer.MAX_VALUE, 100);
    for (Element element : numerators.nonZeroes()) {
      int itemIDIndex = element.index();
      /* preference estimations must be based on at least 2 datapoints */
      if (numberOfSimilarItemsUsed.getQuick(itemIDIndex) > 1) {
        /* compute normalized prediction */

        //可以看到在这里加权平均
        double prediction = element.get() / denominators.getQuick(itemIDIndex);
        recommendationVector.setQuick(itemIDIndex, prediction);
      }
    }

    //writeRecommendedItems这个函数的作用是:
    //对我们得到的预测分数排序,推荐出topN个(这个topN用户可以自定义)推荐item
    //然后将item的ID映射回原来输入ID
    writeRecommendedItems(userID, recommendationVector, context);
  }

这样推荐过程就介绍完了,鉴于代码明细和可视化效果不怎么好,以下我模拟了一个例子来演化一下本recommenderjob的过程:
具有偏好:

userID                              pref                           itemID      Sim
                                                                     1         0.2
                                     4                               2         0.5
                                                                     3         NaN
A                                   
                                                                     1         0.3
                                     5                               2         0.4
                                                                     4         NaN

可以看到上图就是经过map以后的基本格式,然后我们需要进行分数预测,那么根据之前的代码,我们这样计算:

itme1 = (0.2*4 + 0.3*5)/(0.2 + 0.3)
item2 = (0.5*4 + 0.4*5)/(0.5 + 0.4)
item3 = (NaN*4)/NaN
item3 = (NaN*5)/NaN

接下来我们看不具有偏好的布尔类型的数据。
不具有偏好(偏好值为1):

userID                              pref                           itemID      Sim
                                                                     1         0.2
                                     1                               2         0.5
                                                                     3         NaN
A                                   
                                                                     1         0.3
                                     1                               2         0.4
                                                                     4         NaN

可以看到上图就是经过map以后的基本格式,然后我们需要进行分数预测,那么根据之前的代码,我们这样计算:

itme1 = 0.2 + 0.3
item2 = 0.5 + 0.4
item3 = NaN
item3 = NaN

通过以上模拟我们大概知道了mahout是怎么预测推荐的,同时我们通过这个模拟也可以对之前的[itemA , < itemA , NaN >]即将每个item相对于自己的相似度设置为NaN做一个解释。
解释之前,我们先要引入一个问题,这个问题就是,通过以上的推荐,mahout如何做到不给user推荐他已经操作或者说评分过的item?经过一整套分析,我们并没有看到mahout在什么地方做过过滤操作,那么他是怎么是别的呢?
答案就在这个 [itemA , < itemA , NaN >]的处理动作(见上文),在第一个mapreduce中我们将每个item关于自己的item的相似度设置为NaN,这样在接下来的整合、变换中,每个相似度矩阵都保留着这个信息,这个信息从另一个角度说明了该矩阵是关于哪个物品的相似度信息。如上面的例子,我们可以看到第一个相似度矩阵是关于item3的而第二个相似度矩阵是关于item4的。
说了这么半天还是没有说为什么可以排除已经评分过的item。这里我们可以回忆这个转化:

[itemA ,(Vector<itemB , sim> , List<userID> , List<Pref>)]
-> [userID , (Pref , Vector<itemB , sim>)]

可以看到,是根据每个user对itemA的信息进行了拆分,拆分后的信息中并没有itemA,那么我们是丢失了itemA的ID信息吗?并不是,通过[itemA , < itemA , NaN >]的处理,Vector< itemB , sim>中依旧保留着itemA的ID信息,就是那个sim为NaN的那个,这样所有相对于当前userID所评分过的itemA的相似度全部都变成了NaN,这样我们接下来的计算中,所有user已经评分过的item项的相似度都变成了NaN,在预测分数的时候就会自动过滤,因为NaN/NaN = NaN。在计算预测评价的时候我们就直接过滤了这些我们已经打过分的项。

就这样,源于mahout的推荐系统基于hadoop的源码部分我就全部解析完了。接下来还会有一个总结,总体上看一下mahout的hadoop推荐系统做了些什么操作。

转载请注明出处:http://blog.csdn.net/Utopia_1919/article/details/51836903

你可能感兴趣的:(java,源码,hadoop,Mahout,推荐系统)