版权信息

书名：码农·数据技术（第24期）

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

图灵社区会员 Dastan（dastan@139.com） 专享 尊重版权

编者的话

专题：数据技能

“爬虫”的采集行动

遍历单个域名

采集整个网站

收集整个网站数据

通过互联网采集

用Scrapy采集

数据也“洁癖”

理解HTML页面结构

行分隔模型

树形结构模型

项目背景

决策树高精准预测森林植被

Covtype数据集

准备数据

第一棵决策树

决策树的超参数

决策树调优

重谈类别型特征

随机决策森林

进行预测

初识MapReduce

案例：单词计数

为什么是MapReduce

更加一般化的MapReduce

案例：分析状态更新

案例：矩阵计算

题外话：组合器

可视化的数据，鲜活的数据

开箱即用的工具

编程工具

取舍

吐槽广告

人物

新浪肖鹏：微博数据库那些事儿

践行

redux——页面状态管理的优雅方案

页面状态

单向数据流flux

一种对flux的实现，redux

redux与react的配合

高级特性

状态树分治

异步action

同构渲染

鲜阅

该露的能露，该要的能要

少年JavaScript之烦恼

大块头JavaScript的小奋斗

对象字面值模式

IIFE/揭示模块模式

CommonJS

AMD(异步模块定义)

UMD（统一模块定义）

书单

偷偷看下你的书单

电子书榜

妙评

写给大家看的设计

颜色运用

实操中的提示与技巧

编者的话

编者 / 刘敏

大数据、数据挖掘、机器学习、可视化......近年来，计算机界的几件大事好像总也躲不开数据这个主题。一下子所有人都对数据科学产生了兴趣，企业纷纷扩招数据方面的人才，希望从此摆脱依赖经验的传统模式，透过数据做出精准预测，提高商业回报。

《哈佛商业评论》甚至把数据科学家誉为“21世纪最性感的职业”。虽说有些夸张，但这个对数据科学如此推崇的称呼，却预示了数据科学行业的蓬勃发展和无限前途。

那么，数据科学相关的技能有哪些？只有掌握了数据相关的技能，才能找到成为数据科学家的奋斗方向。与数据相关的技术包括：

- 数据采集和预处理方向

- 数据存储和管理方向

- 数据分析和挖掘方向

- 数据可视化方向

- 数据安全方向

本期《码农》选取了数据采集、数据清洗、数据分析和数据可视化方向的代表性文章，帮助读者掌握从事数据科学工作所面临的问题及必备技能。我们也请来了负责微博数据库的技术经理肖鹏，分享他结缘MySQL并成为数据库专家的经历，揭秘新浪MySQL集群结构经历的3次重大变化，以及他对MySQL初学者的诚恳建议。

技术这种东西，自己明白和能给别人讲明白是两个完全不同的境界。本期“践行”“鲜阅”文章就来自图灵社区的技术分享文章。阐述“页面状态管理的优雅方案——redux”和“JavaScript在模块化编程方面的努力”的同时，自己也会“手留余香”！

专题：数据技能

“爬虫”的采集行动

作者/Ryan Mitchell

数据科学家、软件工程师，目前在波士顿LinkeDrive公司负责开发公司的API和数据分析工具。此前，曾在Abine公司构建网络爬虫和网络机器人。她经常做网络数据采集项目的咨询工作，主要面向金融和零售业。另著有Instant Web Scraping with Java。

搜索引擎和网站每时每刻都在采集大量的信息，而用于采集信息的程序一般被称为网络爬虫（Wed crawler）、网络蜘蛛（Web spider）或者网络铲（Web scraper）。之所以有这样的称呼，是因为它可以沿着网络“爬行”，先“爬”到对应的网页上，然后把需要的信息“铲”下来。本质上就是一种递归方式。为了找到 URL 链接，它必须首先获取网页内容，检查这个页面的内容，再寻找另一个 URL，然后获取 URL 对应的网页内容，不断循环这一过程。

不过，值得注意的是：你不必一直这样重复地采集网页。如果你需要的数据都在一个页面上时，爬行一个页面就足以解决问题。使用网络爬虫的时候，必须非常谨慎地考虑需要消耗多少网络流量，还要尽力思考能不能让采集目标的服务器负载更低一些。

遍历单个域名

即使你没听说过“维基百科六度分隔理论”，也很可能听过“凯文 · 贝肯（Kevin Bacon）的六度分隔值游戏”。在这两个游戏中，都是把两个不相干的主题（维基百科里是用词条之间的连接，凯文 · 贝肯的六度分隔值游戏是用出现在同一部电影中的演员来连接）用一个总数不超过六条的主题连接起来（包括原来的两个主题）。

比如，埃里克 · 艾德尔和布兰登 · 弗雷泽都出现在电影《骑警杜德雷》里，布兰登 · 弗雷泽又和凯文 · 贝肯都出现在电影《我呼吸的空气》里。1因此，根据这两个条件，从埃里克· 艾德尔到凯文·贝肯的链条主题长度为 3。

1感谢 The Oracle of Bacon（http://oracleofbacon.org/index.php）的存在，满足了我对这类关系链的好奇心。

接下来，我们将创建一个项目实现“维基百科六度分隔理论”的查找方法。也就是说，实现从埃里克· 艾德尔的词条页面（https://en.wikipedia.org/wiki/Eric_Idle）开始，经过最少的链接点击次数找到凯文· 贝肯的词条页面（https://en.wikipedia.org/wiki/Kevin_Bacon）。

有一定Python编程基础的人应该已经知道如何写一段获取维基百科网站上任何页面并提取页面链接的 Python 代码：

from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://en.wikipedia.org/wiki/Kevin_Bacon")
bsObj = BeautifulSoup(html)
for link in bsObj.findAll("a"):
if 'href' in link.attrs:
print(link.attrs['href'])

观察生成的一列链接，就会看到我们想要的所有词条链接都在里面：“Apollo 13”“Philadelphia”和“Primetime Emmy Award”，等等。但是，也有一些我们不需要的链接：//wikimediafoundation.org/wiki/Privacy_policy //en.wikipedia.org/wiki/Wikipedia:Contact_us

其实维基百科的每个页面都充满了侧边栏、页眉、页脚链接，以及连接到分类页面、对话页面和其他不包含词条的页面的链接：/wiki/Category:Articles_with_unsourced_statements_from_April_2014 /wiki/Talk:Kevin_Bacon

最近我有个朋友在做一个类似维基百科采集这样的项目，他说为了判断维基百科的内链是否链接到一个词条，他写了一个很大的过滤函数，超过 100 行代码。不幸的是，可能在项目启动的时候，他没有花时间去比较“词条链接”和“其他链接”的差异，也可能他后来发现了那个技巧。如果你仔细观察那些指向词条页面（不是指向其他内容页面）的链接，会发现它们都有的三个共同点：

- 它们都在id是bodyContent的div标签里

- URL 链接不包含冒号

- URL 链接都以 /wiki/ 开头

我们可以利用这些规则稍微调整一下代码来获取词条链接：

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

html = urlopen("http://en.wikipedia.org/wiki/Kevin_Bacon")
bsObj = BeautifulSoup(html)
for link in bsObj.find("div", {"id":"bodyContent"}).findAll("a",
href=re.compile("^(/wiki/)((?!:).)*$")):
if 'href' in link.attrs:
print(link.attrs['href'])

运行代码，就会看到维基百科上凯文·贝肯词条里所有指向其他词条的链接。

当然，写程序来找出这个静态的维基百科词条里所有的词条链接很有趣，不过没什么实际用处。我们需要让这段程序更像下面的形式。

- 一个函数getLinks，可以用维基百科词条 /wiki/< 词条名称 > 形式的 URL 链接作为参数，然后以同样的形式返回一个列表，里面包含所有的词条 URL 链接。

- 一个主函数，以某个起始词条为参数调用getLinks，再从返回的 URL 列表里随机选择一个词条链接，再调用getLinks，直到我们主动停止，或者在新的页面上没有词条链接了，程序才停止运行。

完整的代码如下所示：

from urllib.request import urlopen
from bs4 import BeautifulSoup
import datetime
import random
import re

random.seed(datetime.datetime.now())
def getLinks(articleUrl):
html = urlopen("http://en.wikipedia.org"+articleUrl)
bsObj = BeautifulSoup(html)
return bsObj.find("div", {"id":"bodyContent"}).findAll("a",
href=re.compile("^(/wiki/)((?!:).)*$"))
links = getLinks("/wiki/Kevin_Bacon")
while len(links) > 0:
newArticle = links[random.randint(0, len(links)-1)].attrs["href"]
print(newArticle)
links = getLinks(newArticle)

导入需要的 Python 库之后，程序首先做的是用系统当前时间生成一个随机数生成器。这样可以保证在每次程序运行的时候，维基百科词条的选择都是一个全新的随机路径。

伪随机数和随机数种子

在前面的示例中，为了能够连续地随机遍历维基百科，我用 Python 的随机数生成器随机选择每一页上的一个词条链接。但是，用随机数的时候需要格外小心。

虽然计算机很擅长做精确计算，但是它们处理随机事件时非常不靠谱。因此，随机数是一个难题。大多数随机数算法都努力创造一种呈均匀分布且难以预测的数据序列，但是在算法初始化阶段都需要提供随机数“种子”（random seed）。而完全相同的种子每次将产生同样的“随机”数序列，因此我用系统时间作为随机数序列生成的起点。这样做会让程序运行的时候更具有随机性。

其实，Python 的伪随机数（pseudorandom number）生成器用的是梅森旋转（Mersenne Twister）算法（https://en.wikipedia.org/wiki/Mersenne_Twister），它产生的随机数很难预测且呈均匀分布，就是有点儿耗费 CPU 资源。真正好的随机数可不便宜！

然后，我们定义getLinks 函数，其参数是维基百科词条页面中 /wiki/< 词条名称 > 形式的 URL 链接，前面加上维基百科的域名，http://en.wikipedia.org，再用域名中的网页获得一个 BeautifulSoup 对象。之后用前面介绍过的参数抽取一列词条链接所在的标签a并返回它们。

程序的主函数首先把起始页面 https://en.wikipedia.org/wiki/Kevin_Bacon 里的词条链接列表（links变量）设置成链接列表。然后用一个循环，从页面中随机找一个词条链接标签并抽取href属性，打印这个页面链接，再把这个链接传入getLinks函数，重新获取新的链接列表。

当然，这里只是简单地构建一个从一个页面到另一个页面的爬虫，要解决“维基百科六度分隔理论”问题还有一点儿工作得做,还应该存储 URL 链接数据并分析数据。

异常处理

为了方便起见，我们在这些示例中忽略了大多数异常处理过程，但是要注意问题随时可能发生。例如，维基百科改变了bodyContent标签的名称怎么办呢？（提示：那时代码就会崩溃。）

因此，这些脚本作为容易演示的示例也许可以运行得很不错，但是要真正成为自动化产品代码，还需要增加更多的异常处理。

采集整个网站

我们实现了在网站上随机地从一个链接跳到另一个链接。但是，如果需要系统地把整个网站按目录分类，或者要搜索网站上的每一个页面，怎么办？那就得采集整个网站。那是一种非常耗费内存资源的过程，尤其是处理大型网站时，最合适的工具就是用一个数据库来储存采集的资源。除了运行数据库，我们还可以掌握这类工具的行为，并不需要大规模地实际运行，就完成整个网站的数据采集。

深网和暗网

你可能听说过深网（deep Web）、暗网（dark Web）或隐藏网络（hidden Web）之类的术语，尤其是在最近的媒体中。它们是什么意思呢？

深网是网络的一部分，与浅网（surface Web）对立。浅网是互联网上搜索引擎可以抓到的那部分网络。据不完全统计，互联网中其实约 90% 的网络都是深网。因为谷歌不能做像表单提交这类的事情，也找不到那些没有直接链接到顶层域名上的网页，或者因为有 robots.txt 禁止而不能查看网站，所以浅网的数量相对深网还是比较少的。

暗网，也被称为 Darknet 或 dark Internet，完全是另一种“怪兽”。它们也建立在已有的网络基础上，但是使用 Tor 客户端，带有运行在 HTTP 之上的新协议，提供了一个信息交换的安全隧道。这类暗网页面也是可以采集的，就像你采集其他网站一样，不过这些内容超出了我所探讨的范围。

和暗网不同，深网是相对容易采集的。实际上，我提供的很多工具都专注于如何采集那些 Google 爬虫机器人不能获取的深网信息。

那么，什么时候采集整个网站是有用的，而什么时候采集整个网站又是有害无益的呢？遍历整个网站的网络数据采集有许多好处。

- 生成网站地图

几年前，我曾经遇到过一个问题：一个重要的客户想对一个网站的重新设计方案进行效果评估，但是不想让我们公司进入他们的网站内容管理系统（CMS），也没有一个公开可用的网站地图。我就用爬虫采集了整个网站，收集了所有的链接，再把所有的页面整理成他们网站实际的形式。这让我很快找出了网站上以前不曾留意的部分，并准确地计算出需要重新设计多少网页，以及可能需要移动多少内容。

- 收集数据

我的另一个客户为了创建一个专业垂直领域的搜索平台，想收集一些文章（故事、博文、新闻等）。虽然这些网站采集并不费劲，但是它们需要爬虫有足够的深度（我们有意收集数据的网站不多）。于是我就创建了一个爬虫递归地遍历每个网站，只收集那些网站页面上的数据。

一个常用却费时的网站采集方法就是从顶级页面开始（比如主页），搜索页面上的所有链接，然后形成列表。再去采集这些链接的每一个页面，然后把在每个页面上找到的链接形成新的列表，重复执行下一轮采集。

很明显，这是一个复杂度增长很快的情形。假如每个页面有 10 个链接，网站上有 5 个页面深度（一个中等规模网站的主流深度），那么采集整个网站，一共得采集的网页数量就是 10^5，即 100 000 个页面。不过，虽然“5 个页面深度，每页 10 个链接”是网站的主流配置，但其实很少有网站真的有 100 000 甚至更多的页面。这是因为很大一部分内链都是重复的。

为了避免一个页面被采集两次，链接去重是非常重要的。在代码运行时，把已发现的所有链接都放到一起，并保存在方便查询的列表里（下文示例指 Python 的集合set类型）。只有“新”链接才会被采集，之后再从页面中搜索其他链接：

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

pages = set()
def getLinks(pageUrl):
global pages
html = urlopen("http://en.wikipedia.org"+pageUrl)
bsObj = BeautifulSoup(html)
for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
if 'href' in link.attrs:
if link.attrs['href'] not in pages:
# 我们遇到了新页面
newPage = link.attrs['href']
print(newPage)
pages.add(newPage)
getLinks(newPage)

getLinks("")

为了全面地展示网络数据采集示例是如何工作的，我降低了在前面例子里使用的“只寻找内链”的标准。不再限制爬虫采集的页面范围，只要遇到页面就查找所有以 /wiki/ 开头的链接，也不考虑链接是不是包含分号。（提示：词条链接不包含冒号，而文档上传页面、讨论页面之类的页面 URL 链接都包含冒号。）

一开始，用getLinks处理一个空 URL，其实是维基百科的主页，因为在函数里空 URL 就是 http://en.wikipedia.org。然后，遍历首页上每个链接，并检查是否已经在全局变量集合pages里面了（已经采集的页面集合）。如果不在，就打印到屏幕上，并把链接加入pages集合，再用getLinks递归地处理这个链接。

关于递归的警告

递归警告在软件开发图书里很少提到，但是我觉得应该注意：如果递归运行的次数非常多，前面的递归程序就很可能崩溃。

Python 默认的递归限制（程序递归地自我调用次数）是 1000 次。因为维基百科的网络链接浩如烟海，所以这个程序达到递归限制后就会停止，除非你设置了一个较大的递归计数器，或用采用其他手段不让它停止。

对于那些链接深度少于 1000 的“普通”网站，这个方法通常可以正常运行，一些奇怪的异常除外。例如，我曾经遇到过一个网站，有一个在生成博文内链的规则。这个规则是“当前页面把 /blog/title_of_blog.php 加到它后面，作为本页面的 URL 链接”。

问题是它们可能会把 /blog/title_of_blog.php 加到一个已经有 /blog/ 的 URL 上面。因此，网站就多了一个 /blog/。最后，我的爬虫找到了这样的 URL 链接：/blog/blog/blog/blog.../blog/title_of_blog.php。

后来，我增加了一些条件，对可能导致无限循环的部分进行检查，确保那些 URL 不是这么荒谬。如果不去检查这些问题，爬虫很快就会崩溃。

收集整个网站数据

如果只是从一个页面跳到另一个页面，网络爬虫是非常无聊的。为了有效地使用它们，在用爬虫的时候我们需要在页面上做些事情。如何创建一个爬虫来收集页面标题、正文的第一个段落，以及编辑页面的链接（如果有的话）等。

和往常一样，决定如何做好这些事情的第一步就是先观察网站上的一些页面，然后拟定一个采集模式。通过观察几个维基百科页面，包括词条和非词条页面，比如隐私策略之类的页面，就会得出下面的规则。

- 所有的标题（所有页面上，不论是词条页面、编辑历史页面还是其他页面）都是在h1 → span标签里，而且页面上只有一个h1标签。

- 前面提到过，所有的正文文字都在div#bodyContent标签里。如果想更进一步获取第一段文字，可能用div#mw-content-text → p更好（只选择第一段的标签）。这个规则对所有页面都适用，除了文件页面（例如，https://en.wikipedia.org/wiki/File:Orbit_of_274301_Wikipedia.svg），页面不包含内容文字（content text）的部分内容。

- 编辑链接只出现在词条页面上。如果有编辑链接，都位于li#ca-edit标签的li#ca-edit → span → a里面。

调整前面的代码，我们就可以建立一个爬虫和数据收集（至少是数据打印）的组合程序：

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

pages = set()
def getLinks(pageUrl):
global pages
html = urlopen("http://en.wikipedia.org"+pageUrl)
bsObj = BeautifulSoup(html)
try:
print(bsObj.h1.get_text())
print(bsObj.find(id="mw-content-text").findAll("p")[0])
print(bsObj.find(id="ca-edit").find("span").find("a").attrs['href'])
except AttributeError:
print("页面缺少一些属性！不过不用担心！")

for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
if 'href' in link.attrs:
if link.attrs['href'] not in pages:
# 我们遇到了新页面
newPage = link.attrs['href']
print("----------------\n"+newPage)
pages.add(newPage)
getLinks(newPage)
getLinks("")

这个for循环和原来的采集程序基本上是一样的（除了打印一条虚线来分离不同的页面内容之外）。

因为我们不可能确保每一页上都有所有类型的数据，所以每个打印语句都是按照数据在页面上出现的可能性从高到低排列的。也就是说，<h1>标题标签会出现在每一页上（只要能识别，无论哪一页都有），所以我们首先试着获取它的数据。正文内容会出现在大多数页面上（除了文件页面），因此是第二个获取的数据。“编辑”按钮只出现在标题和正文内容都已经获取的页面上，但不是所有这类页面上都有，所以我们最后打印这类数据。

不同模式应对不同需求

在一个异常处理语句中包裹多行语句显然是有点儿危险的。首先，你没法儿识别出究竟是哪行代码出现了异常。其次，如果有个页面没有前面的标题内容，却有“编辑”按钮，那么由于前面已经发生异常，后面的“编辑”按钮链接就不会出现。这种按照网站上信息出现的可能性高低进行排序的方法对许多网站都是可行的，偶而会丢失一点儿数据，只要保存详细的日志就不是什么问题了。

你可能还发现以前出现的所有的例子中，我们都没有“收集”那些“打印”出来的数据。显然，命令行里显示的数据是很难进一步处理的。

通过互联网采集

每次做网络数据采集的演讲时，总有人故意问我：“你怎么建一个谷歌网站？”我的回答通常会包含两点：“首先，你得有几十亿美元能够买得起世界上最大的数据仓库，并把它们隐秘地放在世界各地。其次，你得写一个网络爬虫。”

1994 年，两个斯坦福大学毕业生就是用一个陈旧的服务器和一个 Python 网络爬虫建立的谷歌。现在你应该知道，你已经正式拥有了成为下一个科技亿万富翁需要的工具！

说句实在话，网络爬虫位于许多新式的网络技术领域彼此交叉的中心地带，而且你使用它们也不需要一个大型数据仓库。要实现任何跨站的数据分析，只要构建出可以从互联网上无数网页里解析和储存数据的爬虫就可以了。

就像之前的例子一样，我们后面要建立的网络爬虫也是顺着链接从一个页面跳到另一个页面，描绘出一张网络地图。但是这一次，它们不再忽略外链，而是跟着外链跳转。我们想看看爬虫是不是可以记录我们浏览过的每一个页面上的信息，这将是一个新的挑战。相比我们之前做的单个域名采集，互联网采集要难得多——不同网站的布局迥然不同。这就意味着我们必须在要寻找的信息以及查找方式上都极具灵活性。

不知前方水深浅

如果已经掌握了解决“维基百科六度分隔理论”的方法，那么我们完全有可能从一个像芝麻街 http://www.sesamestreet.org/ 那样的网站开始，经过几跳到达一些非主流网站。

如果读者是小朋友，请在运作代码前咨询一下父母。对那些带有敏感题材或有宗教限制的人来说，某些网站是禁止浏览的，阅读代码示例没问题，但是运行代码的时候请格外小心。

允许爬虫随意跟随外链跳转之前，请问自己几个问题。

- 我要收集哪些数据？这些数据可以通过采集几个已经确定的网站（永远是最简单的做法）完成吗？或者我的爬虫需要发现哪些我可能不知道的网站吗？

- 当爬虫到了某个网站，它是立即顺着下一个出站链接跳到一个新网站，还是在网站上呆一会儿，深入采集网站的内容？

- 有没有我不想采集的一类网站？我对非英文网站的内容感兴趣吗？

- 如果网络爬虫引起了某个网站网管的怀疑，我如何避免法律责任？

几个灵活的 Python 函数组合起来，用不超过 50 行的代码就可以轻松实现不同类型的网络爬虫：

from urllib.request import urlopen
from urllib.parse import urlparse
from bs4 import BeautifulSoup
import re
import datetime
import random

pages = set()
random.seed(datetime.datetime.now())

# 获取页面所有内链的列表
def getInternalLinks(bsObj, includeUrl):
includeUrl = urlparse(includeUrl).scheme+"://"+urlparse(includeUrl).netloc
internalLinks = []
# 找出所有以"/"开头的链接
for link in bsObj.findAll("a", href=re.compile("^(/|.*"+includeUrl+")")):
if link.attrs['href'] is not None:
if link.attrs['href'] not in internalLinks:
internalLinks.append(link.attrs['href'])
else:
internalLinks.append(link.attrs['href'])
return internalLinks

# 获取页面所有外链的列表
def getExternalLinks(bsObj, excludeUrl):
externalLinks = []
# 找出所有以"http"或"www"开头且不包含当前URL的链接
for link in bsObj.findAll("a",
href=re.compile("^(http|www)((?!"+excludeUrl+").)*$")):
if link.attrs['href'] is not None:
if link.attrs['href'] not in externalLinks:
externalLinks.append(link.attrs['href'])
return externalLinks

def getRandomExternalLink(startingPage):
html = urlopen(startingPage)
bsObj = BeautifulSoup(html)
externalLinks = getExternalLinks(bsObj, splitAddress(startingPage)[0])
if len(externalLinks) == 0:
print("No external links, looking around the site for one")
domain = urlparse(startingPage).scheme+"://"+urlparse(startingPage).netloc
internalLinks = getInternalLinks(bsObj, domain)
internalLinks = getInternalLinks(startingPage)
return getNextExternalLink(internalLinks[random.randint(0,
len(internalLinks)-1)])
else:
return externalLinks[random.randint(0, len(externalLinks)-1)]

def followExternalOnly(startingSite):
externalLink = getRandomExternalLink(startingSite)
print("Random external link is: "+externalLink)
followExternalOnly(externalLink)

followExternalOnly("http://oreilly.com")

上面这个程序从 http://oreilly.com 开始，随机地从一个外链跳到另一个外链。输出的结果如下所示：

随机外链是：http://igniteshow.com/

随机外链是：http://feeds.feedburner.com/oreilly/news

随机外链是：http://hire.jobvite.com/CompanyJobs/Careers.aspx?c=q319

