实践
通过一个过程间紧密衔接的实例,讲述如果在数据集上使用Mahout开发推荐系统. 首先选取一个方法,然后收集数据,评估结果,再多次重复这个过程.
生成推荐程序首先要做的就是分析所要用到的数据,并开始琢磨什么样的推荐算法才是适合的.
经过多重测试,本例中选择的最佳方案是:
- 基于用户的推荐程序
- 欧氏距离相似性对量
- 两个最近邻的邻域
引入特定域的信息
通常情况下,可以通过数据中额外的信息来改善推荐的质量.比如引入 性别(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);
}
}
聚合匿名用户
另一种解决方案是,将所有匿名用户视为单一用户,这会简化操作.不再单独跟踪潜在的用户并单独存储他们的浏览历史,将所有这样的用户看做是一个大的临时用户,但这依赖于一种假设,即这些用户的行为是相似的.
推荐结果可以进行周期性的计算,避免匿名用户总是看到同样的推荐,效果比任何推荐不做要好.