Mahout in Action 3

实践

通过一个过程间紧密衔接的实例,讲述如果在数据集上使用Mahout开发推荐系统. 首先选取一个方法,然后收集数据,评估结果,再多次重复这个过程.

生成推荐程序首先要做的就是分析所要用到的数据,并开始琢磨什么样的推荐算法才是适合的.

经过多重测试,本例中选择的最佳方案是:

  1. 基于用户的推荐程序
  2. 欧氏距离相似性对量
  3. 两个最近邻的邻域

引入特定域的信息

通常情况下,可以通过数据中额外的信息来改善推荐的质量.比如引入 性别(gender),可以基于性别定制一个ItemSimilarity,并力求避免推荐性别不当的用户.

采用一个定制的物品相似性度量

因为已经给定了档案的性别,可以仅仅基于性别建立一个简单的相似性度量.因为档案就是这里的物品,所以它在框架中应该是ItemSimilarity.

比如,认为两个男性或者两个女性的档案非常相似,并设置他们的相似度为1.假设男性和女性档案之间的相似度为-1.最后一对档案中一个或两个档案的性别为未知,则相似度为0.

public class GenderItemSimilarity implements ItemSimilarity {

  private final FastIDSet men;
  private final FastIDSet women;

  public GenderItemSimilarity(FastIDSet men, FastIDSet women) {
    this.men = men;
    this.women = women;
  }

  @Override
  public double itemSimilarity(long profileID1, long profileID2) {
    Boolean profile1IsMan = isMan(profileID1);
    if (profile1IsMan == null) {
      return 0.0;
    }
    Boolean profile2IsMan = isMan(profileID2);
    if (profile2IsMan == null) {
      return 0.0;
    }
    return profile1IsMan == profile2IsMan ? 1.0 : -1.0;
  }

  @Override
  public double[] itemSimilarities(long itemID1, long[] itemID2s) {
    double[] result = new double[itemID2s.length];
    for (int i = 0; i < itemID2s.length; i++) {
      result[i] = itemSimilarity(itemID1, itemID2s[i]);
    }
    return result;
  }

  @Override
  public long[] allSimilarItemIDs(long itemID) {
    throw new UnsupportedOperationException();
  }

  private Boolean isMan(long profileID) {
    if (men.contains(profileID)) {
      return Boolean.TRUE;
    }
    if (women.contains(profileID)) {
      return Boolean.FALSE;
    }
    return null;
  }

  @Override
  public void refresh(Collection<Refreshable> alreadyRefreshed) {
    // do nothing
  }
}

这个ItemSimilarity度量可以和标准的GenericItemBasedRecommender一起使用,但是本例中测试的效果并不理想,表示在当前场景下,性别并不能作为有效的判断依据,或者有别的相关的信息,比如兴趣,年龄等.本例的意义在于,它提供了一种结合自身物品信息的手段,在实际的推荐系统中非常实用,并且这种计算的运行速度相当快.

基于内容进行推荐

上例中使用基于内容进行推荐,他对物品相似度的定义不是基于用户的偏好,而是基于物品本身的属性.

Mahout中并不提供基于内容的推荐的实现,但是却支持扩展并提供API,允许在框架中写代码来部署这种实现.

对于基于用户偏好的协同过滤来书这是很好的补充,可以根据自身对物品的知识来强化现有的用户偏好数据,进而生成更好的推荐结果.

利用IDRescorer修改推荐结果

在Recommender.recommend()方法中有一个类型为IDRescorer并用final修饰的可选参数.可以不调用recommend(long userID, int howMany),而是调用recommend(long userID, int howMany, IDRescorer rescorer).

该参数可以根据某种逻辑将推荐引擎中的某些值修改为其他值,也可以在某个过程中将一个实体排除出去.例如IDRescorer可以任意修改recommender对一个物品的估计偏好值,或者将一个物品从考虑范围内移除掉.

下面推荐图书的例子中,将悬疑类型的小说的估计偏好值提高一些,同时过滤掉缺货的图书:

public class GenreRescorer implements IDRescorer {

  private final Genre currentGenre;

  public GenreRescorer(Genre currentGenre) {
    this.currentGenre = currentGenre;
  }

  @Override
  public double rescore(long itemID, double originalScore) {
    Book book = BookManager.lookupBook(itemID);
    if (book.getGenre().equals(currentGenre)) {     // 如果图书类型为悬疑
      return originalScore * 1.2;                   // 增加20%的评分值
    }
    return originalScore;                           // 否则返回原始评分值
  }

  @Override
  public boolean isFiltered(long itemID) {
    Book book = BookManager.lookupBook(itemID);
    return book.isOutOfStock();                     // 过滤掉缺货的图书
  }
}

在IDRescorer中引入性别

对于在乎性别的用户,使用IDRescorer同样可以实现对物品或用户档案的过滤.可以通过检查已经评价过的档案的性别,来猜测用户偏好的性别.然后就可以过滤掉与偏好相反的性别的档案.

