前言
在阿里云推荐引擎的系统设置里,在线API获取推荐结果(以下简称API)和实时算法是相互并列而又可以交互的两条线。
就“并列”而言,是因为API是由后端系统在用户进入一个需要展现推荐列表的前端页面时进行调用,而实时算法是由客户定义的用户所有有意义行为(包括进入推荐页面)、也即日志触发,一般来说用户的行为日志量会远大于API请求量。从系统设置上来说,可以看到在线API的组成算法和实时算法处于两个不同的配置页。
而之所以说“可以交互”,是由于用户的实时行为日志可以有效地指导API进行调整,从系统设置上来说,就是通过在线数据共享读写完成。这里举两个小例子:
1)一个在线购物网站,假设推荐引擎通过离线建模,已了解到用户的兴趣集中在男装、电子产品、运动用品三个类目,而这时候实时行为告诉我们当前用户正在大量浏览运动器械,那么推荐系统就可以有导向性地将推荐结果中隶属于运动用品的项目在展现位中适当提前。
2)一个在线短视频APP,业务规则告诉我们客户基本不会看相同的视频两次及以上,而实时行为告诉我们该用户当前已经看过视频A,那么推荐系统就需要及时地在推荐结果中把视频A去掉,以免造成重要资源位的闲置和浪费。
本文将通过一个例子指导您“优雅地”实现“看过不再推荐”的业务逻辑。
日志端实时修正算法
实时修正算法的参考文档在https://help.aliyun.com/document_detail/32453.html ,里直接给出代码。
function(logs) {
var callback = this;
// 定义针对user_id的在线KV存储中查询key组装格式
var getKey = function(user_id) {
return "U_BHV_HIST" + "#" + user_id.toString();
}
var arr2str = function(arr) {
return arr.join('\001');
}
var str2arr = function(str) {
if (str === null) return [];
return str.split('\001');
}
// 对两个数组做交集+去重
var join = function(arr1, arr2) {
var dict = {};
arr1.concat(arr2).forEach(function(e) {
dict[e] = 1;
});
return Object.keys(dict);
}
var parseLog = function(log) {
// 实时日志过来如果已经是JSON格式,直接返回即可
if (typeof(log) === 'object')
return log;
try {
// 尝试使用json格式解析
return JSON.parse(log);
} catch(err) {
// json解析失败,按kv解析
return require('querystring').parse(log);
}
}
// 解析本批日志生成user对item的行为记录
var user2item = {};
logs.forEach(function(log) {
var logobj = parseLog(log);
var user_id = logobj.user_id, item_id = logobj.item_id;
user2item[user_id] = user2item[user_id] || {};
user2item[user_id][item_id] = 1;
});
// this_user_bhv记录的是本批日志中用户到itemset的行为,重复item已去重
var this_user_bhv = {};
for (var user in user2item)
this_user_bhv[user] = Object.keys(user2item[user]);
// 请求获取用户实时历史行为
var diffusers = Object.keys(user2item);
var DataReader = {
OTHERS: diffusers.map(function(u) {
return getKey(u);
});
};
console.log('DataReader', JSON.stringify(DataReader));
TOOLBOX.read(DataReader, function(err, data) {
if (err) return callback(err);
var kv = {};
data.OTHERS.forEach(function(elem, idx) {
var user_id = diffusers[idx];
// 从在线存储字符串中解析出历史行为
var last_user_bhv = str2arr(elem);
// 合并历史行为和本批行为
var joined = join(last_user_bhv, this_user_bhv[user_id]);
// 转化成字符串以写回在线存储
kv[getKey(user_id)] = arr2str(joined);
});
var DataWriter = {OTHERS: kv};
console.log('DataWriter', JSON.stringify(DataWriter));
TOOLBOX.write(DataWriter, callback);
});
}
API端在线算法
在线算法的参考文档在https://help.aliyun.com/document_detail/30394.html ,以下直接给出代码。
function() {
var callback = this;
// reclist是上游节点传下来的推荐列表,可能是经过一系列复杂逻辑之后产生的推荐列表
var reclist = arguments[0][0];
console.error('before', JSON.stringify(reclist));
// 定义针对user_id的在线KV存储中查询key组装格式
var getKey = function(user_id) {
return "U_BHV_HIST" + "#" + user_id.toString();
}
var str2arr = function(str) {
if (str === null) return [];
return str.split('\001');
}
// 从arr1中去掉arr2中的所有元素
var filter = function(arr1, arr2) {
var dict = [];
arr1.forEach(function(e) { dict[e] = 1; });
arr2.forEach(function(e) { delete dict[e]; });
return Object.keys(dict);
}
TOOLBOX.getKeyValues(CTX, [getKey(CTX.REC_REQ.user_id)], function(err, value) {
if (err) return callback(err);
// user_rt_bhv即用户实时已经有过行为的item集合
var user_rt_bhv = str2arr(value);
// 从原始列表中去除
var filtered = filter(reclist, user_rt_bhv);
console.log('after', JSON.stringify(filtered));
return callback(null, filtered);
});
}
请注意以上两段代码中getKey和str2arr方法的一致性。
另外一点值得说明的是,由于获取用户实时行为历史的操作在每一次API请求中都会执行,而这是通过网络请求完成的,势必有一定时延(尽管很小),那么一种更加合理的做法是,使用在线SDK的共享缓存机制(参考https://help.aliyun.com/document_detail/30394.html 里的“高级用法-使用共享缓存”),执行先加载,在在线流程的第一层中并发完成。具体的实现,作者将在下一篇博文中予以详细介绍。
总结
之所以本文标题修饰以“优雅”二字,作者私认为是由于上面的做法实现了API请求和实时日志两条线的解耦,二者分工更加明确,实时日志为API提供资源,API一端读取资源并决定最终的推荐逻辑,业务逻辑更加清晰。
作者在分析一些已有客户提交的代码时发现,有一些做法是简单地从用户的个性化推荐结果中进行实时过滤,实现上来说就是在实时修正算法中直接修改用户的个性化结果。这个做法的一个比较明显的缺陷在于,实际应用中决定最终给用户展现哪些推荐结果的逻辑可能是非常复杂的,除了个性化结果外,可能还会有一些半个性化结果(如针对用户标签的推荐)、运营规则(如人工强制置顶)、各种抄底策略(即用户无个性化推荐结果的情况下展现一些非个性化推荐,比如TopN规则),那么按照以上做法势必会导致过滤不完全的情况存在。