随机外链是：http://makerfaire.com/

网站首页上并不能保证一直能发现外链。为了能够发现外链，就需要用一种类似前面案例中使用的采集方法，即递归地深入一个网站直到找到一个外链才停止。

下图把程序操作可视化成了一个流程图。

从互联网上不同的网站采集外链的程序流程图

不要把示例程序放进产品代码

我本想把代码写得更完整，但考虑到空间和可读性问题，示例程序没有包含真实产品代码中必须有的检查和异常处理。

例如，如果爬虫遇到一个网站里面一个外链都没有（虽然不太可能，但是如果程序运行的时候够长总会遇到这类情况），这时程序就会一直在这个网站运行跳不出去，直到递归到达 Python 的限制为止。

在以任何正式的目的运行代码之前，请确认你已经在可能出现问题的地方都放置了检查语句。

把任务分解成像“获取页面上所有外链”这样的小函数是不错的做法，以后可以方便地修改代码以满足另一个采集任务的需求。例如，如果我们的目标是采集一个网站所有的外链，并且记录每一个外链，我们可以增加下面的函数：

# 收集网站上发现的所有外链列表
allExtLinks = set()
allIntLinks = set()
def getAllExternalLinks(siteUrl):
html = urlopen(siteUrl)
bsObj = BeautifulSoup(html)
internalLinks = getInternalLinks(bsObj,splitAddress(siteUrl)[0])
externalLinks = getExternalLinks(bsObj,splitAddress(siteUrl)[0])
for link in externalLinks:
if link not in allExtLinks:
allExtLinks.add(link)
print(link)
for link in internalLinks:
if link not in allIntLinks:
print("即将获取链接的URL是："+link)
allIntLinks.add(link)
getAllExternalLinks(link)

getAllExternalLinks("http://oreilly.com")

这段代码可以看出两个循环——一个是收集内链，一个是收集外链——然后彼此连接起来工作，程序的流程如下图所示。

图：收集内链和外链的程序流程图

写代码之前拟个大纲或画个流程图是很好的编程习惯，这么做不仅可以为后期处理节省很多时间，更重要的是，可以防止自己在爬虫变得越来越复杂时乱了分寸。

处理网页重定向

重定向（redirect）允许一个网页在不同的域名下显示。重定向有两种形式：

- 服务器端重定向，网页在加载之前先改变了 URL；

- 客户端重定向，有时你会在网页上看到“10 秒钟后页面自动跳转到……”之类的消息，表示在跳转到新 URL 之前网页需要加载内容。

这里涉及服务器端重定向的内容。服务器端重定向通常不用太过担心。如果你在用 Python 3.x 版本的urllib库，它会自动处理重定向。不过要注意，有时候你要采集的页面的 URL 可能并不是你当前所在页面的 URL。

用Scrapy采集

编写网络爬虫的挑战之一是，你经常需要不断地重复一些简单任务：找出页面上的所有链接，区分内链与外链，跳转到新的页面。掌握这些基本模式非常有用，从零开始编写也完全可行，不过有几个工具可以帮你自动处理这些细节。

Scrapy 就是一个可以大幅度降低网页链接查找和识别工作复杂度的 Python 库，它可以让我们轻松地采集一个或多个域名的信息。不过目前 Scrapy 仅支持 Python 2.7，还不支持 Python 3.x。

当然在一台机器上同时使用多个版本的 Python 是没有问题的（比如，同时安装 Python 2.7 和 Python 3.4）。如果你既想用 Scrapy 做项目，又想用 Python 3.4 写程序，完全没问题。

Scrapy 网站提供了最新版工具的下载页面（http://scrapy.org/download/），也可以用 pip 等第三方安装包安装。记住 Python 的版本必须是 2.7（2.6 和 3.x 都不兼容），而且运行所有使用 Scrapy 的程序也必须在 Python 2.7 环境下。

虽然写 Scrapy 爬虫很简单，但完成一个爬虫还是需要一些设置。如果在当前目录下创建新的 Scrapy 项目，就执行下面的代码：

$scrapy startproject wikiSpider

wikiSpider是新项目的名称。在当前目录中会新建一个名称也是 wikiSpider 的项目文件夹。文件夹的目录结构如下所示：

- scrapy.cfg
— wikiSpider
— __init.py__
— items.py
— pipelines.py
— settings.py
— spiders
— __init.py__

为了创建一个爬虫，我们需要在 wikiSpider/wikiSpider/spiders/ 文件夹里增加一个 articleSpider.py 文件。另外，在 items.py 文件中，我们需要定义一个Article类。

你的 items.py 文件应该像下面这样（Scrapy 自动生成的注释内容可以保留，当然也可以删除）：

# -*- coding: utf-8 -*-
# Define here the models for your scraped items
#
# See documentation in:
# http://doc.scrapy.org/en/latest/topics/items.html

from scrapy import Item, Field

class Article(Item):
# define the fields for your item here like:
# name = scrapy.Field()
title = Field()

Scrapy 的每个Item（条目）对象表示网站上的一个页面。当然，你可以根据需要定义不同的条目（比如url、content、header image等），但是现在我只演示收集每页的title字段（field）。

在新建的 articleSpider.py 文件里面，写如下代码：

from scrapy.selector import Selector
from scrapy import Spider
from wikiSpider.items import Article

class ArticleSpider(Spider):
name="article"
allowed_domains = ["en.wikipedia.org"]
start_urls = ["http://en.wikipedia.org/wiki/Main_Page",
"http://en.wikipedia.org/wiki/Python_%28programming_language%29"]

def parse(self, response):
item = Article()
title = response.xpath('//h1/text()')[0].extract()
print("Title is: "+title)
item['title'] = title
return item

这个类的名称（ArticleSpider）与爬虫文件的名称（wikiSpider）是不同的，这个类只是 wikiSpider 目录里的一员，仅仅用于维基词条页面的采集。对一些信息类型较多的大网站，你可能会为每种信息（如博客的博文、图书出版发行信息、专栏文章等）设置独立的 Scrapy 条目，每个条目都有不同的字段，但是所有条目都在同一个 Scrapy 项目里运行。

你可以在 wikiSpider 主目录中用如下命令运行ArticleSpider：

$ scrapy crawl article

这行命令会用条目名称article来调用爬虫（不是类名，也不是文件名，而是由ArticleSpider的name = "article"决定的）。

陆续出现的调试信息中应该会有两行结果：

Title is: Main Page
Title is: Python (programming language)

这个爬虫先进入start_urls里面的两个页面，收集信息，然后停止。虽然这个爬虫很简单，但是如果你有许多 URL 需要采集，Scrapy 这种用法会非常适合。为了让爬虫更加完善，你需要定义一些规则让 Scrapy 可以在每个页面查找 URL 链接：

from scrapy.contrib.spiders import CrawlSpider, Rule
from wikiSpider.items import Article
from scrapy.contrib.linkextractors.sgml import SgmlLinkExtractor