public class GenderRescorer implements IDRescorer {
                                                                    // 定义数据缓存
  private final FastIDSet men;                                      // 定义男性文档缓存
  private final FastIDSet women;                                    // 定义女性文档缓存
  private final FastIDSet usersRateMoreMen;                         // 定义对男性文档评分过多的用户
  private final FastIDSet usersRateLessMen;                         // 定于对女性文档评分过多的用户
  private final boolean filterMen;

  public GenderRescorer(FastIDSet men,
                        FastIDSet women,
                        FastIDSet usersRateMoreMen,
                        FastIDSet usersRateLessMen,
                        long userID, DataModel model)
      throws TasteException {
    this.men = men;
    this.women = women;
    this.usersRateMoreMen = usersRateMoreMen;
    this.usersRateLessMen = usersRateLessMen;
    this.filterMen = ratesMoreMen(userID, model);
  }

  public static FastIDSet[] parseMenWomen(File genderFile)          // 从文档性别文件中解析所有文档的性别属性
      throws IOException {
    FastIDSet men = new FastIDSet(50000);                           // 初始化缓存
    FastIDSet women = new FastIDSet(50000);
    for (String line : new FileLineIterable(genderFile)) {
      int comma = line.indexOf(',');
      char gender = line.charAt(comma + 1);
      if (gender == 'U') {
        continue;
      }
      long profileID = Long.parseLong(line.substring(0, comma));
      if (gender == 'M') {                                          // 男性文档
        men.add(profileID);
      } else {                                                      // 女性文档
        women.add(profileID);               
      }
    }
    men.rehash();                                                   // 快速访问的重新优化
    women.rehash();
    return new FastIDSet[] { men, women };
  }

  private boolean ratesMoreMen(long userID, DataModel model)
      throws TasteException {
    if (usersRateMoreMen.contains(userID)) {                        // 首先从缓存中都去取该用户的性别偏好,如果已经对该用户进行过判断,则直接返回结果
      return true;
    }
    if (usersRateLessMen.contains(userID)) {
      return false;
    }
    PreferenceArray prefs = model.getPreferencesFromUser(userID);   // 如果尚未判断该用户性别偏好,根据用户ID取出该用户所有偏好数据
    int menCount = 0;                                               // 初始化男性文档和女性文档的数量
    int womenCount = 0;
    for (int i = 0; i < prefs.length(); i++) {                      // 遍历所有偏好数据
      long profileID = prefs.get(i).getItemID();                    // 取出文档ID
      if (men.contains(profileID)) {                                // 根据文档ID在已经进行过性别分类的缓存中取得该文档的性别
        menCount++;                                                 // 根据所有文档的性别,分别对该用户的所有文档进行性别计数
      } else if (women.contains(profileID)) {
        womenCount++;
      }
    }
    boolean ratesMoreMen = menCount > womenCount;                   // 如果男性计数多于女性
    if (ratesMoreMen) {
      usersRateMoreMen.add(userID);                                 // 对判断结果进行缓存,以便后续直接使用
    } else {
      usersRateLessMen.add(userID);
    }
    return ratesMoreMen;                                            // 返回判断结果
  }

  @Override
  public double rescore(long profileID, double originalScore) {     // 根据性别偏好判断结果和当前文档的性别判断
    return isFiltered(profileID) ? Double.NaN : originalScore;      // 将排除的文档偏好值记为NaN,否则不变
  }

  @Override
  public boolean isFiltered(long profileID) {
    return filterMen ? men.contains(profileID) : women.contains(profileID);
  }
}

封装一个定制的推荐程序

在现有的推荐引擎中封装自定义的IDRescorer实现.

public class LibimsetiRecommender implements Recommender {

  private final Recommender delegate;
  private final DataModel model;
  private final FastIDSet men;
  private final FastIDSet women;
  private final FastIDSet usersRateMoreMen;
  private final FastIDSet usersRateLessMen;

  public LibimsetiRecommender() throws TasteException, IOException {            // 初始化DataModel
    this(new FileDataModel(readResourceToTempFile("ratings.dat")));             // 生产环境不需要定义readResourceToTempFile()
  }

  public LibimsetiRecommender(DataModel model)
      throws TasteException, IOException {
    UserSimilarity similarity = new EuclideanDistanceSimilarity(model);         // 构建基于用户的推荐系统
    UserNeighborhood neighborhood =
        new NearestNUserNeighborhood(2, similarity, model);
    delegate =
        new GenericUserBasedRecommender(model, neighborhood, similarity);
    this.model = model;                                                         // 初始化档案性别数据缓存
    FastIDSet[] menWomen = GenderRescorer.parseMenWomen(readResourceToTempFile("gender.dat"));
    men = menWomen[0];
    women = menWomen[1];
    usersRateMoreMen = new FastIDSet(50000);
    usersRateLessMen = new FastIDSet(50000);
  }

  @Override
  public List<RecommendedItem> recommend(long userID, int howMany)
      throws TasteException {
    IDRescorer rescorer = new GenderRescorer(                                   // 初始化GenderRescorer并在所有的推荐上使用
        men, women, usersRateMoreMen, usersRateLessMen, userID, model);
    return delegate.recommend(userID, howMany, rescorer);
  }

