Python 机器学习蓝图(二)
五、创建自定义新闻源
我经常阅读*。有些人甚至会强迫性地说。众所周知,我有时会消费一百多篇文章。但尽管如此,我还是经常发现自己在寻找更多可以阅读的东西。我受着这种潜移默化的怀疑,我错过了一些有趣的东西,我将永远遭受知识上的差距!*
*如果你有类似的症状,不要害怕,因为在这一章中,我将揭示一个简单的技巧,找到你想读的所有文章,而不必去挖掘几十篇你不想读的文章。
在这一章结束时,你将学会如何建立一个系统,了解你的新闻品味,并将每天给你发送一份个人定制的时事通讯。
这就是我们将在本章中介绍的内容:
- 使用 Pocket 应用创建受监督的训练集
- 利用口袋应用编程接口检索故事
- 使用嵌入式应用编程接口提取故事主体
- 自然语言处理基础
- 支持向量机
- 与 RSS 源和谷歌表单的集成
- 建立每日个人简讯
使用 Pocket 创建监督训练集
在我们能够在新闻文章中创建我们品味的模型之前,我们需要训练数据。这些训练数据将被输入到我们的模型中,以便教会它区分我们感兴趣的文章和我们不感兴趣的文章。为了建立这个语料库,我们需要注释大量的文章来对应这些兴趣。我们会给每篇文章贴上y或n的标签,表明它是否是我们希望在日常摘要中发送给我们的文章类型。
为了简化这个过程,我们将使用 Pocket 应用。Pocket 是一个允许您保存故事以供以后阅读的应用。您只需安装浏览器扩展,然后当您想要保存一个故事时,单击浏览器工具栏中的口袋图标。文章将保存到您的个人存储库中。对于我们来说,Pocket 的一个很好的特性是能够保存带有您选择的标签的文章。我们用这个来标记有趣的文章为y,不有趣的文章为n。
安装袖珍镀铬加长件
我正在为此使用谷歌 Chrome,但其他浏览器应该也能类似地工作。按照步骤安装袖珍镀铬扩展:
- 对于 Chrome,请访问谷歌应用商店并查找扩展部分:
Pocket Chrome Extention
- 点击添加到铬。如果你已经有一个帐户,登录,如果没有,继续注册(这是免费的)。
- 完成后,您应该会在浏览器的右上角看到口袋图标。
- 它将灰显,但是一旦有你想要保存的文章,你可以点击它。保存文章后,它会变成红色:
保存的页面如下所示:
The New York Times saved page
现在是有趣的部分!当你度过一天的时候,开始保存你想看的文章,以及那些你不想看的文章。有趣的用y标记,不有趣的用n标记。这需要一些工作。你的最终结果只会和你的训练集一样好,所以你需要为数百篇文章做这件事。如果你在保存的时候忘记给一篇文章贴标签,你可以随时去网站www.get.pocket.com,在那里贴标签。
使用口袋应用编程接口检索故事
现在,您已经努力将文章保存到 Pocket,下一步是检索它们。为了实现这一点,我们将使用口袋应用编程接口。你可以在getpocket.com/developer/a…注册一个账户。遵循以下步骤来实现:
-
点击左上角的创建一个新的应用,并填写详细信息,以获得您的应用接口密钥。
-
确保单击所有权限,以便您可以添加、更改和检索文章:
- 填写并提交后,您将收到您的消费者密钥。
- 你可以在左上角的“我的应用”下找到它。它看起来像下面的截图,但显然有一个真正的关键:
-
设置好之后,您就可以进入下一步,即设置授权。我们现在就做。
-
它要求您输入您的消费者密钥和重定向网址。重定向网址可以是任何东西。在这里,我使用了我的推特账户:
import requests
import pandas as pd
import json
pd.set_option('display.max_colwidth', 200)
CONSUMER_KEY = 'enter_your_consumer_key_here
auth_params = {'consumer_key': CONSUMER_KEY, 'redirect_uri': 'https://www.twitter.com/acombs'}
tkn = requests.post('https://getpocket.com/v3/oauth/request', data=auth_params)
tkn.text
前面的代码产生以下输出:
- 输出将包含下一步所需的代码。在浏览器栏中放置以下内容:
- 如果您将重定向网址更改为您自己的网址,请确保对其进行网址编码(这是您在前面的网址中看到的
%3A类型的内容)。 - 此时,您应该会看到一个授权屏幕。继续批准它,然后我们可以继续下一步:
# below we parse out the access code from the tkn.text string
ACCESS_CODE = tkn.text.split('=')[1]
usr_params = {'consumer_key': CONSUMER_KEY, 'code': ACCESS_CODE}
usr = requests.post('https://getpocket.com/v3/oauth/authorize', data=usr_params)
usr.text
前面的代码产生以下输出:
- 我们将在这里使用输出代码,继续检索故事。首先,我们检索标记为
n的故事:
# below we parse out the access token from the usr.text string
ACCESS_TOKEN = usr.text.split('=')[1].split('&')[0]
no_params = {'consumer_key': CONSUMER_KEY,
'access_token': ACCESS_TOKEN,
'tag': 'n'}
no_result = requests.post('https://getpocket.com/v3/get', data=no_params)
no_result.text
前面的代码产生以下输出:
你会注意到我们标记的所有文章上都有一个很长的 JSON 字符串n。这里面有几个关键点,但我们目前真的只对 URL 感兴趣。
- 我们将继续创建一个所有网址的列表:
no_jf = json.loads(no_result.text)
no_jd = no_jf['list']
no_urls=[]
for i in no_jd.values():
no_urls.append(i.get('resolved_url')) no_urls
前面的代码产生以下输出:
List of URLs
- 这个列表包含了所有我们不感兴趣的故事的网址。现在让我们把它放在一个数据帧中,并这样标记它:
no_uf = pd.DataFrame(no_urls, columns=['urls'])
no_uf = no_uf.assign(wanted = lambda x: 'n') no_uf
前面的代码产生以下输出:
Tagging the URLs
- 现在我们都有不想要的故事了。让我们对那些我们感兴趣的故事做同样的事情:
yes_params = {'consumer_key': CONSUMER_KEY,
'access_token': ACCESS_TOKEN,
'tag': 'y'}
yes_result = requests.post('https://getpocket.com/v3/get', data=yes_params)
yes_jf = json.loads(yes_result.text)
yes_jd = yes_jf['list']
yes_urls=[]
for i in yes_jd.values():
yes_urls.append(i.get('resolved_url'))
yes_uf = pd.DataFrame(yes_urls, columns=['urls'])
yes_uf = yes_uf.assign(wanted = lambda x: 'y')
yes_uf
前面的代码产生以下输出:
Tagging the URLs of stories we are interested in
- 现在,我们的培训数据有了两种类型的故事,让我们将它们结合成一个单一的数据框架:
df = pd.concat([yes_uf, no_uf])
df.dropna(inplace=True)
df
前面的代码产生以下输出:
Joining the URLs- both interested and not interested
现在我们已经在一个框架中设置了所有的网址和它们对应的标签,我们将继续下载每篇文章的 HTML。我们将为此使用另一种免费服务,称为 Embedly。
使用嵌入式应用编程接口下载故事正文
我们有故事的所有网址,但不幸的是,这不足以训练;我们需要完整的文章正文。如果我们想推出自己的刮刀,这本身可能会成为一个巨大的挑战,尤其是如果我们要从几十个网站中提取故事。我们需要编写代码来定位文章主体,同时小心避免围绕它的所有其他网站粘性。幸运的是,就我们而言,有许多免费服务可以为我们做到这一点。我将使用 Embedly 来实现这一点,但是您可以使用许多其他服务来代替。
第一步是注册 Embedly API 访问。你可以在 app.embed.ly/signup 做。这是一个简单的过程。一旦您确认注册,您将收到一个应用编程接口密钥。那真的是你所需要的。您只需在您的 HTTP 请求中使用该密钥。我们现在就开始吧:
import urllib
EMBEDLY_KEY = 'your_embedly_api_key_here'
def get_html(x):
try:
qurl = urllib.parse.quote(x)
rhtml = requests.get('https://api.embedly.com/1/extract?url=' + qurl + '&key=' + EMBEDLY_KEY)
ctnt = json.loads(rhtml.text).get('content')
except:
return None
return ctnt
前面的代码产生以下输出:
HTTP requests
这样,我们就有了每个故事的 HTML。
由于内容嵌入在 HTML 标记中,并且我们希望将纯文本输入到我们的模型中,因此我们将使用解析器来剥离标记标签:
from bs4 import BeautifulSoup
def get_text(x):
soup = BeautifulSoup(x, 'html5lib')
text = soup.get_text()
return text
df.loc[:,'text'] = df['html'].map(get_text)
df
前面的代码产生以下输出:
就这样,我们已经准备好了训练。我们现在可以继续讨论如何将文本转换为模型可以处理的内容。
自然语言处理基础
如果机器学习模型只对数字数据进行操作,我们如何将文本转换为数字表示?这正是自然语言处理 ( NLP )的重点。让我们简单了解一下这是如何实现的。
我们将从一个包含三个句子的小型语料库开始:
- 这只新小猫和其他小猫一起玩耍
- 她吃了午饭
- 她爱她的小猫
我们首先将我们的语料库转换成一个单词包 ( BOW )表示。我们暂时跳过预处理。将我们的语料库转换成 BOW 表示包括获取每个单词及其计数,以创建所谓的术语文档矩阵。在术语-文档矩阵中,每个唯一的单词被分配给一列,每个文档被分配给一行。两者的交叉点是伯爵:
| 先生否。 | 第 | 新增 | 小猫 | 播放了 | 同 | 其他 | 小猫 | 她 | 吃了 | 午餐 | 爱过 | 她 | | one | one | one | one | one | one | one | one | Zero | Zero | Zero | Zero | Zero | | Two | Zero | Zero | Zero | Zero | Zero | Zero | Zero | one | one | one | Zero | Zero | | three | Zero | Zero | one | Zero | Zero | Zero | Zero | one | Zero | Zero | one | one |
注意,对于这三个短句,我们已经有了 12 个特性。正如你可能想象的那样,如果我们处理的是实际的文档,比如新闻文章甚至书籍,那么特征的数量将会激增到几十万个。为了缓解这种爆炸,我们可以采取一些步骤来删除那些对我们的分析几乎没有或根本没有信息价值的特征。
我们可以采取的第一步是删除停止词。这些词非常常见,通常不会告诉您文档的内容。常见的英语停止词有的、的、的、的和上的*。我们将删除这些,并重新计算术语文档矩阵:*
| 先生否。 | 新增 | 小猫 | 播放了 | 小猫 | 吃了 | 午餐 | 爱过 | | one | one | one | one | one | Zero | Zero | Zero | | Two | Zero | Zero | Zero | Zero | one | one | Zero | | three | Zero | one | Zero | Zero | Zero | Zero | one |
如您所见,功能数量从 12 个减少到 7 个。这很好,但我们可以更进一步。我们可以执行词干化或引理化来进一步减少特征。请注意,在我们的矩阵中,我们同时拥有小猫和小猫。通过使用词干化或引理化,我们可以将它合并成仅仅小猫咪:
| 先生否。 | 新增 | 小猫 | 播放 | 吃饭 | 午餐 | 爱情 | | one | one | Two | one | Zero | Zero | Zero | | Two | Zero | Zero | Zero | one | one | Zero | | three | Zero | one | Zero | Zero | Zero | one |
我们的新矩阵合并了小猫和小猫,但是也发生了一些其他的事情。我们失去了玩和爱的后缀,吃被转化为吃。为什么呢?这就是引理化的作用。如果你记得你小学的语法课,我们已经从单词的屈折形式变成了基本形式。如果那是引理化,那词干是什么?词干也有同样的目标,但使用的方法不那么复杂。这种方法有时会产生伪词,而不是实际的基本形式。比如引理,如果你要减少小马,你会得到小马,但是用炮泥,你会得到小马。
现在让我们进一步对矩阵进行另一种变换。到目前为止,我们已经使用了每个单词的简单计数,但是我们可以应用一种算法,该算法将对我们的数据进行过滤,以增强每个文档独有的单词。该算法称为术语频率-逆文档频率 ( tf-idf ) 。
我们计算矩阵中每个项的 tf-idf 比率。让我们举几个例子来计算一下。对于文件一中的新一词,频率一词只是计数,也就是1。反向文档频率计算为语料库中文档数量与该术语出现的文档数量的对数。对于新,这是 log (3/1) ,或者. 4471。所以,对于完整的 tf-idf 值,我们有 tf * idf ,或者,这里是 1 x .4471 ,或者正好是. 4471。对于文档一中的单词 kitten ,tf-idf 为 2 * log (3/2) ,或. 3522。
为了完成其余条款和文件,我们有以下内容:
| 先生否。 | 新增 | 小猫 | 播放 | 吃饭 | 午餐 | 爱情 | | one | .4471 | .3522 | .4471 | Zero | Zero | Zero | | Two | Zero | Zero | Zero | .4471 | .4471 | Zero | | three | Zero | .1761 | Zero | Zero | Zero | .4471 |
为什么会这样?假设,例如,我们有一个关于许多主题(医学、计算、食品、动物等)的文档语料库,我们希望将它们分类为主题。很少有文件会包含血压计这个词,它是用来测量血压的设备;所有的文件都可能与医学相关。显然,这个词在文件中出现的次数越多,就越有可能是关于医学的。因此,一个很少出现在我们整个语料库中,但在一个文档中多次出现的术语,很可能与该文档的主题紧密相关。这样,文档可以说是由那些具有高 tf-idf 值的术语来表示的。
在这个框架的帮助下,我们现在将把我们的训练集转换成 tf-idf 矩阵:
from sklearn.feature_extraction.text import TfidfVectorizer
vect = TfidfVectorizer(ngram_range=(1,3), stop_words='english', min_df=3)
tv = vect.fit_transform(df['text'])
有了这三行,我们已经将所有文档转换为 tf-idf 向量。我们传入了一些参数:ngram_range、stop_words和min_df。让我们分别讨论一下。
首先,ngram_range是文档的标记化方式。在前面的例子中,我们使用每个单词作为标记,但是在这里,我们使用所有一到三个单词的序列作为标记。就拿我们的第二句话来说吧,她吃了午饭。我们暂时忽略停止词。这句话的 n-克数应该是:她、她吃了、她吃了、吃了、吃了午餐、午餐。
接下来,我们有stop_words。我们为此传递english以删除所有的英语停止词。如前所述,这将删除所有缺少信息内容的术语。
最后,我们有min_df。这将删除至少三个文档中没有出现的所有单词。添加这个可以删除非常罕见的术语,并减小矩阵的大小。
现在我们的文章语料库是一个可行的数字格式,我们将继续把它输入到我们的分类器中。
支持向量机
我们将在本章中使用一个新的分类器,一个线性支持向量机 ( SVM )。SVM 算法是一种试图使用最大边缘超平面将数据点线性分类的算法。那是一口,让我们看看它真正的意思。
假设我们有两类数据,我们想用一条线把它们分开。(这里我们只讨论两个特征或维度。)放置那条线最有效的方法是什么?让我们看一个例子:
在上图中,线 H 1 没有有效区分这两个类,所以我们可以排除那一个。线 H 2 能够干净利落的区分它们,但是 H 3 是最大余量线。这意味着直线位于每个类的两个最近点之间的中心,这两个最近点被称为支持向量。这些可以看作下图中的虚线:
如果数据不能如此整齐地分成类呢?如果点之间有重叠怎么办?在这种情况下,仍然有选择。一种是使用所谓的“软保证金 SVM”。这个公式仍然使边际最大化,但代价是落在边际错误一边的点数受到惩罚。另一个选择是使用所谓的内核技巧。这种方法将数据转换到一个更高维的空间,在那里数据可以线性分离。这里提供了一个示例:
二维表示如下:
我们采用了一维特征空间,并将其映射到二维特征空间。映射只是取每个 x 值,并将其映射到 x 、 x 2 。这样做允许我们添加一个线性分离平面。
至此,让我们将 tf-idf 矩阵输入到我们的 SVM:
from sklearn.svm import LinearSVC
clf = LinearSVC()
model = clf.fit(tv, df['wanted'])
tv是我们的矩阵,df['wanted']是我们的标签列表。记住这不是y就是n,表示我们对文章是否感兴趣。一旦运行,我们的模型就被训练好了。
本章中我们没有做的一件事是正式评估我们的模型。您应该总是有一个搁置集来评估您的模型,但是因为我们将不断更新我们的模型,并每天对其进行评估,所以我们将跳过本章的这一步。只要记住这通常是一个可怕的想法。
现在让我们继续设置每天的新闻源。
IFTTT 与提要、谷歌表单和电子邮件的集成
我们使用 Pocket 来构建我们的训练集,但是现在我们需要一个文章流来运行我们的模型。为了设置这一点,我们将再次使用 IFTTT,以及谷歌表单,以及一个允许我们使用谷歌表单的 Python 库。
通过 IFTTT 设置新闻源和谷歌表单
希望此时您已经建立了一个 IFTTT 帐户,但是如果没有,现在就开始建立。完成后,您需要设置与 feed 和 Google Sheets 的集成:
- 首先,在主页的搜索框中搜索提要,然后单击服务,并单击设置:
- 您只需单击连接:
- 接下来,在服务下搜索
Google Drive:
- 点击那个。它会把你带到一个页面,在那里你选择你想连接的谷歌帐户。选择帐户,然后点按“允许”以启用 IFTTT 来访问您的 Google Drive 帐户。完成后,您应该会看到以下内容:
- 现在,通过连接我们的频道,我们可以设置我们的提要。点击右下角用户名下的下拉菜单中的新建小程序。这会把你带到这里:
- 点击+这个。搜索
RSS Feed,然后点击。这应该会把你带到这里:
- 从这里,单击新建订阅源项目:
- 然后,将网址添加到框中,并单击创建触发器。完成后,您将被带回来添加+那个动作:
- 点击+那个,搜索
Sheets,然后点击它的图标。一旦完成,你会发现自己在这里:
- 我们希望我们的新闻项目流入谷歌驱动电子表格,所以点击添加行到电子表格。然后,您将有机会自定义电子表格:
我给这个电子表格起了个名字NewStories,并把它放在了一个名为IFTTT的谷歌驱动文件夹中。单击“创建操作”来完成制作方法,很快您将开始看到新闻项目流入您的谷歌驱动电子表格。请注意,它只会在新项目进入时添加新项目,而不会添加创建工作表时已存在的项目。我建议添加一些提要。你将需要为每个人创建单独的食谱。最好是为训练集中的站点添加提要,换句话说,就是用 Pocket 保存的站点。
给这些故事一两天的时间在纸上积累,然后它应该是这样的:
幸运的是,包含了完整的文章 HTML 正文。这意味着我们不必使用 Embedly 为每篇文章下载它。我们仍然需要从谷歌表单下载文章,然后处理文本以去除 HTML 标签,但这一切都可以很容易地完成。
为了下拉文章,我们将使用名为gspread的 Python 库。这可以 pip 安装。安装完成后,您需要按照设置 OAuth 2 的方向进行操作。这可以在gspread.readthedocs.org/en/latest/o…找到。您将最终下载一个 JSON 凭证文件。重要的是,一旦你有了那个文件,你就可以用client_email键找到里面的电子邮件地址。然后你需要分享你的电子邮件发送的NewStories电子表格。只需点击表格右上角的蓝色共享按钮,然后将电子邮件粘贴到那里。您最终会在 Gmail 帐户中收到一条未能发送的消息,但这是意料之中的。请确保在以下代码中交换文件的路径和文件名:
import gspread
from oauth2client.service_account import ServiceAccountCredentials
JSON_API_KEY = 'the/path/to/your/json_api_key/here'
scope = ['https://spreadsheets.google.com/feeds',
'https://www.googleapis.com/auth/drive']
credentials = ServiceAccountCredentials.from_json_keyfile_name(JSON_API_KEY, scope)
gc = gspread.authorize(credentials)
现在,如果一切顺利,它应该运行没有错误。接下来,您可以下载这些故事:
ws = gc.open("NewStories")
sh = ws.sheet1
zd = list(zip(sh.col_values(2),sh.col_values(3), sh.col_values(4)))
zf = pd.DataFrame(zd, columns=['title','urls','html'])
zf.replace('', pd.np.nan, inplace=True)
zf.dropna(inplace=True)
zf
前面的代码产生以下输出:
这样,我们从提要中下载了所有文章,并将它们放入一个数据框中。我们现在需要去掉 HTML 标签。我们可以使用之前使用的函数来检索文本。然后,我们将使用 tf-idf 矢量器对其进行转换:
zf.loc[:,'text'] = zf['html'].map(get_text)
zf.reset_index(drop=True, inplace=True)
test_matrix = vect.transform(zf['text'])
test_matrix
前面的代码产生以下输出:
在这里,我们看到我们的矢量化是成功的。现在让我们将其传递到我们的模型中,以获得结果:
results = pd.DataFrame(model.predict(test_matrix), columns=['wanted'])
results
前面的代码产生以下输出:
我们在这里看到每个故事都有结果。现在让我们将他们与故事本身联系起来,以便我们可以评估结果:
rez = pd.merge(results,zf, left_index=True, right_index=True)
rez
前面的代码产生以下输出:
此时,我们可以通过检查结果并纠正错误来改进模型。你需要自己做这件事,但我是这样改变自己的:
change_to_no = [130, 145, 148, 163, 178, 199, 219, 222, 223, 226, 235, 279, 348, 357, 427, 440, 542, 544, 546, 568, 614, 619, 660, 668, 679, 686, 740, 829]
change_to_yes = [0, 9, 29, 35, 42, 71, 110, 190, 319, 335, 344, 371, 385, 399, 408, 409, 422, 472, 520, 534, 672]
for i in rez.iloc[change_to_yes].index:
rez.iloc[i]['wanted'] = 'y'
for i in rez.iloc[change_to_no].index:
rez.iloc[i]['wanted'] = 'n'
rez
前面的代码产生以下输出:
这看起来可能有很多变化,但在评估的 900 多篇文章中,我必须改变的很少。通过进行这些修正,我们现在可以将这些反馈到我们的模型中,以进一步改进它。让我们将这些结果添加到之前的训练数据中,然后重建模型:
combined = pd.concat([df[['wanted', 'text']], rez[['wanted', 'text']]]) combined
前面的代码产生以下输出:
用以下代码重新训练模型:
tvcomb = vect.fit_transform(combined['text'], combined['wanted'])
model = clf.fit(tvcomb, combined['wanted'])
现在我们已经用所有可用的数据重新训练了我们的模型。当你在几天或几周内得到更多的结果时,你可能想这样做很多次。你加的越多,结果就会越好。
在这一点上,我们假设您有一个训练有素的模型,并准备开始使用它。现在让我们看看如何部署它来设置个性化新闻源。
设置您的每日个人简讯
为了建立一个包含新闻故事的个人电子邮件,我们将再次使用 IFTTT。和以前一样,在第 3 章中,我们将使用 Webhooks 频道发送POST请求。但这一次,有效载荷将是我们的新闻故事。如果您还没有设置 Webhooks 频道,请现在就设置。说明可以在第 3 章、中找到,建立一个寻找廉价机票的应用。你还应该设置 Gmail 频道。一旦完成,我们将添加一个食谱来结合这两者。按照以下步骤设置 IFTTT:
- 首先,从 IFTTT 主页点击新建小程序,然后点击+this。然后,搜索 Webhooks 频道:
- 选择该选项,然后选择接收网络请求:
- 然后,给请求起一个名字。我在用
news_event:
- 单击创建触发器完成。接下来,点击+以设置电子邮件。搜索 Gmail 并点击:
- 单击 Gmail 后,单击给自己发送电子邮件。在那里,您可以自定义您的电子邮件:
输入主题行,并在邮件正文中包含{{Value1}}。我们将传递我们的故事标题,并将其与我们的POST请求联系起来。单击创建操作,然后单击完成将其完成。
现在,我们已经准备好生成将按计划运行的脚本,自动向我们发送感兴趣的文章。我们将为此创建一个单独的脚本,但是我们需要在现有代码中做的最后一件事是序列化我们的矢量器和模型,如下面的代码块所示:
import pickle
pickle.dump(model, open(r'/input/a/path/here/to/news_model_pickle.p', 'wb'))
pickle.dump(vect, open(r'/input/a/path/here/to/news_vect_pickle.p', 'wb'))
这样,我们就从我们的模型中节省了所有我们需要的东西。在我们的新脚本中,我们将阅读这些内容来生成新的预测。我们将使用与我们在第 3 章中使用的相同的调度库来运行代码,构建一个应用来查找便宜的机票。综上所述,我们有以下脚本:
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
import schedule
import time
import pickle
import json
import gspread
from oauth2client.service_account import ServiceAccountCredentials
import requests
from bs4 import BeautifulSoup
def fetch_news():
try:
vect = pickle.load(open(r'/your/path/to/news_vect_pickle.p', 'rb'))
model = pickle.load(open(r'/your/path/to /news_model_pickle.p', 'rb'))
JSON_API_KEY = r'/your/path/to/API KEY.json'
scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive']
credentials = ServiceAccountCredentials.from_json_keyfile_name(JSON_API_KEY, scope)
gc = gspread.authorize(credentials)
ws = gc.open("NewStories")
sh = ws.sheet1
zd = list(zip(sh.col_values(2),sh.col_values(3), sh.col_values(4)))
zf = pd.DataFrame(zd, columns=['title','urls','html'])
zf.replace('', pd.np.nan, inplace=True)
zf.dropna(inplace=True)
def get_text(x):
soup = BeautifulSoup(x, 'html5lib')
text = soup.get_text()
return text
zf.loc[:,'text'] = zf['html'].map(get_text)
tv = vect.transform(zf['text'])
res = model.predict(tv)
rf = pd.DataFrame(res, columns=['wanted'])
rez = pd.merge(rf, zf, left_index=True, right_index=True)
rez = rez.iloc[:20,:]
news_str = ''
for t, u in zip(rez[rez['wanted']=='y']['title'], rez[rez['wanted']=='y']['urls']):
news_str = news_str + t + '\n' + u + '\n'
payload = {"value1" : news_str}
r = requests.post('https://maker.ifttt.com/trigger/news_event/with/key/bNHFwiZx0wMS7EnD425n3T', data=payload)
# clean up worksheet
lenv = len(sh.col_values(1))
cell_list = sh.range('A1:F' + str(lenv))
for cell in cell_list:
cell.value = ""
sh.update_cells(cell_list)
print(r.text)
except:
print('Action Failed')
schedule.every(480).minutes.do(fetch_news)
while 1:
schedule.run_pending()
time.sleep(1)
这个脚本将每 4 小时运行一次,从 Google Sheets 中下拉新闻故事,通过模型运行这些故事,通过向 IFTTT 发送POST请求来生成一封电子邮件,请求那些预测感兴趣的故事,然后,最后,它将清除电子表格中的故事,这样在下一封电子邮件中只会发送新的故事。
恭喜你!你现在有自己的个性化新闻源了!
摘要
在本章中,我们学习了在训练机器学习模型时如何处理文本数据。我们还学习了自然语言处理和支持向量机的基础知识。
在下一章中,我们将进一步发展这些技能,并尝试预测什么样的内容会像病毒一样传播。*
六、预测你的内容是否会迅速传播
像许多伟大的事情一样,这一切都始于打赌。那是 2001 年,当时麻省理工学院的研究生乔纳·佩雷蒂正在拖延。他没有写论文,而是决定接受耐克的提议,个性化一双运动鞋。根据最近启动的一个项目,任何人都可以从他们的网站 NIKEiD 这样做。唯一的问题是,至少从耐克的角度来看,按照佩雷蒂的要求,在他们身上印上“血汗工厂”这个词是行不通的。佩雷蒂在一系列电子邮件中提出异议,指出该词绝不属于会导致他的个性化请求被拒绝的任何令人反感的术语类别。
佩雷蒂认为其他人可能会觉得与耐克客服代表的交流也很有趣,于是将这些交流转发给了一些密友。几天之内,这些电子邮件就进入了世界各地的收件箱。《时代》、《沙龙》、《卫报》甚至《今日秀》等主要媒体都开始关注此事。佩雷蒂是一场病毒式轰动的中心。
但开始困扰佩雷蒂的问题是,这种事情能被复制吗?他的朋友卡梅伦·马洛一直在准备写他关于病毒现象的博士论文,并坚持认为这样的事情太复杂了,任何人都无法设计。赌注就在这里开始了。马洛打赌说,佩雷蒂不可能重复他与耐克最初的一系列电子邮件所获得的成功。
快进 15 年,乔纳·佩雷蒂领导的网站已经成为病毒的代名词——BuzzFeed。2015 年,该网站拥有超过 7700 万的独特访客,总访问量排名高于《纽约时报》。我认为可以肯定地说,佩雷蒂赢了那笔赌注。
但是佩雷蒂到底是怎么做到的呢?他是如何拼凑出创造像野火一样传播的内容的秘密公式的?在这一章中,我们将试图解开其中的一些谜团。我们将研究一些最常分享的内容,并尝试找出区别于人们不太愿意分享的内容的共同要素。
本章将涵盖以下主题:
- 关于病毒性,研究告诉了我们什么?
- 获取共享计数和内容
- 探索共享性的特征
- 构建预测性内容评分模型
关于病毒性,研究告诉了我们什么?
理解分享行为是大生意。随着消费者年复一年地对传统广告越来越视而不见,这种推动正在超越简单的推销,转而讲述引人入胜的故事。这些努力的成功越来越多地以社会份额来衡量。为什么要这么麻烦?因为,作为一个品牌,我收到的每一份都代表着我接触到的另一个消费者——所有这些都不需要额外花费一分钱。
由于这一价值,一些研究人员检查了分享行为,希望了解它的动机。研究人员发现的原因如下:
- 为他人提供实用价值(利他动机)
- 将自己与某些想法和概念联系起来(认同动机)
- 围绕共同的情感(共同的动机)与他人建立联系
关于最后一个动机,一项特别精心设计的研究查看了《纽约时报》的 7000 篇内容,以考察情绪对分享的影响。他们发现,单纯的情绪情绪不足以解释分享行为,但当结合情绪唤醒时,解释力更大。
例如,虽然悲伤有很强的负价,但它被认为是一种低唤醒状态。另一方面,愤怒具有负价,这与高唤醒状态成对出现。因此,让读者难过的故事往往比引发愤怒的故事少得多。那么,如今在政治中扮演如此重要角色的许多虚假新闻都是以这种形式出现的,这有什么好奇怪的吗?下图显示了相同的结果:
Figure taken from What Makes Online Content Viral? by Jonah Berger and Katherine L. Milkman, Journal of Marketing Research, available at: jonahberger.com/wp-content/…
这涵盖了激励的方面,但是如果我们保持这些因素不变,其他属性如何影响一个内容的虚拟性?其中一些因素可能包括以下内容:标题措辞、标题长度、标题词性、内容长度、帖子的社交网络、主题、主题的及时性等等。毫无疑问,一个人可以用一生的时间来研究这种现象。然而,就目前而言,我们将在接下来的 30 页左右的时间里这样做。从那里,你可以决定你是否愿意更进一步。
获取共享计数和内容
在我们开始探索哪些功能可以共享内容之前,我们需要获得相当多的内容,以及共享频率的数据。不幸的是,在过去几年中,保护这类数据变得更加困难。事实上,当这本书的第一版在 2016 年出版时,这些数据很容易获得。但是今天,这种类型的数据似乎没有免费的来源,尽管如果你愿意付费,你仍然可以找到。
对我们来说幸运的是,我有一个数据集,它是从一个现已关闭的网站ruzzit.com收集的。该网站在活动时,跟踪了一段时间内共享最多的内容,这正是我们对该项目的要求:
我们将像往常一样,首先将导入内容加载到笔记本中,然后加载数据。这个特殊的数据是以 JSON 文件的形式出现的。我们可以使用 pandas read_json()方法读取它,如下面的代码块所示:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
dfc = pd.read_json('viral_dataset.json')
dfc.reset_index(drop=True, inplace=True)
dfc
上述代码生成以下输出:
让我们看一下这个数据集的列,以便更好地理解我们将使用的内容:
dfc.columns
上述代码生成以下输出:
现在,让我们来看看每一列代表什么:
title:文章标题link:链接ruzzit.combb:脸书喜欢的人数lnkdn:领英股份数量pins:Pinterest 针数date:文章的日期redirect:原文链接pg_missing:描述该页面是否可用的字段img_link:文章图片的链接json_data:与文章相关的附加数据site:文章所在的域img_count:文章中包含的图片数量entities:文章的人物、地点、事物相关特征html:文章正文text:文章正文
另一个有启发性的特点是每篇文章的字数。我们目前的数据中没有这些,所以让我们创建一个函数来为我们提供这些:
def get_word_count(x):
if not x is None:
return len(x.split(' '))
else:
return None
dfc['word_count'] = dfc['text'].map(get_word_count)
dfc
上述代码生成以下输出:
让我们添加更多功能。我们将在页面上添加第一个图像最突出的颜色。每个图像的颜色在 JSON 数据中按 RGB 值列出,因此我们可以从中提取颜色:
import matplotlib.colors as mpc
def get_rgb(x):
try:
if x.get('images'):
main_color = x.get('images')[0].get('colors')[0].get('color')
return main_color
except:
return None
def get_hex(x):
try:
if x.get('images'):
main_color = x.get('images')[0].get('colors')[0].get('color')
return mpc.rgb2hex([(x/255) for x in main_color])
except:
return None
dfc['main_hex'] = dfc['json_data'].map(get_hex)
dfc['main_rgb'] = dfc['json_data'].map(get_rgb)
dfc
上述代码生成以下输出:
我们已经从第一张图像中提取了最突出的颜色作为 RGB 值,但我们也将其转换为十六进制值。我们稍后在检查图像颜色时会用到它。
数据准备就绪后,我们就可以开始进行分析了。我们将尝试找到内容高度可共享的原因。
探索共享性的特征
我们在这里收集的故事大致代表了 2015 年和 2016 年初 500 条最常分享的内容。我们将尝试解构这些文章,找出让它们如此易于分享的共同特征。我们将从查看图像数据开始。
探索图像数据
让我们从每个故事中包含的图片数量开始。我们将进行数值计算,然后绘制数字:
dfc['img_count'].value_counts().to_frame('count')
这将显示类似于以下内容的输出:
现在,让我们绘制相同的信息:
fig, ax = plt.subplots(figsize=(8,6))
y = dfc['img_count'].value_counts().sort_index()
x = y.sort_index().index
plt.bar(x, y, color='k', align='center')
plt.title('Image Count Frequency', fontsize=16, y=1.01)
ax.set_xlim(-.5,5.5)
ax.set_ylabel('Count')
ax.set_xlabel('Number of Images')
此代码生成以下输出:
我已经对这些数字感到惊讶了。绝大多数故事都有五张图片在里面,而那些要么有一张图片要么根本没有图片的故事则相当罕见。
因此,我们可以看到人们倾向于与大量图像共享内容。现在,让我们看看这些图像中最常见的颜色:
mci = dfc['main_hex'].value_counts().to_frame('count')
mci
此代码生成以下输出:
我不知道你怎么想,但鉴于我不认为十六进制值是颜色,这并没有多大帮助。然而,我们可以在熊猫中使用一个叫做条件格式的新特性来帮助我们:
mci['color'] = ' '
def color_cells(x):
return 'background-color: ' + x.index
mci.style.apply(color_cells, subset=['color'], axis=0)
mci
上述代码生成以下输出:
使聚集
这当然有帮助,但是颜色是如此的精细,我们总共有超过 450 种独特的颜色。让我们使用一点聚类来将这个范围缩小到更易管理的范围。由于我们有每种颜色的 RBG 值,我们可以创建一个三维空间来使用 k-means 算法对它们进行聚类。我不会在这里详细讨论算法,但它是一个相当简单的迭代算法,基于通过测量到中心的距离并重复来生成聚类。算法确实需要我们选择 k ,或者我们期望的聚类数量。因为 RGB 的范围是从 0 到 256,所以我们将使用 256 的平方根,也就是 16。这应该给我们一个可管理的数字,同时保留我们调色板的特性。
首先,我们将 RGB 值分成单独的列:
def get_csplit(x):
try:
return x[0], x[1], x[2]
except:
return None, None, None
dfc['reds'], dfc['greens'], dfc['blues'] = zip(*dfc['main_rgb'].map(get_csplit))
接下来,我们将使用它来运行我们的 k 均值模型并检索中心值:
from sklearn.cluster import KMeans
clf = KMeans(n_clusters=16)
clf.fit(dfc[['reds', 'greens', 'blues']].dropna())
clusters = pd.DataFrame(clf.cluster_centers_, columns=['r', 'g', 'b'])
clusters
这将生成以下输出:
现在,我们有了每张图片中第一张图片的十六种最受欢迎的主色。让我们检查他们是否正在使用我们的熊猫DataFrame.style()方法和我们之前创建的功能来给我们的细胞着色。我们需要将我们的索引设置为三列的十六进制值,以使用我们的color_cells函数,因此我们也将这样做:
def hexify(x):
rgb = [round(x['r']), round(x['g']), round(x['b'])]
hxc = mpc.rgb2hex([(x/255) for x in rgb])
return hxc
clusters.index = clusters.apply(hexify, axis=1)
clusters['color'] = ' '
clusters.style.apply(color_cells, subset=['color'], axis=0)
这将生成以下输出:
所以你有它;这些是您将在最常共享的内容中看到的最常见的颜色(至少对于第一张图像)。这比我预想的要单调一些,因为前几部似乎都是米色和灰色的色调。
现在,让我们继续研究我们故事的标题。
探索头条新闻
让我们从创建一个可以用来检查最常见元组的函数开始。我们将对其进行设置,以便稍后也可以在正文中使用它。我们将使用 Python 自然语言工具包 ( NLTK )库来实现这一点。如果您当前没有 pip 安装,可以安装它:
from nltk.util import ngrams
from nltk.corpus import stopwords
import re
def get_word_stats(txt_series, n, rem_stops=False):
txt_words = []
txt_len = []
for w in txt_series:
if w is not None:
if rem_stops == False:
word_list = [x for x in ngrams(re.findall('[a-z0-9\']+', w.lower()), n)]
else:
word_list = [y for y in ngrams([x for x in re.findall('[a-z0-9\']+', w.lower())\
if x not in stopwords.words('english')], n)]
word_list_len = len(list(word_list))
txt_words.extend(word_list)
txt_len.append(word_list_len)
return pd.Series(txt_words).value_counts().to_frame('count'), pd.DataFrame(txt_len, columns=['count'])
里面有很多,我们打开包装吧。我们创建了一个函数,它接受一个序列、一个整数和一个布尔值。整数决定了我们将用于 n-gram 解析的 n ,而布尔值决定了我们是否排除停止词。该函数返回每行元组的数量和每个元组的频率。
让我们在标题上运行它,同时保留停止词。我们将从一个词开始:
hw,hl = get_word_stats(dfc['title'], 1, 0)
hl
这将生成以下输出:
现在,我们有了每个标题的字数。让我们看看这上面的统计数据是什么样子的:
hl.describe()
此代码生成以下输出:
我们可以看到,我们的病毒式报道的标题长度中值正好是 11 个字。让我们来看看最常用的词:
这并不完全有用,但符合我们的预期。现在,让我们看看 bi-gram 的相同信息:
hw,hl = get_word_stats(dfc['title'], 2, 0)
hw
这将生成以下输出:
这绝对更有趣。我们可以开始一遍又一遍地看到标题的一些组成部分。突出的两个是(donald, trump)和(dies, at)。特朗普在选举期间说了一些引人注目的话,这是有道理的,但我对去世的头条感到惊讶。我看了一下头条,显然有一些高知名度的人在有问题的年份去世了,所以这也是有道理的。
现在,让我们在删除停止词的情况下运行这个程序:
hw,hl = get_word_stats(dfc['title'], 2, 1)
hw
这将生成以下输出:
同样,我们可以看到许多我们可能期待的事情。看起来,如果我们改变解析数字的方式(用像 number 这样的单个标识符替换它们),我们可能会看到更多这样的泡沫。如果你想尝试的话,我会把它留给读者。
现在,让我们来看看三克:
hw,hl = get_word_stats(dfc['title'], 3, 0)
此代码生成以下输出:
似乎我们包含的单词越多,标题就越像经典的 BuzzFeed 原型。事实上,让我们看看是否如此。我们还没有看到哪些网站产生的病毒故事最多;让我们看看 BuzzFeed 是否领先于图表:
dfc['site'].value_counts().to_frame()
这将生成以下输出:
我们可以清楚地看到,BuzzFeed 在列表中占据主导地位。在遥远的第二个地方,我们可以看到《赫芬顿邮报》,顺便说一下,这是乔纳·佩雷蒂工作过的另一个网站。研究病毒科学似乎能带来巨大的收益。
到目前为止,我们已经检查了图片和标题。现在,让我们继续检查故事的全文。
探索故事内容
在最后一节中,我们创建了一个函数来检查我们故事标题中常见的 n-grams。现在,让我们用它来探索我们故事的全部内容。
我们将从探索去掉了终止词的连词开始。由于与故事正文相比,标题太短了,所以完整地看一下停止词是有意义的,尽管在故事中,消除它们通常是有意义的:
hw,hl = get_word_stats(dfc['text'], 2, 1)
hw
这将生成以下输出:
有趣的是,我们可以看到我们在头条看到的轻浮已经完全消失了。文本现在充满了讨论恐怖主义、政治和种族关系的内容。
怎么可能头条轻松,正文阴暗有争议?我认为这是因为像《13 只看起来像猫王的小狗》这样的文章比《T2》和《伊斯兰国史》要少得多。
让我们再看一个。我们将评估故事主体的三重图:
hw,hl = get_word_stats(dfc['text'], 3, 1)
hw
此代码生成以下输出:
我们似乎突然进入了广告和社会迎合的领域。接下来,让我们继续构建内容评分的预测模型。
构建预测性内容评分模型
让我们利用我们所学的知识来创建一个模型,该模型可以估计给定内容的份额计数。我们将使用已经创建的特性,以及一些附加特性。
理想情况下,我们将拥有更大的内容样本,尤其是具有更典型的份额计数的内容,但我们将不得不满足于我们这里所拥有的。
我们将使用一种叫做随机森林回归的算法。在前几章中,我们研究了基于分类的随机森林的一个更典型的实现,但是在这里我们将尝试预测份额计数。我们可以将我们的共享类合并到范围中,但是在处理连续变量时最好使用回归,这就是我们正在处理的。
首先,我们将创建一个简单的模型。我们将使用图像数量、网站和字数。我们将根据脸书喜欢的数量来训练我们的模型。我们还将把数据分成两组:训练集和测试集。
首先,我们将导入 scikit-learn 库,然后我们将通过删除带有空值的行、重置索引来准备数据,最后将框架拆分为我们的训练和测试集:
from sklearn.ensemble import RandomForestRegressor
all_data = dfc.dropna(subset=['img_count', 'word_count'])
all_data.reset_index(inplace=True, drop=True)
train_index = []
test_index = []
for i in all_data.index:
result = np.random.choice(2, p=[.65,.35])
if result == 1:
test_index.append(i)
else:
train_index.append(i)
我们使用了一个随机数发生器,其概率设置为大约三分之二和三分之一,以确定哪一行项目(基于它们的index)将被放置在每一组中。像这样设置概率可以确保我们得到的训练集行数大约是测试集的两倍。我们可以在下面的代码中看到这一点:
print('test length:', len(test_index), '\ntrain length:', len(train_index))
上述代码生成以下输出:
现在,我们将继续准备数据。接下来,我们需要为我们的站点设置分类编码。目前,我们的数据框架用字符串表示每个站点的名称。我们需要使用虚拟编码。这将为每个站点创建一列,如果该行有该特定站点,则该列将填充一个1,而站点的所有其他列将使用一个0进行编码。我们现在就开始吧:
sites = pd.get_dummies(all_data['site'])
sites
上述代码生成以下输出:
您可以从前面的输出中看到虚拟编码是如何出现的。
我们现在继续:
y_train = all_data.iloc[train_index]['fb'].astype(int)
X_train_nosite = all_data.iloc[train_index][['img_count', 'word_count']]
X_train = pd.merge(X_train_nosite, sites.iloc[train_index], left_index=True, right_index=True)
y_test = all_data.iloc[test_index]['fb'].astype(int)
X_test_nosite = all_data.iloc[test_index][['img_count', 'word_count']]
X_test = pd.merge(X_test_nosite, sites.iloc[test_index], left_index=True, right_index=True)
至此,我们已经设置了X_test、X_train、y_test和y_train变量。现在,我们将使用我们的培训数据来构建我们的模型:
clf = RandomForestRegressor(n_estimators=1000)
clf.fit(X_train, y_train)
有了这两行代码,我们已经训练了我们的模型。让我们用它来预测脸书喜欢我们的测试集:
y_actual = y_test
deltas = pd.DataFrame(list(zip(y_pred, y_actual, (y_pred - y_actual)/(y_actual))), columns=['predicted', 'actual', 'delta'])
deltas
此代码生成以下输出:
在这里,我们可以并排看到预测值、实际值和差异百分比。让我们看看这方面的描述性统计数据:
deltas['delta'].describe()
上述代码生成以下输出:
这看起来很神奇。我们的中位数误差是 0!嗯,不幸的是,这是一个特别有用的信息,因为错误是正反两面的,并且趋向于平均,这就是我们在这里看到的。让我们看一个更有信息的度量来评估我们的模型。我们要看均方根误差占实际平均值的百分比。
评估模型
为了说明为什么这更有用,让我们在两个示例系列上运行以下场景:
a = pd.Series([10,10,10,10])
b = pd.Series([12,8,8,12])
np.sqrt(np.mean((b-a)**2))/np.mean(a)
这将生成以下输出:
现在,将它与平均值进行比较:
(b-a).mean()
这将生成以下输出:
显然,后者是更有意义的统计数据。现在,让我们为我们的模型运行它:
np.sqrt(np.mean((y_pred-y_actual)**2))/np.mean(y_actual)
这将生成以下输出:
突然间,我们令人敬畏的模型看起来不那么令人敬畏了。让我们看一下我们的模型所做的一些预测与数据中可以看到的实际值的对比:
deltas[['predicted','actual']].iloc[:30,:].plot(kind='bar', figsize=(16,8))
上述代码生成以下输出:
基于我们在这里看到的,这个模型——至少对于这个样本来说——倾向于适度低估典型文章的病毒率,但是严重低估一小部分文章的病毒率。让我们看看那些是什么:
all_data.loc[test_index[:30],['title', 'fb']].reset_index(drop=True)
前面的代码产生以下输出:
从前面的输出中,我们可以看到一篇关于马拉拉的文章和一篇关于丈夫抱怨他的全职太太花了他多少钱的文章大大超出了我们模型的预测数字。两者似乎都有很高的情感价。
为我们的模型添加新功能
现在,让我们给我们的模型添加另一个特性。让我们看看增加字数是否有助于我们的模型。我们将使用CountVectorizer来完成此操作。就像我们对网站名称所做的一样,我们将把单个单词和 n-grams 转换成特性:
from sklearn.feature_extraction.text import CountVectorizer
vect = CountVectorizer(ngram_range=(1,3))
X_titles_all = vect.fit_transform(all_data['title'])
X_titles_train = X_titles_all[train_index]
X_titles_test = X_titles_all[test_index]
X_test = pd.merge(X_test, pd.DataFrame(X_titles_test.toarray(), index=X_test.index), left_index=True, right_index=True)
X_train = pd.merge(X_train, pd.DataFrame(X_titles_train.toarray(), index=X_train.index), left_index=True, right_index=True)
在前面的几行中,我们已经将现有功能加入到新的 n-gram 功能中。让我们训练我们的模型,看看我们是否有任何改进:
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
deltas = pd.DataFrame(list(zip(y_pred, y_actual, (y_pred - y_actual)/(y_actual))), columns=['predicted', 'actual', 'delta'])
deltas
此代码生成以下输出:
如果我们再次检查错误,我们将看到以下内容:
np.sqrt(np.mean((y_pred-y_actual)**2))/np.mean(y_actual)
上述代码生成以下输出:
所以看起来我们有一个适度改进的模型。让我们在模型中增加一个特性——标题的字数:
all_data = all_data.assign(title_wc = all_data['title'].map(lambda x: len(x.split(' '))))
X_train = pd.merge(X_train, all_data[['title_wc']], left_index=True, right_index=True)
X_test = pd.merge(X_test, all_data[['title_wc']], left_index=True, right_index=True)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
np.sqrt(np.mean((y_pred-y_actual)**2))/np.mean(y_actual)
此代码生成以下输出:
似乎每个特性都适度地改进了我们的模型。当然,我们还可以添加更多的功能。例如,我们可以添加发布的日期和时间,我们可以通过在标题上运行正则表达式来确定文章是否是列表,或者我们可以检查每篇文章的情绪。但这只是触及了可能对虚拟性建模很重要的特性。我们当然需要更进一步,继续减少模型中的错误数量。
我还应该注意到,我们只对我们的模型进行了最粗略的测试。每次测量应运行多次,以获得更准确的实际误差率。因为我们只进行了一次测试,所以我们的最后两个模型之间可能没有统计上可辨别的差异。
摘要
在这一章中,我们研究了病毒内容的共同特征是什么,以及我们如何使用随机森林回归来建立一个预测病毒的模型。我们还学习了如何组合多种类型的特征,以及如何将我们的模型分成训练集和测试集。
希望你能利用在这里学到的知识,建立下一个病毒帝国。如果这种方法行不通,或许下一章关于掌握股票市场的内容会奏效。
七、使用机器学习预测股票市场
就在最近,我读到一篇文章,描述了一种特殊疗法在对抗耐甲氧西林金黄色葡萄球菌方面取得的巨大成功。如果你没有直接听说过耐甲氧西林金黄色葡萄球菌,很可能你已经听说了一些关于目前的担忧,即我们正走向抗生素不再有效的时代。这在很大程度上是一种不可避免的现象,因为群体中的一些细菌对相关药物的遗传抗性更强。当对这种药物敏感的细菌在治疗过程中被消灭时,剩余的抗药性细菌就会繁殖并成为种群中的主要变种。为了解决这个问题,科学家们不断突破科学的界限,寻找新的方法来解决这些问题。
在生物学中,这种情况被称为红皇后竞赛:这个术语来自刘易斯·卡罗尔的《透过镜子看 T2:中的一句话
"Now, here, you see, it takes all the running you can do, to keep in the same place."
这有效地描述了我们在抗生素方面的处境,但也许答案在转向新的、越来越先进的药物时找不到。也许答案可以从理解更大的循环中找到,并利用它为我们带来好处。
我之前讨论的耐甲氧西林金黄色葡萄球菌的新疗法?这实际上来自于 10 世纪的一本医学药剂书,名为《T2·巴尔德的书》。列举的成分包括大蒜、葡萄酒和洋葱。这种组合被发现已经超过了我们目前的最后治疗手段万古霉素的结果。
但是这些和预测股市有什么关系呢?我想说的是,在这两种情况下,同样的现象都在起作用。例如,偶尔会有一篇论文发表,提醒金融界存在一种有利可图的异常现象。最有可能的是,这种现象是一些外部强加的现实世界约束的下游效应。
以年终税损销售为例。由于税法的性质,交易者在年底出售损失是有意义的。这给临近年底的亏损股票带来了价格下行压力。价格下跌意味着股票可以被折价超过其公允价值。这也意味着,1 月份,随着新资金投入这些被低估的资产,下行压力消失,取而代之的是上行压力。但一旦这种现象被传播开来,交易者试图走在前面,在 12 月下旬开始买入这些股票,并在 1 月份卖给那些预计会成为买家的其他交易者,这才有意义。这些新交易者,通过进入市场,现在已经稀释了影响。他们正在缓解年底的抛售压力,减少 1 月份的买入压力。这种影响基本上是随着盈利能力一起被套利的。曾经有效的方法不再有效,交易者将开始放弃策略,转向下一个新事物。
到现在,我希望你开始看到相似之处。大蒜、葡萄酒和洋葱的组合很可能曾经是治疗细菌感染的非常有效的方法,但随着细菌的适应,这种方法逐渐失去了效力。作为一种治疗方法,这种细菌在很久以前就被放弃了,因此没有理由避开使它们容易受到这种治疗的原始基因。现实世界的限制使得这种类型的循环几乎不可避免地会发生——无论是在生物体内还是在市场中。关键是利用这一点对我们有利。
在本章中,我们将花一些时间讨论如何构建和测试交易策略。然而,我们将花更多的时间来研究如何做到这一点。当试图设计你自己的系统时,有无数的陷阱需要避免,这几乎是一个不可能的任务,但它可能会很有趣,有时甚至会有利可图。话虽如此,不要做傻事,比如拿你输不起的钱去冒险。
If you do decide to use anything you learned here to trade, you're on your own. This shouldn't be deemed investment advice of any kind, and I accept no responsibility for your actions.
在本章中,我们将涵盖以下主题:
- 市场分析的类型
- 关于股票市场,研究告诉了我们什么?
- 如何开发交易系统
市场分析的类型
让我们从讨论一些处理金融市场时的关键术语和分析方法开始。虽然有无数的金融工具,包括股票、债券、ETF、货币和掉期,但我们将只讨论股票和股票市场。股票只是上市公司所有权的一部分。当公司的未来前景上升时,股票价格预计会上升,当这些前景下降时,股票价格会下降。
投资者一般分为两大阵营。第一类是基本面分析师。这些分析师仔细研究公司财务状况,寻找表明市场在某种程度上低估公司股票价值的信息。这些投资者关注各种因素,如收入、收益和现金流,以及各种价值比率。这通常包括查看一家公司的财务状况与另一家公司的财务状况的比较。
投资者的第二个阵营是技术分析师。技术分析师认为,股价已经反映了所有可公开获得的信息,浏览基本面在很大程度上是浪费时间。他们认为,通过查看历史价格——股票图表——你可以看到价格可能上涨、下跌或停滞的领域。一般来说,他们觉得这些图表揭示了投资者心理的线索。
这两个群体的共同点是一个潜在的信念,即正确的分析可以带来利润。但这是真的吗?
关于股票市场,研究告诉了我们什么?
也许过去 50 年里对股票市场最有影响的理论是有效市场假说。尤金·法玛发展的这一理论规定,市场是理性的,所有可获得的信息都适当地反映在股票价格中。因此,投资者不可能在风险调整的基础上始终如一地跑赢市场。有效市场假说通常被认为有三种形式:弱形式、半强形式和强形式:
-
在弱形态下,市场是有效的,因为你不能用过去的价格信息来预测未来的价格。信息在股票中的反映相对较快,虽然技术分析可能无效,但在某些情况下,基本面分析可能有效。
-
在半强形式下,价格立即以不偏不倚的方式反映所有相关的新公共信息。在这里,无论是技术分析还是基本面分析都不会有效。
-
最后,在强形式中,股票价格反映了所有公共和私人信息。
基于这些理论,通过利用市场模式赚钱的希望不大。但幸运的是,尽管市场总体上以一种基本上有效的方式运行,但明显的低效区域已经被发现。其中大部分都是短暂的,但有些已经被记录为持续存在。其中最值得注意的——甚至根据法玛的说法——是动量策略的出色表现。
那么,动量策略到底是什么?
关于这个主题有许多不同的说法,但基本的观点是,股票是根据它们在前一个时期的回报从最高到最低排列的。排名靠前的表演者被购买并持有一段时间,然后在固定的持有期后重复该过程。典型的只做多的动量策略可能包括买入过去一年标准普尔 500 表现最好的 25 只股票,持有一年,卖出,然后重复这个过程。
这听起来像是一个荒谬的简单策略,事实也确实如此,但它始终会带来出乎意料的结果。但是为什么呢?你可以想象,很多研究已经检验了这种影响,假设是,关于人类如何处理新信息,存在某种内在的系统性偏见。研究表明,他们在短期内对新闻反应不足,然后在长期对新闻反应过度。这意味着,当股票在特别好的消息下开始上涨时,投资者不会将股价完全提升到充分反映这一消息的水平;他们需要时间来接受这个美好的前景。
This tendency of investors to fail to adequately reprice shares in the face of exceedingly good news may be the result of a well-documented bias called the anchoring effect. Essentially, when presented with a number, even a random number, and then asked to estimate a real-world value, such as the number of countries in Africa, for instance, our answer will be mentally tethered to that number we were primed with. Remarkably, this happens even if we know the number is randomly generated and unrelated to the question.
那么,随着越来越多的交易者了解并涌入,动量策略会不会被套利者抛弃?近年来有一些证据表明了这一点,但仍不清楚。无论如何,这种影响是显而易见的真实,并且持续的时间远远超过了有效市场假说目前所能解释的时间。因此,至少市场预测似乎有一些希望。考虑到这一点,现在让我们继续探索如何挖掘我们自己的市场异常。
如何制定交易策略
我们将从关注技术方面开始我们的战略发展。让我们来看看过去几年的标准普尔 500。我们将使用pandas来导入我们的数据。这将使我们能够访问几个股票数据来源,包括雅虎!还有谷歌。
- 首先,您需要安装数据读取器:
!pip install pandas_datareader
- 然后,继续整合您的导入:
import pandas as pd
from pandas_datareader import data, wb
import matplotlib.pyplot as plt
%matplotlib inline
pd.set_option('display.max_colwidth', 200)
- 现在,我们将得到
SPYETF 的数据,它代表了 S & P 500 的股票。我们将提取 2010 年初至 2018 年 12 月的数据:
import pandas_datareader as pdr
start_date = pd.to_datetime('2010-01-01')
stop_date = pd.to_datetime('2018-12-01')
spy = pdr.data.get_data_yahoo('SPY', start_date, stop_date)
此代码生成以下输出:
- 我们现在可以绘制我们的数据。我们将只选择收盘价:
spy_c = spy['Close']
fig, ax = plt.subplots(figsize=(15,10))
spy_c.plot(color='k')
plt.title("SPY", fontsize=20);
- 这将生成以下输出:
在上图中,我们看到了我们所选时期标准普尔 500 每日收盘价的价格图。
数据分析
让我们进行一些分析,看看如果我们投资这个 ETF,这段时间的回报会是多少:
- 我们将首先提取
first_open的数据:
first_open = spy['Open'].iloc[0]
first_open
这将生成以下输出:
- 接下来,让我们得到期末的收盘价:
last_close = spy['Close'].iloc[-1]
last_close
这将生成以下输出:
- 最后,让我们看看整个时期的变化:
last_close - first_open
这将生成以下输出:
因此,在该期间开始时购买 100 股股票将花费我们大约 11,237 美元,在该期间结束时,同样的 100 股股票的价值将大约为 27,564 美元。这笔交易会让我们在此期间获得略高于 145%的收益。一点也不坏。
现在让我们来看看同期的回报率,仅仅是日内涨幅。这假设我们在每天开盘时买入股票,并在当天收盘时卖出:
spy['Daily Change'] = pd.Series(spy['Close'] - spy['Open'])
这将使我们每天从开放到关闭的变化。让我们来看看:
spy['Daily Change']
这将生成以下输出:
现在让我们总结一下这段时间的变化:
spy['Daily Change'].sum()
这将生成以下输出:
所以,正如你所看到的,我们已经从超过 163 点的收益变成了刚刚超过 53 点的收益。哎哟!市场收益的一半以上来自这一时期的隔夜持有。
回报的波动性
隔夜收益好于日内收益,但波动性如何?回报总是在风险调整的基础上进行判断,所以让我们看看隔夜交易和盘中交易是如何根据它们的标准差进行比较的。
我们可以使用 NumPy 为我们计算如下:
np.std(spy['Daily Change'])
这将生成以下输出:
spy['Overnight Change'] = pd.Series(spy['Open'] - spy['Close'].shift(1))
np.std(spy['Overnight Change'])
这将生成以下输出:
因此,与日内交易相比,我们的隔夜交易不仅收益更高,而且波动性也更低。但并非所有的波动都是平等的。让我们比较两种策略在下跌日和上涨日的平均变化:
spy[spy['Daily Change']<0]['Daily Change'].mean()
此代码生成以下输出:
在有利的日子运行此代码:
spy[spy['Overnight Change']<0]['Overnight Change'].mean()
我们得到如下输出:
同样,我们看到,我们的隔夜交易策略的平均下行波动远小于我们的盘中交易策略。
每日收益
到目前为止,我们已经从积分的角度看了一切,但现在让我们看看每日回报。这将有助于把我们的得失放到一个更现实的背景中。让我们为每个场景创建一个熊猫系列:每日回报(接近收盘变化)、日内回报和隔夜回报:
daily_rtn = ((spy['Close'] - spy['Close'].shift(1))/spy['Close'].shift(1))*100
id_rtn = ((spy['Close'] - spy['Open'])/spy['Open'])*100
on_rtn = ((spy['Open'] - spy['Close'].shift(1))/spy['Close'].shift(1))*100
我们所做的是使用熊猫.shift()方法从前一天的系列中减去每个系列。例如,对于前面的第一个系列,我们将每天从前一天的收盘中减去收盘。这将导致少一个数据点。如果您打印出新系列,您可以看到如下内容:
Daily_rtn
这将生成以下输出:
战略统计
现在让我们来看看这三种策略的统计数据。我们将创建一个函数,它可以接收每个系列的返回,并将打印出汇总结果。我们将获得我们的每一次赢、输和盈亏平衡交易的统计数据,以及一种叫做夏普比率的东西。我之前说过,回报是在风险调整的基础上判断的;这正是夏普比率提供给我们的;这是一种通过计算回报的波动性来比较回报的方法。这里,我们使用夏普比率,并对比率进行年度调整:
def get_stats(s, n=252):
s = s.dropna()
wins = len(s[s>0])
losses = len(s[s<0])
evens = len(s[s==0])
mean_w = round(s[s>0].mean(), 3)
mean_l = round(s[s<0].mean(), 3)
win_r = round(wins/losses, 3)
mean_trd = round(s.mean(), 3)
sd = round(np.std(s), 3)
max_l = round(s.min(), 3)
max_w = round(s.max(), 3)
sharpe_r = round((s.mean()/np.std(s))*np.sqrt(n), 4)
cnt = len(s)
print('Trades:', cnt,\
'\nWins:', wins,\
'\nLosses:', losses,\
'\nBreakeven:', evens,\
'\nWin/Loss Ratio', win_r,\
'\nMean Win:', mean_w,\
'\nMean Loss:', mean_l,\
'\nMean', mean_trd,\
'\nStd Dev:', sd,\
'\nMax Loss:', max_l,\
'\nMax Win:', max_w,\
'\nSharpe Ratio:', sharpe_r)
现在让我们运行每个策略来查看统计数据。我们将从买入并持有策略(每日回报)开始,然后进入另外两个策略,如下所示:
get_stats(daily_rtn)
这将生成以下输出:
运行以下代码获取当天回报:
get_stats(id_rtn)
这将生成以下输出:
为隔夜退货运行以下代码:
get_stats(on_rtn)
这将生成以下输出:
如你所见,买入并持有策略的平均回报率最高,标准差也最高。它也有最大的每日提款(损失)。你还会注意到,即使隔夜策略的平均回报率高于日内策略,它的波动性也要小得多。这反过来又使其夏普比率高于日内策略。
在这一点上,我们有了比较未来战略的坚实基础。现在,我要告诉你一个策略,把这三个策略都从水里吹出来。
神秘策略
让我们看看这个新的神秘策略的统计数据:
通过这一策略,我基本上将夏普比率提高了一倍,大幅降低了波动性,增加了最大收益,并将最大损失降低了一个显著水平。
我是如何设计出这种打败市场的策略的?(要说出令人吃惊或高兴的事情)听着...我通过生成 5000 个随机的过夜信号来做到这一点,并选择了最好的一个。
这显然不是打败市场的方法。那我为什么要这么做呢?为了证明这一点,如果你测试了足够多的策略,你会偶然发现一个看起来很神奇的数字。这就是所谓的数据挖掘谬误,是交易策略制定中真正的风险。这就是为什么找到一种基于现实世界投资者偏见和行为的策略如此重要。如果你想在交易中占据优势,你就不要交易市场;你们这些交易市场的交易者*。*
一个优势来自深思熟虑地理解人们对某些情况的错误反应。
现在让我们扩展我们的分析。首先,我们将从 2000 年开始提取指数数据:
start_date = pd.to_datetime('2000-01-01')
stop_date = pd.to_datetime('2018-12-01')
sp = pdr.data.get_data_yahoo('SPY', start_date, stop_date)
现在让我们看看我们的图表:
fig, ax = plt.subplots(figsize=(15,10))
sp['Close'].plot(color='k')
plt.title("SPY", fontsize=20)
这将生成以下输出:
这里我们看到SPY从 2000 年初到 2018 年 12 月 1 日的价格走势。在此期间,市场无疑出现了许多波动,因为市场经历了高度积极和高度消极的局面。
让我们为我们的三个基本战略的新的扩展时期得到我们的基线。
首先,让我们为每个变量设置变量:
long_day_rtn = ((sp['Close'] - sp['Close'].shift(1))/sp['Close'].shift(1))*100
long_id_rtn = ((sp['Close'] - sp['Open'])/sp['Open'])*100
long_on_rtn = ((sp['Open'] - sp['Close'].shift(1))/sp['Close'].shift(1))*100
现在,让我们看看每一项的总分是多少:
(sp['Close'] - sp['Close'].shift(1)).sum()
这将生成以下输出:
现在,让我们来看看开盘价和收盘价的总和:
(sp['Close'] - sp['Open']).sum()
这将生成以下输出:
现在,让我们看看接近开仓的总点数是多少:
(sp['Open'] - sp['Close'].shift(1)).sum()
这将生成以下输出:
现在让我们看看每一个的统计数据:
get_stats(long_day_rtn)
这将生成以下输出:
现在,让我们看看日内回报率的统计数据:
get_stats(long_id_rtn)
这将生成以下输出:
现在,让我们看看隔夜回报的统计数据:
get_stats(long_on_rtn)
这将生成以下输出:
我们可以看到,在更长的时间内,这三者之间的差异甚至更加明显。如果你在过去 18 年里只在白天持有,你就会在这个 S&P 交易所交易基金中亏损。如果你只持有一夜,你的总积分回报会提高 18%以上!显然,这假定没有交易成本,没有税收以及完美的填充,但无论如何,这是一个了不起的发现。
建立回归模型
现在我们有了一个可以比较的基线,让我们构建第一个回归模型。我们将从一个非常基本的模型开始,仅使用股票的前一个收盘价来预测第二天的收盘价,我们将使用支持向量回归来构建它。这样,让我们建立我们的模型:
- 第一步是建立一个包含每天价格历史的数据框架。我们将在模型中包含过去 20 次收盘:
for i in range(1, 21, 1):
sp.loc[:,'Close Minus ' + str(i)] = sp['Close'].shift(i)
sp20 = sp[[x for x in sp.columns if 'Close Minus' in x or x == 'Close']].iloc[20:,]
sp20
- 这段代码给了我们每天的收盘价,以及之前的 20,都在同一条线上。我们代码的结果可以在下面的输出中看到:
- 这将形成我们将为模型提供的 X 数组的基础。但是在我们准备好之前,还有一些额外的步骤。
- 首先,我们将反转我们的列,以便时间从左向右运行:
sp20 = sp20.iloc[:,::-1]
sp20
这将生成以下输出:
- 现在,让我们导入我们的支持向量机,并设置我们的训练和测试矩阵和向量:
from sklearn.svm import SVR
clf = SVR(kernel='linear')
X_train = sp20[:-2000]
y_train = sp20['Close'].shift(-1)[:-2000]
X_test = sp20[-2000:]
y_test = sp20['Close'].shift(-1)[-2000:]
- 我们只有 5000 个数据点可以使用,所以我选择使用最后 2000 个数据点进行测试。现在,让我们调整我们的模型,并使用它来检查样本外数据:
model = clf.fit(X_train, y_train)
preds = model.predict(X_test)
- 现在我们有了预测,让我们将它们与实际数据进行比较:
tf = pd.DataFrame(list(zip(y_test, preds)), columns=['Next Day Close', 'Predicted Next Close'], index=y_test.index)
tf
上述代码生成以下输出:
模型的性能
现在让我们看看我们模型的性能。如果预期收盘价高于开盘价,我们将买入第二天的开盘价。然后我们将在当天收盘时卖出。我们需要在数据框中添加一些额外的数据点来计算结果,如下所示:
cdc = sp[['Close']].iloc[-1000:]
ndo = sp[['Open']].iloc[-1000:].shift(-1)
tf1 = pd.merge(tf, cdc, left_index=True, right_index=True)
tf2 = pd.merge(tf1, ndo, left_index=True, right_index=True)
tf2.columns = ['Next Day Close', 'Predicted Next Close', 'Current Day Close', 'Next Day Open']
tf2
这将生成以下输出:
在这里,我们将添加以下代码来获取我们的信号以及信号的损益:
def get_signal(r):
if r['Predicted Next Close'] > r['Next Day Open']:
return 1
else:
return 0
def get_ret(r):
if r['Signal'] == 1:
return ((r['Next Day Close'] - r['Next Day Open'])/r['Next Day Open']) * 100
else:
return 0
tf2 = tf2.assign(Signal = tf2.apply(get_signal, axis=1))
tf2 = tf2.assign(PnL = tf2.apply(get_ret, axis=1))
tf2
这将生成以下输出:
现在让我们看看,仅使用价格历史记录,我们是否能够成功预测第二天的价格。我们将从计算所得积分开始:
(tf2[tf2['Signal']==1]['Next Day Close'] - tf2[tf2['Signal']==1]['Next Day Open']).sum()
这将生成以下输出:
哎哟!这看起来很糟糕。但是我们测试的时间段呢?我们从来没有单独评估过。在过去的 2000 天里,我们的基本日内策略会产生多少积分:
(sp['Close'].iloc[-2000:] - sp['Open'].iloc[-2000:]).sum()
这将生成以下输出:
所以看起来我们的策略很糟糕。让我们比较两者。
一、本期基本盘中策略:
get_stats((sp['Close'].iloc[-2000:] - sp['Open'].iloc[-2000:])/sp['Open'].iloc[-2000:] * 100)
这将生成以下输出:
现在我们模型的结果是:
get_stats(tf2['PnL'])
这将生成以下输出:
很明显,我们的战略不是我们想要实施的。我们如何改进我们这里的东西?如果我们修改交易策略呢?如果我们只接受那些比开盘价高一个点或更多的交易,而不仅仅是比开盘价高一个点或更多。有帮助吗?让我们试试。我们将使用修改后的信号重新运行我们的策略,如以下代码块所示:
def get_signal(r):
if r['Predicted Next Close'] > r['Next Day Open'] + 1:
return 1
else:
return 0
def get_ret(r):
if r['Signal'] == 1:
return ((r['Next Day Close'] - r['Next Day Open'])/r['Next Day Open']) * 100
else:
return 0
tf2 = tf2.assign(Signal = tf2.apply(get_signal, axis=1))
tf2 = tf2.assign(PnL = tf2.apply(get_ret, axis=1))
(tf2[tf2['Signal']==1]['Next Day Close'] - tf2[tf2['Signal']==1]['Next Day Open']).sum()
这将生成以下输出:
现在是统计数据:
get_stats(tf2['PnL'])
这将生成以下输出:
我们每况愈下。看来,如果过去的价格历史表明好事即将到来,你可以期待正好相反。我们的模型似乎开发了一个反向指标。如果我们探索一下呢?让我们看看,如果我们翻转我们的模型,当我们预测强劲的收益时,我们不会交易,但除此之外,我们会交易:
def get_signal(r):
if r['Predicted Next Close'] > r['Next Day Open'] + 1:
return 0
else:
return 1
def get_ret(r):
if r['Signal'] == 1:
return ((r['Next Day Close'] - r['Next Day Open'])/r['Next Day Open']) * 100
else:
return 0
tf2 = tf2.assign(Signal = tf2.apply(get_signal, axis=1))
tf2 = tf2.assign(PnL = tf2.apply(get_ret, axis=1))
(tf2[tf2['Signal']==1]['Next Day Close'] - tf2[tf2['Signal']==1]['Next Day Open']).sum()
这将生成以下输出:
让我们得到我们的统计数据:
get_stats(tf2['PnL'])
这将生成以下输出:
看起来我们确实有一个反向指标。当我们的模型预测第二天会有强劲的上涨时,市场表现明显不佳,至少在我们的测试期间是这样。这在大多数情况下都成立吗?不太可能。市场倾向于从均值回归机制转向趋势持续机制。
在这一点上,我们可以对这个模型进行一些扩展。我们甚至还没有触及在我们的模型中使用技术指标或基本数据,我们已经将交易限制在一天内。所有这些都可以调整和扩展,但有一个重要的点我们没有解决,必须提到。
我们正在处理的数据是一种特殊类型的数据,称为时间序列数据。时间序列数据需要特殊处理才能正确建模,因为它通常违反统计建模所需的假设,例如恒定的均值和方差。
不恰当地处理时间序列数据的一个后果是错误的度量给出了非常不准确的度量。由于显著的自相关性,换句话说,下一个时期的数据与当前时期的数据高度相关,似乎我们已经实现了比实际更好的预测。
为了解决这些问题,时间序列数据经常被差分(在股票数据的情况下,这意味着我们看的是日变化,而不是指数的绝对水平)以使其成为我们所说的平稳;也就是说,它具有恒定的均值和方差,并且缺乏显著的自相关性。
如果你打算继续研究时间序列数据,我恳求你更详细地研究这些概念。
动态时间扭曲
然而,接下来我想介绍另一个模型,它使用了完全不同的算法。这个算法叫做动态时间扭曲。它的作用是给你一个度量,代表两个时间序列之间的相似性:
- 首先,我们需要
pip install``fastdtw库:
!pip install fastdtw
- 安装后,我们将导入所需的其他库:
from scipy.spatial.distance import euclidean
from fastdtw import fastdtw
- 接下来,我们将创建两个系列的函数,并返回它们之间的距离:
def dtw_dist(x, y):
distance, path = fastdtw(x, y, dist=euclidean)
return distance
- 现在,我们将把 18 年的时间序列数据分成不同的 5 天周期。我们将把每个周期与一个额外的点配对。这将用于创建我们的 x 和 y 数据,如下所示:
tseries = []
tlen = 5
for i in range(tlen, len(sp), tlen):
pctc = sp['Close'].iloc[i-tlen:i].pct_change()[1:].values * 100
res = sp['Close'].iloc[i-tlen:i+1].pct_change()[-1] * 100
tseries.append((pctc, res))
- 我们可以看一下我们的第一个系列,了解一下数据是什么样的:
tseries[0]
这将生成以下输出:
- 现在我们有了每个系列,我们可以通过我们的算法运行它们,以获得每个系列相对于其他系列的距离度量:
dist_pairs = []
for i in range(len(tseries)):
for j in range(len(tseries)):
dist = dtw_dist(tseries[i][0], tseries[j][0])
dist_pairs.append((i,j,dist,tseries[i][1], tseries[j][1]))
一旦我们有了它,我们就可以把它放入DataFrame中。我们将删除具有0距离的系列,因为它们代表相同的系列。我们还将根据系列的日期进行排序,只查看第一个系列在第二个系列之前的那些,按时间顺序来说:
dist_frame = pd.DataFrame(dist_pairs, columns=['A','B','Dist', 'A Ret', 'B Ret'])
sf = dist_frame[dist_frame['Dist']>0].sort_values(['A','B']).reset_index(drop=1)
sfe = sf[sf['A']<sf['B']]
最后,我们将限制距离小于1且第一个系列有正回报的交易:
winf = sfe[(sfe['Dist']<=1)&(sfe['A Ret']>0)]
winf
这将生成以下输出:
让我们看看我们的一个顶级模式(A:6 和 B:598)在绘制时是什么样子的:
plt.plot(np.arange(4), tseries[6][0]);
上述代码生成以下输出:
现在,我们将绘制第二个:
plt.plot(np.arange(4), tseries[598][0])
上述代码生成以下输出:
如你所见,曲线几乎完全相同,这正是我们想要的。我们将试图找到所有第二天有正收益的曲线,然后,一旦我们有一条与这些盈利曲线高度相似的曲线,我们将购买它,期待另一个收益。
评估我们的交易
现在让我们构造一个函数来评估我们的交易。我们将购买类似的曲线,除非它们不能返回正的结果。如果发生这种情况,我们将消除它们,如下所示:
excluded = {}
return_list = []
def get_returns(r):
if excluded.get(r['A']) is None:
return_list.append(r['B Ret'])
if r['B Ret'] < 0:
excluded.update({r['A']:1})
winf.apply(get_returns, axis=1);
现在我们已经将交易的所有回报存储在return_list中,让我们评估结果:
get_stats(pd.Series(return_list))
这将生成以下输出:
这些结果是迄今为止我们看到的最好的。胜败比和平均值远高于我们的其他模型。看来我们可能对这种新模式有所了解,尤其是与我们已经看到的其他模式相比。
此时,为了进一步检查我们的模型,我们应该通过检查匹配的其他时间段来探索它的健壮性。超过四天是否会改善模型?我们应该总是排除产生损失的模式吗?在这一点上有大量的问题需要探索,但是我将把这个留给读者作为练习。
摘要
在这一章中,我们研究了股票市场的内部运作,并探索了在交易策略中利用机器学习的多种方法。毫无疑问,这一章的材料可以填满一本书。我们甚至没有涵盖交易的一些最重要的方面,比如投资组合构建、风险缓解和资金管理。这些是任何战略的关键组成部分,甚至可能比贸易信号更重要。
希望这将成为你自己探索的起点,但我再次提醒你,击败市场是一个几乎不可能的游戏——在这个游戏中,你与世界上最聪明的人竞争。如果你决定尝试,我祝你好运。只要记住,我警告过你,如果结果不像你希望的那样!
八、使用卷积神经网络分类图像
在这一章中,我们将探索广阔而令人敬畏的计算机视觉世界。
如果你曾经想用图像数据构建一个预测性的机器学习模型,这一章将作为一个容易消化和实用的资源。我们将逐步构建一个图像分类模型,交叉验证它,然后以更好的方式构建它。在本章的最后,我们将有一个该死的好模型,并讨论一些未来增强的路径。
当然,预测性建模基础中的一些背景知识将有助于顺利进行。正如您将很快看到的,将图像转换为我们模型的可用特征的过程可能会感觉很新,但是一旦提取了我们的特征,模型构建和交叉验证过程就完全相同了。
在本章中,我们将构建一个卷积神经网络来对来自 Zalando Research 数据集的服装物品图像进行分类——该数据集包含 70,000 幅图像,每幅图像描绘了 10 种可能的服装物品中的 1 种,如 t 恤/上衣、裤子、毛衣、连衣裙、外套、凉鞋、衬衫、运动鞋、包或踝靴。但是首先,我们将一起探索一些基础知识,从图像特征提取开始,逐步了解卷积神经网络是如何工作的。
那么,让我们开始吧。说真的!。
这就是我们将在本章中介绍的内容:
- 图像特征提取
- 卷积神经网络;
- 网络拓扑结构
- 卷积层和滤波器
- 最大池层数
- 变平
- 全连接层和输出
- 使用 Keras 构建卷积神经网络,对 Zalando 研究数据集中的图像进行分类
图像特征提取
当处理非结构化数据时,无论是文本还是图像,我们都必须首先将数据转换为机器学习模型可用的数字表示。将非数值数据转换为数值表示的过程称为特征提取。对于图像数据,我们的特征是图像的像素值。
首先,让我们想象一个 1,150 x 1,150 像素的灰度图像。1,150 x 1,150 像素的图像将返回 1,150 x 1,150 像素强度矩阵。对于灰度图像,像素值的范围可以从 0 到 255,0 是完全黑色的像素,255 是完全白色的像素,中间是灰色阴影。
为了演示代码中的样子,让我们从灰度猫卷饼中提取特征。图片可在 GitHub 上获得,网址为。
I've made the image assets used throughout this chapter available to you at github.com/mroman09/pa…. You can find our cat burritos there!
现在让我们看看下面代码中的一个示例:
import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import pandas as pd
%matplotlib inline
cat_burrito = mpimg.imread('img/grayscale_cat_burrito.jpg')
cat_burrito
If you're unable to read a .jpg by running the preceding code, just install PIL by running pip install pillow.
在前面的代码中,我们从matplotlib导入了pandas和两个子模块:image和pyplot。我们使用matplotlib.image中的imread方法读入图像。
运行前面的代码会得到以下输出:
输出是包含我们模型特征的二维数组。与大多数应用机器学习应用一样,您需要对这些提取的特征执行几个预处理步骤,其中一些我们将在本章稍后在 Zalando 时尚数据集上一起探讨,但这些是图像的原始提取特征!
为我们的灰度图像提取的特征的形状是image_height行 x image_width列。我们可以通过运行以下命令轻松检查形状:
cat_burrito.shape
前面的代码返回以下输出:
我们也可以轻松检查阵列中的最大和最小像素值:
print(cat_burrito.max())
print(cat_burrito.min())
这将返回以下内容:
最后,我们可以通过运行以下代码来显示我们阵列中的灰度图像:
plt.axis('off')
plt.imshow(cat_burrito, cmap='gray');
前面的代码返回了我们的图像,可在https://github . com/PacktPublishing/Python-机器学习-蓝图-第二版/树/主/第 08 章中找到作为output_grayscale_cat_burrito.png。
彩色图像的特征提取过程是相同的;然而,对于彩色图像,我们的阵列输出的形状将是三维的——一个张量——代表我们图像的红色、绿色和蓝色 ( RGB )像素值。在这里,我们将执行与之前相同的过程,这次是在彩色版的猫卷饼上。图片可在 GitHub 上https://GitHub . com/PacktPublishing/Python-机器学习-蓝图-第二版/树/主/章节 08 作为color_cat_burrito.jpg获得。
让我们使用以下代码从彩色版的猫卷饼中提取特征:
color_cat_burrito = mpimg.imread('img/color_cat_burrito.jpg')
color_cat_burrito.shape
运行此代码将返回以下输出:
同样,在这里我们看到这个图像包含三个通道。我们的color_cat_burrito变量是一个张量,包含三个矩阵,告诉我们图像中每个像素的 RGB 值是多少。
我们可以通过运行以下命令来显示阵列中的彩色图像:
plt.axis('off')
plt.imshow(color_cat_burrito);
这将返回我们的彩色图像。图片可在 GitHub 上获得,网址为。
这是我们图像特征提取的第一步。我们一次只拍摄一张图像,只需几行代码就可以将这些图像转换成数值。在这样做的时候,我们看到从灰度图像中提取特征产生了二维数组,从彩色图像中提取特征产生了像素强度值的张量。
不过,有一个小问题。请记住,这只是我们数据的单个图像、单个训练样本和单个行。在我们的灰度图像的例子中,如果我们将这个矩阵展平成一行,我们将有image_height x image_width列,或者在我们的例子中,1,322,500 列。我们可以通过运行下面的代码片段来确认这一点:
# flattening our grayscale cat_burrito and checking the length
len(cat_burrito.flatten())
这是一个问题!与其他机器学习建模任务一样,高维度会导致模型性能问题。在这个维度的数量级上,我们构建的任何模型都可能会过度拟合,并且训练时间会很慢。
这种维度问题是这类计算机视觉任务的通病。即使是分辨率较低的数据集,即 400 x 400 像素灰度的猫卷饼,每张图像也会有 160,000 个特征。
然而,这个问题有一个已知的解决方案:卷积神经网络。在下一节中,我们将继续使用卷积神经网络来构建这些原始图像像素的低维表示的特征提取过程。我们将讨论它们的工作原理,并继续构建它们在图像分类任务中如此出色的原因。
卷积神经网络
卷积神经网络是一类神经网络,它解决了我们在前面部分中提到的高维问题,因此在图像分类任务中表现出色。事实证明,给定图像区域中的图像像素高度相关——它们告诉我们关于该特定图像区域的类似信息。因此,使用卷积神经网络,我们可以扫描图像的区域,并在低维空间中总结该区域。正如我们将看到的,这些被称为特征图的低维表示告诉我们许多关于各种形状存在的有趣的事情——从最简单的线条、阴影、循环和漩涡,到非常抽象、复杂的特定于我们的数据的形式,在我们的例子中是猫耳朵、猫脸或玉米饼——并且以比原始图像更少的维度来做这件事。
在使用卷积神经网络从我们的图像中提取这些低维特征之后,我们将把卷积神经网络的输出传递到适合于我们想要执行的分类或回归任务的网络中。在我们的例子中,当对 Zalando 研究数据集建模时,我们的卷积神经网络的输出将被传递到一个完全连接的神经网络中,用于多类分类。
但是这是如何工作的呢?关于灰度图像上的卷积神经网络,我们将讨论几个关键组件,这些对于建立我们的理解都很重要。
网络拓扑结构
您可能已经遇到了类似于上述的图表,该图表描述了卷积神经网络到前馈神经网络的体系结构。我们很快就会建造这样的东西!但是这里描绘的是什么呢?看看吧:
在上图中,在最左边,我们有我们的输入。这些是我们图像的提取特征,从 0 到 255 范围内的值的矩阵(如灰度猫卷饼的情况)描述了图像中存在的像素强度。
接下来,我们通过交替的卷积层和最大池层传递数据。这些层定义了所描述的体系结构的卷积神经网络组件。我们将在接下来的两个部分中描述这些层类型的作用。
之后,我们将数据传递到一个完全连接的层,然后到达输出层。这两层描述了一个完全连接的神经网络。在这里,您可以自由使用任何您喜欢的多类分类算法,而不是完全连接的神经网络——也许是逻辑回归或随机森林分类器——但是对于我们的数据集,我们将使用完全连接的神经网络。
所描绘的输出层与任何其他多类分类器相同。以我们的猫卷饼为例,假设我们正在构建一个模型来预测图像来自五个不同的类别:鸡猫卷饼、牛排猫卷饼、猫卷饼、素食猫卷饼或鱼猫卷饼(我将让您利用您的想象力来想象我们的训练数据可能是什么样子)。输出层是图像属于五个类别之一的预测概率,其中max(probability)表示我们的模型认为最有可能的类别。
在高层次上,我们已经浏览了前面网络的架构或拓扑。我们已经讨论了我们的输入与前面拓扑的卷积神经网络组件和全连接神经网络组件的对比。现在让我们稍微深入一点,添加一些概念,让我们能够更详细地描述拓扑:
- 网络有多少个卷积层?两个。
- 而在每个卷积层中,有多少个特征映射?卷积层 1 有 7 个,卷积层 2 有 12 个。
- 网络有多少个池层?两个。
- 有多少个完全连接的层?一个。
- 全连接层有多少神经元?10.
- 产出是什么?五个。
建模者决定使用两个卷积层来对抗任何其他数字,或者仅使用一个完全连接的层来对抗任何其他数字,这应该被认为是模型的超参数。也就是说,这是我们作为建模者应该尝试和交叉验证的东西,但不是我们的模型正在明确学习和优化的参数。
通过查看网络拓扑,您可以推断出您正在解决的问题的其他有用信息。正如我们所讨论的,我们网络的输出层包含五个节点的事实让我们知道,这个神经网络被设计来解决一个有五个类的多类分类任务。如果这是一个回归或二进制分类问题,我们的网络架构将(在大多数情况下)只有一个输出节点。我们还知道,建模器在第一个卷积层中使用了 7 个滤波器,在第二个卷积层中使用了 12 个内核,这是因为每一层产生的特征图的数量(我们将在下一节中更详细地讨论这些内核是什么)。
太好了。我们学习了一些有用的行话,这些行话将帮助我们描述我们的网络,并建立我们对它们如何工作的概念理解。现在让我们来探索一下我们架构的卷积层。
卷积层和滤波器
卷积层和滤波器是卷积神经网络的核心。在这些层中,我们将一个过滤器(在本文中也称为窗口或内核)滑过我们的标准特征,并在每一步获取内积。以这种方式对我们的数组和内核进行卷积会得到一个低维的图像表示。让我们来探索一下这在这个灰度图像上是如何工作的(可在图像资产存储库中找到):
前面的图像是一个 5 x 5 像素的灰度图像,在白色背景下显示了一条黑色对角线。
从下图中提取特征,我们得到以下像素强度矩阵:
接下来,让我们假设我们(或 Keras)实例化了以下内核:
我们现在将可视化卷积过程。窗口的移动是从图像矩阵的左上开始的。我们将窗口向右滑动一个预定的步幅。在这种情况下,我们的步幅大小将是 1,但一般来说,步幅大小应该被视为您的模型的另一个超参数。一旦窗口到达图像的最右边,我们将窗口向下滑动 1(我们的步幅大小),将窗口移回图像的最左边,并再次开始获取内积的过程。
现在让我们一步一步来:
- 将内核滑过矩阵的左上角,计算内积:
我将明确规划第一步的内部产品,以便您可以轻松地完成:
(0x0)+(255x0)+(255x0)+(255x0)+(0x1)+(255x0)+(255x0)+(255x0)+(0x0) = 0
我们将结果写入我们的要素地图并继续!
- 获取内部产品,并将结果写入我们的功能图:
- 第三步:
- 我们已经到达图像的最右边。将窗口向下滑动 1,即我们的步幅大小,并在图像的最左边重新开始该过程:
- 第五步:
- 第六步:
- 第七步:
- 第八步:
- 第九步:
瞧啊。我们现在已经在一个 3×3 矩阵(我们的特征图)中表示了我们最初的 5×5 图像。在这个玩具示例中,我们已经能够将维度从 25 个特征减少到只有 9 个。让我们看一下这个操作产生的图像:
如果你认为这看起来和我们原来的黑色对角线一模一样,但是更小,你是对的。内核取的值决定了什么被识别,在这个具体的例子中,我们使用了所谓的身份内核。取其他值的内核将返回图像的其他属性——检测线条、边缘、轮廓、高对比度区域等的存在。
我们将在每个卷积层同时对图像应用多个核。使用的内核数量取决于建模者——另一个超参数。理想情况下,您希望使用尽可能少的,同时仍然获得可接受的交叉验证结果。越简单越好!但是,根据任务的复杂程度,我们可能会通过使用更多来获得性能提升。当调整模型的其他超参数时,例如网络中的层数或每层神经元的数量,可以应用相同的思想。我们用简单换取复杂,用概括和速度换取细节和精确。
虽然内核的数量是我们的选择,但每个内核取的值是我们模型的参数,它是从我们的训练数据中学习的,并在训练过程中以降低成本函数的方式进行优化。
我们已经看到了如何将过滤器与图像特征进行卷积以创建单个特征图的逐步过程。但是当我们同时应用多个内核时会发生什么呢?这些要素地图如何穿过网络的每一层?让我们看看下面的截图:
Image source: Lee et al., Convolutional Deep Belief Networks for Scalable Unsupervised Learning of Hierarchical Representations, via stack exchange. Source text here: ai.stanford.edu/~ang/papers…
前面的截图可视化了在人脸图像上训练的网络的每个卷积层生成的特征图。在网络的早期层(最底层),我们检测到简单视觉结构的存在——简单的线条和边缘。我们用我们的身份内核做到了这一点!第一层的输出传递到下一层(中间一行),它将这些简单的形状组合成抽象的形式。我们在这里看到,边缘的组合构成了一张脸的组成部分——眼睛、鼻子、耳朵、嘴巴和眉毛。这个中间层的输出依次传递到最后一层,最后一层将边缘组合成完整的对象——在这种情况下,是不同人的脸。
整个过程的一个特别强大的特性是,所有这些特征和表示都是从数据中学习的。我们没有明确告诉我们的模型:模型,对于这个任务,我想在第一个卷积层中使用一个身份核和一个底部 sobel 核,因为我认为这两个核将提取信号最丰富的特征图。一旦我们为我们想要使用的核的数量设置了超参数,模型就通过优化来学习什么线、边、阴影以及它们的复杂组合最适合于确定一张脸是什么或者不是什么。该模型执行这种优化,没有关于什么是脸、猫卷饼或衣服的特定领域的硬编码规则。
卷积神经网络还有许多其他迷人的特性,这一章我们不会涉及。然而,我们确实探索了基本原理,希望你能意识到使用卷积神经网络提取高表达、信号丰富、低维特征的重要性。
接下来,我们将讨论最大池层。
最大池层数
我们已经讨论了减少维度空间的重要性,以及如何使用卷积层来实现这一点。出于同样的原因,我们使用最大池层来进一步降低维度。非常直观地说,顾名思义,使用最大池,我们在要素地图上滑动一个窗口,并获取该窗口的最大值。让我们从对角线示例返回到要素地图来说明,如下所示:
让我们看看当我们使用 2 x 2 窗口最大化前面的要素地图时会发生什么。同样,我们在这里所做的就是返回max(values in window):
- 返回
max(0,255,255,0),得到 us 255:
- 第二步:
- 第三步:
- 第四步:
通过将我们的要素地图与一个 2 x 2 窗口最大化,我们去掉了一列和一行,使我们从一个 3 x 3 的表示变成了一个 2 x 2 的表示——还不错!
还有其他形式的统筹——例如平均统筹和最低统筹;但是,您会看到最常使用的 max pooling。
接下来,我们将讨论展平,这是我们将执行的一个步骤,用于将最大集合要素图转换为适合建模的形状。
变平
到目前为止,我们专注于构建尽可能精简和表达的特征表示,并使用卷积神经网络和最大池层来做到这一点。我们转换的最后一步是将卷积和最大集合的数组(在我们的例子中是一个 2×2 的矩阵)展平成一行训练数据。
我们的最大池对角线黑线示例在代码中看起来如下所示:
import numpy as np
max_pooled = np.array([[255,255],[255,255]])
max_pooled
运行此代码将返回以下输出:
我们可以通过运行以下命令来检查形状:
max_pooled.shape
这将返回以下输出:
要把这个矩阵变成一个单一的训练样本,我们只需运行flatten()。让我们这样做,看看我们的扁平矩阵的形状:
flattened = max_pooled.flatten()
flattened.shape
这将生成以下输出:
最初是像素强度的 5×5 矩阵,现在是具有四个特征的单行。我们现在可以把这个传递到一个完全连接的神经网络中。
全连接层和输出
完全连接的图层是我们将输入映射到目标类的地方,这些输入是我们卷积、最大池化和展平原始提取要素后得到的行。这里,每个输入连接到下一层的每个神经元或节点。这些连接的强度或权重和网络每个节点中存在的偏差项是模型的参数,在整个训练过程中进行优化以最小化目标函数。
我们模型的最后一层将是我们的输出层,它给出我们的模型预测。我们的输出层中的神经元数量和我们应用于它的激活函数由我们试图解决的问题类型决定:回归、二元分类或多类分类。在下一节中,当我们开始使用 Zalando Research 时尚数据集时,我们将看到如何为多类分类任务设置完全连接的层和输出层。
The fully-connected layers and output—that is, the feedforward neural network component of our architecture—belong to a distinct neural network type from the convolutional neural networks we discussed in this section. We briefly described how feedforward networks work in this section only to provide color on how the classifier component of our architecture works. You can always substitute this portion of the architecture for a classifier you are more familiar with, such as a logit!
有了这些基础知识,您现在就可以构建您的网络了!
使用 Keras 构建卷积神经网络,对 Zalando 研究数据集中的图像进行分类
在这一节中,我们将使用 Zalando Research 的时尚数据集构建卷积神经网络来对服装图像进行分类。该数据集的存储库位于github.com/zalandorese…。
这个数据集包含 70,000 个灰度图像——每个图像描绘一件衣服——来自 10 件可能的衣服。具体来说,目标类如下:t 恤/上衣、裤子、毛衣、连衣裙、外套、凉鞋、衬衫、运动鞋、包包和踝靴。
总部位于德国的电子商务公司 Zalando 发布了这个数据集,为研究人员提供了手写数字的经典 MNIST 数据集的替代方案。此外,这个他们称之为时尚 MNIST 的数据集在出色预测方面更具挑战性——MNIST 手写数字数据集可以以 99.7%的准确率进行预测,而无需大量预处理或特别深入的神经网络。
所以,让我们开始吧!请遵循以下步骤:
- 将存储库克隆到我们的桌面上。从终端运行以下命令:
cd ~/Desktop/
git clone git@github.com:zalandoresearch/fashion-mnist.git
If you haven't done so already, please install Keras by running pip install keras from the command line. We'll also need to install TensorFlow. To do this, run pip install tensorflow from the command line.
- 导入我们将使用的库:
import sys
import numpy as np
import pandas as pd
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPool2D
from keras.utils import np_utils, plot_model
from PIL import Image
import matplotlib.pyplot as plt
这些库中的许多现在应该看起来很熟悉了。然而,对于你们中的一些人来说,这可能是你第一次使用 Keras。Keras 是一个流行的 Python 深度学习库。它是一个包装器,可以运行在机器学习框架上,如 TensorFlow、CNTK 或 Antano。
对于我们的项目,Keras 将在幕后运行 TensorFlow。直接使用 TensorFlow 将允许我们更明确地控制我们网络的行为;然而,由于 TensorFlow 使用数据流图来表示其操作,这可能需要一些时间来适应。对我们来说幸运的是,Keras 提取了很多这样的东西,对于那些对sklearn感到舒适的人来说,它的应用编程接口很容易学习。
对于在座的一些人来说,唯一新的库将是 Python 图像库 ( PIL )。PIL 提供了某些图像处理功能。我们将使用它来可视化我们的 Keras 网络的拓扑。
- 载入数据。Zalando 为我们提供了一个帮助脚本,为我们进行加载。我们只需要确保
fashion-mnist/utils/在我们的道路上:
sys.path.append('/Users/Mike/Desktop/fashion-mnist/utils/')
import mnist_reader
- 使用助手脚本加载数据:
X_train, y_train = mnist_reader.load_mnist('/Users/Mike/Desktop/fashion-mnist/data/fashion', kind='train')
X_test, y_test = mnist_reader.load_mnist('/Users/Mike/Desktop/fashion-mnist/data/fashion', kind='t10k')
- 看一下
X_train、X_test、y_train、y_test的形状:
print(X_train.shape, y_train.shape)
print(X_test.shape, y_test.shape)
运行该代码会得到以下输出:
在这里,我们可以看到我们的训练集包含 60,000 个图像,我们的测试包含 10,000 个图像。每个图像当前是元素长的值 784 的向量。现在让我们检查数据类型:
print(type(X_train))
print(type(y_train))
print(type(X_test))
print(type(y_test))
这将返回以下内容:
接下来,让我们看看数据是什么样子的。请记住,在当前形式下,每个图像都是一个值向量。我们知道图像是灰度的,所以为了可视化每幅图像,我们必须将这些向量重塑为 28×28 的矩阵。让我们来看看第一张图片:
image_1 = X_train[0].reshape(28,28)
plt.axis('off')
plt.imshow(image_1, cmap='gray');
这将生成以下输出:
太棒了。我们可以通过运行以下命令来查看该图像所属的类:
y_train[0]
这将生成以下输出:
这些类是从 0 到 9 编码的。在自述文件中,Zalando 为我们提供了映射:
考虑到这一点,我们现在知道我们的第一张图片是踝靴。太好了。让我们创建这些编码值到它们类名的显式映射。这会马上派上用场:
mapping = {0: "T-shirt/top", 1:"Trouser", 2:"Pullover", 3:"Dress",
4:"Coat", 5:"Sandal", 6:"Shirt", 7:"Sneaker", 8:"Bag", 9:"Ankle Boot"}
太好了。我们已经看到了一张图片,但是我们仍然需要对数据中的内容有所了解。这些图像看起来像什么?掌握这一点会告诉我们一些事情。举个例子,我很想看看这些类在视觉上有多明显。看起来与其他类相似的类比更独特的类更难区分。
在这里,我们定义了一个助手函数来帮助我们完成可视化之旅:
def show_fashion_mnist(plot_rows, plot_columns, feature_array, target_array, cmap='gray', random_seed=None):
'''Generates a plot_rows * plot_columns grid of randomly selected images from a feature array. Sets the title of each subplot equal to the associated index in the target array and unencodes (i.e. title is in plain English, not numeric). Takes as optional args a color map and a random seed. Meant for EDA.'''
# Grabs plot_rows*plot_columns indices at random from X_train.
if random_seed is not None:
np.random.seed(random_seed)
feature_array_indices = np.random.randint(0,feature_array.shape[0], size = plot_rows*plot_columns)
# Creates our plots
fig, ax = plt.subplots(plot_rows, plot_columns, figsize=(18,18))
reshaped_images_list = []
for feature_array_index in feature_array_indices:
# Reshapes our images, appends tuple with reshaped image and class to a reshaped_images_list.
reshaped_image = feature_array[feature_array_index].reshape((28,28))
image_class = mapping[target_array[feature_array_index]]
reshaped_images_list.append((reshaped_image, image_class))
# Plots each image in reshaped_images_list to its own subplot
counter = 0
for row in range(plot_rows):
for col in range(plot_columns):
ax[row,col].axis('off')
ax[row, col].imshow(reshaped_images_list[counter][0],
cmap=cmap)
ax[row, col].set_title(reshaped_images_list[counter][1])
counter +=1
这个函数是做什么的?它创建了一个从数据中随机选择的图像网格,这样我们就可以同时查看多个图像。
它将所需的图像行数(plot_rows)、图像列数(plot_columns)、我们的X_train ( feature_array)和y_train ( target_array)作为参数,并生成一个很大的图像矩阵。作为可选参数,如果复制可视化很重要,您可以指定cmap或颜色图(默认为‘gray',因为它们是灰度图像)和random_seed。
让我们看看如何运行它,如下所示:
show_fashion_mnist(4,4, X_train, y_train, random_seed=72)
这将返回以下内容:
Visualization output
删除random_seed参数,并多次重新运行该函数。具体来说,运行以下代码:
show_fashion_mnist(4,4, X_train, y_train)
您可能已经注意到,在这个分辨率下,一些类看起来非常相似,而另一些类则非常不同。例如,t 恤/上衣目标类的样本看起来与衬衫和外套目标类的样本非常相似,而凉鞋目标类似乎与其他目标类有很大不同。当思考我们的模型在哪里可能是弱的,在哪里可能是强的时,这是一个值得思考的问题。
现在让我们来看一下目标类在数据集中的分布。我们需要进行上采样还是下采样?让我们检查一下:
y = pd.Series(np.concatenate((y_train, y_test)))
plt.figure(figsize=(10,6))
plt.bar(x=[mapping[x] for x in y.value_counts().index], height = y.value_counts());
plt.xlabel("Class")
plt.ylabel("Number of Images per Class")
plt.title("Distribution of Target Classes");
运行前面的代码会生成如下图:
太棒了。这里没有阶级平衡。
接下来,让我们开始预处理数据,为建模做准备。
正如我们在图像特征提取部分所讨论的,这些灰度图像包含的像素值范围从 0 到 255。我们通过运行以下代码来确认这一点:
print(X_train.max())
print(X_train.min())
print(X_test.max())
print(X_test.min())
这将返回以下值:
出于建模的目的,我们希望将这些值标准化为 0–1。这是为建模准备图像数据时常见的预处理步骤。将我们的值保持在这个范围内将允许我们的神经网络更快地收敛。我们可以通过运行以下命令来规范化数据:
# First we cast as float
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# Then normalize
X_train /= 255
X_test /= 255
我们的数据现在从 0.0 缩放到 1.0。我们可以通过运行以下代码来确认这一点:
print(X_train.max())
print(X_train.min())
print(X_test.max())
print(X_test.min())
这将返回以下输出:
在运行我们的第一个 Keras 网络之前,我们需要执行的下一个预处理步骤是重塑我们的数据。记住,我们X_train和X_test的形状目前分别是(60,000,784)和(10,000,784)。我们的图像仍然是矢量。为了将这些可爱的内核在整个图像中进行卷积,我们需要将它们重塑为 28×28 的矩阵形式。此外,Keras 要求我们明确声明数据的通道数。因此,当我们重塑这些灰度图像进行建模时,我们将宣布1:
X_train = X_train.reshape(X_train.shape[0], 28, 28, 1)
X_test = X_test.reshape(X_test.shape[0], 28, 28, 1)
最后,我们将对我们的y向量进行一次热编码,以符合 Keras 的目标形状要求:
y_train = np_utils.to_categorical(y_train, 10)
y_test = np_utils.to_categorical(y_test, 10)
我们现在准备好建模了。我们的第一个网络将有八个隐藏层。前六个隐藏层将由交替的卷积层和最大池层组成。然后,在生成我们的预测之前,我们将展平这个网络的输出,并将其输入两层前馈神经网络。这是代码的样子:
model = Sequential()
model.add(Conv2D(filters = 35, kernel_size=(3,3), input_shape=(28,28,1), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 35, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 45, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Flatten())
model.add(Dense(64, activation='relu'))
model.add(Dense(32, activation='relu'))
model.add(Dense(10, activation='softmax'))
model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
让我们深入描述一下每一行发生了什么:
- 第 1 行:这里,我们只是实例化我们的模型对象。我们将通过接下来的一系列
.add()方法调用来进一步定义体系结构——也就是层数。这就是 Keras API 的妙处。 - 第 2 行:这里,我们添加我们的第一个卷积层。我们指定
35个内核,每个大小为 3×3。之后,我们指定图像输入形状,28 x 28 x 1。我们只需要在我们网络的第一个.add()调用中指定输入形状。最后,我们将激活函数指定为relu。激活函数在一个层的输出传递到下一层之前对其进行转换。我们将对Conv2D和Dense图层应用激活功能。这些转换有许多重要的属性。在这里使用relu加快了我们网络的收敛速度,www.cs.toronto.edu/~fritz/absp…www.cs.toronto.edu/~fritz/absp… 和relu相对于替代激活函数来说,计算起来并不昂贵——我们只是将负值转换为 0,否则保留所有正值。数学上,relu函数由max(0, value)给出。为了本章的目的,除了输出层,我们将坚持对每一层进行relu激活。 - 第 3 行:这里,我们添加我们的第一个最大池层。我们指定该层的窗口大小为 2 x 2。
- 第 4 行:这是我们的第二个卷积层。我们设置它就像我们设置第一个卷积层一样。
- 5 号线:这是第二个最大池层。我们设置这个层就像我们设置第一个最大池层一样。
- 第 6 行:这是我们的第三层也是最后一层卷积层。这一次,我们添加了额外的过滤器(
45相对于前面层中的35)。这只是一个超参数,我鼓励你尝试它的多种变体。 - 7 号线:这是第三层也是最后一层最大池层。它的配置与之前的所有最大池层相同。
- 第 8 行:这里是我们展平卷积神经网络输出的地方。
- 9 号线:这是我们全连接网络的第一层。我们在这一层指定
64神经元和一个relu激活函数。 - 10 号线:这是我们全连接网络的第二层。我们为该层指定
32神经元和一个relu激活函数。 - 11 号线:这是我们的输出层。我们指定
10神经元,等于我们数据中目标类的数量。由于这是一个多类分类问题,我们指定一个softmax激活函数。输出将表示图像属于类别 0–9 的预测概率。这些概率加起来就是1。10的最高预测概率将代表我们的模型认为最有可能的类别。 - 第 12 行:这里是我们编译 Keras 模型的地方。在编译步骤中,我们指定我们的优化器
Adam,一个自动调整其学习速率的梯度下降算法。我们指定我们的损失函数—在本例中为categorical cross entropy,因为我们正在执行一个多类分类问题。最后,对于度量参数,我们指定accuracy。通过指定这一点,Keras 将告知我们模型运行的每个时期的训练和验证精度。
我们可以通过运行以下命令来获得模型的摘要:
model.summary()
这将输出以下内容:
请注意,当数据通过模型时,输出形状会如何变化。具体来说,查看展平发生后我们输出的形状——只有 45 个特征。X_train和X_test中的原始数据由每行 784 个特征组成,所以这太棒了!
You'll need to install pydot to render the visualization. To install it, run pip install pydot from the terminal. You may need to restart your kernel for the install to take effect.
使用 Keras 中的plot_model函数,我们可以以不同的方式可视化网络拓扑。为此,请运行以下代码:
plot_model(model, to_file='Conv_model1.png', show_shapes=True)
Image.open('Conv_model1.png')
运行前面的代码将拓扑保存到Conv_model1.png并生成以下内容:
This model will take several minutes to fit. If you have concerns about your system's hardware specs, you can easily reduce the training time by reducing the number of epochs to 10.
运行以下代码块将适合该模型:
my_fit_model = model.fit(X_train, y_train, epochs=25, validation_data=
(X_test, y_test))
在拟合步骤中,我们指定我们的X_train和y_train。然后,我们指定想要训练模型的纪元数量。然后我们插入验证数据——X_test和y_test——来观察我们模型的样本外性能。我喜欢将model.fit步骤作为变量my_fit_model保存下来,这样我们以后就可以很容易地想象各个时期的训练和验证损失。
随着代码的运行,您将看到模型的训练和验证损失,以及每个时期后的准确性。让我们使用下面的代码来绘制模型的列车损失和验证损失:
plt.plot(my_fit_model.history['val_loss'], label="Validation")
plt.plot(my_fit_model.history['loss'], label = "Train")
plt.xlabel("Epoch", size=15)
plt.ylabel("Cat. Crossentropy Loss", size=15)
plt.title("Conv Net Train and Validation loss over epochs", size=18)
plt.legend();
运行前面的代码会生成下面的图。你的图不会完全相同——这里有几个随机过程发生——但它看起来应该大致相同:
快速浏览一下这个图,我们会发现我们的模型过度拟合了。我们看到我们的列车损耗在每个时期都在下降,但是验证损耗并没有同步下降。让我们看一下我们的准确性分数,以了解该模型在分类任务中的表现。我们可以通过运行以下代码来做到这一点:
plt.plot(my_fit_model.history['val_acc'], label="Validation")
plt.plot(my_fit_model.history['acc'], label = "Train")
plt.xlabel("Epoch", size=15)
plt.ylabel("Accuracy", size=15)
plt.title("Conv Net Train and Validation accuracy over epochs",
size=18)
plt.legend();
这会生成以下内容:
这个情节也告诉我们,我们已经过度了。但是看起来我们的验证准确率在 80%以上,这很好!为了获得我们的模型达到的最大精度和它发生的时间,我们可以运行以下代码:
print(max(my_fit_model.history['val_acc']))
print(my_fit_model.history['val_acc'].index(max(my_fit_model.history['v
al_acc'])))
您的具体结果将与我的不同,但以下是我的输出:
使用我们的卷积神经网络,我们在 21 世纪实现了 89.48%的最大分类准确率。太神奇了!但是我们仍然需要解决过度拟合的问题。接下来,我们将使用脱落正则化重建我们的模型。
脱落正则化是正则化的一种形式,我们可以将其应用于神经网络的全连接层。使用脱落正则化,我们在训练过程中从网络中随机脱落神经元及其连接。通过这样做,网络不会变得过于依赖与任何特定节点相关联的权重或偏差,从而允许它更好地从样本中进行归纳。
在这里,我们添加了丢失正则化,指定我们想要删除每个Dense层的35%个神经元:
model = Sequential()
model.add(Conv2D(filters = 35, kernel_size=(3,3), input_shape=
(28,28,1), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 35, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 45, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Flatten())
model.add(Dense(64, activation='relu'))
model.add(Dropout(0.35))
model.add(Dense(32, activation='relu'))
model.add(Dropout(0.35))
model.add(Dense(10, activation='softmax'))
model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
运行前面的代码将编译我们的新模型。让我们通过重新运行以下内容来再次查看摘要:
model.summary()
运行前面的代码将返回以下输出:
让我们通过重新运行以下命令来重新调整我们的模型:
my_fit_model = model.fit(X_train, y_train, epochs=25, validation_data=
(X_test, y_test))
一旦你的模型重新调整,重新运行绘图代码来可视化损失。这是我的:
这看起来更好!我们的培训和验证损失之间的差异已经缩小,这是预期的目的,尽管似乎确实有一些改进的空间。
接下来,重新绘制精度曲线。这是我的跑步记录:
从过度拟合的角度来看,这也更好。太棒了!应用正则化后,我们达到的最佳分类精度是多少?让我们运行以下代码:
print(max(my_fit_model.history['val_acc']))
print(my_fit_model.history['val_acc'].index(max(my_fit_model.history['v
al_acc'])))
这个模型运行的输出如下:
有意思!我们获得的最佳验证精度低于我们的非正规模型,但也差不了多少。而且还是挺好的!我们的模型告诉我们,88.85%的情况下我们预测的是正确的服装物品类型。
思考我们在这里做得有多好的一种方法是将我们模型的精度与数据集的基线精度进行比较。基线精度仅仅是我们通过天真地选择数据集中最常见的类别而获得的分数。对于这个特定的数据集,因为类是完全平衡的,并且有 10 个类,所以基线精度是 10%。我们的模型轻而易举地超过了这个基线精度。它显然对数据有所了解!
从这里你可以去很多不同的地方!尝试构建更深层次的模型,或者在模型中使用的许多超参数上进行网格搜索。评估你的分类器的性能,就像评估任何其他模型一样——试着建立一个混淆矩阵来理解我们预测的好的类和我们没有预测的强的类!
摘要
我们在这里确实走了很多路!我们讨论了如何从图像中提取特征,卷积神经网络是如何工作的,然后我们构建了一个卷积神经网络到完全连接的网络架构。一路上,我们也学到了很多新的术语和概念!
希望读完这一章,你会觉得这些图像分类技术——你可能曾经认为是巫师的领域——实际上只是出于直觉原因而进行的一系列数学优化!希望这些内容能帮助你解决你感兴趣的图像处理项目!