让我们把时间推回到28年前那个风雨交加的夜晚,在一个小破屋里,几个老男人捣鼓出了人类历史上第一个推荐系统——Tapestry[1]
这个成立于1970年的小破屋并不简单,在这里诞生了很多有趣的玩意,比如激光打印机、图形用户界面、以太网、数字视频、文字处理等等。今天要介绍的推荐系统,也许是他们觉得最微不足道的发明之一。
这个小破屋的全名是Xerox Palo Alto Research Center,简称PARC,翻译成中文是“湿了公司怕啦熬秃研究中心”,听名字就很geek.
1992年,PARC的Goldberg、Nichols、Oki、Terry一行四人在《Communications of the ACM》上发表了一篇论文《Using Collaborative Filtering to Weave an Information Tapestry》,从此开启了推荐系统28年的历史行程。
下面主要介绍这篇论文:
Motivation
上世纪90年代,随着电子邮件的广泛普及,美帝的人们开始被信息爆炸的问题困扰。为了解决这个问题,Tapestry应运而生。Tapestry允许你基于文档的内容以及其他用户对文档的反应来进行搜索。比如你可以搜索包含“松果弹抖闪电鞭”并且隔壁老王看了觉得很棒
的内容。
这种做法基于这样的信念:人的参与可以使信息过滤更高效。
系统架构
下图是Tapestry的架构图,主要包括8个组件,分别为
- Indexer
- Document Store
- Annotation Store
- Filter
- Little box
- Remailer
- Appraiser
- Reader/Browser
后面会逐一进行介绍。
Indexer
Indexer主要是从外部源读取文档、对文档进行解析、提取相关的字段、创建相关的索引并存入Document Store.
Document Store
提供文档及索引长期持久存储的数据库,限制为append-only,这个约束主要是为了连续查询的效率考虑。详见他们的另一篇论文Continuous Queries over Append-Only Databases[4]
Annotation Store
主要存储用户对文档的标注,也是append-only
之所以将Annotation与Document分开存储,主要是出于以下两个考虑:
- 由于过滤器要求文档是不可变的(immutable),annotation是在文档之后到达,将其作为文档的字段存储会违反文档的不可变约束。
- Annotation本身的结果也可能会比较复杂,将其独立存储并引用对应的文档是更简洁的方案。
Filterer
执行用户提供的查询,将符合要求的文档放入用户的Little Box中。其中过滤器使用他们专门为这个系统提出的TQL语言编写,后面会有详细介绍。
Little Box
存放用户感兴趣的文档,每个用户都有一个专属小盒子,文档由过滤执行器放入,由Reader删除。
Remailer
周期性地将用户Little Box中的文档通过邮件推送给用户
Appraiser
根据用户的个性化设定为小盒子中的文档设定优先级及分类等
Filter只是一个二元筛选,即一个文档要么通过过滤器,要么被过滤掉
Appraiser可以根据文档的重要性为其设定优先级,例如会议通知比促销信息有更高的优先级。Appraiser还需要支持优先级的调整,比如会议更新通知(如更换会议室)会使原来的会议通知优先权降低,但不能删掉,因为原来的邮件里可能有更新邮件中没有的信息。
将Appraiser和Filterer分开主要是基于性能的考虑,如果直接对所有文档使用Appraiser进行打分,性能将会非常低下。因此先使用Filterer对文档进行二元过滤,只有很少一部分文档会通过过滤器而被放入用户的小盒子中,然后再对小盒子中的文档使用Appraiser进行打分将会大大降低计算量。
Tapestry中的Appraiser的作用类似于现代推荐系统当中的rank阶段,与rank不同的是,Appraiser是运行在客户端的。
Appraiser在完成对LittleBox中的文档打分之后,会记录下这些文档的id,然后将Little Box中的对应文档删除。
Reader/Browser
提供基于客户端或者浏览器的UI,用户添加、删除、编辑过滤器,获取和展示little box中的新文档,将文档归入不同的文件夹、对文档进行标注以及提供临时的查询功能。
对于不愿与使用Tapestry专用客户端或者浏览器界面的用户,可以直接使用他们的邮箱与Tapestry交互,上面提到的Remailer会定期地将用户感兴趣的文档推送到用户的邮箱中,用户也可以通过邮件向Tapestry系统发送相关指令,例如新增过滤器,添加标注,运行临时查询等等。
临时查询(ad hoc queries)的功能有点类似于Filter,区别在于Filter设置后会周期性地执行,而临时查询是用户临时起意的查询,一般只执行一次。
由于Tapestry提供了文档的持久化存储,客户端在删除对应的文档后,依然可以通过构建一个临时查询来从Document Store中检索出需要的文档。
临时查询的一个好处时可以利用private fields,例如文档是否已读、文档被放在哪个目录下等。这些信息只存储在客户端,因而在服务端周期运行的Filter无法利用这些信息,而临时查询既可以利用服务端的文档信息,也可以利用客户端保存的这些私有信息。
TQL(Tepastry Query Language)
motivation
为什么不用SQL?
SQL的优点是通用,而且使用SQL可以简化系统的实现。
但SQL的最大缺点是复杂,毕竟这个系统是给普通用户(而非程序员)用的。
程序员的基本职业操守就是“方便用户,恶心自己”。况且这个系统支持的功能很简单,因此他们决定自己搞一个专用且简单的DSL
有些系统设计者/开发者可能会抱怨:
“这玩意已经这么简单了你告诉我你**不会用?”
这其实是一种知识诅咒(The Curse of Knowledge),有过辅导孩子写作业的经历之后就会深有体会。
使用简化版的查询语言可以降低系统的使用门槛。时至今日,依然有系统这样做,例如中国国家知识产权局开发的专利检索与分析系统, 他们的查询查询语句如下图所示:
我敢打赌现在有相当一部分程序员其实SQL都写不利索(特别是在他们用了MyBatis之后),就更别说普通用户了。但是上面这种简化的查询语句就显得老少咸宜了。
除了学习成本高之外,SQL还有一个问题就是它的Schema相对比较固定,而文档的结构不太固定,又有各种集合查询之类的骚操作。因而想用SQL编写这样的查询显然不太优雅。当然如果那会有MongoDB的话这就不是个问题了,可惜MongoDB直到15年后的2007年才诞生,即便是我们现在常用的关系型数据库mysql也是直到1996年才发布了1.0版本,而当时却是茹毛饮血的1992年.
值得注意的一点是,虽然TQL已经很简单了,但这其实还是提供给高级用户的功能,大部分普通用户其实是不会用的。普通用户需要的是更加傻瓜式的操作,最好点点点就能完成任务。为此Tapestry中提供了很多常用的预定义查询,用户只要提供相应的参数就能轻而易举地构建出想要的查询语句。这一点同样在专利检索与分析系统中得到了体现。
基本语法
TQL的语法基本类似于一阶谓词,另外还增加了对集合的操作。
TQL其实是一个布尔表达式,主要包括以下三种元素:
- 原子表达式(atomic formulas)
- 布尔操作符 (boolean operators)
- 限定词(qualifiers)
最简单的TQL查询是一个原子表达式,包含=,>,<,LIKE
等算子
比如下面的表达式会筛选出主题为马保国的内容
m.subject = '马保国'
稍微复杂的一点就是原子表达式加上布尔操作符,例如:
m.sender = '一个朋友'
AND m.subject LIKE '%马老师发生什么事了%'
TQL还支持集合查询:
m.to = {'英国大力士', LIKE ‘%小伙子%’}
协同查询需要用到量化变量,例如下面的查询找到所有得到马老师回复的文档
EXISTS (m1: m1.send = '马老师'
AND m1.in-reply-to = {m})
最后,一个用户的query可以引用另外一个用户的query,
例如下面的查询可以找到隔壁老王的funnyQuery
中包含‘松果糖豆闪电鞭’的文档
m IN OldWang.funnyQuery
AND m.words = {'松果糖豆闪电鞭'}
Annotations
包含annotation的查询例子如下:
a.type = 'vote'
AND a.owner = 'weiser'
AND a.msg = m
这个查询会找出所有被weiser这个人投过票的文档
注意,这个系统最开始是针对邮件系统的,所以用的字段是msg,而不是我们现在常见的document
上面这个查询其实暗含了上面提到的EXISTS关键字,完整写法是:
EXISTS (a: a.type = 'vote'
AND a.owner = 'weiser'
AND a.msg = m)
下面这个查询会找出优先级为10的文档:
a.type = 'priority'
AND a.value = 10
AND a.msg = 'm'
过滤查询
对于用户设置的过滤器,最直观的方法是定时执行所有过滤器查询,不过有两个问题需要考虑
- 多查
- 漏查
多查:当前查询的结果包含了上次查询结果,需要将这部分重复的文档剔除
关于漏查询的问题举个栗子:
假设定时任务每小时执行一次,现在要查询所有未被回复的文档,某文档 m 在 08:15到达,08::45被回复
如果定时任务是每个整点执行,那么结果中永远不会包含m,因为08:00执行查询的时候还没有文档m,09:00执行查询的时候文档m被回复了已经不满足条件了(下图a)
如果定时任务是每个半点执行,那么结果中就会包含m,因为08:30执行查询的时候m满足条件。(下图b)
这相当于让查询变成了薛定谔的猫,就像你在微信里发出的每一条消息都是薛定谔的消息,你永远无法知道这条消息是否已被微信屏蔽了。
为了解决这种问题,Tapestry要求查询遵循连续语义(continuous semantics)
连续语义
假设 Q ( t ) Q(t) Q(t)表示在时间 t t t 执行查询 Q Q Q 的结果,那么在连续语义下在 t t t 时刻执行查询 Q Q Q 的结果为 ⋃ s ≤ t Q ( s ) \bigcup_{s\le t}Q(s) ⋃s≤tQ(s)
这种语义虽然避免了漏查,但是对于上面的查询“找出所有未被回复的文档”依然无能为力。因为所有的文档在刚到达时都是未被回复的,按照这种语义将会返回所有文档。
这显然不是用户想要的!
不过问题不在于连续语义,而在于查询本身。
用户的真实意图既不可能是查询所有“未在第一时间回复”的文档(包含所有文档),也不可能是查询“永远未被回复的文档”,因为要判断一个文档是否永远未被回复,系统就需要永远的等下去以探究竟。不过系统只要等待一个较短的时间就能获取后者的近似结果,因为绝大部分邮件都会在一个较短的时间内被回复,否则将永远不会被回复。
因此,一个更有意义的查询应该类似于 “找出未在两周内被回复的文档”。
当然如果用户就是要查询“当前未被回复”的文档怎么办呢?毕竟用户是大爷嘛。
显然这种查询并不适合作为filter query被周期性执行的。不过用户可以选择使用前面提到的ad hoc query进行这样的查询。
那么连续语义如何实现呢?
显然按照连续语义的定义那样在每一个时刻去执行查询是不可能的。(也许量子计算机会给我们带来惊喜)
他们在一篇更早的论文[4]中提供了一种在append-only的数据库上更高效地实现连续语义的方法。
高效地实现连续语义的关键在于下面这样一个事实:
只要一个查询的结果集随时间不减,那么只要周期性地执行这个查询就能实现连续语义。
这也就解释了前面提到的查询“找出没有被回复的邮件”在被周期执行时不能满足连续语义了,因为当一个邮件被回复,它就不会再出现在结果集中。
同时也解释了为什么“未在两周内被回复”这个查询在周期执行时可以满足连续语义,因为“未在两周内被回复”这个事实一旦成立,就永远不会被时间改变了。
基于以上观察,Tapestry通过对用户的filter query进行两阶段改写以实现连续语义:
两阶段改写
阶段1:改写为单调形式
阶段2:改写为增量模式
单调形式
结果集随时间不减的查询称为单调查询(monotone query), 单调查询满足:
Q
M
(
t
1
)
⊆
Q
M
(
t
2
)
,
t
1
<
t
2
Q^M(t_1)\subseteq Q^M(t_2), t_1<t_2
QM(t1)⊆QM(t2),t1<t2
这种查询在时刻
t
t
t 执行时会返回当前及过去满足原始查询的所有文档,即
Q
M
(
t
)
=
⋃
s
≤
t
Q
(
s
)
Q^M(t)=\bigcup_{s\le t}Q(s)
QM(t)=⋃s≤tQ(s)
假设同样的过滤查询在时间
τ
\tau
τ 已经执行过一次,结果集为
Q
M
(
τ
)
Q^M(\tau)
QM(τ)
因为
Q
M
(
τ
)
Q^M(\tau)
QM(τ)已经返回给用户了,因此当前查询只需要返回
Q
M
(
t
)
−
Q
M
(
τ
)
Q^M(t)-Q^M(\tau)
QM(t)−QM(τ) 即可
直接计算
Q
M
(
t
)
−
Q
M
(
τ
)
Q^M(t)-Q^M(\tau)
QM(t)−QM(τ) 是低效的,因此需要阶段2的改写,将查询改为增量形式
增量形式
这一阶段主要是将查询改为 Q I ( t , τ ) Q^I(t,\tau) QI(t,τ), 作为 Q M ( t ) − Q M ( τ ) Q^M(t)-Q^M(\tau) QM(t)−QM(τ) 的近似。可以看出经过两阶段改写后的查询,其执行周期只会影响每个批次的结果集,而不会影响整体的结果集,这就解决了前面提到了“薛定谔的查询”问题。
示例
例如对于查询
m.sender = 'Joe'
这一查询已经是单调形式(发件人永远不会随着时间改变),因此无需阶段1的改写,直接进行阶段2的改写,将其改写为以下形式:
m.sender = 'Joe'
AND (m.ts > τ AND m.ts ≤ t)
其中 ts
是每个文档到达的时间戳, τ
是上次执行的时间,t
是当前执行的时间
我们再来看一个复杂一点的例子,对于查询“两周前的还未被回复的bug”
m.to = 'BugReports'
AND m.ts + [2 weeks] < now()
AND NOT EXISTS (
mreply: mreply.in_reply_to = {m}
)
这个查询不是单调的,原因是回复状态会随着时间改变,首先进行阶段1的改写,将其改写为单调形式:
m.to = 'BugReports'
AND m.ts + [2 weeks] < now()
AND NOT EXISTS (
mreply: mreply.in_reply_to = {m}
AND mreply.ts < m.ts + [2 weeks]
)
这个查询表示“未在两周内被回复的bug”,这个查询就是单调的了。
值得注意的一点是,为了满足连续语义,这个改写后的查询与原始查询的意义略有差别。这一点在前面已经有过讨论。
然后这个查询还要经过阶段2的改写,将其改写为增量形式:
m.to = 'BugReports'
AND m.ts + [2 weeks] > τ
AND m.ts + [2 weeks] ≤ t
AND NOT EXISTS (
mreply: mreply.in_reply_to = {m}
AND mreply.ts < m.ts + [2 weeks]
)
整体流程
当用户通过TQL语言编写了过滤查询之后,Tapestry对用户的原始查询进行上述的两阶段改写使其满足连续语义,然后周期性的执行这些过滤查询,一旦某个文档满足了某个过滤器的条件(比如被添加了特定的annotation),这个文档就会被推送给用户。
这个过程的伪代码如下:
系统实现
存储模块
由于没有非关系型数据库可用,只能强行把复杂的数据结构塞进关系型数据库Sybase
文档的公共字段被放入一张表,每个文档占一行
文档的其他字段被放入另外一张表,每个文档可能会占多行
集合单独存放在一张表中,集合中的每个value占一行
annotation类似,也需要多张表来存储
从这里可以看出,让用户用SQL去查询这么复杂的表结果简直是失了智,因此Tapestry才决定引入TQL以屏蔽数据结构的复杂性
Tapestry中文档的存储形式为append-only,即不允许删除文档,原因是如果文档可以被删除,那同一个query,在文档删除前和删除后查询,就会得到不同的结果,这样就不符合连续语义了。
索引模块
Indexer负责理解给定的文档,从中抽取相关的字段塞入数据库中。
每种特定的文档对应一种索引实现,增加一种新的文档类型,只需要增加一种新的索引实现即可。到这里我们不得不惊叹,1992年的系统设计者竟然已经懂得了设计模式的开闭原则
以NetNews消息为例,对应的索引程序会将消息头中字段全部转换为对应的Tapestry字段,然后将正文中的词去掉停用词之后全部塞入words
字段。并且单词在被加入索引之前会做词干提取和词形还原处理,比如ran会被处理为run.
TQL2SQL translator
这个模块的功能其实就是把TQL“编译”为可以直接在Sybase上执行的SQL语句。
对于临时查询,不经改写直接转化为sql
对于filter中的查询,先进行前面介绍的两阶段改写,然后再转化为SQL,并且保存为Sybase的存储过程。
下图是一个TQL查询转为SQL查询的示例,可以看出TQL要比SQL简单很多:
通过创建合适的数据库索引和利用优秀的查询优化器,可以使这种复杂的查询高效的运行。大部分情况下,增量查询的开销与执行间隔内新增数据集而非整个数据集的大小成比例。
Remailer
Filter筛选出来的结果会被Remailer通过邮件推送给用户,推送前会在邮件中添加额外的header标记对应的文档是由哪个filter筛选出来的。有点类似埋点信息,有利于后续的反馈、调试和优化。
Appraiser
一些用户使用卡内基梅隆大学研发的Andrew Message Reader[5],这种邮件阅读器支持一种叫做“FLAMES”的语言,可以编写简单的“appraiser”来自动地将符合条件的邮件放入指定的文件夹。比如用户可以通过邮件的header信息,判断邮件是否是Tapestry的推送,以及具体来自于哪个过滤器,并将归入自定义的目录。
Tapestry还在一个叫做Walnut[6]的邮件阅读器中加入了appraiser支持。用户可以定义一些规则并制定符合这个规则后的得分。如果一个邮件命中多个规则,则得分为这些命中的规则得分之和。Walnut支持将邮件归类到不同的文件夹下并按照得分排序。
总结
主要贡献
这篇文章最大的贡献是提出了协同过滤的思想,迄今为止,协同过滤依然活跃在各大公司的推荐系统当中。
协同过滤的主要思想是:在过去达成一致的用户很可能在未来也会达成一致。基于这点,我们才可以通过分析用户过去的行为来为其推荐未来可能喜欢的物品。
这有点像内存局部性原理,即内存访问具有时间局部性和空间局部性,因此cache才有了用武之地。
Tapestry中的协同过滤依赖于用户的标注,他们发现存在这样两类用户:
- 饥渴用户(eager readers)
- 佛系用户(casual readers)
对于饥渴用户来说,他们总是在第一时间阅读最新的文档,并按捺不住要给文档打上标注。对于佛系用户来说,他们会依赖于前者的评价来筛选文档。这其实有点类似于豆瓣的设计思路。比如我就是一个重度豆瓣评分依赖者,不过我一般看完也会为“人肉标注”做出一点自己的贡献。
与现代推荐系统对比
Tapestry和现在的推荐系统的不同之处在于,它的协同过滤是一个手动的过程,也相对透明。你必须自己设置符合你独特偏好的过滤器,甚至可以选择某个你认为和你臭味相投的人,直接使用他们的过滤器或者在其基础上进一步优化。哪些信息能够通过过滤器是由你说了算的,甚至连打分规则你都可以自己定义。
相比之下,现在的推荐系统更像一个自动运行的黑盒。比如在User-Based协同过滤中,推荐系统会自动找出和你相似的用户,然后将他们的打分做加权平均以预测你对物品的打分。你对打分规则一无所知,和哪些用户相似也是算法说了算。算法就像独裁的家长一样替你做了所有决定(但却不会替你付钱)。
协同过滤经常面临的一个问题是“冷启动”,因为它需要使用大量用户与系统的交互信息作为推荐依据,因此也会造成“少者愈少,多者更多”的问题。在Tapestry中,就算文档没有任何标注,你依然可以根据文档的元数据以及内容设置相应的过滤器,这在一定程度上缓解了冷启动的问题。
写在最后
协同过滤有时候会给你推荐一些新鲜的事物,这是好事,但也有可能恰恰相反,将你困在利基社区中。用扎克伯格的话来说就是——你院前奄奄一息的松鼠可能比挣扎在生死边缘的非洲人民与你的兴趣更加“相关”[3]。
Eli Pariser在其分享[3]中提到两个人同一时间使用Google搜索“埃及”这个关键词,却得到了完全不同的内容,其中一个人得到大量关于游行抗议的结果,而另一个人看到的却是一片歌舞升平。
这种算法用于网络媒体和搜索引擎会构建出一个什么样的世界呢?也许是一个网络巴尔干化,偏见盛行的世界。那么,这到底是不是我们的“福报”呢?
参考文献:
[1] Using Collaborative Filtering to Weave an Information Tapestry
[2] How recommender systems make their suggestions
[3] Beware Online Filter Bubbles
[4] Continuous Queries over Append-Only Databases
[5] An Overview of the Andrew Message System
[6] Browsing Electronic Mail: Experiences Interfacing a Mail System to a DBMS