  @Override
  public List<RecommendedItem> recommend(long userID,
                                         int howMany,
                                         IDRescorer rescorer)
      throws TasteException {
    return delegate.recommend(userID, howMany, rescorer);
  }

  @Override
  public float estimatePreference(long userID, long itemID)             // 根据性别偏好对文档评分进行修改
      throws TasteException {
    IDRescorer rescorer = new GenderRescorer(
        men, women, usersRateMoreMen, usersRateLessMen, userID, model);
    return (float) rescorer.rescore(
        itemID, delegate.estimatePreference(userID, itemID));
  }

  @Override
  public void setPreference(long userID, long itemID, float value)
      throws TasteException {
    delegate.setPreference(userID, itemID, value);                      // 委托给底层的推荐程序
  }

  @Override
  public void removePreference(long userID, long itemID)
      throws TasteException {
    delegate.removePreference(userID, itemID);
  }

  @Override
  public DataModel getDataModel() {
    return delegate.getDataModel();
  }

  @Override
  public void refresh(Collection<Refreshable> alreadyRefreshed) {
    delegate.refresh(alreadyRefreshed);
  }

  static File readResourceToTempFile(String resourceName) throws IOException {
    String absoluteResource = resourceName.startsWith("/") ? resourceName : '/' + resourceName;
    InputSupplier<? extends InputStream> inSupplier;
    try {
      URL resourceURL = Resources.getResource(LibimsetiRecommender.class, absoluteResource);
      inSupplier = Resources.newInputStreamSupplier(resourceURL);
    } catch (IllegalArgumentException iae) {
      File resourceFile = new File(resourceName);
      inSupplier = Files.newInputStreamSupplier(resourceFile);
    }
    File tempFile = File.createTempFile("taste", null);
    tempFile.deleteOnExit();
    Files.copy(inSupplier, tempFile);
    return tempFile;
  }

}

为匿名用户做推荐

未登陆的用户则会面临冷启动问题,一种解决方案是不做对应的个性化推荐,比如按照一个预定义的产品列表进行推荐.或者登陆是赋予一个用户ID用于跟踪,但这会带来用户爆发式增长并且这些用户的信息量会比较少.

合理的方案是生成临时用户并将所有的匿名用户当做一个用户.

利用PlusAnonymousUserDataModel处理临时用户

PlusAnonymousUserDataModel类提供了一个增加临时用户到DataModel的方法.真实的底层用户并不会增加这些用户,也不会知道他们的存在.

但是在临时用户的处理上有一个特殊地方,它每次只处理一个此类用户的偏好值.基于这个类的Recommender同样必须每次只处理一个用户.

下面的例子是对上面例子的扩展,采用了一个可以向匿名用户推荐的方法,但是取偏好值作为输入,而不是用户ID:

public class LibimsetiWithAnonymousRecommender extends LibimsetiRecommender {

  private final PlusAnonymousUserDataModel plusAnonymousModel;

  public LibimsetiWithAnonymousRecommender()
      throws TasteException, IOException {
    this(new FileDataModel(readResourceToTempFile("ratings.dat")));
  }

  public LibimsetiWithAnonymousRecommender(DataModel model)
      throws TasteException, IOException {
    super(new PlusAnonymousUserDataModel(model));               // 封装底层的DataModel
    plusAnonymousModel =
        (PlusAnonymousUserDataModel) getDataModel();
  }

  public synchronized List<RecommendedItem> recommend(          // 使用同步
      PreferenceArray anonymousUserPrefs, int howMany)
      throws TasteException {
    plusAnonymousModel.setTempPrefs(anonymousUserPrefs);
    List<RecommendedItem> recommendations =
        recommend(PlusAnonymousUserDataModel.TEMP_USER_ID, howMany, null);  // 设置匿名用户ID
    plusAnonymousModel.clearTempPrefs();
    return recommendations;
  }

  public static void main(String[] args) throws Exception {
    PreferenceArray anonymousPrefs =
        new GenericUserPreferenceArray(3);                      // 存储匿名用户的偏好值
    anonymousPrefs.setUserID(0,
        PlusAnonymousUserDataModel.TEMP_USER_ID);
    anonymousPrefs.setItemID(0, 123L);
    anonymousPrefs.setValue(0, 1.0f);
    anonymousPrefs.setItemID(1, 123L);
    anonymousPrefs.setValue(1, 3.0f);
    anonymousPrefs.setItemID(2, 123L);
    anonymousPrefs.setValue(2, 2.0f);
    LibimsetiWithAnonymousRecommender recommender =
        new LibimsetiWithAnonymousRecommender();
    List<RecommendedItem> recommendations =
        recommender.recommend(anonymousPrefs, 10);
    System.out.println(recommendations);
  }

}

聚合匿名用户

另一种解决方案是,将所有匿名用户视为单一用户,这会简化操作.不再单独跟踪潜在的用户并单独存储他们的浏览历史,将所有这样的用户看做是一个大的临时用户,但这依赖于一种假设,即这些用户的行为是相似的.

推荐结果可以进行周期性的计算,避免匿名用户总是看到同样的推荐,效果比任何推荐不做要好.