原文: 003: Building a podcast recommendation algorithm
这几年,我一直在收听播客 —— 开始于the usual suspects, This American Life和Serial —— 但是,我深陷The Political Gabfest不可自拔。我很快发现,每周1小时远远不够。我需要找到更多的播客来填充我的播放列表。但后来我遇到了一个问题:你如何找到类似于那些你喜欢的播客?iTunes是终极播客资源库,但他们的建议和搜索是非常糟糕的。
就拿Meowster来说,这是一个关于猫(cat)的播客。如果你在iTunes上搜索相关播客,那么可能会想要获得其它目标群体为宠物爱好者的播客。但是,你实际得到的却是这个:
它仅仅是_Society & Culture_目录下的排名靠前的播客列表,可以肯定的是,你可能会喜欢,但是几乎与我所谓的“相关”没有任何实质性的关系。
如果你尝试在iTunes上搜索"cats"播客,那么结果也不会好点。有一半的结果实际上是关于汽车(car)的,一些只是碰巧在标题中包含了单词"cat",而其中有两个是不活跃的;你只得到了一个好的结果。
所以一个播客迷要怎么做?好吧,当我开始洞察数据科学(Insight Data Science)方面的研究时,我发现这是一个完美的问题,可以作为我的项目。我想我可以通过每个播客相关的文本数据(即,标题和描述)来建立一个更好的播客推荐算法。我就是这样做的!其结果是thesauropod.us,这是一个播客词库。
为了让我的播客推荐算法工作,我需要获得大量的播客的标题和相关说明。幸运的是,有一个iTunes API,但令我失望的是,它并不允许我查询我所需要的信息。
我需要解决的第一个问题是要弄清楚要查询哪个播客。iTunes API不允许你获得iTunes集合中部分或全部的播客列表。[1] 您必须要么搜索特定的播客(例如,通过标题)或使用其iTunes ID查找特定的播客。我通过在github找到的一个~135,000个播客的数据集获得了成千上万的播客的标题解决了这个问题。
我需要解决的第二个问题是获得每个播客的标题和描述。无奈的是,这些信息是无法通过API获得的,但如果你知道播客的ID,那么它是可以从iTunes网站上抓取的。这使我要进行两步处理。首先,使用iTunes API对我的数据集中的每一个播客进行标题。然后使用从API查询获得的播客ID查找与播客相关的iTunes网页,并利用BeautifulSoup从那里获取标题和描述。
由于API查询和网页抓取都是耗费时间,我试着减少我的数据集。查询API之前,我使用guess_language包淘汰任何非英语播客。在抓取网页之前,我淘汰了那些在过去45天内没有发布一集的播客(因为我只是想要活跃播客)。不过,在试图下载数据的时候,我遇到了一些问题。首先,iTunes API有一个限速,但他们不会告诉你它是什么。这样一来,我的查询经过一段时间后就开始超时了,导致我的脚本停止。我使用下面的函数解决了这个问题,它允许我测试API查询URL当前是否工作,如果不工作,则等待并重试。间隔60秒的4次尝试对我来说运行良好。
def check_url(url, num_tries, wait_secs):
"""Check whether a url is valid. Allows multiple checks with waiting in between."""
import time
import urllib2
for x in range(0, num_tries):
try:
urllib2.urlopen(url)
str_error = None
except urllib2.URLError as str_error:
pass
if str_error:
time.sleep(wait_secs)
else:
break
return str_error
第二,标题搜索并不总是有用。有时候,我会得不到结果,但有时我会获得一打结果(这很可能发生在短播客的标题上,如"This American Life"或者"Serial")。因为我不想不断地手动干预,因此只是抛出了那些标题搜索无法返回1个结果的播客。而且因为这导致扔出去了很多最流行的播客,所以我使用iTunesCharts.net[2]中的数据将它们加回去。最后,我只有包含~6,000个播客的数据集。
现在,我有了每个播客的标题和描述,是时候将这些文本数据转换成可以用于相似性分析的表示形式了。首先是:预处理。
因为我想建立一个关于特别的播客是关于什么的这样一个总体的表示形式,所以我串接了单一的播客中的所有文本数据到一个长文档中[3]。接下来,我需要除去任何我不想用在算法中的文本数据。没有一个放之四海而皆准的解决办法可以解决这一点。你需要仔细思考你的数据集中有什么(这意味着寻找大量的原始数据),以及该数据中是否有任何会阻碍你的算法成功的东西。就我而言,我选择了删除:
- 混合的字母数字词 - 一些播客标题或者描述中包含了一些季/集数 (例如,"S4E11"),但大部分不会。如果我留下这个数据,那么有这种数字模式的播客可能会被标记为与彼此相似。但因为我对内容相似性感兴趣,所这对我的算法而言并无意义。
- 日,月 - 同样的,一些播客的描述中包含日期 (例如,"Thursday May 26"),但大部分不会。我移除了一周中的天,以及一年中的月份信息,这样,它就不会影响到我的相似性指标。
- 赞助声明 - 播客最主要的赚钱方式就是在集之间插播广告。在某些情况下,这些赞助商还会出现在情节描述中 (例如,"This podcast is sponsored by Squarespace…")。虽然有相同赞助商的播客内容上比较相似的这一观点可能是对的,但是我不想对其进行假设,所以我尽可能将这些声明从数据中移除。
清理数据之后,接下来的步骤是将文本的长串标记化成单个词的列表。我使用正则表达式删除了任何非字母数字字符的文本,然后用NLTK的RegexpTokenizer,根据空格切分文本。在这一点上,我还使用了stop_words包移除高频词(例如,a, the, or, but)。最后,使用NLTK的PorterStemmer移除每个单词的后缀。该步骤的优点是,相关的词(例如,walk, walks, walking, walked)都被转换成相同的词干。下面是这个过程:
我们先从原文开始:
'Sure people who arent fascinated by cats and all of their uniquely feline awesomeness might be unable to tell a calico from a ortoiseshell, let alone a Burmese from a Balinese'
标记化后:
['Sure', 'people', 'who', 'arent', 'fascinated', 'by', 'cats', 'and', 'all', 'of', 'their', 'uniquely', 'feline', 'awesomeness', 'might', 'be', 'unable', 'to', 'tell', 'a', 'calico', 'from', 'a', 'tortoiseshell', 'let', 'alone', 'a', 'Burmese', 'from', 'a', 'Balinese']
去除停用词后:
['Sure', 'people', 'fascinated', 'cats', 'uniquely', 'feline', 'awesomeness', 'might', 'unable', 'tell', 'calico', 'tortoiseshell', 'let', 'alone', 'Burmese', 'Balinese']
词干后:
[u'sure', u'peopl', u'fascin', u'cat', u'uniqu', u'felin', u'awesom', u'might', u'unabl', u'tell', u'calico', u'tortoiseshel', u'let', u'alon', u'burmes', u'balines']
在这一点上,对于每个播客,我们都有一个词干单词列表。现在,我们需要把那些字符串列表转换成数字矢量,以便最终可以用来计算播客之间的相似性。对于所有剩余的处理,我使用极出色的gensim包。它具有优良的文档和教程,并且对于我的目的而言,它还包含一个称为simserver的文档相似服务器。simserver提供了一个内存独立和有效的方式来存储所有的文字数据,并在我的网站的后台运行。,为每个播客查询提供相似性结果。它也可以安全地在网上进行更 新,这意味着我可以添加新的播客到我的数据集中,而无需中断当前用户。
gensim做的第一件事是为文档语料库创建一个字典(在我的情况中,每个播客的词干单词就是一个“文档”)。该字典包含每个唯一“令牌”的条目(即,词干词),以及它在所有播客的出现频率。这个频率信息在这之后将对我们非常重要。
一旦我们在字典中定义了所有的唯一令牌,就可以将每个播客的词干单词列表转换成一个更有效的矢量,称为_词袋(bag of words)_。例如,假定我们的Meowster文档包括令牌'cat'27次,令牌'paw'15次,以及令牌'felin'5次。我们可以采取字符串长长的列表,并重新将其表示为元组的列表,其中,第一个值指示与该令牌关联的正数,第二个值表示该令牌出现的频率:
[(0, 27), (1, 15), (2, 5)]
在这一点上,没有什么东西可以阻止你只是使用这些矢量表示来计算播客之间的相似阻性。但对于为什么你可能并不想这样做至少有两个原因。
首先,每个播客的当前表示将所有的单词都当成同样的信息,即使我们知道这不是真的。例如,"wrestling"很可能在语料库是一个出现频率非常低的单词,但对于关于摔跤(wrestling)的播客来说,它是一个_极具诊断性_的词:如果我们发现两个播客都包含单词“wrestling”很多次,那么这是一个很好的迹象表明这些播客内容相似。与此相反,如“today”一词可能在语料库中高频出现; 即使两个播客都包含单词“today”,这并没有告诉我们他们的内容有多相似(例如,一个可能是关于今天的新闻,另一个可能是关于今天发行的电影)。
为了解决这个问题,我应用了TF-IDF (词频逆文档频率)转换到我的原始词袋表示。在输出中,比那些在语料库中频繁出现的单词,在语料库中罕见的单词被赋予更高的权重。这正是我们所需要的(例如, wrestling>today)。
与原模型的第二个问题是,它包含了大量的特性 —— 在我的语料库中,我有~174,000个唯一令牌。减少模型中的特性数通常是一个好主意。出于实践考量,为每个播客存储100个值比存储174,000个值更有效。或许违反直觉,更少的功能也趋于提高模型的性能。这是因为模型中的许多特性是多余的,不提供附加的独特信息。例如,如果我们知道一个播客包含单词“cat”100次,然后知道它也包含单词“feline”50次,这并没有告诉我们任何新的东西 —— 我们已经相当清楚这就是关于猫的。然后,我们可以折叠“cat”和“feline”(也许还有其他诸如“fur”和“paw”的词),以合并为一个“猫性(cat-ness)”的特性,会比单个单词更好地表示该_主题_。
在自然语言处理中,有许多不同的方法来解决这个问题。Gensim的simserver包括三个选项:LSI(潜在语义索引),LDA(潜在Dirichlet分配),和日志熵。我尝试了所有这三个,然后发现LSI最适合我的数据集(对于LSI是如何工作的,见这里)。我如何选择哪一个是最好的呢?因为我没有两个播客多么相似的任何基底事实(如果我有,也不会需要建立一种算法!),这都是采用了老式的方法来完成:eyeballometrically。我在TF-IDF转换的数据上运行每个模型,为每个播客输出一个更短的矢量。然后,简单计算每对矢量之间的余弦相似度,看看对于一个特定的测试播客,每个模型得到的最相似的播客是哪个。下面是Meowster的结果。你可以看到,即使在调整模型的参数(例如,有多少个“topics”减少单词特性)之前,LSI也是工作良好的。我用同样的eyeballometric战术为我的LSI模型选定主题数(我发现,100个主题是最好的)。
我刚刚说了对于播客相似,我没有任何基底事实,那么我怎么验证我的模型呢?理想情况下,我会访问关于个人用户如何评论播客,或者个人用户订阅哪个播客的数据。我无法从iTunes获得这种数据,但我是能够获得播客订阅的一些信息。对于流行的播客,iTunes会给你建议,“听众还订阅了。” [4] 我假设有两个播客通常被共同认购应比两个随机播客更相似。因此,如果我从我的模型比较相似度,那么共同订购的播客应该比随机播客配对具有较高的相似性。这正是我发现的。
所以,这就是thesauropod.us的内部工作!它花了3周的时间,但是我对结果感到高兴。没有什么比构建一些你真正需要使用的东西更令人满意的了。
- 如果你可以访问Enterprise Partner Feed,那么可以下载所有的元数据,但是Insight研究的快速发展意味着我不能及时访问。
- 通过解析他们提供的iTunes URL,我能够直接识别播客ID。
- 如果我想要建立一个集推荐算法,那么我已经将每集的文本分成单独的文本了。
- 由于较之于在网站上,在iTunes应用上,你可以获得更多的建议(最多15个,网站上最多5个),因此在进行网络爬取的时候,我使用了PycURL来模拟iTunes用户代理。