class ArticleSpider(CrawlSpider):
name="article"
allowed_domains = ["en.wikipedia.org"]
start_urls = ["http://en.wikipedia.org/wiki/Python_
%28programming_language%29"]
rules = [Rule(SgmlLinkExtractor(allow=('(/wiki/)((?!:).)*$'),),
callback="parse_item", follow=True)]

def parse_item(self, response):
item = Article()
title = response.xpath('//h1/text()')[0].extract()
print("Title is: "+title)
item['title'] = title
return item

虽然这个爬虫和前面那个爬虫的启动命令一样，但是如果你不用 Ctrl+C 中止程序，它是不会停止的（很长时间也不会停止）。

Scrapy 日志处理

Scrapy 生成的调试信息非常有用，但是通常太罗嗦。你可以在 Scrapy 项目中的 setting.py 文件中设置日志显示层级：

LOG_LEVEL = 'ERROR'

Scrapy 日志有五种层级，按照范围递增顺序排列如下：

- CRITICAL

- ERROR

- WARNING

- DEBUG

*INFO

如果日志层级设置为ERROR，那么只有CRITICAL和ERROR日志会显示出来。如果日志层级设置为INFO，那么所有信息都会显示出来，其他同理。

日志不仅可以显示在终端，也可以通过下面命令输出到一个独立的文件中：

$ scrapy crawl article -s LOG_FILE=wiki.log

如果目录中没有 wiki.log，运行程序会创建一个新文件，然后把所有的日志都保存到里面。如果已经存在，会在原文后面加入新的日志内容。

Scrapy 用Item对象决定要从它浏览的页面中提取哪些信息。Scrapy 支持用不同的输出格式来保存这些信息，比如 CSV、JSON 或 XML 文件格式，对应命令如下所示：

$ scrapy crawl article -o articles.csv -t csv
$ scrapy crawl article -o articles.json -t json
$ scrapy crawl article -o articles.xml -t xml

当然，你也可以自定义Item对象，把结果写入你需要的一个文件或数据库中，只要在爬虫的parse部分增加相应的代码即可。

Scrapy 是处理网络数据采集相关问题的利器。它可以自动收集所有 URL，然后和指定的规则进行比较；确保所有的 URL 是唯一的；根据需求对相关的 URL 进行标准化；以及到更深层的页面中递归查找。

尽管这点内容只碰到了 Scrapy 强大功能的一角，但我依然鼓励你去学习 Scrapy 文档（http://doc.scrapy.org/en/latest/）和其他在线的学习资源。Scrapy 的内容非常丰富，具有很多特性。

本文节选自《Python网络数据采集》

《Python网络数据采集》中不仅介绍了网络数据采集的基本原理，还深入探讨了更高级的主题，比如分析原始数据、用网络爬虫测试网站等。此外，书中还提供了详细的代码示例，以帮助你更好地理解书中的内容。

数据也“洁癖”

作者/Megan Squire

依隆大学计算科学专业教授，主要教授数据库系统、Web开发、数据挖掘和数据科学课程。有二十年的数据收集与清洗经验。她还是FLOSSmole研究项目的领导者，致力于收集与分析数据，以便研究免费软件、自由软件和开源软件的开发。

在所有的厨具当中，滤器是最常用的工具之一，它的作用就是在烹饪的过程中将固体从液体中分离出来。真实世界中的数据也有很多问题。在使用数据之前，通常需要对数据进行一定的预处理，为来自网络的数据构建滤器，利用不同类型的程序找到并保存想要的数据，同时去除不需要的内容。

我们将要完成以下几项内容的学习。

- 理解两种不同的HTML页面结构，第一种是行集合，我们可以根据样式匹配其中的内容；第二种是带有节点的树形结构，我们可以利用这些节点来识别并找出想要的数据。

- 尝试使用三种方法来解析页面，一种是使用逐行方法（基于正则表达式的HTML分析技术），另外两种则是使用树形结构方法（Python的BeautifulSoup库和一个叫作Scraper的基于Chrome浏览器的工具）。

- 根据实际的数据情况来实现这三种技术方案：从已经发布的网络论坛消息中去除日期和时间。

理解HTML页面结构

网页就是一个纯文本文件，其中包含了一些特殊的标记元素（有时候称为HTML标签），用来告知浏览器应该如何向用户呈现要显示的内容。例如，如果我们想强调某个单词，那么只需像下面的代码那样把单词用<em>标签围住即可：

It is <em>very important</em> that you follow these instructions.

所有的网页都具备这样的特性:它们本身都是由文本组成的，并且这些文本中会包含许多不同的标签。如果想从网页中提取数据，大抵可以采用两类心智模型。这两类模型分别有着各自的侧重点。下面我们先对这两类结构化模型做一番介绍，然后使用三种不同的工具来进行数据提取演示。

行分隔模型

理解网页的最简单方式就是专注于这一事实：网页上有许多不同的HTML元素/标签，用来在网络上显示页面中的内容。如果我们想从这个简单的模型中抽取自己比较感兴趣的数据，就必须把网页中的文字和嵌套在其中的HTML元素本身当作分隔符。就拿前面的示例来说，我们可以提取标签<em>里面的数据，也可以是标签<em>之前或是标签</em>之后的数据。

在这个模型中，我们需要发挥一下想象力，把网页想象成由超大的无结构文本和一些根据需求组织起来的HTML标签（或别的文本特性，如反复出现的词）共同组成的一个集合，其中的结构化标签可以帮助我们把内容分隔成不同的部分。有了这些分隔标记，从页面提取数据就不会成为难事。

请看下面的HTML页面代码片段，它们取自Django IRC频道的聊天日志。现在让我们想想怎么把HTML元素当成分隔符，提取我们需要的数据：

<div id="content">
<h2>Sep 13, 2014</h2>

<a href="/2014/sep/14/">← next day</a> Sep 13, 2014  <a
href="/2014/sep/12/">previous day →</a>

<ul id="ll">
<li class="le" rel="petisnnake"><a href="#1574618"
name="1574618">#</a> <span style="color:#b78a0f;8"
class="username" rel="petisnnake">&lt;petisnnake&gt;</span> i
didnt know that </li>
...
</ul>
...
</div>

在示例文本中，我们可以使用<h2></h2>标签作为分隔符来提取聊天日志中的日期。还可以使用<li></li>作为分隔符并找到其中的rel=""来提取聊天人的名字。最后可以提取</span>到</li>之间由用户向聊天频道发出的消息。

这些聊天日志可以从Django IRC日志网站获取，地址为http://django-irc-logs.com。这个网站同时还支持使用关键字搜索日志内容的接口。为了保持代码简洁，部分内容采用省略号（…）代替。

从上面杂乱无章的文本中，我们可以使用分隔符的概念来提取三段数据（日志时间、用户和消息）。

树形结构模型

另一种理解方式是把文本页面想象成一个由HTML元素/标签组成的树形结构，其中每个元素/标签都与页面上的其他一些标签相关。每个标签都是一个节点，整棵树是由页面中所有节点组成的。在HTML中，如果一个标签出现在另一个标签的内容中，那么内部标签就被称为子标签，外部标签被称为父标签。在之前的IRC聊天日志的例子中，HTML片段展现的树形图如下：

如果把HTML文本想象成树形结构，那么我们就可以使用编程语言来构建这棵树。这样，我们就可以根据元素的名字或是在元素列表中的位置，提取需要的文本数据。例如：

- 我们想要根据标签的名字提取数据（节点<h2>中的文本）；

- 我们想要抓取某种标签类型下的所有节点（<div>内部的<ul>页签下的所有<li>节点）；

- 我们想要一个指定元素中的所有属性（<li>元素中的rel属性）。

接下来的部分，我们将把心智模型——行分隔模型和树形结构——应用到实际的案例当中，采用三种不同的方法来提取和清洗HTML页面中的数据。

方法一：Python和正则表达式

我们使用简单的方法提取HTML页面中的数据。这个方法是基于识别文件中的分隔符的概念，并同时配合正则表达式的使用，最终提取我们需要的数据。

在开始实践之前还有一件事我们得铭记于心，即正则表达式这种实现方案虽然非常容易理解，但也有很大的局限性，具体取决于项目的的细节。

第一步：查找并保存实验用的Web文件

在这个例子中，我们将要抓取前面Django项目中提到的IRC聊天记录。这些文件都是公开的，并且结构也比较常规，所以比较适合我们这个项目使用。进入Django IRC日志归档网站http://django-irc-logs.com/，选择一个你认为比较有意义的日期。进入所选日期对应的目标页面后，把文件保存到你的本地工作目录。下载完成后你会得到一个以.html结尾的文件。

第二步：观察文件内容并判定有价值的数据

.html文件是纯文本文件，可以通过文本编辑器查看相关内容，所以这一步的目标很容易实现。只需要用文本编辑器打开HTML文件，然后仔细查看其中的内容即可。但是有哪些内容可以提取呢？

在观察过程中，我确实发现了几项可以提取的内容。从每一条聊天记录的备注内容中可以看出，里面有行号、用户名、备注这些信息。接下来我们得为抽取这三项内容做一些准备了。

下面截图显示的是在文本编辑器中打开的HTML文件。当时我打开了折行功能，因为有些行的内容实在是太长了（在Text Wrangler中可以通过View | Text Display | Soft Wrap Text打开这个功能）。在29行上下我们可以看到聊天记录的开始部分，每一行都包括我们想要的三项内容。

接下来的工作就是从每一行中找出看起来具有相同特点的内容，这样就可以从聊天记录里抓取我们所需的那三项内容了。从文本内容中我们可以发现，只要遵循下面的规则，就可以在改动最小的情况下准确地抽取每一项数据。

- 我们需要抽取的三项数据都可以在标签<li>内找到，并且这个标签全部处于标签<ul id="ll">内部。每一个<li>代表一条聊天消息记录。

- 在消息中，行号信息可以从两个位置找出：一处是字符串<a href="#，另一处是name属性后面的双引号之间。在示例中，第一个出现的行号是1574618。

- 属性username可以在三个地方找到，第一处是标签li class="le"的rel属性值，第二处是标签span中的rel属性，最后一处则是处于&lt;和&gt;之间。在示例中，第一个username的值是petisnnake。

- 消息信息处于标签</span>和</li>之间。在例子中，第一条消息是i didnt know that。

有了数据的查找规则，就开始写程序吧。

第三步：编写Python程序把数据保存到CSV文件中

我们编写了下面的代码，用来打开一个指定的IRC日志文件，文件的格式与我们之前描述的一样。利用这份代码我们可以分别解析出所需的三项数据并把它们输出到CSV文件：

import re
import io

row = []

infile  = io.open('django13-sept-2014.html', 'r', encoding='utf8')
outfile = io.open('django13-sept-2014.csv', 'a+', encoding='utf8')
for line in infile:
pattern = re.compile(ur'<li class=\"le\" rel=\"(.+?)\"><a
href=\"#(.+?)\" name=\"(.+?)<\/span> (.+?)</li>', re.UNICODE)
if pattern.search(line):
username = pattern.search(line).group(1)
linenum = pattern.search(line).group(2)
message = pattern.search(line).group(4)
row.append(linenum)
row.append(username)
row.append(message)
outfile.write(', '.join(row))
outfile.write(u'\n')
row = []
infile.close()

这段代码最复杂的地方就是pattern那一行。文件中的每一行文本都会与该行代码构建出来的样式匹配进行比较。

注意，网站的开发者可以随时改变HTML结构，所以我们是冒着风险干这件事的，因为说不定哪天我们精心编写的样式就无法工作了。

每组匹配的数据样式都是.+?。这样的数据一共有五组，其中三组是我们有兴趣的数据（用户名、行号和消息），另外两组对我们没有什么用处，可以直接丢弃。页面中的其余内容也都可以丢弃，因为它们与我们设计的样式无法匹配。我们的程序就像一个筛子一样，当中恰好留了三个功能漏点。有用的数据都会从这些漏点中过滤下来，留下的都是没有用的数据。

第四步：查看文件并确认清洗结果

用文本编辑器打开新创建的CSV文件，这时我们看到的前几行内容应该与下面一样：

1574618, petisnnake, i didnt know that
1574619, dshap, FunkyBob: ahh, hmm, i wonder if there's a way to do
it in my QuerySet subclass so i'm not creating a new manager subclass
*only* for get_queryset to do the intiial filtering
1574620, petisnnake, haven used Django since 1.5

结果看起来还不赖。不过有件事你可能已经注意到了，第三列文本没有使用封闭字符。由于我们把逗号作为分隔符号使用，这下我们得面对新的问题了。第三列文本中的逗号该怎么处理呢？如果这个问题让你苦恼的话，你可以选择用引号把第三列内容封闭起来，或者是改用制表符来作为分隔符号。要想改变分隔符号，我们需要对第一个outfile.write()语句做一些修改，把当中连接用的逗号全部替换成\t（制表符）。在处理中，你还可以使用函数ltrim()来清洗消息中包含的多余空白字符。

使用正则表达式解析HTML的局限性

正则表达式这个方案乍看起来挺简单的，但它是有局限性的。首先，对于数据清洗工作者来说，设计正则表达式样式可能就是一件苦差事。你得花费大量的时间和精力来调试样式、编写冗长的说明文档。所以，为了辅助正则表达式的开发，我极力推荐使用像http://Pythex.org这样的正则表达式测试工具，当然，你也可以用搜索引擎找一款更适合你自己使用的测试工具。如果你使用的语言是Python的话，一定要指明你想要的是Python正则表达式测试工具。

接下来，你应该事先搞清楚一件事，正则表达式是完全依赖网页结构的。因此，如果你打算从网站上收集数据，那么你今天编写的正则表达式很有可能明天就无法工作了。表达式样式只有在页面布局没有发生变化的时候才能正常工作。或许刚刚在两个标签之间加入的一个空格字符就能让整个正则表达式失效，但分析起来却是困难重重。还有一点需要谨记的是，你无法干预网站的变化，因为要收集的大部分数据不是源于你自己的网站！

最后要讲的是，很多情况下无法使用正则表达式来精确地匹配HTML结构的。正则表达式虽然强大，但它并非完美得无懈可击。对于这个问题，我推荐你参考著名的Stack Overflow上一个超过4000次点赞的答案：http://stackoverflow.com/questions/1732348/。在这个答案中，作者幽默地表达了许多程序员的失望之情，他们曾经一次次尝试解释为什么正则表达式不是解析无规则的频繁变化的HTML页面的最佳方案。

方法二：Python和BeautifulSoup

因为正则表达式有诸多局限性，我们必须得用更多的工具来进行补充。在这里，我们将使用Python的BeautifulSoup库来解析树形结构的HTML页面。

第一步：找到并保存实验用的文件

在这一步中，我们还使用方法一中的那个文件：来自Django IRC频道的文件。我们还是提取相同的三个内容。这样做很容易比较出这两种方法的差异。

第二步：安装BeautifulSoup

BeautifulSoup目前已经发展到第四版。这个版本同时兼容Python 2.7和Python 3。

如果你使用的是Enthought Canopy Python环境，在Canopy Terminal里运行pip install beautifulsoup4就可以安装BeautifulSoup。

第三步：编写抽取数据用的Python程序

我们需要的三项内容可以从li标签集合中找到，更准确地说是那些带有class="le"样式的标签。在这个文件中虽然再没有别的li标签了，但为了谨慎起见我们在操作时还是应该尽可能地指明具体信息。下面的列表描述了在解析树形结构时我们需要获取的内容以及它们的所在位置。

- 我们可以从li标签中的rel属性提取username值。

- 我们可以从标签中的name属性提取linenum值。这个标签指的是li内部的第一个a标签。

请记住，数组索引是从零开始的，所以我们需要使用0获取第一个项目的内容。

在BeautifulSoup中，标签的内容就是被解析的树形结构的标签的内部项目，在有些包中被称为子项。

- 我们可以把message当作li标签的第四项内容（也就是数组中的内容[3]）来处理。还有一点需要注意的是，每条消息的前面都有一个打头的空白字符，我们需要在保存数据之前把它处理掉。

下面是解析树形结构的Python代码：

from bs4 import BeautifulSoup
import io

infile  = io.open('django13-sept-2014.html', 'r', encoding='utf8')
outfile = io.open('django13-sept-2014.csv', 'a+', encoding='utf8')
soup = BeautifulSoup(infile)

row = []
allLines = soup.findAll("li","le")
for line in allLines:
username = line['rel']
linenum = line.contents[0]['name']
message = line.contents[3].lstrip()
row.append(linenum)
row.append(username)
row.append(message)
outfile.write(', '.join(row))
outfile.write(u'\n')
row = []
infile.close()

第四步：查看文件并确认清洗结果

用文本编辑器打开新创建的CSV文件，从前几行数据中我们可以发现，这基本上与第一种方法产生的结果一样：

1574618, petisnnake, i didnt know that
1574619, dshap, FunkyBob: ahh, hmm, i wonder if there's a way to do
it in my QuerySet subclass so i'm not creating a new manager subclass
*only* for get_queryset to do the intiial filtering
1574620, petisnnake, haven used Django since 1.5

与正则表达式方案类似，如果你对最后一列中的逗号分隔符不放心的话，可以用双引号把其中的文本封闭起来，或者是改用制表符作为字段分隔符。

方法三：Chrome Scraper

如果你不想用程序解析数据的话，还有一些基于浏览器的工具可以让你从树形结构中抽取数据。我认为最简单省事的方案就是使用Chrome应用扩展中一个叫作Scraper的工具，它的开发者名为mnmldave（真名：Dave Heaton）。

第一步：安装Chrome扩展Scraper

如果你还没有安装Chrome浏览器的话，那就先下载一个。然后再确认一下你是不是安装了正确版本的Scraper，因为名字类似的扩展插件不止一个。我推荐直接从开发者本人的GitHub站点获取这个扩展插件，地址为http://mnmldave.github.io/scraper/。通过这种途径获取的程序肯定没有问题，这样你就不用在Chrome商店中到处找了。从http://mmldave.github.io/scraper点击链接安装完扩展插件后，重新启动浏览器。

第二步：从网站上收集数据

现在可以用你的浏览器访问我们之前两个数据提取实验所使用的URL，随便访问一个Django IRC日志。我使用的是2014年9月13日的数据，所以我的访问地址是http://django-irc-logs.com/2014/sep/13/。

我浏览页面时的样子如下：

IRC日志中已经包含了我们需要提取的三项内容：

- 行号信息（从之前的两个实验中我们可以得知，链接内容里符号#之后的文本就是行号）

- 用户名（符号<和>之间的文本）

- 消息信息

Scraper可以依次把那些要提取的数据进行高亮显示，并把结果输出到Google Spreadsheet中去，之后我们可以把这些输出结果重新整理到一张工作表里，并以CSV格式（或是别的格式）输出。下面是具体的操作步骤。

(1) 使用鼠标高亮选中你想要抓取的数据。

(2) 点击鼠标右键并从菜单中选择Scrape similar...。从下面的截图中可以看出，我选择用户名petisnnake作为抓取对象。

(3) 选择Scrape similar之后，工具显示出一个新的窗口，其中包括了页面中其他相似的项目。下面的截图显示了Scraper找出的完整用户名列表。

图：Scraper根据示例中的用户名找出了所有类似的项目。

(4) 在窗口的底部有一个名为Export to Google Docs...的按钮。这里需要注意的是，在不同的设置条件下，你可能需要点击同意之后才可以让Scraper访问Google Docs。

第三步：清洗数据

当我们把所有需要的数据元素都从页面中取出来并放到Google Docs后，就可以将它们组织到一个文件里并做最后的清洗工作了。下面是行号信息抽取之后的样子，而此时我们还没有对它们进行清洗。

对于A列中的内容我们并不关心，而且打头的符号#也不是我们想要的。用户名和每行的消息数据看起来也差不多——其中大部分数据都是有效的，但我们还是得先移除不需要的符号，然后把所有的内容都集中到一个独立的Google Spreadsheet中去。

使用删除符号#、<和>之后，把所有的行粘贴到一个新的工作表中，可以得到像下面一样干净的数据集：

Scraper非常适合从网页中抓取少量数据。它有一个便捷的Google Spreadsheets接口，如果你不想编写程序的话，这不失为一个快捷的解决方案。在接下来的一节中，我们将迎接一个更大的项目。它非常复杂，可能把这一章中的多个概念组合到一起才能形成一个完整的解决方案。

示例项目：从电子邮件和论坛中抽取数据

Django IRC日志项目非常简单。它的目的就是向你展示三种常见的HTML页面数据抽取技术之间的差异。我们需要抽取的数据包括行号、用户名和IRC聊天消息，找到这些数据并不难，几乎也不需要什么额外的清洗操作。而在即将开始的新项目中，概念上和之前的项目还是有相似之处的，但我们得把数据抽取思路扩展到HTML格式之外的两种Web中常见的半结构文本格式：Web中的电子邮件消息和基于网页的论坛消息。

项目背景

我最近做了一项关于社交媒体如何为软件技术提供支持的研究。具体说来，我想知道，是不是应该让某些API和框架的软件开发组织把他们对开发人员的支持转移到Stack Overflow上去，或者他们是不是应该继续使用传统的媒体模式，比如电子邮件和网络论坛。为了完成这项研究，我特意比较了软件开发人员获取API问题答案的时间，其中有的是通过Stack Overflow，有的是通过传统的社交媒体，比如网络论坛和电子邮件组。

我们会下载两种不同类型的原始数据，分别代表了不同的传统社交媒体：网络论坛的HTML文件和Google Groups的电子邮件。我们会编写Python代码来提取这两个论坛消息里所包含的日期和时间。之后再整理出哪些消息是用于回复其他消息的，并对回复的间隔时间做一个简单的汇总统计。

我们暂时不抽取Stack Overflow上的数据样本来回答刚才提出的问题。

这个项目被划分为两个部分。在第一个部分中，我们从Google Groups的一个项目的归档邮件中抽取数据，第二部分从另一个项目的HTML文件抽取数据。

第一部分：清洗来自Google Groups电子邮件的数据

现在还有许多软件公司在使用传统的电子邮件列表，或是同时使用邮件与网络论坛来为他们的产品提供技术支持。Google Groups就是这种服务的一种流行应用。用户可以向组内发送邮件，或是通过网络浏览器阅读与搜索邮件消息。但是，有的公司已经把技术支持搬离了Google Groups（包括Google本身的一些产品），取而代之的是使用Stack Overflow。数据库产品Google BigQuery就是使用Stack Overflow的组织之一。

第一步：收集Google Groups消息

为了研究BigQuery在Google Group上的问题响应时间，我特意为组内所有的回复创建了一份URL清单列表。你可以从https://github.com/megansquire/stackpaper2015/blob/master/BigQuery-GGurls.txt找到完整的列表内容。

有了目标URL列表之后，我们就可以编写Python程序来下载URL中所涉及的所有电子邮件，并把它们保存到磁盘中。在下面的程序里，我把URL清单列表保存在一个名为GGurls.txt的文件里。程序引用了time库，这样就可以在请求Google Groups服务器时使用sleep()函数稍作停歇：

import urllib2
import time

with open('GGurls.txt', 'r') as f:
urls = []
for url in f:
urls.append(url.strip())

currentFileNum = 1
for url in urls:
print("Downloading: {0} Number: {1}".format(url, currentFileNum))
time.sleep(2)
htmlFile = urllib2.urlopen(url)
urlFile = open("msg%d.txt" %currentFileNum,'wb')
urlFile.write(htmlFile.read())
urlFile.close()
currentFileNum = currentFileNum +1

程序运行之后一共在磁盘上生成了667个文件。

第二步：从Google Groups消息中抽取数据

现在我们已经有了667组电子邮件消息，每组消息都以独立文件的形式存在。接下来的任务就是编写一个程序每次读取一个文件，并用本章中学到的技术从文件中提取我们需要的信息。无论从哪个邮件中我们都可以发现很多头部信息，其中存储了电子邮件信息或是相关的元数据。现在让我们快速地浏览一下三段头部信息，找出需要的原数据：

In-Reply-To: <ab71b72a-ef9b-4484-b0cc-a72ecb2a3b85@r9g2000yqd.
googlegroups.com>
Date: Mon, 30 Apr 2012 10:33:18 -0700
Message-ID: <CA+qSDkQ4JB+Cn7HNjmtLOqqkbJnyBu=Z1Ocs5-dTe5cN9UEPyA@mail.
gmail.com>

所有的消息都有Message-ID和Date，但In-Reply-To只会在回复消息中出现。In-Reply-To的值必须是另一条消息的Message-ID。

下面的代码演示的是一种基于正则表达式的解决方案，它能够抽取Date、Message-ID和In-Reply-To值，并创建出原始消息与回复消息的列表。然后计算出消息和它们对应的回复消息之间的时间差。

import os
import re
import email.utils
import time
import datetime
import numpy

originals = {}
replies = {}
timelist = []

for filename in os.listdir(os.getcwd()):
if filename.endswith(".txt"):
f=open(filename, 'r')
i=''
m=''
d=''
for line in f:
irt = re.search('(In\-Reply\-To: <)(.+?)@', line)
mid = re.search('(Message\-ID: <)(.+?)@', line)
dt = re.search('(Date: )(.+?)\r', line)
if irt:
i= irt.group(2)
if mid:
m= mid.group(2)
if dt:
d= dt.group(2)
f.close()
if i and d:
replies[i] = d
if m and d:
originals[m] = d

for (messageid, origdate) in originals.items():
try:
if replies[messageid]:
replydate = replies[messageid]
try:
parseddate = email.utils.parsedate(origdate)
parsedreply = email.utils.parsedate(replydate)
except:
pass
try:
# 这个地方有时会生成错误的时间值
timeddate = time.mktime(parseddate)
timedreply = time.mktime(parsedreply)
except:
pass
try:
dtdate = datetime.datetime.fromtimestamp(timeddate)
dtreply = datetime.datetime.fromtimestamp(timedreply)
except:
pass
try:
difference = dtreply - dtdate
totalseconds = difference.total_seconds()
timeinhours =  (difference.days*86400+difference.seconds)/3600
# 这个地方应该处理负数时间的
# 或许用时区处理比较合适，暂时还是算了吧
if timeinhours > 1:
# 打印小时数
timelist.append(timeinhours)
except:
pass
except:
pass

print numpy.mean(timelist)
print numpy.std(timelist)
print numpy.median(timelist)

从代码中可以看出，最开始的for循环会依次提取我们所需的三种信息。（这段程序不会把提取出来的信息存放到一个独立的文件或是磁盘中，如果有需要的话你可以自己实现这个功能。）它创建了两个重要的列表。

- originals[]是包含原始消息的列表。这些消息都是最初提出的问题。

- replies[]是包含回复消息的列表。这些消息都是针对最初提出的问题的回复。

第二段for循环处理会对原始消息列表中的每条消息做进一步处理，如果原始消息有对应的回复消息，就尝试找出经过多长时间回复消息才被发出。我们会为回复时间做出一个新的记录列表。

(1) 数据提取代码

这里我们最为关心的代码是数据清洗和数据抽取部分，所以直接阅读下面的代码就可以了。程序会逐行处理电子邮件中的内容，目的是找出关键的三个邮件头部信息：In-Reply-To、Message-ID和Date。代码中使用正则表达式来搜索与组织数据，就像我们在前面的方法一中那样，对头部信息进行了拆分并提取出相应的数据：

for line in f:
irt = re.search('(In\-Reply\-To: <)(.+?)@', line)
mid = re.search('(Message\-ID: <)(.+?)@', line)
dt = re.search('(Date: )(.+?)\r', line)
if irt:
i = irt.group(2)
if mid:
m = mid.group(2)
if dt:
d = dt.group(2)

我们为什么要用正则表达式来代替树形结构的解析方法呢？原因主要有以下两个。

(a) 因为下载下来的电子邮件不是HTML格式，所以我们不能使用父子关系的树形结构来描述文件内容。因此，像BeautifulSoup这样基于树形结构的方案是行不通的。

(b) 因为电子邮件头部信息的结构都是固定的，并且内容也是可以预测的（尤其是我们需要的那三条数据），所以我们可以采用正则表达式来完成这个处理。

(2) 程序输出

程序的输出就是打印出三个数字，表示在该Google Group中以小时为计算单位的情况下，其均方差、标准差和中位差分别是多少。下面是代码在我的环境中运行出来的结果：

178.911877395
876.102630872
18.0

这意味着在BigQuery Google Group上对一条消息的平均响应时间是18小时。现在让我们再研究一下怎么从另一种数据源——网络论坛——中提取类似的数据。你觉得网络论坛中的消息响应会怎么样呢？更快、更慢，还是跟Google Group上一样？

第二部分：清洗来自网络论坛的数据

在这个项目中我们要研究的网络论坛来自一家名为DocuSign的公司。他们也把对开发者的支持转移到Stack Overflow上了，但同时对之前基于网络的开发者论坛做了归档处理，并且可以在线访问。当时我正在他们的网站上闲游，直到发现了下载旧论坛消息的方法。这里演示的处理方法比Google Groups的例子要多，比如数据的自动化收集。

第一步：收集指向HTML文件的RSS信息

DocuSign开发者论坛中有几千条消息。我们可以组织一份URL列表来指向所有的消息或是讨论主题，这样就可以用代码实现自动下载，更高效地提取回复时间。

要实现这个功能，首先需要的是拿到所有讨论主题的URL。我发现DocuSign在Dev-Zone开发者站点上的归档文件地址为https://community.docusign.com/t5/DevZone-Archives-Read-Only/ct-p/dev_zone。

网站看起来和下面的浏览器截图一样：

我们当然不想挨个点击论坛中的每一个链接，然后手工把每条消息保存下来。要是那样做的话，差不多得花一辈子时间，太烦了。难道就没有别的什么好办法了吗？

DocuSign网站的帮助页面已经表明，通过下载Really Simple Syndication（RSS）文件可以获取每个论坛中最新发布的主题和消息。这样的话就可以利用RSS文件自动收集网站上各种讨论话题的URL信息。而我们最为关注的RSS文件只有开发者支持论坛（并非广告或销售论坛）。这些RSS文件可以从下面的地址获取：

- https://community.docusign.com/docusign/rss/board?board.id=upcoming_releases

- https://community.docusign.com/docusign/rss/board?board.id=DocuSign_Developer_Connection

- https://community.docusign.com/docusign/rss/board?board.id=Electronic_Signature_API

- https://community.docusign.com/docusign/rss/board?board.id=Java

- https://community.docusign.com/docusign/rss/board?board.id=php_api

- https://community.docusign.com/docusign/rss/board?board.id=dev_other

- https://community.docusign.com/docusign/rss/board?board.id=Ask_A_Development_Question_Board

我们可以通过浏览器访问列表中的每一个URL（或者只访问其中的一个）。由于文件是RSS格式的，所以看起来像是带有标签的半结构化文本文件，与HTML比较类似。把访问到的RSS文件保存到本地系统并为每个文件添加一个.rss文件扩展名。在整个处理结束的时候，你应该拿到至少七个RSS文件，每一个文件能找到与上面列表中相对应的URL。

每个RSS文件的内容都是关于论坛中各个讨论话题的元数据，当中包括一些在这个阶段我们迫切需要的数据：每个讨论话题对应的URL地址。用文本编辑器随便打开其中一个RSS文件，你就可以定位到我们需要的URL。具体形式看起来应该跟下面的例子差不多，从文件的内部可以看出，每个讨论话题都含有这样的数据：

<guid>http://community.docusign.com/t5/Misc-Dev-Archive-READ-ONLY/
Re-Custom-CheckBox-Tabs-not-marked-when-setting-value-to-quot-X/m-
p/28884#M1674</guid>

现在我们可以编写程序来遍历每一个RSS文件，从而找出与之关联的URL数据，接着访问这些地址，提取出我们希望得到的回复时间。接下来，我们会把这个过程划分为一系列小步骤，并在后面使用程序来演示如何完成整个工作。

第二步：从RSS中提取URL，收集并解析HTML

在这一步中，我们将编写一个程序来完成以下步骤。

(1) 打开我们在第一步中保存下来的每一个RSS文件。

(2) 每当碰到<guid>和</guid>的标签组合的时候，就提取其中的URL信息并把它添加到一个列表中。

(3) 根据列表中的每一个URL，下载其对应的HTML文件。

(4) 读取HTML文件内容，并抽取原始消息的发布时间和对应的每一条回复消息的回复时间。

(5) 计算平均差、中位差和标准差，这跟我们当时在第一部分中做的差不多。

下面的Python代码会完成这些步骤。看完代码之后我们将详细说明数据抽取部分的内容：

import os
import re
import urllib2
import datetime
import numpy

alllinks = []
timelist = []
for filename in os.listdir(os.getcwd()):
if filename.endswith('.rss'):
f = open(filename, 'r')
linktext = ''
linkurl = ''
for line in f:
# 找出讨论主题的的URL
linktext = re.search('(<guid>)(.+?)(<\/guid>)', line)

if linktext:
linkurl= linktext.group(2)
alllinks.append(linkurl)
f.close()

mainmessage = ''
reply = ''
maindateobj = datetime.datetime.today()
replydateobj = datetime.datetime.today()
for item in alllinks:
print "==="
print "working on thread\n" + item
response = urllib2.urlopen(item)
html = response.read()
# 定义一个用于匹配时间戳的正则表达式样式
tuples = re.findall('lia-message-posted-on\">\s+<span
class=\"local-date\">\\xe2\\x80\\x8e(.*?)<\/span>\s+<span
class=\"local-time\">([\w:\sAM|PM]+)<\/span>', html)
mainmessage = tuples[0]
if len(tuples) > 1:
reply = tuples[1]
if mainmessage:
print "main: "
maindateasstr = mainmessage[0] + " " + mainmessage[1]
print maindateasstr
maindateobj = datetime.datetime.strptime(maindateasstr,
'%m-%d-%Y %I:%M %p')
if reply:
print "reply: "
replydateasstr = reply[0] + " " + reply[1]
print replydateasstr
replydateobj = datetime.datetime.strptime(replydateasstr,
'%m-%d-%Y %I:%M %p')

# 只针对有回复的数据进行时间差计算
difference = replydateobj - maindateobj
totalseconds = difference.total_seconds()
timeinhours =  (difference.days*86400+difference.seconds)/3600
if timeinhours > 1:
print timeinhours
timelist.append(timeinhours)

print "when all is said and done, in hours:"
print numpy.mean(timelist)
print numpy.std(timelist)
print numpy.median(timelist)

(1) 程序状态

在程序工作的过程中，它会打印出一些状态信息，这样我们就可以知道它是否还处在工作的状态中。状态信息与下面的内容类似，并且每次从RSS文件中找到URL时都会有这样的信息输出：

===
working on thread
http://community.docusign.com/t5/Misc-Dev-Archive-READ-ONLY/Can-you-
disable-the-Echosign-notification-in-Adobe-Reader/m-p/21473#M1156
main:
06-21-2013 08:09 AM
reply:
06-24-2013 10:34 AM
74

这里的74表示在当前这个话题中，第一条提问消息的发布时间与第一条回复消息的发布时间经过四舍五入之后的差值（约为三天又两个小时）。

(2) 程序输出

在得到结论的时候，程序会分别打印出平均差、标准差和以小时为单位的平均回复时间，这与我们在第一部分的Google Groups程序中做的一样：

when all is said and done, in hours:
695.009009009
2506.66701108
20.0

看起来DocuSign论坛上的回复时间比Google Groups还要慢一些。Google Groups需要18小时比，它则需要花费20个小时，不过好在这两个数字还是在比较接近的范围内。你的实验结果可能会有所不同，这是因为总有新的消息不断地加入实验文件。

(3) 数据提取

我们的重点是数据提取。下面是最为重要的代码：

tuples = re.findall('lia-message-posted-on\">\s+<span class=\"local-
date\">\\xe2\\x80\\x8e(.*?)<\/span>\s+<span class=\"local-
time\">([\w:\sAM|PM]+)<\/span>', html)

就像前面的那些例子一样，这段代码也是依靠正则表达式实现的。但这里的表达式看起来乱得一塌糊涂。也许我们用BeautifulSoup会有所改善吧？那就让我们先看一下原始的HTML内容，这样就可以充分理解这段代码的含义，并能更好地判断是否应该换一种实现方式。下面的截图显示的是页面在浏览器中的样子。我们需要的时间信息在截图中加上了注释：

底层的HTML又是什么样子呢？那正是我们的程序需要解析的。从HTML的内容中我们可以发现，原始消息的日期信息分布在几处不同的地方，但原始消息和回复消息的日期和时间组合只在页面上打印了一次。下面是HTML的内容（为了方便查看，HTML经过简单的压缩处理，并移除了一些多余的空行）：

<p class="lia-message-dates lia-message-post-date lia-component-post-
date-last-edited" class="lia-message-dates lia-message-post-date">
<span class="DateTime lia-message-posted-on lia-component-common-
widget-date" class="DateTime lia-message-posted-on">
<span class="local-date">06-18-2013</span>
span class="local-time">08:21 AM</span>

<p class="lia-message-dates lia-message-post-date lia-component-post-
date-last-edited" class="lia-message-dates lia-message-post-date">
<span class="DateTime lia-message-posted-on lia-component-common-
widget-date" class="DateTime lia-message-posted-on">
<span class="local-date">06-25-2013</span>
<span class="local-time">12:11 AM</span>

结果内容明显可以用正则表达式来解决，只要我们编写一个正则表达式样式就可以一次性找到这两种类型的消息。在代码里，我们把第一次找到的内容当作原始消息处理，而接下来的则当作回复消息处理，请看下面的代码：

mainmessage = tuples[0]
if len(tuples) > 1:
reply = tuples[1]

我们本来也可以采用BeautifulSoup这样基于树形结构的解决方案，但这就不得不处理两套一模一样的日期组合，因为它们对应的span标签样式是完全相同的，即使我们将解析范围扩展到上一层父元素（<p>标签），也要面对相同样式的问题。所以，解析这样的树形结构要比之前的方法二复杂得多。

如果你就是想用BeautifulSoup来完成数据提取，我的建议是先用浏览器的Developer Tools功能好好观察一下页面的结构，比如在Chrome浏览器里，你可以将鼠标移到你想要查看的元素上——也就是这个项目中的日期和时间部分——点击鼠标右键，然后选择Inspect Element。这样就会打开一个Developer Tools面板，你可以从完整的文档树中找到对应数据的所在位置。每个HTML元素左侧的箭头符号表明该元素是否还有子节点。这个时候，你可以决定如何以编程的方式来定位树形结构中的目标元素，并进一步做好辨别不同节点的计划。这项任务的细节无法在此尽述，读者可以把它留作学后练习。

本文节选自《干净的数据：数据清洗入门与实践》

《干净的数据：数据清洗入门与实践》从文件格式、数据类型、字符编码等基本概念讲起，通过真实的示例，探讨如何提取和清洗关系型数据库、网页文件和PDF文档中的数据。最后提供了两个真实的项目，让读者将所有数据清洗技术付诸实践，完成整个数据科学过程。

决策树高精准预测森林植被

作者/Sandy Ryza等

Cloudera公司资深数据科学家，Apache Spark项目的活跃代码贡献者。最近领导了Cloudera公司的Spark开发工作。他还是Hadoop项目管理委员会委员。

决策树算法家族能自然地处理类别型和数值型特征。决策树算法容易并行化，它们对数据中的离群点（outlier）具有鲁棒性（robust），这意味着一些极端或可能错误的数据点根本不会对预测产生影响。算法可以接受不同类型和量纲的数据，对数据类型和尺度不相同的情况不需要做预处理或规范化。

如果进一步推广决策树，可以得到更为强大的决策森林（random decision forest）算法。由于决策森林算法的灵活性，我们有必要在这里介绍它。

基于决策树的算法还有一个优点，那就是理解和推理起来相对直观。实际上，日常生活中我们大概都无意间用过决策树所体现的推理方法。举个例子，早上我正要坐下来喝杯加奶咖啡，决定往咖啡中加牛奶之前，我会预测：牛奶有没有变质呢？我不确定牛奶是否变质。于是我会检查一下牛奶的建议食用期。如果建议食用期没过，我会预测牛奶没有坏。如果已经超过建议食用期三天，我认为牛奶已经变坏。如果超过建议食用期 3 天之内，我会闻一下。如果牛奶的气味有点儿异常，我会预测牛奶已经坏了，否则就没坏。

这一系列的“是 / 否”判断就是决策树算法所体现的预测过程。每个判断会走到两个分支中的一个，非此即彼，如图所示。

图：决策树：牛奶坏了吗？

前面提到的规则是我在上大学期间学会的，很直观。这些规则不但简单而且能有效地帮我区分牛奶是否已经坏了。这些都是一棵好的决策树的特点。

上面是一棵简化的决策树，构造过程非常灵活。为了更详细地说明决策树，我们来看看另外一个例子。在一个新奇的宠物店，一个机器人正忙于工作。在宠物店开门营业之前，它要学会什么动物适合孩子。最开始，店长给出了 9 种动物，其中有的适合做宠物，有的不适合。经过一番检查，机器人把这些信息编撰成一张表格，如表所示。

表：新奇宠物店的“特征向量”

数据中已经给定了名字，但名字并不能作为一个特征。我们没有道理认为名字本身具有预测性：就机器人所知，“Felix”可能是只猫的名字，也可能是只有毒的狼蛛。因此，这里所剩的特征有：两个数值型特征（重量、腿数），一个类别型特征（颜色），这三个特征用来预测一个类别型目标（是否适合做小孩的宠物）。

机器人可能会用一个简单的决策树拟合训练数据集，这棵决策树只根据“重量”做决策，如图所示。

图：机器人的第一棵决策树

可见，决策树的逻辑很好理解，而且有一定的道理：500 公斤的动物肯定不适合作宠物。这条规则能对 9 个样本中的 5 个作出正确预测。快速看一下训练数据，我们就能发现把重量的阈值降低为 100 公斤能够改进决策规则。这样我们就能正确预测 6 个样本。现在重量大的动物都能预测正确了，但重量轻的动物只能部分预测正确。

因此，为了进一步提高对体重小于 100 公斤的动物的预测准确度，机器人进行了第二次决策。如果能找到一个特征，通过这个特征将错误的“是”预测纠正为“否”预测，那就太好了。比如，训练集中有一种小型的绿色动物，听起来有点儿像条蛇，不适合做宠物，机器人就可以根据颜色对此作出正确判断，如图所示。

图：机器人的第二棵决策树

现在 9 个样本中有 7 个可以正确预测。当然，我们可以一直增加决策规则，直到对所有 9 个样本都能全部正确地作出预测。但这样得出的决策树很可能不合理，如果翻译成常用语，决策树可能是：“如果动物的重量小于 100 公斤，并且颜色是棕色而不是绿色，并且腿的数量少于 10，那么它适合做宠物。”虽然能完美拟合给定样本，但这样的决策树不能预测出棕色有四条腿的小型豹熊不合适做宠物。看来，为避免这种被称为过度拟合的现象，还是需要继续改进啊。

不过到目前为止，对决策树算法的介绍已经足够我们在 Spark 中使用决策树算法了。接下来的内容将描述怎样选择决策规则，何时停止决策过程以及怎样通过创建决策森林来提高准确度。

Covtype数据集

著名的数据集 Covtype 数据集，可以在线下载（http://t.cn/R2wmIsI），里面包含一个 CSV 格式的压缩数据文件 covtype.data.gz，附带一个描述数据文件的信息文件 covtype.info。

该数据集记录了美国科罗拉多州不同地块的森林植被类型（也就是现实中的森林，这仅仅是巧合！）每个样本包含了描述每块土地的若干特征，包括海拔、坡度、到水源的距离、遮阳情况和土壤类型，并且随同给出了地块的已知森林植被类型。我们需要总共 54 个特征中的其余各项来预测森林植被类型。

人们已经用该数据集进行了研究，甚至在 Kaggle 大赛（https://www.kaggle.com/c/forest-cover-type-prediction）中也用到过。之所以研究这个数据集，原因在于它不但包含了数值型特征而且包含了类别型特征。该数据集共有 581 012 个样本，虽然还称不上大数据，但作为一个范例来讲已经足够大，能够反映出一些数据上的问题。

准备数据

幸运的是，数据已经是简单的 CSV 格式了。在开始用 Spark 的 MLlib 之前，我们不需要进行大量的清洗或其他准备工作。

解压 covtype.data 文件并复制到 HDFS。我们假定文件放在 /user/ds/ 目录下。请启动spark-shell。

Spark MLlib 将特征向量抽象为LabeledPoint，它由一个包含多个特征值的 Spark MLlibVector和一个称为标号（label）的目标值组成。该目标为Double类型，而Vector本质上是对多个Double类型值的抽象。这说明LabeledPoint只适用于数值型特征。但只要经过适当编码，LabeledPoint也可用于类别型特征。

其中一种编码是 one-hot（http://en.wikipedia.org/wiki/One-hot）或 1-of-n 编码。在这种编码中，一个有 N 个不同取值的类别型特征可以变成 N 个数值型特征，变换后的每个数值型特征的取值为 0 或 1。在这 N 个特征中，有且只有一个取值为 1，其他特征取值都为 0。比如，类别型特征“天气”可能的取值有“多云”“有雨”或“晴朗”。在 1-of-n 编码中，它就变成了三个数值型特征：多云用1,0,0表示，有雨用0,1,0表示，晴朗用0,0,1表示。可以为这三个数值型特征分别取名：is_cloudy、is_rainy和is_clear。

另一种可能的编码方式是为类别型特征的每个可能取值分配一个不同数值，比如多云 1.0，有雨 2.0 等。

在编码过程中，将类别型特征当成数值型特征时要小心。类别型特征值原本是没有大小顺序可言的，但被编码为数值之后，它们就“显得”有大小顺序了。被编码后的特征若被视为数值，算法在一定程度上会假定有雨比多云大，而且大两倍，这样就可能导致不合理的结果。当然，只要算法不把数字编码当作数值来用也没什么问题。

虽然 Covtype 数据集所有列都是数值，但从本质上讲，该数据集并不是完全由数值型特征组成。covtype.info 文件显示，有 4 列是由同一个类别型特征Wilderness_Type经过 one-hot 编码生成的，它被赋予了 4 个可能的取值。同样，还有 40 列也是同一个类别型特征Soil_Type。目标本身也是类别型值，用 1 到 7 编码。其余的才是由不同单位度量的数值型特征，比如米、度或某个定性的指标值。

在 Covtype 数据集中，我们看到它同时使用了前面提到的类别型变量的两种编码方法。如果对类别型特征不用这两种编码方式，而是直接使用类似“Rawah Wilderness Area”这样的值，有可能会更简单更直接。这可能是历史的产物：Covtype 数据集在 1998 年发布。基于性能方面的考虑，或者为了满足当时处理工具要求的格式（当时工具更多是面向回归问题的），数据集往往用这样的方式进行编码。

第一棵决策树

开始时我们原样使用数据。决策树（DecisionTree）的实现，以及 Spark MLlib 中其他几个实现，都要求输入必须是LabeledPoint对象格式：

import org.apache.spark.mllib.linalg._
import org.apache.spark.mllib.regression._

val rawData = sc.textFile("hdfs:///user/ds/covtype.data")

val data = rawData.map { line =>
val values = line.split(',').map(_.toDouble)
val featureVector = Vectors.dense(values.init) 1
val label = values.last - 1 2
LabeledPoint(label, featureVector)
}

1init 返回除最后一个值之外的所有值；最后一列是目标。

2决策树要求 label 从 0 开始，所以要减 1。

虽然我们不知道怎样用 54 个特征来描述科罗拉多州的一个从未见过的地块，也不知道如何预测地块上的森林植被类型。我们可以直接从数据集中取出部分数据，用以评估所得到的模型。之前，为了评测保留的收听数据和模型预测之间的一致性，我们采用 AUC 指标。这里我们采用同样的原理，不过评价指标改为精确度指标。我们现在将数据分成完整的三部分：训练集、交叉检验集（CV）和测试集。在下面的代码中你会看到，训练集占 80%，交叉检验集和测试集各占 10%：

val Array(trainData, cvData, testData) =
data.randomSplit(Array(0.8, 0.1, 0.1))
trainData.cache()
cvData.cache()
testData.cache()

和 ALS 实现一样，DecisionTree 实现也有几个超参数，我们需要从训练集和 CV 集中为它们选择合适的值。第三个数据集，也就是测试集，用于对基于选定超参数的模型期望准确度做无偏估计。模型在交叉检验集上的准确度往往有点儿过于乐观，不是无偏差的。因此，我们决定在测试集上评估最终模型。

现在我们还是先来试试在训练集上构造一个 DecisionTreeModel 模型，参数采用默认值，并用 CV 集来计算结果模型的指标：

import org.apache.spark.mllib.evaluation._
import org.apache.spark.mllib.tree._
import org.apache.spark.mllib.tree.model._
import org.apache.spark.rdd._

def getMetrics(model: DecisionTreeModel, data: RDD[LabeledPoint]):
MulticlassMetrics = {
val predictionsAndLabels = data.map(example =>
(model.predict(example.features), example.label)
)
new MulticlassMetrics(predictionsAndLabels)
}

val model = DecisionTree.trainClassifier(
trainData, 7, Map[Int,Int](), "gini", 4, 100)

val metrics = getMetrics(model, cvData)

这里用 trainClassifier，而不用 trainRegressor，trainClassifier 指示每个 LabeledPoint 里的目标都应该当作不同的类别标号，而不是数值型特征值。（对于回归问题 trainRegressor 情况类似，这里不再单独讨论。）

示例代码中，我们必须指明数据集中目标的取值个数，也就是 7。Map 保存类别型特征的信息，后面在解释参数值 gini、最大深度 4 和最大桶数 100 的含义时我们一并讨论。

MulticlassMetrics 以不同方式计算分类器预测质量的标准指标，这里分类器运行在 CV 集上。理想情况下，分类器对 CV 集中每个样本的目标类别应该都能做出正确预测。这里的指标以不同方式度量这种正确性。

和 MulticlassMetrics 一起，Spark 还提供了 BinaryClassificationMetrics。它提供类似 MulticlassMetrics 的评价指标实现，不过仅适用常见的类别型目标只有两个可能取值的情况。由于这里目标类别的可能取值有多个，所以我们不能直接使用 BinaryClassificationMetrics。

我们有必要先看看混淆矩阵：

metrics.confusionMatrix

...
14019.0  6630.0   15.0    0.0    0.0  1.0   391.0
5413.0   22399.0  438.0   16.0   0.0  3.0   50.0
0.0      457.0    2999.0  73.0   0.0  12.0  0.0
0.0      1.0      163.0   117.0  0.0  0.0   0.0
0.0      872.0    40.0    0.0    0.0  0.0   0.0
0.0      500.0    1138.0  36.0   0.0  48.0  0.0
1091.0   41.0     0.0     0.0    0.0  0.0   891.0

你得到的值可能稍有不同。构造决策树过程中的一些随机选项会导致分类结果存在偏差。

因为目标类别的取值有 7 个，所以混淆矩阵是一个 7×7 的矩阵，矩阵每一行对应一个实际的正确类别值，矩阵每一列按序对应预测值。第 i 行第 j 列的元素代表一个正确类别为 i 的样本被预测为类别为 j 的次数。因此，对角线上的元素代表预测正确的次数，而其他元素则代表预测错误的次数。对角线上的次数多是好的。但也确实出现了一些分类错误的情况，比如分类器甚至没有将任何一个样本类别预测为 5。

将准确度用一个数字概括是有帮助的。显然，我们可以想到用预测正确的样本数占整个样本数的比例来计算准确度：

metrics.precision

...
0.7030630195577938

大约 70% 样本的分类是正确的。这个比例通常被称为准确度（accuracy），在 Spark 的 MulticlassMetrics 指标中称为精确度（precision），意思差不多。

实际上精确度（precision）是二元分类问题中一个常用的指标。二元分类问题中的目标类别只有两个可能的取值，而不是多个取值，其中一个类代表正，另一类代表负，精确度就是被标记为“正”而且确实是“正”的样本占所有标记为“正”的样本的比例。和精确度一起出现的还有另一个指标召回率（recall）。召回率是被分类器标记为“正”的所有样本与所有本来就是“正”的样本的比率。

比如，假设数据集有 50 个样本，其中 20 个为正。分类器将 50 个样本中的 10 个标记为“正”，在这 10 个被标记为“正”的样本中，只有 4 个确实是“正”（也就是 4 个分类正确），所以这里的精确度为 4/10=0.4，召回率为 4/20=0.2。

我们可以把这些概念应用到多元分类问题，把每个类别单独视为“正”，所有其他类型视为“负”。比如，要计算每个类别相对其他类别的精确度，请看如下代码：

(0 until 7).map( 3
cat => (metrics.precision(cat), metrics.recall(cat))
).foreach(println)

...
(0.6805931840866961,0.6809492105763744)
(0.7297560975609756,0.7892237892589596)
(0.6376224968044312,0.8473952434881087)
(0.5384615384615384,0.3917910447761194)
(0.0,0.0)
(0.7083333333333334,0.0293778801843318)
(0.6956168831168831,0.42828585707146427)

3DecisionTreeModel 模型的类别标号从 0 开始。

由此可以看到每个类型的准确度都各不相同。就本例而言，我们没道理认为某个类型的准确度要比其他类型的准确度更重要，因此用一个多元分类的总体精确度就可以较好地度量分类准确度。

虽然 70% 的准确度听起来还不错，但我们还不能立马看出这个准确度是优秀还是糟糕。作为基准，一个朴素方法的准确度是多少呢？即使是一个坏了的时钟，每天也会有两次显示的时间是正确的。类似地，为每个样本随便猜一个类别，偶尔也能得到正确答案。

按照类别在训练集中出现的比例来预测类别，我们来构建一个“分类器”。每次分类的正确度将和一个类型在 CV 集中出现的次数成正比。比如，一个类别在训练集中占 20%，在 CV 集中占 10%，那么该类别将贡献 10% 的 20%，即 2% 的总体准确度。通过按 20% 的时候将样本猜测为该类，CV 集样本中有 10% 的样本会被猜对。将所有类别在训练集和 CV 集出现的概率相乘，然后把结果相加，我们就得到了一个对准确度的评估：

import org.apache.spark.rdd._

def classProbabilities(data: RDD[LabeledPoint]): Array[Double] = {
val countsByCategory = data.map(_.label).countByValue() 4
val counts = countsByCategory.toArray.sortBy(_._1).map(_._2) 5
counts.map(_.toDouble / counts.sum)
}

val trainPriorProbabilities = classProbabilities(trainData)
val cvPriorProbabilities = classProbabilities(cvData)
trainPriorProbabilities.zip(cvPriorProbabilities).map { 6
case (trainProb, cvProb) => trainProb * cvProb
}.sum

...
0.37737764750734776

4计算数据中每个类别的样本数：（类别，样本数）。

5对类别的样本数进行排序并取出样本数。

6把训练集和 CV 集中的某个类别的概率结成对，相乘然后相加。

随机猜测的准确度为 37%，所以前面得到 70% 的准确度看起来还不错。但是 70% 的准确度是在 DecisionTree.trainClassifier() 中用默认参数的条件下取得的。如果在决策树构建过程中试试超参数的其他值，准确度还可以提高。

决策树的超参数

ALS 算法可以提供几个超参数。我们先构造超参数取不同值时的不同组合的模型，然后用某个指标评估每个组合结果的质量，通过这种方式来选择超参数值。这里我们采用相同的过程，但指标由 AUC 改为多元分类准确度。这里控制决策树选择过程的超参数为最大深度、最大桶数和不纯性度量。

最大深度只对决策树的层数作出限制，它是分类器为了对样本进行分类所作的一连串判断的最大次数。限制判断次数有利于避免对训练数据产生过拟合。

决策树算法负责为每层生成可能的决策规则，比如在宠物店示例中，决策规则类似“重量≥ 100”或者“重量≥ 500”。决策总是采用相同形式：对数值型特征，决策采用特征≥值的形式；对类别型特征，决策采用特征在（值 1, 值 2,…）中的形式。因此，要尝试的决策规则集合实际上是可以嵌入决策规则中的一系列值。Spark MLlib 的实现把决策规则集合称为“桶”（bin）。桶的数目越多，需要的处理时间越多但找到的决策规则可能更优。

什么因素促使好的决策规则产生？直观上讲，好的决策规则应该通过目标类别值对样本作出有意义的划分。比如，如果一个规则将 Covtype 数据集划分为两个子集，其中一个子集的样本全部属于类别 1~3，第二个子集中的样本则都属于和类别 4~7，那么它就是一个好规则，因为这个规则清楚地把一些类别和其他类别分开。如果样本集用一个决策规则划分，划分前后每个集合各类型的不纯性程度没有改善，那么这个规则就没什么价值。沿着该决策规则的分支走下去，每个目标类别的可能取值的分布仍然是一样的，因此实际上它在构造可靠的分类器方面没有任何进步。

换句话说，好规则把训练集数据的目标值分为相对是同类或“纯”（pure）的子集。选择最好的规则也就意味着最小化规则对应的两个子集的不纯性（impurity）。不纯性有两种常用的度量方式：Gini 不纯度（http://en.wikipedia.org/wiki/Decision_tree_learning#Gini_impurity）或熵（http://en.wikipedia.org/wiki/Entropy_(information_theory)）。

Gini 不纯度直接和随机猜测分类器的准确度相关。在每个子集中，它就是对一个随机挑选的样本进行随机分类时分类错误的概率（随机挑选样本和随机分类时要参照子数据集的类别分布）。这就是用 1 减去每个类别的比例与自身的乘积之和。假设子数据包含 N 个类别的样本，pi 是类别 i 的样本所占比例，于是可以得到如下 Gini 不纯度公式：

如果子数据集中所有样本都属于一个类别，则 Gini 不纯度的值为 0，因为这个子数据集完全是“纯”的。当子数据集中的样本来自 N 个不同的类别时，Gini 不纯度的值大于 0，并且在每个类别的样本数都相同时达到最大，也就是最不纯的情况。

熵是另一种度量不纯性的方式，它来源于信息论。解释熵的本质更困难，但熵代表了子集中目标取值集合的不确定程度。如果子集只包含一个类别，则是完全确定的，熵为 0。熵可以用以下熵计算公式定义：

有意思的是，不确定性是有单位的。由于取自然对数（以 e 为底），熵的单位是纳特（nat）。相对于以 e 为底的纳特，我们更熟悉它对应的比特（以 2 为底取对数即可得到）。它实际上度量的是信息，因此在使用熵的决策树中，我们也常说决策规则的信息增益。

不同的数据集上对于挑选好的决策规则方面，这两个度量指标各有千秋。Spark 的实现默认采用 Gini 不纯度。

有些决策树实现会对候选决策规则设定最小信息增益，或最小不纯度降低。在改善子集合的不纯性方面不达标的决策规则将不被采用。与通过减少最大深度一样，这也有利于避免过拟合，因为对训练集没有什么区分度的决策规则实际上对区分将来的数据也没什么帮助。然而，Spark MLlib 目前还没有实现最小信息增益之类的规则。

决策树调优

采用哪个不纯性度量所得到的决策树的准确度更高，或者最大深度或桶数取多少合适，从数据上看，回答这些问题是困难的。幸运的是，我们可以让 Spark 来尝试这些值的许多组合并报告结果：

val evaluations =
for (impurity 7
yield {
val model = DecisionTree.trainClassifier(
trainData, 7, Map[Int,Int](), impurity, depth, bins)
val predictionsAndLabels = cvData.map(example =>
(model.predict(example.features), example.label)
)
val accuracy =
new MulticlassMetrics(predictionsAndLabels).precision
((impurity, depth, bins), accuracy)
}

evaluations.sortBy(_._2).reverse.foreach(println) 8

...
((entropy,20,300),0.9125545571245186)
((gini,20,300),0.9042533162173727)
((gini,20,10),0.8854428754813863)
((entropy,20,10),0.8848951647411211)
((gini,1,300),0.6358065896448438)
((gini,1,10),0.6355669661959777)
((entropy,1,300),0.4861446298673513)
((entropy,1,10),0.4861446298673513)

7看成是三重 for 循环。

8根据第二个值（准确度）降序排序并打印。

很显然最大深度为 1 太小，得到的结果比较差。桶数多有点儿帮助，但帮助也不大。在合理的最大深度下，两个不纯性度量的结果也差不多。我们可以继续这个过程来探寻更好的超参数。桶数应该越多越好，但会减慢模型构造过程且增加内存的使用量。在所有情况下我们都应该试试两种不纯性度量。增加最大深度能提高准确度，但这里有个拐点，超过它之后增加深度也没有用了。

到目前为止，所有示例代码都没用到占数据集 10% 的保留测试集。如果说 CV 集的目的是评估适合训练集的参数，那么测试集的目的是评估适合 CV 集的超参数。也就是说，测试集保证了对最终选定的超参数及模型准确度的无偏估计。

前面的测试表明，目前为止超参数的最佳选择是：不纯性度量采用熵，最大深度为 20，桶 数为 300，这时得到的准确度为 91.2%。但是，模型在构建过程中还有一个随机元素。最 好的模型和评估结果可能还需要一点儿运气的成分，因此准确度评估可能有一些乐观。换 句话说，超参数也可能有过拟合现象。

要想真正评估这个最佳模型在将来的样本上的表现，当然需要在没有用于训练的样本上进行评估。但是，我们也需要避免使用在评估环节中用过的 CV 集样本。这也就是需要把第三个子集即测试集保留在一边的原因。最后一步，用得到的超参数同时在训练集和 CV 集上构造模型并且像前面那样进行评估：

val model = DecisionTree.trainClassifier(
trainData.union(cvData), 7, Map[Int,Int](), "entropy", 20, 300)

结果准确度为 91.6%，基本上没变。因此，开始的估计看来是可靠的。

现在该重新回顾一下过拟合的问题了。如前所述，我们可能构造这样一棵决策树：它的深度非常深，非常复杂，它能很好地甚至是完美拟合给定的训练样本，但却不能把这种准确度推广到其他样本上，因为它过于紧密地拟合了训练样本中的细微特质和噪声。过拟合问题不只是在决策树算法中存在，而是大多数机器学习算法普遍存在的问题。

当决策树有过拟合问题时，在与模型拟合相同的训练数据上它的准确度很高，但在其他样本上准确度很低。最终模型在其他新样本上的准确度大约为 91.6%。当然，通过 trainData.union(cvData)，我们就能轻易在训练数据上评估准确度。这时的准确度大约为 95.3%。

差别不是很大，但也显示决策树在一定程度上存在对训练数据的过拟合。减小最大深度可能会使过拟合问题有所改善。

重谈类别型特征

到目前为止我们还没有对示例代码中的参数 Map[Int,Int]() 作出解释。这个参数值，比如 7，指明了输入数据中每个类别型特征预期的不同取值的个数。这个 Map 中元素的键是特征在输入向量 Vector 中的下标，Map 中元素的值是类别型特征的不同取值个数。目前 Spark MLlib 实现中要求事先给定这些信息。

参数取为空 Map()，则表示算法不需要把任何特征作为类别型，也就是说所有特征都是数值型的。实际上，Spark MLlib 实现中所有特征都是数值，但概念上其中某些是类别型特征。如前所述，如果简单地把类别型变量当作数值型对待，将其映射到不同的数字，这种做法是错误的，原因在于算法会试图从一个没有意义的大小顺序中学习。

好在，这里的类别型特征已经用 one-hot 方式编码成了多个二元的 0/1 值。把这些单个的特征当作数值型来处理并没有什么问题，因为任何基于数值型特征的决策规则都需要选择 0 或 1 作为其阈值。所有的阈值都是 0 或 1，所以都是等价的。

当然，这种编码迫使决策树算法在底层要单独考虑类别型特征的每一个值。如果用一个类别型变量就不会有这个方面的限制。如果某个类别型特征有 40 个取值，决策树可以在一次决策中对多个类别组进行判断。这样的方式更直接更优。另一方面，用 40 个数值型特征表示一个有 40 个取值的类别型特征会增加内存使用量并且减慢决策速度。

如果取消数据集已经完成的 one-hot 编码，情况会怎样？下面我们试试解析输入，将 one-hot 编码所得到的两个类别型特征转换回一系列不同的数值型值：

val data = rawData.map { line =>
val values = line.split(',').map(_.toDouble)
val wilderness = values.slice(10, 14).indexOf(1.0).toDouble 9
val soil = values.slice(14, 54).indexOf(1.0).toDouble 10
val featureVector =
Vectors.dense(values.slice(0, 10) :+ wilderness :+ soil) 11
val label = values.last - 1
LabeledPoint(label, featureVector)
}

9“wilderness”对应的 4 个二元特征中哪一个取值为 1。

10类似地，“soil”对应 40 个二元特征。

11将推导出的特征加回到前 10 个特征中。

数据集可以重复拆分成训练集 /CV 集 / 测试集和模型评估的过程。这里，我们指定两个新的类别型特征的不同取值个数，这样这两个特征就会被当作类别型而不是数值型特征处理。由于地块（soil）特征有 40 个不同的取值，DecisionTree 需要桶数目最少增加到 40。考虑前面的结果，增加决策树的深度直至 30，30 是当前 DecisionTree 能支持的最大深度。最后，在训练集和 CV 集上的准确度报告如下：

val evaluations =
for (impurity  4, 11 -> 40),
impurity, depth, bins) 12
val trainAccuracy = getMetrics(model, trainData).precision
val cvAccuracy = getMetrics(model, cvData).precision
((impurity, depth, bins), (trainAccuracy, cvAccuracy)) 13
}

...
((entropy,30,300),(0.9996922984231909,0.9438383977425239))
((entropy,30,40),(0.9994469978654548,0.938934581368939))
((gini,30,300),(0.9998622874061833,0.937127912178671))
((gini,30,40),(0.9995180059216415,0.9329467634811934))
((entropy,20,40),(0.9725865867933623,0.9280773598540899))
((gini,20,300),(0.9702347139020864,0.9249630062975326))
((entropy,20,300),(0.9643948392205467,0.9231391307340239))
((gini,20,40),(0.9679344832334917,0.9223820503114354))
((gini,10,300),(0.7953203539213661,0.7946763481193434))
((gini,10,40),(0.7880624698753701,0.7860215423792973))
((entropy,10,40),(0.78206336500723,0.7814790598437661))
((entropy,10,300),(0.7821903188046547,0.7802746137169208))

12指定类别型特征 10 和 11 的取值个数。

13返回在训练集和 CV 集上的准确度。

如果在集群上运行以上代码，会发现决策树构建过程比之前快了好几倍。

在深度为 30 的时候，训练集几乎完美拟合。这时虽然存在一定程度的过拟合，但是在交叉检验集上的准确度是最高的。选择熵作为不纯性度量和更多的桶数目，再一次看起来有助于提高准确度。在本次测试集上，准确度为 94.5%。通过把类别型特征真正作为类别型，分类器的准确度提高了将近 3%。

随机决策森林

如果一步一步运行所有的示例代码，你可能已经注意到运行结果和文章中给出的结果稍有不同。这是由决策树构建过程中的随机因素造成的，在决定采用什么数据和尝试哪些决策规则时都有随机因素的影响。

在决策树的每层，算法并不会考虑所有可能的决策规则。如果在每层上都要考虑所有可能的决策规则，算法的运行时间将无法想象。对一个有 N 个取值的类别型特征，总共有 2N – 2 个可能的决策规则（除空集和全集以外的所有子集）。即使对于一个一般大的 N，这也将创建数十亿个候选决策规则。

相反，如果决策树使用一些启发式策略，就能够聪明地找到需要实际考虑的少数规则。在选择规则的过程中也涉及一些随机性；每次只考虑随机选择少数特征，而且只考虑训练数据中一个随机子集。在牺牲一些准确度的同时换回了速度的大幅提升，同时每次决策树算法构造的树都不相同。这是件好事。

因为集体的智慧常常比个体预测要更准确。

为了说明问题，我们来做个快速测验：伦敦运营的黑色的士数量有多少？

请猜一下，先不要偷看答案。

正确答案是约 19 000，我猜 10 000，这离正确答案差了很多。因为我猜的数比较小，所以你有可能猜的比我大，这样我们平均一下就可能更准确了。这里貌似又有点儿“趋均值回归”的味道了。我随便问了办公室里的 13 个人，大家的平均值是 11 170，这个答案确实要更接近正确答案。

要取得这种效应，关键是每个人猜的时候要独立，互不影响。（你没偷看答案，是吧？）如果大家事先都已经统一过意见并用同一个方法猜，这个练习就没有意义了，因为大家猜的答案都一样，而且这个相同的答案可能错得离谱。如果在你猜之前，我把我的情况告诉你，这会影响你的猜测，那么咱俩的平均数可能并不会更准确，甚至会更糟。

基于上述考虑，树最好不只是一棵，而应该有很多棵，每一棵都能对正确目标值给出合理、独立且互不相同的估计。这些树的集体平均预测应该比任一个体预测更接近正确答案。正是由于决策树构建过程中的随机性，才有了这种独立性，这就是随机决策森林的关键所在。

通过 RandomForest，Spark MLlib 可以构建随机决策森林。顾名思义，随机决策森林是由多个决策树独立构造而成。

val forest = RandomForest.trainClassifier(
trainData, 7, Map(10 -> 4, 11 -> 40), 20,
"auto", "entropy", 30, 300)

与 DecisionTree.trainClassifier() 相比，这里出现了两个新参数。第一个代表要构建多少棵树，这里是 20。由于要构造 20 棵决策树，而之前我们只构造了一棵决策树，因此这里模型构建过程耗时可能比之前长得多。

第二个新参数是特征决策树每层的评估特征选择策略，这里设为 "auto"（自动）。随机决策森林在实现过程中决策规则不会考虑全部特征，而只考虑全部特征的一个子集。特征选择策略参数控制算法如何选择该特征子集。只检查少数特征速度明显要快，并且由于速度快，随机决策森林才得以构造多棵决策树。

但是，只考虑全部特征的一个子集，这种做法也使个体决策树的决策更加独立，因此决策森林作为整体往往更不会产生过拟合问题。如果某个特征包含噪声数据，或只针对训练集有预测性，则这种预测性是有误导性质的。采用随机森林后大多数树在大多数时候将因此不会考虑这个问题特征。大多数的树将不会拟合噪声，因此它们的“票数”将超过那些拟合噪声的树。

实际上在构造决策森林的时候，每棵树甚至都没必要用到全部训练数据。同理，每棵树的输入数据可以随机选择。

随机决策森林的预测只是所有决策树预测的加权平均。对于类别型目标，这就是得票最多的类别，或有决策树概率平均后的最大可能值。随机决策森林和决策树一样也支持回归问题，这时森林作出的预测就是每棵树预测值的平均。

RandomForestModel 模型的准确度立刻变为 96.3%——提高了约 2%。换个角度看，比之前我们得到的最好决策树的错误率降低了 33%，错误率由 5.5% 降到了 3.7%。

在大数据的背景下，随机决策森林非常有吸引力，因为决策树往往是独立构造的，诸如 Spark 和 MapReduce 这样的大数据技术本质上适合数据并行问题。也就是说，总体答案的每个部分可以通过在部分数据上独立计算来完成。随机决策森林中决策树可以并且应该只在特征子集或输入数据子集上进行训练，基于这个事实，决策树构造的并行化就很简单了。

由于决策树通常在全体训练数据的一个子集上构造，可以用剩余数据对其进行内部交叉验证，因此随机决策森林也可以顺便评估其准确度，尽管 Spark MLlib 还没有对该功能提供直接支持。这意味着随机决策森林甚至能知道其内部哪棵决策树是最准确的，因而可以增加其权重。

这个特点也用于评估哪些输入特征对预测目标最有帮助，因此它有助于解决特征选择问题。

进行预测

构建分类器有趣又简单，但它不是最终目的。我们的目的是利用它进行预测。之前的辛苦努力在这里都将得到回报，做起来也相对容易。训练集由 LabeledPoint 类型实例组成，每个实例包含一个 Vector 和目标值，它们分别是输入和已知的输出。波尔先生说，当进行预测时，特别是预测未来时，输出当然是未知的。

现在我们已经展现了 DecisionTree 和 RandomForest 训练的结果，它们分别是 DecisionTreeModel 和 RandomForestModel 对象。这两个模型对象本质上都只有一个方法 predict()。 和 LabeledPoint 的特征向量部分一样，predict() 方法接受一个 Vector。因此通过把每个新样本转换成一个特征向量，我们同样可以对它进行分类并预测它的目标类别：

val input = "2709,125,28,67,23,3224,253,207,61,6094,0,29"
val vector = Vectors.dense(input.split(',').map(_.toDouble))
forest.predict(vector) 14

14同样我们可以一次性对整个 RDD 做预测。

结果应该为 4.0，对应原始 Covtype 数据集中的类别 5（原始特征从 1 开始）。显然，算法预测示例中讨论的地块植被类型为“山杨”（Aspen）。

通过使用 Covtype 数据集和 Spark MLlib 中实现的决策树和决策森林算法，我们演示了如何根据位置和土壤类型等信息预测森林植被的类型。构建的分类器结果也相当准确。一般情况下，准确度超过 95% 是很难达到的。通过包括更多特征，或将已有特征转换成预测性更好的形式，可以进一步提高准确度。在分类器模型的迭代式改进过程中，我们常常这样反复尝试。比如，对数据集，距离地表水的水平和垂直距离这两个特征可以生成第三个特征：离地表水的直线距离。或者，如果能收集到更多数据，为了提高准确度，我们可能会尝试增加更多特征，比如土壤湿度。

当然，用 Covtype 数据集预测森林植被类型只是预测问题的一种类型，现实中我们还有其他的预测问题。比如，有些问题要求预测连续型的数值，而不是类别型值。再者，Spark MLlib 实现的算法也不限于决策树和决策森林。对分类问题，Spark MLlib 提供的实现包括：

- 朴素贝叶斯（http://en.wikipedia.org/wiki/Naive_Bayes_classifier）

- 支持向量机（http://en.wikipedia.org/wiki/Support_vector_machine）

- 逻辑回归（http://en.wikipedia.org/wiki/Logistic_regression）

是的，你没看错，逻辑回归是一种分类技术。逻辑回归底层通过预测类别的连续型概率函数来进行分类。

这些算法与决策树和决策森林很不相同。但是，其中许多元素还是一样的：它们接受一个 LabeledPoint 类型的 RDD 作为输入，需要通过将输入数据划分为训练集、交叉检验集和测试集来选择超参数。对这些其他算法，我们可以用相同的通用原理为分类和决策问题建模。

本文选自《Spark高级数据分析》

《Spark高级数据分析》首先介绍了Spark及其生态系统，接着详细介绍了将分类、协同过滤及异常检查等常用技术应用于基因学、安全和金融领域的若干模式。如果你对机器学习和统计学有基本的了解，并且会用Java、Python或Scala编程，这些模式将有助于你开发自己的数据应用。

初识MapReduce

作者/Joel Grus

Joel Grus是Google的一位软件工程师，曾于数家创业公司担任数据科学家。目前住在西雅图，专注于数据科学工作并乐此不疲。偶尔在joelgrus.com发表博客，长期活跃于Twitter @joelgrus。

明天已经照耀现在，只是尚未洒满每个角落。

——威廉 · 吉布森

MapReduce 是一个用在大型数据集上执行并行处理的算法模型。尽管这是一个非常强大的技术，但它的原理却很简单。

假设我们有一组待处理的项目，这些项目可能是网页日志、许多本书的文本、图像文件或者是其他东西。处理项目时，MapReduce 算法包括下面几个基本步骤。

- 使用 mapper 函数把每个项目转化成零个或多个键值对。（功能类似于Python 里的 map 函数，为了便于区分我们使用mapper。）

- 用相同的键把所有的键值对收集起来。

- 在每一个分好组的值集合上使用 reducer 函数，对每个对应的键生成输出值。

这样的讲解很抽象，我们接下来看一个具体的例子。数据科学里的绝对规则很少，但有一个必须遵守：第一个面对的 MapReduce 例子中必须要涉及单词计数。

案例：单词计数

DataSciencester 网站的用户数量已增长到了数百万！这对你的工作是极大的保障，但也使日常分析变得有点困难。

比如，内容部门的副总想知道用户的状态更新都涉及什么内容。作为初步的尝试，你决定统计一下出现的单词数量，这样你就可以准备一个关于出现频度最高的单词的报告。

当只有几百个用户的时候，下面的做法很简单：

def word_count_old(documents):
"""word count not using MapReduce"""
return Counter(word
for document in documents
for word in tokenize(document))

但面对数百万个用户的时候，documents 集合就变得很大，你的电脑根本装不下。如果你能把它们纳入 MapReduce 模型处理，就可以使用你的引擎上已经部署的一些“大数据”架构。

首先，我们需要一个函数来把文档转化成一系列的键值对。我们希望输出的结果能按单词分组，这意味着键应该是单词。对每一个单词，我们只发送值 1 来表示这个键值对对应于单词出现一次：

def wc_mapper(document):
"""for each word in the document, emit (word,1)"""
for word in tokenize(document):
yield (word, 1)

先暂时跳过第二步，假设对某些词我们已经收集了所发送过的对应计数的列表。接下来生成我们所需要的这个词的全部计数：

def wc_reducer(word, counts):
"""sum up the counts for a word"""
yield (word, sum(counts))

再返回步骤 2，现在我们需要收集来自 wc_mapper 的结果，再把它们传递给 wc_reducer。让我们思考一下怎么在一台计算机上完成这件事：

def word_count(documents):
"""count the words in the input documents using MapReduce"""

# 存放分好组的值
collector = defaultdict(list)

for document in documents:
for word, count in wc_mapper(document):
collector[word].append(count)

return [output
for word, counts in collector.iteritems()
for output in wc_reducer(word, counts)]

假设我们有三个文档 ["data science", "big data", "science fiction"]。

然后把 wc_mapper 应用到第一个文档，产生两个键值对 ("data", 1) 和 ("science", 1)。处理完所有三个文档之后，列表 collector 会包含：

{ "data" : [1, 1],
"science" : [1, 1],
"big" : [1],
"fiction" : [1] }

然后 wc_reducer 函数生成了每个单词的计数：

[("data", 2), ("science", 2), ("big", 1), ("fiction", 1)]

为什么是MapReduce

MapReduce 的主要优点就是通过将处理过程移动到数据来进行分布式的计算。

我们最初的（非 MapReduce 的）方法要求机器在每一个文档上进行处理。这意味着所有的文档要么存在机器上要么在处理期间转移到机器上。更重要的是，这意味着机器一次只能处理一个文档。

如果机器是多核的，且如果代码是为了利用多核的优势而重写过的，那它是有可能一次处理几个文档的。但即使这样，所有的文档也都要放到机器中来。

假设现在我们将数十亿个文档分散到 100 台机器上。利用正确的架构（并且掩盖掉一些细节），我们可以做下面的事。

- 让每一台机器在它的文档上运行 mapper 函数，产生大量的键值对。

- 把那些键值对分配到一些“reducing”的机器上，确保对应任何一个给定键的对在同一台机器上完成计算。

- 每一台 reducing 机器通过键分组这些对，然后对每个值的集合运行 reducer 函数。

- 返回每个键值对。

它可以处理的横向规模水平令人惊叹。如果我们把机器的数量加倍，那么（忽略运行 MapReduce 系统的某些固定成本）计算的运行速度大约会快两倍。每一个 mapper 机器只需要做一半的工作，（假设有足够多不同的键来进一步分配 reducer 工作）reducer 机器也是。

更加一般化的MapReduce

思考一下，前面例子中所有的单词计数代码是包括在 wc_mapper 和 wc_reducer 这两个函数中的。这意味着通过几个改变我们会得到一个更通用的框架（仍然是运行在单个机器上的）：

def map_reduce(inputs, mapper, reducer):
"""runs MapReduce on the inputs using mapper and reducer"""
collector = defaultdict(list)

for input in inputs:
for key, value in mapper(input):
collector[key].append(value)

return [output
for key, values in collector.iteritems()
for output in reducer(key,values)]

然后我们可以通过下面的方法简单地完成单词计数：

word_counts = map_reduce(documents, wc_mapper, wc_reducer)

这为我们解决各种类型的问题提供了灵活性。

在继续讲解之前，我们先观察一下 wc_reducer 函数，它仅仅是把对应于每一个键的值加起来。这种聚合是非常普遍的，值得把它抽象出来：

def reduce_values_using(aggregation_fn, key, values):
"""reduces a key-values pair by applying aggregation_fn to the values"""
yield (key, aggregation_fn(values))

def values_reducer(aggregation_fn):
"""turns a function (values -> output) into a reducer
that maps (key, values) -> (key, output)"""
return partial(reduce_values_using, aggregation_fn)

之后我们就可以轻松创建以下内容：

sum_reducer = values_reducer(sum)
max_reducer = values_reducer(max)
min_reducer = values_reducer(min)
count_distinct_reducer = values_reducer(lambda values: len(set(values)))

案例：分析状态更新

内容部门的副总对单词计数印象深刻，并询问你还能从用户的状态更新中学到什么。你设法提取了一个类似下面这样的状态更新的数据集：

{"id": 1,
"username" : "joelgrus",
"text" : "Is anyone interested in a data science book?",
"created_at" : datetime.datetime(2013, 12, 21, 11, 47, 0),
"liked_by" : ["data_guy", "data_gal", "mike"] }

假设我们想找出每周内的哪一天人们讨论数据科学最多。为了找到这个结果，只需计算一下一周内每一天有多少个数据科学更新。这意味着我们需要按每周内的每天进行分组，这就是我们的键。而且如果对每一个包含“数据科学”的更新发送一个值 1，就可以使用 sum 函数得到总数：

def data_science_day_mapper(status_update):
"""yields (day_of_week, 1) if status_update contains "data science" """
if "data science" in status_update["text"].lower():
day_of_week = status_update["created_at"].weekday()
yield (day_of_week, 1)

data_science_days = map_reduce(status_updates,
data_science_day_mapper,
sum_reducer)

再举一个稍微复杂一点的例子，假设我们需要找出每个用户在其状态更新中最常用的单词是什么。为了建立 mapper，我们的脑海中会浮现以下三种方法。

- 把用户名放到键当中：把单词和计数放到值当中。

- 把单词放到键当中：把用户名和计数放到值当中。

- 把用户名和单词放到键当中：把计数放到值当中。

稍作考虑，很容易就能判断出应该按 username 分组，因为我们想分别考虑每个人的单词。我们不想用 word 来分组，因为 reducer 需要看到每个人所有的词汇，找到哪一个是最流行的。这意味着第一种选择是最优选择：

def words_per_user_mapper(status_update):
user = status_update["username"]
for word in tokenize(status_update["text"]):
yield (user, (word, 1))

def most_popular_word_reducer(user, words_and_counts):
"""given a sequence of (word, count) pairs,
return the word with the highest total count"""

word_counts = Counter()
for word, count in words_and_counts:
word_counts[word] += count

word, count = word_counts.most_common(1)[0]

yield (user, (word, count))

user_words = map_reduce(status_updates,
words_per_user_mapper,
most_popular_word_reducer)

或者我们能为每个用户找到各自的状态点赞者的个数：

def liker_mapper(status_update):
user = status_update["username"]
for liker in status_update["liked_by"]:
yield (user, liker)

distinct_likers_per_user = map_reduce(status_updates,
liker_mapper,
count_distinct_reducer)

案例：矩阵计算

回想“矩阵乘法”，给定一个 m × n 的矩阵 A 和一个 n × k 的矩阵 B，可以把它们乘起来得到 m × k 的矩阵 C，其中 C 的第 i 行第 j 列的元素由下式给出：

如同我们之前所见的，表示一个 m × n 矩阵的“自然的”方法是列表的列表，其中元素 Aij 是第 i 个列表的第 j 个元素。

但大型矩阵有时候是稀疏的，即大部分的元素等于 0。对于大型稀疏矩阵而言，列表的列表是一种非常浪费的表达方式。一种更简洁的表达方式是元组的列表 (name, i, j, value)，其中 name 代表矩阵，而 i、j、value 表示一个非零元素的位置。

比如，一个十亿 × 十亿的矩阵会有亿亿级别（quintillion，1×10^18）的元素，这是难以存储在一个计算机中的。但是如果每行当中只有不多的一些非零元素，上面那种替代的表示法就会小很多个数量级。

基于这种表示法，我们可以使用 MapReduce 以分布式的方式执行矩阵乘法。

为使用这种算法，请注意，Aij 只用于计算 C 的第 i 行的元素，Bij 只用于计算 C 的第 j 列的元素。我们的目标是使 reducer 的每一个输出构成矩阵 C 的一个元素。这意味着我们需要用 mapper 发送键值，以确定 C 中的每个元素。建议像下面这样处理：

def matrix_multiply_mapper(m, element):
"""m is the common dimension (columns of A, rows of B)
element is a tuple (matrix_name, i, j, value)"""
name, i, j, value = element

if name == "A":
# A_ij是每个C_ik之和的第j个元素，其中k=1..m
for k in range(m):
# 与C_ik的其他元素分组
yield((i, k), (j, value))
else:
# B_ij是每个C_kj之和的第i个元素
for k in range(m):
# 与C_kj的其他元素分组
yield((k, j), (i, value))

def matrix_multiply_reducer(m, key, indexed_values):
results_by_index = defaultdict(list)
for index, value in indexed_values:
results_by_index[index].append(value)

# 对有两个结果的位置把所有的乘积加起来
sum_product = sum(results[0] * results[1]
for results in results_by_index.values()
if len(results) == 2)

if sum_product != 0.0:
yield (key, sum_product)

比如，如果你有如下的两个矩阵：

A = [[3, 2, 0],
[0, 0, 0]]

B = [[4, -1, 0],
[10, 0, 0],
[0, 0, 0]]

你可以把它们重写为元组：

entries = [("A", 0, 0, 3), ("A", 0, 1,  2),
("B", 0, 0, 4), ("B", 0, 1, -1), ("B", 1, 0, 10)]
mapper = partial(matrix_multiply_mapper, 3)
reducer = partial(matrix_multiply_reducer, 3)

map_reduce(entries, mapper, reducer) # [((0, 1), -3), ((0, 0), 32)]

在这样一个小矩阵上操作并不太有趣，但是如果你有百万行百万列的矩阵，MapReduce 就会起很大作用。

题外话：组合器

你很可能已经注意到，许多 mapper 包括一些额外的信息。比如，在计数单词的时候，与其发送 (word, 1) 并累加求和，不如发送 (word, None) 再取其长度。

没有这么做的一个原因是，在分布式情景下，我们有时会想用组合器（combiner）来缩减在计算机之间转移的数据数量。如果一个 mapper 机器发现单词“data”500 次，可以让它在移交数据给缩减机器之前把 500 个 ("data", 1) 组合成一个单独的 ("data", 500)。这使得转移的数据少了很多，算法会大大加快。

基于我们编写 reducer 的方法，它能正确地处理这些组合的数据。（如果我们已经用 len 函数写了 reducer，它就不能处理组合了。）

最广泛使用 MapReduce 系统的是 Hadoop（http://hadoop.apache.org/），它值得用很多本书来阐述。它有许多商业或非商业的发行版，以及一个由 Hadoop 相关工具组成的巨大生态系统。

为了使用 Hadoop，你需要建立自己的聚类（或者可以在允许的情况下使用别人建好的聚类），当然，对于承压能力较差的人来说，可以不把这当成必然的任务。Hadoop mapper 和 reducer 通常是用 Java 写成的，尽管有一种被称为“Hadoop streaming”的功能允许你用其他的语言（包括 Python）来写。

Amazon.com 提供 Elastic MapReduce（http://aws.amazon.com/cn/elasticmapreduce/）服务，它可以用编程的方式来创建或销毁聚类，仅仅根据使用该服务的时长来收费。

mrjob（https://github.com/Yelp/mrjob）是 Python 的一个 Hadoop（或 Elastic MapReduce）的接口包。

Hadoop 任务是典型的高延迟的，这对于“实时分析”来说不是个好选择。有多种建立在 Hadoop 之上的“实时分析”工具，同时还有一些替代性的框架也日益流行。最流行的两种是 Spark（http://spark.apache.org/）和 Storm（http://storm.incubator.apache.org/）。

总之，还有很多新的分布式框架等待我们去开发。

本文节选自《数据科学入门》

《数据科学入门》基于Python语言环境从零开始讲解数据科学工作。Python易于理解，包含丰富的与数据科学相关的库。具体内容包括：Python速成，可视化数据，线性代数，统计，概率，假设与推断，梯度下降法，如何获取数据，k近邻法，朴素贝叶斯算法，等等。借助大量具体例子以及数据挖掘、统计学、机器学习等领域的重要概念，本书详细展示了什么是数据科学。

可视化的数据，鲜活的数据

作者/Nathan Yau

加州大学洛杉矶分校统计学专业在读博士、超级数据迷，专注于数据可视化与个人数据收集。他曾在《纽约时报》、CNN、Mozilla和SyFy工作过，认为数据和信息图不仅适用于分析，用来讲述与数据有关的故事也非常合适。Yau的目标是让非专业人士读懂并用好数据。他创建了一个设计、可视化和统计方面的博客http://flowingdata.com，我们可以从中欣赏到他的最新数据可视化实验作品。

关于数据可视化，人们最常问的问题是“我应该用什么软件来对数据可视化？”

幸运的是，我们有很多选择。有些软件是开箱即用的（out-of- the-box），只要动动鼠标就能操作，还有些软件则需要一点编程技巧，还有些工具并非专门用于制作数据图，但依然很有帮助。

了解的可视化工具多了之后，面对数据时就不会束手无策，创作出你想要的数据图也会更加容易。

开箱即用的工具

开箱即用的软件是目前最简单的解决方案，适合新手学习。我们只需要对数据进行一些复制粘贴，或者载入某个CSV文件就可以开始了，然后选择想要的图形类型，并稍微调整一下选项即可。

开箱即用的工具彼此之间有很多不同，这取决于它们各自面向的对象。其中一些（例如，Microsoft Excel和Google Documents）主要用于基础的数据管理和图形创建，而另一些则更偏向于深入分析或可视化研究。

Microsoft Excel

几乎所有人都知道这款软件。输入一定数据之后，在菜单栏单击“图表”的选项就可以生成想要的图表了。Excel提供了各种标准的图表类型以供选择，包括柱形图、折线图、饼图和散点图（scatter plot）等，如图所示。

图：Microsoft Excel的图表选择

有些人对Excel嗤之以鼻，但其实它并不是如此不堪。比如说，虽然我不会用Excel来做深度分析，生成的图表也不会用于出版，但如果我手中正好有一个Excel格式的小型数据集（这种情况很常见），同时又想快速找找感觉，那么自然就会随手点击几下鼠标用它生成一个图形。

图形也可以很好玩

我在计算机上制作的第一张数据图就是用的Microsoft Excel，是为了应付小学四年级的一次课外科学研究。我和搭档试图研究蜗牛在什么样的平面上爬得最快。这是一次史无前例的研究，我向你保证。

虽然还是个小屁孩，那时候我就已经开始喜欢上制图了。我花了很长时间才学会（当时计算机对我来说还是个新鲜玩意），但在最终掌握之后，回报是令人激动的。我在电子表格里输入数字，然后立刻就能得到图形，还可以随意改变颜色，一切都棒极了。

正是Excel的方便易用让它获得了大众的欢迎，这很好。但如果你想要高质量的数据图就不要止步于此。其他工具会更适合你。

Google Spreadsheets

Google Spreadsheets其实就是Microsoft Excel的云版本，两者的界面非常相似。它也提供了标准的图表类型，如图所示。

图：Google Spreadsheets的图表选择

不过和Excel相比，Google Spreadsheets拥有更多优势。首先，数据都存储在Google的服务器上，所以你可以在任何安装了浏览器的计算机上查看自己的数据，只要登录你的Google账号就行。同时你也能很方便地把自己的电子表格共享给其他人，还能做到实时协作。此外，Google Spreadsheets还在Gadget （小工具）选项中提供了很多其他的图表类型，如图所示。

图：Google小工具

大多数小工具都没什么用，但其中也有几个极好的。比如说，你可以轻而易举地为自己的时间序列数据创建运动图表（就像Hans Rosling做的那样）。此外还有一种可交互的时间序列图表，如果你访问过Google Finance的话，就会发现它非常眼熟。

图：Google Finance

访问Google Docs试用一下spreadsheets，地址是http://docs.google.com。

Many Eyes

Many Eyes是IBM视觉传达实验室（IBM Visual Communication Lab）主导的一个研究项目，目前还在进行中。它是一个在线应用，带有一系列交互式的可视化工具，可以识别带分隔符的文本文件。Many Eyes的初衷是想了解人们能否以群组的形式探索大型数据集——这也正是它的名字的来历。如果一个群组内的众多双眼睛来观察某个数据集，是否会从中挖掘到更多有意思的地方？效率是否会更高？

虽然Many Eyes目前尚未提供多人的数据分析功能，作为个人使用来说它依然很有价值。其中涵盖了绝大多数传统的可视化类型，例如折线图和散点图。

Many Eyes的优势在于其中的可视化数据图都是可交互的，而且有一些定制选项。比如说在散点图中就可以用第3种指标来测量各个数据节点，而且鼠标悬停到感兴趣的数据节点上时还能查看具体的数值。

除了传统图表和一些基础的地图工具之外，Many Eyes还提供了多种更为先进和试验化的可视化方法。单词树（word tree）可以帮助我们研究一整段文本，例如书或文章中的某个段落。首先选择一个单词或短语，然后就能通过与它相连的单词了解到它在整段文本中的使用状态。比如说，下图就显示了在美国宪法中搜索“right”一词得到的结果。

图：Many Eyes的单词树显示了美国宪法的部分内容

针对同样的数据，我们还可以轻松地在各个工具之间转换。下面的图是一种特殊风格的单词云，被称为“Wordle”，同样显示了美国宪法的内容。单词使用得越频繁，字号就越大。

图：美国宪法的Wordle

如你所见，Many Eyes有许多选项便于我们处理数据，而且它也是目前数据探索中用途最为广泛的免费工具（在我看来也是最好的）。不过有一两点仍然需要注意。第一是其中的大部分工具都是Java小应用程序，所以如果没有安装Java，可能就无法充分利用它。（对大多数人来说这其实不算什么问题，但我确实认识一些人，不管是什么原因，他们对自己电脑上安装了什么非常在意。）

另一点对多数人来说则可能更为敏感一些：上传到网站的数据是存储在公共空间里的。所以最好不要用Many Eyes来挖掘你的公司客户信息，或者销售数据。

访问http://many-eyes.com，试着上传你自己的数据并对数据进行可视化。

Tableau Software

Tableau Software是一款只面向Windows的软件，相对比较新，但在过去几年中开始受到越来越多的欢迎。它的设计初衷主要是用于视觉化的数据研究和分析。很明显该软件在美学和设计上花了不少心思，这也是人们喜欢它的原因。

Tableau Software提供了许多可交互的可视化工具，在数据管理方面也表现不错。数据可以从Excel、文本文件和数据库服务器中导入，生成标准的时间序列图表、柱形图、饼图、基本地图等多种图形。你可以挂接动态数据源，将各种图形混合搭配形成定制视图，或者通过仪表盘视图随时关注数据的状态。

最近，Tableau发布了Tableau Public，这是免费版本，包含桌面版的一部分功能。你可以把数据上传到Tableau的服务器，创建可交互的图形，然后轻而易举地发布到你的网站或博客上。不过和Many Eyes一样，上传到服务器的所有数据都是公开的，所以需要小心。

访问http://tableausoftware.com了解Tableau软件。网站提供了带完整功能的免费试用。

your.flowingdata

对个人数据收集的兴趣让我开发了自己的应用，your.flowingdata（YFD）。这是一款在线应用，用户可以从Twitter收集数据，通过一系列可交互的可视化工具探索其中的模式和联系。有人通过它来追踪自己的饮食习惯或者起居时间。还有人用它来记录自己婴儿的每日状况，稍微改动一下数据就成了一本宝贝剪贴簿。

YFD本来主要针对的是个人数据方面，但有些人发现这个应用对于通用类型的数据收集也很有帮助，例如网络活动监控或者火车时刻表。

访问http://your.flowingdata.com，尝试通过Twitter来收集个人数据。

取舍

尽管这些工具都非常容易使用，但也存在一些缺憾。只需鼠标操作无疑十分便捷，但随之而来的却是丧失一定的灵活性。你可以改变颜色、字体和标题，但仅限于软件所提供的那些元素。如果界面上没有你想要的图表按钮，你就只有唉声叹气的份。

另一方面，有些软件可能确实提供了充分的功能，但你可能需要学习大量的按钮。比如说，我曾花了一个周末去突击学习某款软件（前文并未列出），而且很明显，如果投入更多时间，我就能通过它完成很多事情。但是这款软件的整个操作流程完全匪夷所思，让人根本没有心思学下去。而且要想针对不同的数据集重复一遍流程也很困难，因为我必须记住之前点击过的所有按钮。而相比之下，通过代码来处理数据就会更加容易，因为针对不同的数据集只需稍微改动一下代码就可以解决。

不要误解我的意思。我并不是说这些开箱即用的软件就应该完全弃之不用。它们能帮你快速且方便地研究数据。但当你开始接触更多的数据集时，可能这些软件就不能满足所需了。到这个时候，你可以借助于编程手段。

编程工具

不用太紧张。掌握一点点编程技巧，你就能利用数据做更多的事情，远远超过那些开箱即用的软件。编程技巧能赋予你更加灵活的能力，而且各种类型的数据都能适应。

大多数设计新颖、令人惊艳的数据图都是通过代码或绘图软件实现的，很有可能两者兼有。

对于新手来说，代码可能颇为神秘，我也是这样过来的。但我们可以把它当作一门新语言来看待，因为它确实如此。每一行代码都告诉计算机去做某件事情。计算机不懂我们和朋友之间所用的语言，所以你必须用它自己的语言或语法才能和它交流。

与任何语言一样，你不可能立刻就开始进行对话。要从基础开始，然后逐步建立自己的学习方式。很可能在自己意识到之前，你就已经开始写代码了。编程最酷的事情在于，一旦你掌握了一门语言，学习其他语言就会更加容易，它们的逻辑思路是共通的。

可选项

看来你已经决定要卷起袖子写代码了，好样的！在这里你有很多选择。针对不同的任务，各种语言之间互有优劣。有些语言善于处理大量数据，其他一些则可能偏向于更好的视觉效果，或者提供交互功能。选择何种语言取决于你对数据图的目标，或者看你对哪种语言更习惯。

有些人坚持只学一门编程语言，并把它学得很透。这没问题，而且如果你是编程新手，我强烈推荐这一策略。尽量熟悉代码的基础规范和重要概念。

我们应该选择最能满足自己需要的编程语言。不过，掌握新语言、了解新玩法也是很有乐趣的。因此在作决定之前，不妨先积累一些编程经验。

Python

Python善于处理大批量的数据，不会造成宕机。这使得该语言能够胜任繁重的计算和分析工作。Python干净易读的语法也很受程序员们欢迎，还可以利用很多模块来创建数据图形，例如下图这种的。

图：利用Python生成的图表

从美学方面来看，这个图表还不够好。直接拿Python输出的图片用于印刷可能会比较勉强，尤其是在边缘处给人感觉比较粗糙。但不管怎样，这是数据探索阶段一个很不错的开始。或者你也可以先输出图片，然后再利用其他的图形编辑软件来润色或添加信息。

有用的Python资源

- Python官方网站http://python.org。

- NumPy和SciPy1 http://numpy.scipy.org/——科学计算模块。

1NumPy是Python的一个数据处理的函数库，里面主要是一些矩阵的运算等。SciPy是Python语言中用于科学研究的函数库，它是在Numpy基础上开发的。

PHP

PHP是我刚开始网页编程时学到的第一门语言。有些人说它很松散，确实如此，但也可以让它很有条理。大部分Web服务器都预安装了PHP的开源软件，因此要想着手写PHP是非常容易的。

绝大多数预安装中都会包含一个叫做GD的图形函数库。这个库非常灵活，能让你从无到有地创建图形，或者修改已有图形。此外还有很多PHP图形函数库能帮助我们创建各类基本的图表。最受欢迎的是Sparkline（微线表）库，它能让你在文本中嵌入小字号的微型图表，或者在数字表格中添加视觉元素，如图所示。

图：利用PHP图形函数库生成的微线表

一般PHP的出现都会伴随着MySQL等数据库，而不是一堆CSV文件。这使它能物尽其用，处理大型的数据集。

有用的PHP资源

- PHP官方网站（http://php.net）。

- Sparkline PHP图形函数库（http://sparkline.org）。

Processing

Processing是一门适合于设计师及数据艺术家的开源语言。最早的Processing还只能算作是小品级别，可以让用户快速生成图形，但随后获得了长足的发展，完成了很多高质量的项目。

Processing很棒的一点是能很快上手：轻量级的编程环境，只需几行代码就能创建出带有动画和交互功能的图形。这款工具确实很基础，但由于它偏重于视觉思维的创造性，你很容易就能知道如何创造出更高级的作品。

虽然在一开始主要是设计师和艺术家使用Processing，但如今它的受众群体已经越来越多样化了。你可以借助各种函数库来提升它的威力。

它的缺点之一是要使用到Java小应用程序，在某些计算机上载入时可能会很慢，而且并不是每个人都安装了Java（尽管多数人都安装了）。不过这也有解决办法，Processing在不久前发布了它的JavaScript版本。

无论如何，它对于新手来说是个很好的起点。即使是毫无编程经验的用户也能够做出有价值的东西。

有用的Processing资源

- Processing的官方网站（http://processing.org）。

Flash和ActionScript

网上大多数可交互的动画数据图都是通过Flash和ActionScript开发的，尤其是在《纽约时报》这样的主流新闻网站上。我们可以直接用Flash来设计图形，这也是一款所见即所得的软件，但有了ActionScript的帮助，就能更好地控制交互行为。许多应用都是完全用ActionScript写的，无需用到Flash环境，不过这些代码还是作为Flash应用来进行编译。

虽然有很多免费、开源的ActionScript函数库，但是Flash软件和Flash编译器的价格比较昂贵，在你选择软件时需要考虑这一点。

比如说，表现沃尔玛企业成长的可交互动画演示地图就是用ActionScript写成的。其中调用了Modest Maps库。这是一个用于区块拼接地图（tile-based map）的显示和交互的函数库，以BSD许可协议2发布，这表示它是免费的，大家可以随心所欲地使用。

2BSD许可协议（Berkeley Software Distribution license），是自由软件（开源软件的一个子集）中使用最广泛的许可协议之一。

图：动画演示沃尔玛企业成长的地图，以ActionScript写成

下图中的可交互堆叠面积图也是由ActionScript写成的。它能让你搜索数年来各种消费开支的大小变化，例如住房（Housing）和交通（Transportation）。这一艰巨的任务是由加州大学伯克利分校可视化实验室开发的Flare Action Script库完成的。

图：显示消费开支分类的可交互堆叠面积图，以ActionScript写成

如果你对网上的可交互图形感兴趣，那么Flash和ActionScript是非常好的选择。Flash应用的下载相对较快，而且绝大多数人的计算机上都安装了Flash播放器。

不过ActionScript并不好学。虽说语法并不复杂，但是安装和代码的组织却可能会难倒初学者。你不可能像Processing那样只用几行代码就运行成功。在后面的章节中我会带领大家实践其中的基本步骤，另外由于Flash的广泛使用，在网上还能找到很多极有帮助的教程。

与此同时，Web浏览器一直在飞速发展，其运行速度和效率也有了显著的提高。这让我们有了很多其他的选择。

有用的Flash和ActionScript资源

- Adobe支持（http://www.adobe.com/products/flash/whatisflash/）——Flash和ActionScript（以及其他Adobe产品）的官方文档。

- Flare可视化工具包（http://flare.prefuse.org）。

- Modest Maps函数库（http://modestmaps.com）。

HTML、JavaScript和CSS

Web浏览器的运行速度越来越快，功能也越来越完善。不少人使用浏览器的时间要超过计算机上的其他任何程序。可视化在近期也有了相应的转变，开始借助HTML、JavaScript和CSS代码直接在浏览器中运行。在过去，可交互的数据图一般都是通过Flash和ActionScript来实现，而静态数据图则需要存储为图片格式。现在的情况也大抵如此，但不再只有这一种选择。

一些功能强健的工具包和函数库可以帮助我们快速创建可交互或静态的可视化图形。它们还提供了大量的选项，以便你针对数据需要进行定制。

比如说，斯坦福大学可视化团队开发的Protovis就是一款免费开源的可视化函数库，它能帮助你创建网页原生的可视化作品。Protovis提供了一系列开箱即用的可视化工具，但你在创建几何图形时不会受到任何限制。下图显示的是一幅可交互的堆叠面积图。

图：由Protovis生成的堆叠面积图

Protovis中内嵌了这一图表类型。我们还可以生成形式更加新颖的流线图（streamgraph），如图所示。

图：由Protovis自定义生成的流线图

我们还可以利用多个库来扩充功能。Flash也能做到这一点，但JavaScript在代码方面的工作量会小很多。有了jQuery和MooTools等库的协助，JavaScript也更加容易阅读和使用。这些库并非专为可视化而开发的，但仍然能带来很大帮助。它们提供了大量基础功能，只需几行代码就能实现。如果没有这些库，我们要写的代码就会更多，而且稍不注意就可能漏洞百出。

这些库还提供了一些插件，帮助我们制作较为基础的图形。例如，你可以利用jQuery的Sparklines（微线表）插件来生成微线表。

图：通过jQuery的Sparklines插件生成的微线表

用PHP也可以做到这一点，但这种方法具有更多优势。首先，数据图是在用户的浏览器中生成的，而非服务器端。这能缓解服务器的压力，否则在流量较大的情况下你的网站就可能会出问题。

另一个优势在于你无需在自己的服务器上安装PHP图形库。很多服务器上都预安装了这些图形库，但也有一些没有。如果你对PHP图形库不熟悉，安装过程可能会非常麻烦。

也许有人不想用插件。没关系，利用标准的网页编程也可以定制可视化的效果。下面的图就是一副可交互的日历，同时也是用户使用your.flowingdata工具的热度图（heatmap）。

不过还是有几点需要注意。由于相关的软件和技术还比较新，在不同浏览器中你的设计可能在显示上会有所差别。在Internet Explorer 6这类老旧的浏览器中，有些工具可能无法正常运行。不过这种情况正在逐渐好转，因为绝大多数人都已经转向了Firefox或Google Chrome等更现代的浏览器。归根结底，这还是取决于你的受众群体。在FlowingData网站的访问者中，只有不到5%的人还在用低版本的Internet Explorer，因此浏览器兼容性并不是很严重的问题。

图：可交互日历，同时也是用户使用your.flowingdata的热度图

同样，由于该技术的普及率尚不够高，为JavaScript可视化提供支持的函数库不像在Flash和ActionScript中那么多。这也是为什么许多主流新闻机构仍然大量使用Flash的原因。不过随着技术的发展这一局面终会发生改变。

有用的HTML、JavaScript和CSS资源

- jQuery（http://jquery.com/）——一个JavaScript库，能让该语言的编程更加高效，而且让最终代码更加易读。

- jQuery Sparklines（http://omnipotent.net/jquery.sparkline/）——通过JavaScript 生成静态及动画的微线图。

- Protovis（http://vis.stanford.edu/protovis/）——专用于可视化的JavaScript库，提供了实例以便学习。

- JavaScript InfoVis工具包（http://datafl.ws/15f）—— 另一个可视化库，但不如Protovis成熟。

- Google Charts API（http://code.google.com/apis/chart/）——动态创建传统形式的图表，只需修改URL即可。

R

如果你浏览过FlowingData，可能就会知道我最喜欢的数据图形软件就是R。它是一款免费且开源的统计学计算软件，图形功能也很强大。它也是绝大多数统计学家最中意的分析软件之一。此外还有一些功能近似的付费软件，例如S-plus和SAS，不过它们很难比得上R的完全免费以及活跃的开发社区氛围。

相较于之前提到的所有软件，R的优势之一在于它是专为数据分析而设计的。HTML的用途主要是创建网页，而Flash则用于其他方面，例如视频以及动态广告。但R却是由统计学家开发并维护，主要的面向对象也是统计学家。至于这是好是坏，取决于你从哪种角度来看待它。

支持R的工具包也有很多，你只需把数据载入到R里面，写一两行代码就可以创建出数据图形。比如说，你可以利用Portfolio工具包快速创建出板块层级图（treemap），如图所示。

图：在R中利用Portfolio工具包生成的板块层级图

创建热度图也同样简单，如图所示。

当然，还可以创建更多传统的统计图表，例如散点图和时间序列图。

坦白说，R的网站看起来极为落后，而且软件本身在引导新用户方面做得不够好。不过你要记住，R毕竟是一门编程语言，学习任何语言都必须经历开头的艰难。我也读到过一些关于R的负面评论，但通常都是那些习惯于按钮或者鼠标操作的人在发牢骚。所以如果你打算使用R，不要对用户界面的期待过高，否则你一定会觉得它很不友好。

但如果克服了这一点，我们就能用R做很多事情。它能够生成达到印刷品质的图片（至少也是雏形），而且极其灵活。如果愿意，你可以写出自己的函数或者程序包，按自己想要的方式来创建图形，你也可以借用R函数库里其他人开发的成品。

R提供了基础的绘图功能，它基本上可以让你随心所欲地绘制想要的图形，例如线条、形状以及框架坐标轴。所以和其他编程工具一样，能限制你的只有想象力而已。事实上，任何一种图表类型都能通过R或者R的工具包实现。

在搜索引擎中搜索有关R的内容时，它的本名可能会带来很多不相干的结果。可以尝试以“r-project”作为关键词，这样的搜索结果会更加精确。

既然R这么强大，为什么还要学习其他工具呢？为什么不干脆用R来做所有事情？原因有以下几方面，R是在你的桌面上运行的，所以它不太适合于动态网页。存储为图片然后发布到网页上并不是问题，但这一过程不会自动完成。你也可以通过网页来动态生成图片，但截至目前，R的这一功能还不是特别强大，无法比拟JavaScript等网页原生工具。

在创建可交互图形或动画方面，R也不是特别擅长。同样，尽管也可以用R来实现，但还有其他更方便的途径，比如Flash或者Processing。

最后，大家也许已经注意到，有些R创建的图形还欠缺一些雕琢。你恐怕不希望在报纸上看到这种水平的图形。当然，你也可以尝试依靠不同的选项或者添加代码来强化R的设计感觉，但我的策略一般是在R中创建基本图形，然后再利用Adobe Illustrator等绘图软件对其进行编辑加工。如果是用于数据分析，那么R的原始输出就可以了，但如果是用于演示或印刷，则最好还是从视觉和美感上稍加调整。

有用的R资源 - 适用于统计运算的R项目（http://www.r-project.org）。

取舍

学习编程就是在学一门新的语言，一门有关位和逻辑的计算机语言。例如，当你使用Excel或Tableau时，本质上是在和程序的翻译器打交道。按钮和菜单是你熟悉的语言，而当你点击它们时，软件会把你的行为翻译为指令，并发送给计算机。然后计算机才会服从你的命令，例如生成图片或者处理数据。

所以，时间绝对是主要的障碍。学习一门新的语言需要时间。对很多人来说，这一障碍太高了，对我来说也是如此。你需要立刻完成工作，因为你面前有一大堆数据，而且还有人等着要结果。如果你只会与数据打这么一次交道、以后不会再接触，那么选择开箱即用的可视化工具可能会更合适。

不过，如果你打算深入研究你的数据，而且日后可能（或者希望日后）还会接触大量与数据相关的项目，那么现在花些时间学习编程最终会节省其他项目的时间，并且作品也会给人留下更加深刻的印象。你的编程技巧会在每一次项目中获得提高，你会发现编程越来越容易。和任何一门外语一样，我们都会从基础学起，然后再逐步扩大范围，不可能从一开始就用它来著书立说。

让我们换一种思路来看这个问题。假设你被扔到一个陌生的国度，你不会说当地的语言，但有一名随身翻译。如果要和当地人交流，你会先说出来，然后让翻译去传递你要表达的信息。如果翻译不理解你的意思，或者不懂你用的某一个词，又该怎么办？他可能会干脆忽略这个词，或者如果他足够聪明，说不定去查查字典。

对于开箱即用的可视化工具来说，软件本身就是翻译。如果它不知道该怎样做一件事情，你就只能卡在那里，或者不得不寻找替代的解决办法。和现实中的翻译不同，软件通常不会立刻学会“新词”（在这里指新的图表类型或者数据处理方式）。新的功能需通过软件更新获得，也就是说你只能干等着。那么，如果你自己学会了这门语言会怎样呢？

再一次声明，我并不是说要避免使用开箱即用的工具。我一直都在使用它们。它们让很多沉闷乏味的任务变得轻松容易，这很棒。但我们不能被软件限制住，不是吗？

本文节选自《鲜活的数据：数据可视化指南》

《鲜活的数据：数据可视化指南》是一本系统介绍数据可视化的图书。书中主要阐述了如何将冰冷枯燥的数据转换成易于理解、生动有趣、主题清晰的图表。作者根据数据可视化的一般顺序，先后介绍了如何获取数据，将数据格式化，然后用可视化工具（如R）生成图表，最后在图形编辑软件（如Illustrator）中修改完善，使图表达到最佳的可视化效果。

书中详细介绍了柱形图、饼图、折线图和散点图等图表的绘制方法及各自的优缺点，还用专门的一章介绍与地图相关的数据可视化技巧。实例丰富、图文并茂，适合数据分析师、视觉设计师和对数据感兴趣的开发人员学习。

吐槽广告

活动规则：在吐槽贴中留言，选出任何一期中你最喜欢的文章和最不喜欢的文章，即可获得图灵社区银子2两！凡吐槽吐得掷地有声者，加赠银子3两（共5两）！（怎样使用银子兑换图书）

活动时间：本活动长期有效。

人物

新浪肖鹏：微博数据库那些事儿

肖鹏，微博研发中心技术经理，主要负责微博数据库（MySQL/Reids/HBase/Memcached）相关的业务保障、性能优化、架构设计，以及周边的自动化系统建设。经历了微博数据库各个阶段的架构改造，包括服务保障及SLA体系建设、微博多机房部署、微博平台化改造等项目。10年互联网数据库架构和管理经验，专注于数据库的高性能和高科用技术保障方向。

问：您是如何与MySQL结缘，并成为数据库方面的专家的？

与MySQL结缘主要也是源于兴趣，第一份工作在一家小公司，各个领域的工作都会有接触，全都做下来发现还是对数据库最感兴趣，所以就一直从事和数据库相关的技术工作了。随着工作年限的增加，我在数据库方面积累的经验也逐步增加，越来越发现数据库管理员（DBA）是一个偏实践的工种，很多理论上的东西在现实中会有各种的变化，比如「反范式」设计等。所以我建议大家：如果想成为数据库方面的专家，一定要挑选好环境，大平台很多时候会由于量变引发质变产生很多有挑战的问题，而解决这些问题是成为技术专家的必经之路。

问：一路走来，微博的数据规模和业务场景都发生了很大的改变，请问新浪MySQL集群结构发展到今天都经历了哪些阶段？

微博到今天已经有6年了，非常有幸全程参与了这6年的变化。新浪的MySQL集群结构主要经历了3次重大的变化。

第一阶段：初创阶段

初期微博作为一个内部创新产品，功能比较简洁，而数据库架构也采用1M2S1MB的标准结构，按照读写分离设计，主库承担写入，而从库承担访问。如果访问压力过大，可以通过扩容从库的数量获得scale out的能力。

第二阶段：爆发阶段

微博上线之后，随着用户活跃度的增加，数据库的压力也与日俱增。我们首先通过采购高性能的硬件设备来对单机性能进行scale up，以满足支撑业务的高速发展的需求。然后，通过使用高性能设备争取来的时间对微博进行整体上的业务垂直拆分，将用户、关系、博文、转发、评论等功能模块分别独立存储，并在垂直拆分的基础上，对一些预期会产生海量数据的业务模块再次进行了二次拆分。

以博文为例，博文是微博用户主要产生的内容，可以预见会随着时间维度不断增大，最终会变得非常巨大。如何在满足业务性能需求的情况下，尽可能使用较少的成本存储，这是我们面临的一个比较有挑战的问题。

- 首先，我们将索引同内容进行了拆分，因为索引所需存储的空间较少，而内容存储所需空间较大，且对这两者的使用需求也不尽相同。

- 然后，分别对索引和内容采用hash，然后再按照时间维度拆分的方式进行水平拆分，尽量保障每张表的容量在可控范围之内，保障查询的性能指标。

- 最后，业务先通过索引获得实际所需内容id，然后再通过内容库获得实际的内容，并通过部署memcached来加速整个过程。虽然看上去步骤变多，但是实际效果完全可以满足业务需求。

第三阶段：沉淀阶段

在上一个阶段，微博的数据库经历了很多的拆分改造，这也就直接造成了规模的成倍增长，而随着业务经历了高速增长之后，也开始趋于稳定。在这个阶段，我们开始着重进行自动化的建设。将之前在快速扩张期间积攒下来的经验转变为自动化工具，对外形成标准化和流程化的平台服务。我们相继改造了备份系统、监控系统、AutoDDL系统、MHA系统、巡检系统、慢查系统、maya中间件系统，等等。为了提高业务使用效率和降低沟通成本，相对于内部管理系统而言，我们重新开发了iDB系统对数据库平台的用户使用。通过iDB系统，用户可以便捷地了解自己业务数据库的运行状态，并可以直接提交对数据库的DDL修改需求。DBA仅需点击审核通过，即可交由Robot在线上执行，这不但提高了工作效率，也提高了安全性和规范性。

问：在过去的2015年，新浪数据库平台都做了哪些重大的改进和优化？

数据库平台并不仅有MySQL，还有Redis、Memcached、HBase等数据库服务，而在缓存为王的趋势下，2015年我们的重点研发精力主要投入在Redis上。

Redis中间件

2015年，我们自研的Redis中间件tribe系统完成了开发和上线。tribe采用有中心节点的proxy架构设计，通过configer server管理集群节点，并借鉴官方Redis cluster的slot分片设计思路来完成数据存储。最终实现了路由、分片、自动迁移、fail over等功能，并且预留了操作和监控的API接口，以便同其他的自动化运维系统对接。

Databus

由于我们先有MySQL后有的Redis和HBase等数据库，所以存在一种场景，就是目前数据已经写入到MySQL中，但是需要将这些数据同步到其他数据库软件中。为此我们开发了Databus，可以基于MySQL的binlog将数据同步到其他异构的数据库中，并且支持自定义业务逻辑。目前已经实现了MySQL到Redis和MySQL到HBase的数据流向，下一步计划开发Redis到MySQL的数据流向。

问：微博用户库设计采用了反范式设计，但是反范式设计也有自己的问题，比如在规模庞大时，数据冗余多，编码及维护的困难增加。请问你们是如何解决这些问题的？

反范式设计带来便利的同时确实也带来了一些问题，尤其是在数据规模变大之后，通常来说会有如下几种解决方案。

- 预拆分，在接到需求的时候提前针对于容量进行评估，并按照先垂直后水平进行拆分，如果可以按照时间维度设计，那就纳入归档机制。通过数据库的库表拆分，解决容量存储问题。

- 引入消息队列，利用队列的一写多读特性或多队列来满足冗余数据的多份写入需求，但仅能保障最终的一致性，中间可能会出现数据延迟。

- 引入接口层，通过不同业务模块的接口将数据进行汇总之后再返回给应用层，降低应用层开发的编码复杂度。

问：微博平台当前使用并维护着可能是世界上最大的Redis集群，在应用Redis的过程中，你们都产生了哪些具有独创性的解决方案？

微博使用Redis的时间较早，并且一开始量就很大，于是在实际使用过程中遇到了很多实际的问题，我们的内部分支版本都是针对这些实际问题进行优化的。比较有特点的有如下三个。

增加基于pos位同步功能

在2.4版本中，Redis的同步一旦出现中断就会重新将主库的数据全部传输到从库上，这就会造成瞬时的网络带宽峰值，并且对于数据量较大的业务，从库恢复的时间较为缓慢。为此我们联合架构组的同事借鉴MySQL的主从同步复制机制，将Redis的aof改造为记录pos位，并让从库记录已经同步的pos位置。这样在网络出现波动的时候，即使重传也只是一部分数据，并不会影响到业务。

在线热升级

使用初期，由于很多新功能的加入，Redis版本不断升级，每次升级为了不影响业务都需要进行主库切换，这对于运维带来了很大的挑战。于是我们开发了热升级机制，通过动态加载libredis.so来实现版本的改变，不再需要进行主库切换，极大地提升了运维的效率，也降低了变更带来的风险。

定制化改造

在使用Redis的后期，由于微博产品上技术类的需求非常多，所以我们为此专门开发了兼容Redis的redisscounter存储技术类的数据。通过使用array替换hash table极大降低了内存占用。在此之后，我们开发了基于bloom filter的phantom解决判断类场景需求。

问：您在一次分享中曾经透露新浪数据库备份系统正计划结合水位系统实现智能扩容，请问现在实现到哪一步了？

目前这个事情的进展不是很理想，主要是我们发现MySQL的扩容速度跟不上业务的变化。有些时候扩容完毕之后业务的高峰已经过去了，接下来就需要做缩容，等于做了无用功。所以，目前我们的思路改为扩缩容cache层，首先实现cache层的自动扩缩容，然后同业务监控系统或者接入层的自动化系统进行联通，比如，如果计算节点扩容100个node，那么我们的cache层就联动扩容20node，以此来达到业务联动。

问：未来5年内新浪数据库还将做出什么样的改变？

随着业务的发展，会遇到越来越多的场景，我们希望可以引进最适合的数据库来解决场景问题，比如PostgreSQL、SSDB等。同时，利用MySQL新版本的特性（比如5.7的并行复制、GTID、动态调整BP），不断优化现有服务的性能和稳定性。

另外，对于现有的NoSQL服务，推进服务化，通过使用proxy将存储节点进行组织之后对外提供服务。对外降低开发人员的开发复杂度和获取资源的时间，对内提高单机利用率并解决资源层横向扩展的瓶颈问题。

同时，尝试利用各大云计算的资源，实现cache层的动态扩缩容；充分利用云计算的弹性资源，解决业务访问波动的问题。

问：您如何看待新兴的NewSQL？

数据库圈子的变化确实很快，NoSQL还刚刚方兴未艾，NewSQL又开始你方唱罢我登场。我个人并不认为某种数据库会取代另一种数据库，就如同NoSQL刚刚兴起的时候很多声音认为它会彻底取代MySQL，但是从实际情况看依然是互依并存的关系。以我负责的集群来说，反倒是MySQL更多一些。我个人认为，每种数据库都有其最擅长的场景，在特定场景下它是最佳的数据库，但是如果脱离了场景则很难说谁优谁劣。

问：能否请您横向对比一下MySQL、MongoDB, 以及PostgreSQL？

我个人使用MySQL较多，MongoDB和PostgreSQL都有过一些接触，MySQL作为LAMP中的一员，老实说在大部分场景都是合适的，尤其是在并发和数据库量并没有达到一个很大值的时候。但是，在某些场景下MongoDB和PostgreSQL确实更胜一筹。

比如我们在门户的新闻发布系统中使用了MongoDB，其schema less的设计模式和新闻非常贴合，而其sharding功能又解决了容量上的横向扩张问题，在这个场景下，MySQL并不具备什么优势。

而在LBS（基于地理位置信息服务）相关的方面，PostgreSQL和PostGIS更具有优势，利用其空间数据索引R-tree和实体类型点、线、线段、方形，以及特定的函数，可以很方便地实现空间计算需求。

就我个人来说，每种数据库都有其擅长的场景。如果没有特殊的架构需求，一般选择MySQL都不会出问题。如果有特殊的架构需求，那么就需要根据需求的特点来选择不同的数据库。

问：对于想要掌握MySQL的同学，您有哪些学习上的建议？

首先，多读书，至少将High Performance MySQL通读一遍。

其次，有条件的话，最好找一些大平台历练一下，在很多情况下经验和能力等同于你解决过的问题的广度和深度，而环境决定你遇到的问题。

最后，有机会的话多做一些技术分享，很多知识点自己明白和能给别人讲明白是两个完全不同的境界。

本文出自图灵访谈

践行

redux——页面状态管理的优雅方案

作者/积木村の研究所

硕士毕业于中山大学，毕业后加入百度，曾参与百度地图用户中心后端系统的设计与研发，并主导百度国际化手机助手前端架构的设计，目前在腾讯QQ手机浏览器团队从事前端开发工作。专注web全栈架构领域！

前端是强调工程能力胜过技术能力的领域，最近一两年，前端在构建流程、组件化、同构渲染等方面有了深入的发展，其入行门槛也在逐步提高。

前端社区的活跃程度让人惊叹，各种工具层出不穷，比如grunt、gulp、webpack、fis等，即便你没有全部用过，也该了解过它们中的大部分，这些工具极大的解放了前端的生产力，解决了前端的构建流程问题。而React的出现将前端引入新的境界，它优雅的解决了前端UI层组件化的问题，使得组件化也成了前端项目的标配。为了提高页面渲染速度，页面首屏由后端直出，也已经有了很多解决方案。这里我们探讨一下容易被大家忽略的领域：页面状态的管理。

页面状态

页面上所有UI层的显示都可以用对应的状态描述，比如当前的列表项、当前被选中的标签等。如下图所示：

可以简单的将前端项目抽象为对UI的管理和对状态的管理。UI和状态之间相互作用，处理它们之间的相互关系很复杂，行业内有不同的解决方案，比如以angularJS为代表的双向绑定、以及flux提出的单向数据流。本文将抽象地理解facebook提出的单项数据流方案flux，以及它的具体实现redux。

单向数据流flux

flux是facebook提出的一种应用程序框架，其基本架构如下入所示。flux的核心理念是单向数据流，完善了React对应用状态的管理。

上图描述了页面的启动和运行原理：

- 通过dispatcher派发action，并利用store中的action处理逻辑更新状态和view

- 而view也可以触发新的action，从而进入新的步骤1

其中的action是用于描述动作的简单对象，通常通过用户对view的操作产生，包括动作类型和动作所携带的所需参数，比如描述删除列表项的action:

{
type: types.DELETE_ITEM,
id: id
};

而dispatcher用于对action进行分发，分发的目标就是注册在store里的事件处理函数:

dispatcher.register(function (action) {
switch(action.type) {
case 'DELETE_ITEM':
sotre.deleteItem(action.id); //更新状态
store.emitItemDeleted(); //通知视图更新
break;
default:
// no op
}
})

store包含了应用的所有状态和逻辑，它有点像传统的MVC模型中的model层，但又与之有明显的区别。store包括一个应用特定功能的全部状态和逻辑，它代表了应用的整个逻辑层；而不像Model一样包含的是数据库中的一些记录和与之对应的逻辑。

一种对flux的实现，redux

随着前端应用复杂性指数级的提升，前端页面需要管理的状态也越来越多，flux给出了管理状态的基本数据流，而redux对flux就是最好的实现之一，而且其对flux的理念进行了更进一步的扩展。

redux倡导三大原则：

1. 一个对象存储整个应用的状态

2. 状态对象是只读的，只能通过action触发改变

3. 通过普通函数处理action的逻辑

其基本流程如下：

相对于flux，redux有如下不同之处：

1. redux没有dispacher，其通过普通函数处理action逻辑，并改变应用状态

2. redux的状态对象是immutable的，每一个action都会局部地创建新的状态对象

需要强调的是，redux不一定要和react搭配，它是一种应用状态管理方案，不涉及UI层，你可以任意选择自己的UI层；正因为redux脱离UI层，提供了整个应用状态的管理，使得我们的开发流程有了颠覆性的改变。

我们可以在UI层ready之前，完成应用的逻辑设计和实现。比如我们将应用的逻辑设计如下：

ADD_ITEM的action触发todos列表状态的改变，SET_FILTER的action触发filter状态的改变。因为每一个action都是简单对象，我们可以方便地模拟。这也就使得我们可以在UI层ready之前，对前端全部逻辑写单独的测试用例。

然后，在UI层ready之后，将UI层或网络层的事件映射为redux的action。比如将表单提交事件映射为ADD_ITEM，将标签页切换按钮点击事件映射为SET_FILTER。UI层和逻辑层相互独立，并仅仅通过事件与action的映射来建立联系，这种方案使得复杂的前端项目有了更清晰的架构。

redux与react的配合

redux只负责应用的逻辑层，而通过使用react-redux模块，其可以和react无缝地配合。

经典案例

我们简单了解一下，如何使用react+redux实现经典的todolist案例。案例的源代码在https://github.com/foio/react-redux-isomorphic-todolist。最终的界面如下：

逻辑层设计

前端应用本质上是通过事件触发应用状态的改变。而redux包办了应用状态(state)、事件(action)、和事件处理函数(reducer)，使得我们可以抛开UI层，先设计应用的逻辑层。redux使用单一对象存储整个应用的状态，todolist应用的状态(state)树如下：

{
filter: 'show_all'
todos: [
{
id: 1,
text: 'todo1',
marked: true
}
]
}

其中filter为列表过滤策略，用于标志底部三个按钮选中的状态，而todos作为代办事项列表。上文提到过state是immutable的，只能通过action触发对state的改变，一个编辑todo条目的action如下：

var editTodo = function (id, text) {
return {
type: types.EDIT_TODO,
id: id,
text: text
};
}

相应的，redux提倡用简单函数(又称作reducer)处理action，如下为EDIT_TODO的处理逻辑：

module.todo = function(state, action) {
state = state || [];
switch (action.type) {

case types.EDIT_TODO:
return state.map(function(todo) {
return todo.id === action.id ?
assign({}, todo, { text: action.text }) :
todo
});

default:
return state;
}
}

每一个action处理函数都是将action作用在old state上，从而产生new state。state、action、reducer太零散，通过createStore可以将它统一在store中，而store代表了整个应用的逻辑。

var store = createStore(reducer);

UI层设计

本案例采用react作为UI层的组件化方案，在这里不再详述。需要注意的是为了配合redux，在写react组件时，我们需要对组件类别进行划分，将其划分为展示型组件和容器型组件。redux只需要和容器型组件通信，而不用管理展示型组件。用一个表格可以很好的说明它们和区别：

组合逻辑层与UI层

上文中提到，redux只关注react的容器型组件，而且容器型组件可以由react-redux动态生成，以防止state注入容器型组件时的硬编码。比如我们使用下面的代码将应用状态注入一个容器型组件TodoApp中：

var App = React.createClass({
render: function() {
return (
<Provider store={store}>
{function() { return <TodoApp />; }}
</Provider>
);
}
});

module.exports = App;

上述代码通过Provider将store注入容器型组件TodoApp中。在TodoApp组件中，我们可以通过this.props来获取store中存储的应用状态：

var TodoApp = React.createClass({
render: function () {
var todos = this.props.todos;
var filter = this.props.filter;
return (
<div>
......
</div>
);
},
});
function mapStateToProps(state) {
return state;
}
module.exports = connect(mapStateToProps)(TodoApp);

其中connect函数用于动态的创建容器型组件。借助Provider和容器型组件，我们就将应用的逻辑层和UI层组合在一起了。

高级特性

了解了react和redux结合的基本思路以后，让我们一起看一看redux的高级特性。

状态树分治

redux提倡用一个对象存储整个应用的状态，而复杂应用的状态对象是很大的，这样会不会有性能问题？各个容器型组件都对整个应用状态对象进行操作，会不会引起混乱？对此，redux有充分的考虑。首选在逻辑层设计时，我们就应该充分考虑到状态树的分治，比如在设计action的处理函数(reducer)时，针对状态树的不同部分，将其对应的actions处理函数存储在不同的文件中，redux通过combineReducers对此提供了支持。比如

var todos = require('../reducers/todos');
var filter = require('../reducers/filter');
combineReducers({filter: filter, todos: todos});

其次，在UI层我们也可以方便地只将部分状态树注入某个容器型组件,redux在使用connect生成容器型组件时，接收一个函数(mapStateToProps)作为参数，该函数可以只返回整个状态树的部分状态。因此，connect生成的容器型组件也就只能感知到部分状态树。这种方式，避免了应用状态树过大的混乱，分治降低了复杂度。如下代码，创建了一个只关注整个状态树中state.todos的容器型组件:

var TodoApp = React.createClass({
render: function () {
var todos = this.props.todos;
return (
<div>
......
</div>
);
},
});

function mapStateToProps(state) {
return state.todos;
}
module.exports = connect(mapStateToProps)(TodoApp);

异步action

一般来说，异步action并不能算是高级特性，因为它太常见了。比如发送一个网络请求，这是再寻常不过的需求了。只是用redux触发异步action并不是那么直接。我们需要首先了解redux的中间件概念，它可以用于在action被触发和action到达处理函数reducer之前，对action进行处理。

可以在创建store时，通过applyMiddleware函数提供redux的中间件：

createStore( todosApp,applyMiddleware(someMiddleWare))

一个典型的redux中间件是redux-logger，它在控制台中记录每一次action作用前后的应用状态变化，非常适合在开发阶段进行调试。

var fetchTodos = function () {
return function (dispatch) {
return fetch('/todos');
}
}

我们可以使用dispach函数，像派发普通action一样派发异步函数，异步函数的返回值还可以是Promise，其返回值会透传过dispch函数。

dispach(fetchTodos)
.then(function(json){
//handle response
})
.catch(function(error){
//handle error
});

通过网络加载数据并在数据到达时更新应用状态是一种比较常见的应用场景。对于这种场景，存在一种最优雅的方案：

1. 派发异步函数，用于进行网络请求

2. 在网络请求完成时，派发同步action用于更新应用状态

可以用如下代码表示：

var fetchTodos = function () {
return function (dispatch) {
return fetch('/todos')
.then(function (json) {
//派发同步aciton，用于更新应用状态，初始化todo列表
dispatch(initTodos(json.data || []));
}).catch(function () {
//派发同步action，用于更新应用状态，设置加载失败标志
dispatch(failLoadedTodos());
});
}
}

同构渲染

前后端同构，应用首屏由后端直出是近年来比较流行的性能优化方案，redux对此也有完善的支持。基本流程是：

1. 服务端初始化state

2. 将服务端state传递到应用的页面端

3. 页面端用服务端传递的状态初始化应用state

在遵从这个基本流程的情况下，服务端和页面端的使用方法开发方法基本一致，如下是服务端代码：

store.dispatch(todoActions.loadInitTodos()).then(function () {
var contentHtml = React.renderToString(
<Provider store={store}>
{function () {
return <TodoApp />;
}}
</Provider>
);
var initialState = JSON.stringify(store.getState());
res.render('index.ejs', {contentHtml: contentHtml, initialState: initialState});
}).catch(function(error){
res.json({errMsg: 'internal error'})
});

上述服务端代码通过派发初始化异步函数更新应用状态，该异步函数返回一个Promise，Promise对象会透传过dispach函数。在Promise处理完成后，我们得到应用的最新状态。最后我们把React输出的HTML字符串contentHtml和初始化应用状态initialState，传递到模板文件index.ejs中，模板文件如下：

<html>
<head>
<title>Redux TodoMVC</title>
</head>
<body>
<div class="todoapp" id="root"><%-contentHtml%></div>
</body>
<script>
window.__INITIAL_STATE__ =  <%-initialState%>;
</script>
</html>

通过浏览器的window对象，我们将服务端的初始状态传递到了页面端。

var todosApp = combineReducers({filter: filter, todos: todos});
var store = createStore(  todosApp,
window.__INITIAL_STATE__,
applyMiddleware(thunkMiddleware, reduxLogger())
);

参考文献：

http://redux.js.org/

https://facebook.github.io/flux/docs/overview.html

https://github.com/gaearon/redux-devtools

https://github.com/foio/react-redux-isomorphic-todolist

本文选自图灵社区

文章同时发表在博客：http://foio.github.io/redux-state-manage/

案例源代码： https://github.com/foio/react-redux-isomorphic-todolist

鲜阅

该露的能露，该要的能要

作者/海兴

老Javaer新Noder，爱技术，爱电影，爱文字，常稀里糊涂，总不知所谓。

德鲁克说：“在人类历史上，管理首次解释了为什么我们能够在生产领域中雇用了大量知识工作者与技术人员，这使得生产效率有了很大的提高，在以往的任何一个社会却无法做到这一点。事实的确如此，以往的社会无法容纳如此众多的人员。即使在不久以前，仍然没有人知道如何把具备不同技术与不同知识的人集合在一起，以实现一个共同的目标。”

其实不止人类社会，编程语言JavaScript也经历过同样的过程。

少年JavaScript之烦恼

科技是推动历史前进的主要动力，不过历史并不是直线向前的，而是走着螺旋式上升的路线。比如上网这个事，自从进入4G时代，通过手机上网方便得很。实际上，在G前时代，也就是上个世纪90年代之前，大家也是通过电话上网的。只是那时候的电话很顾家，不会跟着主人到处跑。我们总是从电话上引出一根电话线插到“猫”上，从电脑上引出一根网线也插到“猫”上。每次上网前要先拨号，等着猫叫了一阵儿后，就可以到网上去冲浪了。都是靠电话上网，但形式上已经螺旋式上升了。回想起来，那时候条件确实艰苦，不像现在，走路吃饭的时候都可以泡在浪里，泡的整个人浪得不要不要的。

那时候的页面还都是静态的，人们连上网之后，会打开Mosaic（早期最流行的浏览器之一，中文名“马赛克”），输入网址，冲好咖啡，对着白白的屏幕安静地等Mosaic从服务器上把HTML下载下来，然后再等着它稳稳地把页面渲染出来。一般等几分钟后，就可以开始阅读本文这种严肃认真的文字了。

本来Mosaic可以一直这么稳稳地幸福下去的。可它的一个开发人员Marc Andreessen大学毕业后搬到了加利福尼亚，又遇到了肯投资给他的Jim Clark，于是Marc创办了网景公司，推出了革命性的Navigator（现代浏览器鼻祖，中文名“导航者”）。Marc知道大家都很猴急，不能等，所以Navigator可以一边下载HTML一边渲染页面。在敲响了Mosaic的丧钟之后，Navigator很快就坐到了浏览器霸主的宝座上。

然而Marc并没有停下前进的脚步，因为他知道，互联网的广阔天地大有可为。1995年4月，网景请到了编程小能手Brendan Eich，希望他能让Schema在Navigator中跑起来。不过这个当时还只有34岁的小伙子明显有更远大的抱负，经过10天的不懈努力，愣是赶在Navigator 2.0 Beta版发布之前搞出了一种新语言。啥也别说了，赶紧跪拜吧！

不过网景众神最初对这门语言的期望应该并不是很高，从起名字的过程就能看出来。这门语言刚出来的时候叫Mocha，后来可能觉得“抹茶”这样的名字太不严肃，所以在9月份改成了LiveScript，然而9月还没过完，就又改成了JavaScript。如果按我们中国人给孩子起名字的习惯，大致相当于走了一条“宝宝-狗剩-富贵”这样的路线。

扯了这么多闲篇，其实只是想说JavaScript这孩子先天条件不太好，最突出的问题是天生不带搞组织工作的细胞。这对于一个孩子来说不是什么大问题，毕竟在早些年，JavaScript干的都是跟用户互动一下，检查检查表单数据有效性这样的任务，一般有一两行代码也就够了，多了也就是百十来行，根本遇不到码际关系这么成人的问题。所有代码往<script>标签里一丢就行了，最多也就是多搞几个标签的事儿。

后来网站越来越多，交互体验好的网站明显更受欢迎，JavaScript这个倒霉孩子肩上的担子也就越来越重。2004年4月1日，谷歌推出的Gmail更是靠JavaScript完成了大部分页面渲染工作。大家都想要Gmail那样的效果，但基本上没有几家公司能出得起那样的工资。所以2006年jQuery一出来就迅速流行开，直到现在，排名在一千万名之前的网站里大概依然有百分之六七十用到了jQuery。用JavaScript随便写个什么也动辄要成千上万行的代码了，代码多了，就要考虑变量名冲突、依赖管理等一系列问题。面对没有组织能力的JavaScript，码农们表示很愁，非常愁，恰似面对一堆乱线头。

这么多年了，是时候解决组织问题了，JavaScript长大了，就要有长大的样子！

大块头JavaScript的小奋斗

模块化编程不是什么新玩意，别的语言已经搞了几十年。JavaScript因为出身问题之前没有搞，半路走模块化路线比较困难。但码农是战斗族群，怎么可能会被困难吓倒呢？老司机，有套路，先搞清楚需求：

- 我希望，代码可以分开，能切成一块块，不大不小地存在文件里；

- 我希望，代码不用复制，可以分享，需要时可以出现在另一个现场；

- 我希望，代码可以依赖，还要管理好，需要的时候加载一次就好；

- 我希望，代码都有自己的空间，你给变量用的名字，别人用也无妨。

需求明确了就好办，办法总比困难多，语法层面不支持可以靠模式，模式不够强还可以用库。所以就有了：

1. 对象字面值模式

2. IIFE/揭示模块模式

3. CommonJS

4. AMD

5. UMD

虽然这些现在都不用了，可了解历史才能明白现在的幸福生活来之不易，才能明白现状为什么会这样。所以我们还是有必要一个个看看。

对象字面值模式

其实JavaScript也不是完全没有组织的，它有Object，可以完成小范围的组织工作。

这种办法最大的好处就是容易理解，实现起来简单。但要保证这个全局的person变量的安全很困难，一旦别人再声明一个person变量，那你的东西就全丢了。

另外这种代码是没办法重用的，比如再写一个会打字的猴子，只能把代码再复制一次：

并且这种代码对文件的加载顺序也有很严格的要求，一搞不好就会出错。

IIFE/揭示模块模式

IIFE（一飞）是Immediately Invoked Function Expression的缩写。就是把一个函数的定义放在括号里，然后再紧跟着一个括号。如果那个函数需要参数，就把参数放到第二个括号里，这个函数会立即执行。让这个函数返回一个对象，也就是揭示模块模式。

借助闭包的特性，我们在IIFE内部的控制力度大大增强了。比如说，innerName基本上是私有的啦，因为外部代码都访问不到它，而同时也给开了个口子，可以通过返回对象的name属性访问。我们有了构造器一样的功能，可以给person内部的name设定默认值。如果需要从外部重新设定name，可以在参数中指定。最后，将这些技术整合到一起，我们可以在author中实现修饰器模式，从而将author的代码跟它所依赖的创建者解耦。

IIFE/揭示模块模式提供了很多特性，并且对后面要介绍的AMD产生了很大影响。但这个写法太丑并且没能完全解决全局作用域的问题。

CommonJS

其实早在Node.js之前，JavaScript就有服务器端版本。不提网景的Server Side JavaScript，还有Ringo、Narwhal和Wakanda等先驱者，不过最终只有Node.js火起来了。服务器端代码都是比较复杂的，组织不好就是灾难性的后果。所以大家成立了一个工作组，想搞一个服务器端JavaScript的模块标准。

有个笑话，骆驼是委员会设计出来的马。设计这种活不是委员会的长项，CommonJS工作组一直也没能搞出什么像样的成果来。

Ryan Dahl写Node.js时借鉴了CommonJS的一些思想，所以Node.js的模块管理机制被大家误以为是CommonJS的成果。但Ryan Dahl认为：“I generally support the CommonJS idea, but let’s be clear: it’s hardly a specification handed down by the gods (like ES5); it’s just some people discussing ideas on a mailing list. Most of these ideas are without actual implementations.”

由于是为服务器端设计的，所以使用这种模式要求有一个符合标准的脚本加载器。这个加载器要支持require和module.export，虽然这种实现在浏览器中没流行起来，但确实可以通过Browserify等工具在浏览器中使用。

这种写法即清晰又简洁，ES 6的模块语法在很大程度上就是借鉴了CJS。另外，模块限定了变量的作用域，我们甚至都不能在模块中声明全局变量了。但它不适合在比较慢的网络环境或者比较弱的设备上使用，因为它必须在所有的require都加载完之后才能开始执行，不能异步加载，懒加载实现起来也很困难。

AMD(异步模块定义)

CommonJS工作组干的最搞笑的事就是这个，他们没能在服务器端模块标准上形成统一意见，倒是通过他们的讨论促成了AMD（异步模块定义）的形成。AMD的幕后推手主要是IBM和BBC，并且很快在前端开发中形成了主流。

AMD也要有脚本加载器的支持，不过它的加载器只要支持一个define方法，这个方法有三个参数：模块的名称，运行这个模块所需的依赖项，一个在所有依赖项都加载完后运行的函数。只有最后这个参数是必须的。

AMD的define方法跟CJS的export作用一样，但AMD没有指定跟require对应的方法。模块内的依赖项是由define的第二个参数指定的。

AMD辛苦打拼多年，但这只丑小鸭最终也没能变成天鹅。一直要写很多套路化的代码，并且由于浏览器要求的异步特性，它无法进行静态分析。

UMD（统一模块定义）

据说好的折中方案就是最后谁都不满意。UMD（统一模块定义）试图统一CJS和AMD，但通常只是在跟AMD兼容的封装器中封装CJS语法。没得到多少好处，两边的毛病倒是一个都没少的拿过来了。不过，它确实同时兼顾了服务器端和浏览器端，所以这里还是提一下好了。

ES 6的“露”和“要”

负责设计ES 6的TC39委员会充分吸取了CJS和AMD的经验教训，时隔15年后推出的这个版本终于开始支持模块化编程。虽然现在还有很多浏览器不支持，但有Bable等工具可以转换，所以最起码可以写出下面这种整洁的模块化代码了：

在ES 6中，一个包含JS代码的文件就是一个模块，跟模块相关的关键字只有两个——export和import，用法也比较简单。（哦，default、from和as是配角，不算。）

export

JS文件中所有顶层的function、class、var、let和 const之前都可以出现export，也就是说这些关键字定义的元素都可以开发给别的模块使用。我们也可以把所有要export的元素放在一个对象中：

export {
MessageManager,
UserManager,
MenuManager,
CustomerMessageManager,
CustomerServiceManager
}

虽然可以少写很多export，不过这种方式并不是为了减少输入，元素的名称都要重新写一次。目前为止，我只发现了两个场景需要使用这种写法。

第一个是要把底层开放的API向上层传递。比如在wechat-es中，要把所有的manager从managers目录下向上层目录传递。可以在managers目录下创建了一个index.js文件，然后把这个目录下的所有Manager import进来，再export出去。

还有一个场景跟这个有关，上面提到的每个Manager中都有很多function，需要把这些function export出去，但要能作为一个对象中的方法调用。所以我将这些function放到了一个对象中，然后将这个对象作为defaultexport出去。

你看到了，export后面还可以出现default，不过这个default只是表示它后面所跟的值名称是default，没有任何其它特殊的含义。所以export default myObject跟export {myObject as default}是一样的。

default后面只能是值，在JavaScript中，字符串、数值是值，function、class是值，对象字面量也是值，所以它们都可以出现在export default后面，但var、let和 const不行。不过，可以把变量名放在export default后面，也就是说虽然export default const test = 1不行，但

const test =1
export default test

这种情况是可以的。既然default跟test一样都是值的名称，似乎也没必要多此一举，export default 1就可以了。

export除了可以开放自身模块中的元素之外，还可以加上from开放其他模块中的元素。这种情况通常出现在一个包的主模块中，因为主模块通常就是为包中各模块开放的元素提供一个统一的访问入口。比如：

不过要注意的是，这样并不会把export后面的元素引入到当前模块中。还有，要慎用*，因为它可能会引起名称冲突。为了避免冲突，export语句中可以用as给元素指定一个新名字。

import

import有三种用法：带括号{}的，不带括号的，和带*的。

带括号的import要在括号中指定从from指定的模块中导入哪些元素，括号里的名称要和那个模块export中指定的名称一致。为了避免命名冲突，括号中的每个名称都可以用as指定一个新名称，比如：

import {each as _each} from "lodash"

不带括号的相当于从from指定的模块中导入它的default并指定一个名称。即import _ from "lodash";相当于import {default as _} from "lodash";，其效果等同于CJS中的var _ = require("loadsh");。

import * as cows from "cows"这样的语句导入的其实是一个模块命名空间对象。也就是说，如果一个模块有多个export，但没有export default时，import *的作用相当于将那个模块的default指定为cows，而cows中包含了它所有的export元素。也就是说，如果cows模块导出一个名为moon()的函数，用上面这种方法将cows导入后，我们就可以调用cows.moo()了。但从Babel编译的结果来看，最好还是在cows模块中定义一个default。

from

from既可以出现在import之后，也可以出现在export后面，用来指定要引入或开放的模块。from后面一般是指定文件名，.js可以忽略；如果是目录，则是表示该目录下的index.js文件中的模块。

综上所述，ES 6中的模块化编程就是用好export、import以及配合它们两个用好default、*、as和from。很简单，我们并不需要了解JS引擎运行模块代码的过程：

1. 语法解析：读源码，检查语法错误。

2. 加载：加载所有被导入的模块，以及被导入模块导入的模块，这是个层层递归的过程。

3. 连接：每遇到一个新加载的模块，为其创建作用域，并将模块内声明的所有绑定填充到该作用域中，其中包括由其他模块导入的内容。举个例子，如果代码中有import {cake} from "paleo"这样的语句，而paleo模块并没有export cake，这段代码就会触发一个错误。

4. 运行时：最终，在每一个新加载的模块体内执行所有语句。导入的过程在连接阶段就已经结束了，所以当执行到达有import语句的时候什么都不会发生！

参考

- JavaScript Modules

- Understanding ES6 Modules via Their History by Elias Carlston

- Brendan Eich

- ES6 In Depth: Modules

本文选自图灵社区

书单

偷偷看下你的书单

计算机程序设计艺术 卷2：半数值算法（第3版）

作者：Donald E. Knuth

书号：978-7-115-36069-4

图灵社区推荐：12

《计算机程序设计艺术》系列被公认为计算机科学领域的权威之作，深入阐述了程序设计理论，对计算机领域的发展有着极为深远的影响。本书为该系列的第2卷，全面讲解了半数值算法，分“随机数”和“算术”两章。书中总结了主要算法范例及这些算法的基本理论，广泛剖析了计算机程序设计与数值分析间的相互联系。

走近2050：注意力、互联网与人工智能

作者：集智俱乐部

书号：978-7-115-42421-1

图灵社区推荐：5

本书从注意力的角度解读了包括互联网、人工智能、众包、人类计算、计算机游戏、虚拟现实在内的技术领域及其对社会生活的影响，还创造性地提出了一系列全新的概念：占意理论、图灵-参与者模型、“游戏＋”时代、意本家、自动游戏设计、自动化创业、占意通货、许愿树，等等。所有这些将为我们理解技术与人类的关系、透视人类社会的未来发展和走向提供深刻的洞察。

iOS开发指南：从Hello World到App Store上架（第4版）

作者：关东升

书号：978-7-115-42318-4

图灵社区推荐：1

作为iOS开发权威指南，本书以Swift和Objective-C语言为基础，分5部分讲解如何从零起步编写并上线iOS 9应用。第一部分介绍iOS开发基础知识，第二部分介绍设计与架构的相关知识，第三部分介绍了iOS的一些高级知识，第四部分介绍测试、调试和优化等相关知识，第五部分为实战篇，涵盖代码版本管理、项目依赖管理、App Store发布流程，以及两个真实iOS应用的分析设计、编程、测试与分布过程。

图解密码技术（第3版）

作者：结城浩

书号：978-7-115-42491-4

图灵社区推荐：10

本书以图配文的形式，详细讲解了6种最重要的密码技术：对称密码、公钥密码、单向散列函数、消息认证码、数字签名和伪随机数生成器。 第3版对全书内容进行了大幅更新，并新增POODLE攻击、“心脏出血”漏洞、Superfish事件、SHA-3竞赛、Keccak、认证加密、椭圆曲线密码、比特币等新内容。

设计师要懂心理学2

作者：Susan M. Weinschenk

书号：978-7-115-42784-7

图灵社区推荐：3

垃圾回收的算法与实现

作者：中村成洋，相川光

书号：978-7-115-42747-2

图灵社区推荐：23

Python编程：从入门到实践

作者：Eric Matthes

书号：978-7-115-42802-8

图灵社区推荐：6

趣味学数学

作者：邦尼·埃弗巴克，奥林·钱恩

书号：978-7-115-42937-7

图灵社区推荐：9

好吃的信息图

作者：伯兰特•洛盖，安娜-洛尔•埃斯特维

书号：978-7-115-42461-7

图灵社区推荐：2

写给大家看的安卓应用开发书

作者：David Wolber，Hal Abelson，Ellen Spertus，Liz Looney

书号：978-7-115-42388-7

图灵社区推荐：5

电子书榜

1. 有趣的二进制：软件安全与逆向分析

——逆向工程可以提升软件的安全性，无论是对开发者还是维护者，都是必备的技能。跟着这本书动手实践，你将完美入门。

2. GitHub入门与实践 　——指导读者如何在开发现场使用GitHub进行高效开发，适合所有想要使用GitHub进行开发的程序员或团队阅读。

3. React Native开发指南

——在React Native中利用现有的JavaScript和React知识，开发和部署功能完备的、真正原生的移动应用，同时支持iOS与Android平台。

4. Java 8实战

——从实例入手，涵盖Java 8的主要新特性，包括Lambda表达式、方法引用、流、默认方法、Optional、CompletableFuture以及新的日期和时间API，是程序员了解Java 8新特性的终极指南。

5. 高效团队开发：工具与方法

——内容丰富，涵盖团队开发中发生的问题、版本管理系统、缺陷管理系统、持续集成、持续交付以及回归测试等开发现场常有的问题。

6. PostgreSQL即学即用（第2版）

——不仅学到PostgreSQL 9.2、9.3和9.4版中的企业级特性，还将了解到PostgreSQL不但是一套数据库系统，更是一个功能强大的应用开发平台。

7. 注意力：专注的科学与训练

——全面解读注意力的生理与心理机制，破解分心、走神、注意涣散、过度焦虑等困扰人们的注意力问题，提供改善和提高注意力的可行方法。

8. C#敏捷开发实践

——学习敏捷编程的最佳实践、原则和模式，编写出灵活的自适应性代码，创造更大的商业价值。

9. Unity 5.x游戏开发指南

——一本让你迅速上手的游戏开发宝典，全面介绍Unity 5.x全新特性和核心功能。从入门到精通，涵盖大量游戏实例和实战经验。

10. Python性能分析与优化

——内容实用，尤其是有关代码性能分析方法与工具的介绍。如果你想写出优雅、漂亮、高性能的Python代码，绝对应该看看这本书！

妙评

写给大家看的设计

作者/Freda

设计出身，玩过化学，冲过胶卷，从环艺跨到视传，转行做运营。

《写给大家看的设计书》由人民邮电出版社在2009年发行后，至今已发行第四版。翻看完这本书后，对于不是设计科班出身但又必须涉及设计工作的我们来说，当面对一些简单的设计工作时，不再会那么手足无措。整理了读书时的笔记，在此与大家分享。

颜色运用

◆ 色轮（以12色色轮为例）：

色彩的三原色是红黄蓝（三种颜色混合后变成黑色）

提醒大家，光的三原色为红绿蓝，三种颜色混合后是白色。

◆ 三间色是绿、紫、橙，其中绿色=黄色＋蓝色，紫色=蓝色＋红色，橙色=红色＋黄色。将三原色中每一个颜色的相邻颜色等量混合，就是三间色。

◆ 第三色是指在12色的色轮中加入三原色和三间色后，还剩下六个空白位置，将空白位置两边的颜色等量混合后得到的颜色成为第三色。

◆ 互补色是色轮上物理位置完全对立的颜色，最佳搭配是一种颜色为主色，另一种用于强调。

◆ 三色组：在色轮中，彼此等距的三种颜色组成的颜色组叫作三色组。红黄蓝被称为基色三色组，绿橙紫称为间色三色组。

◆ 分裂互补三色组：从色轮一边选择一种颜色，再在色轮上找出它对面的互补色，不过并不直接使用这个互补色，而是使用该互补色两侧的颜色。

◆ 类似色由色轮上彼此相邻的颜色组成。

以上色轮中的色彩只是涉及“色调”，即这些颜色都是纯色。纯色就是色调，向色调增加黑色就构成一个暗色，向色调增加白色就构成一个亮色。当颜色足够多时，会形成色立体。

◆ 亮色与暗色

◆ 冷色与暖色

设计中，冷色是后退型的，常作为背景色；暖色是趋进型的，但实际设计中注意不要使用太多的暖色。

实操中的提示与技巧

实际设计项目无论大小，都会运用到书中讲过的四项基本原则。无论设计作品的种类如何变化，设计原则都是相同的。

创建包装或品牌

◆ 关于设计包装或品牌的提示

涉及标示性包装时，遵循重复原则。每件作品中都必须包含某个标志性图像或某种标识性风格，方便人们从相似点中确立特色。

◆ 关于设计企业名片的提示

对比原则：把少而有效的信息放在一面，其他内容放在另一面。利用对比原则，让位给真正重要的信息。

格式：水平格式或者垂直格式。

字体：强调名片设计的整体、精美效果比把字体设置得足够大效果好得多。

成系统：如果还有信笺和信封的话，企业名片、信笺、信封的整体设计包装应当有一个一致图像来呈现给客户和顾客。

◆ 关于设计信笺和信封的提示

创建中心点：有一个最突出的主导元素。

对齐原则：为信纸选择一种对齐方式，信笺上要确保留有摆放元素的空间，以便文本能够很好地适应信纸的设计。

第二页：如果需要或有条件增加第二页信纸时，可以取第一页上出现的一个小元素作为重复元素。

传真和复印：如果通过传真机或者复印机发送信笺，不要选择深色或者带很多小点的纸张。避免大面积的深色墨迹、反色文本或太小的字体。建议设计两种版本的信笺，一种用于打印，一种用于传真。

◆ 关于设计传单的提示

创建一个中心点：页面上放一个占位大、内容有趣的元素，能让读者的视线停在这个中心点上，从而增大阅读其余文本的可能性。

使用有对比的句子：有了中心点之后，使用视觉效果强烈并能充分表达含义的子标题。这能促使读者很快扫描这个传单，确定消息的要点，并乐意花时间解读文本。

重复原则：无论标题使用何种字体，都应尝试在文本中适当增加同样字体来实现重复。字体的强烈对比能够增加传单的效果。

对齐原则：只选择一种对齐方式。

◆ 关于设计新闻简报的提示

多页出版物最重要的特性之一就是重复性。

空白：新闻简报里完全可以有留白，不要让这些空白“截留”在其他元素之间。空白要像其他元素一样有组织，让留白有条理。

对齐原则：选择一种对齐方式，然后一直坚持这种对齐。让所有一切都对齐。要么对齐，要么不对齐。如果只是稍稍偏离对齐规则，看上去则像个失误。

段落缩进：段落之间要么有额外的空间，要么缩进，但不要二者都有。

字体选择：采用一种粗黑字体使用在标题和引用文字上。

◆ 关于设计宣传册的提示

宣传页中各个面的顺序应该就是读者打开宣传册的顺序。比如，读者打开首页，看到的不应该是版权声明和联系信息。

对比原则：与其他设计一样，对比不仅能增强页面视觉效果、吸引读者视线，还有助于建立信息的层次结构，让读者抓住要点。应当在元素、字体、线、颜色、间隔、大小等方面使用对比原则。

重复原则：设计中重复使用一些元素，为作品建立统一的外观。可重复颜色、字体、线、空间布局、项目符号等。

对齐原则：明确而鲜明的边界可以营造一种强烈、尖锐的感觉。有时可以有意地打破单一的对齐方式，用其他的对齐方式与这种形成对比。

◆ 关于设计明信片的提示

明信片直观而且直接，因此需要与众不同、形成“系列”、明确具体、力求简练、尽可能使用颜色。

关键：每种外在效果都自有其独特而确定的用途，而且针对各自特定的市场。

吸引人注意：其他出版物的设计原则同样适用于明信片设计，明信片必须在很短的时间内吸引别人来阅读。设计要大胆，用色要明快，图片要震撼。不要把所有东西都变成一个尺寸或类似尺寸。

对比原则：设计的颜色与纸的颜色应当有强烈对比，空白也能产生对比效果。

◆ 关于设计报纸广告的提示

对于报纸广告而言，没有什么能比一条机敏的标题更能吸引人，如果设计也棒，成功会加倍。标题吸引了读者的注意，接下来的广告就要具体告诉读者该做什么。因此广告内容要简单，切中要点。

对比原则：不仅广告本身需要对比，广告与它所在那一页报纸上的其他内容也要有对比。

字体选择：不要用精巧、纤细的字体。因为印刷时油墨会在纸张上散开。

反色字体：尽量避免使用反色文字，避开深色背景搭配白色文字。

◆ 关于设计简历的提示

对比原则：微妙但明确地利用对比原则让信息更清晰。

重复原则：重复信息的基本结构，如果把某种类型的元素对齐了，在别的地方也要重复这种对齐；如果用颜色来特别指出某样东西，可以把这种颜色用到其他地方，比如线或项目符号。

对齐原则：对齐原则对于整体的简洁和专业都是至关重要的。

亲密性：把标题靠近相关的信息，然后结构才会清晰。把项目符号和项目内容拉近。

让设计和媒介相匹配：根据简历传播方式不同，应有不同的设计方案。每种不同的情况都会影响你的布局、字体选择、页面大小、颜色等。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

- 微博 @图灵教育 : 好书、活动每日播报

- 微博 @图灵社区 : 电子书和好文章的消息

- 微博 @图灵新知 : 图灵教育的科普小组

- 微信 图灵访谈 : ituring_interview，讲述码农精彩人生

- 微信 图灵教育 : turingbooks

图灵社区会员 Dastan（dastan@139.com） 专享 尊重版权