乐者为王

Do one thing, and do it well.

循环神经网络不可思议的效用

英文原文:http://karpathy.github.io/2015/05/21/rnn-effectiveness/

循环神经网络(RNN)有些神奇的地方。我仍然记得我为图像标注而训练我的首个循环网络的情景。我的第一个幼稚模型(带有相当随意选择的超参数)在经过几十分钟的训练后就开始在图像的有意义的边缘产生非常漂亮的描述。有时候,模型的简单与你从中得到的结果的质量之间的比例超过你的预期,这次就是一个例子。为什么当时这个结果如此令人震惊?普遍的看法是,RNN应该是很难训练的(随着更多的实践,我实际上得到了相反的结论)。在这之后的1年:我一直在训练RNN,并多次见证它的能力和健壮性,但它的神奇输出仍然让我感到有趣。这篇文章将与你分享一些RNN的魔法。

我们将训练RNN让它一个字符一个字符地生成文本,然后思考“这怎么可能?”

顺便说一句,我会把这篇文章涉及的代码发布到GitHub上,它可以让你基于多层LSTM来训练字符级别的语言模型。你给它输入大量的文本,它将学会生成类似的文本。你也可以用它来复现我下面的实验。那么RNN究竟是什么呢?

循环神经网络

序列。Vanilla神经网络(还有卷积网络)最大的局限性在于它的API太受约束:它接受固定大小的向量作为输入(例如图像),然后产生固定大小的向量作为输出(例如不同分类的概率)。不仅如此:这些模型使用固定数量的计算步骤(例如模型中的层数)执行这种映射。循环神经网络如此令人兴奋的主要原因是它允许我们对向量的序列进行操作:输入中的序列、输出中的序列、或者最普遍情况下的输入输出序列。下面是几个具体的示例:

diags

每个矩形都是一个向量,箭头代表函数(例如矩阵乘法)。输入向量为红色,输出向量为蓝色,绿色向量是RNN的状态。从左到右是:

  1. 没有RNN的Vanilla处理模式,从固定大小的输入到固定大小的输出(例如图像分类)。
  2. 序列输出(例如图像标注,输入图像然后输出一段文字序列)。
  3. 序列输入(例如情感分析,给定的文字被分类为表达正面或者负面情感)。
  4. 序列输入和序列输出(例如机器翻译:RNN读取英语句子然后以法语的形式输出)。
  5. 同步序列输入和输出(例如视频分类,对视频的每个帧打标签)。

注意,每个案例都没有对序列长度进行预先规定,因为循环变换(绿色)是固定的,且根据需要可以多次使用。

正如你预想的那样,与使用固定数量的计算步骤的固定网络相比,序列组织方法的操作要更为强大。RNN通过固定的(但可以学习的)函数把输入向量与其状态向量结合起来以产生新的状态向量。这在编程术语中可以被解释为,运行一个具有某些输入和一些内部变量的固定程序。从这个角度看,RNN本质上是在描述程序。事实上,就它可以模拟任意程序(使用恰当的权值向量)而言,RNN是图灵完备的。但是类似于神经网络的通用近似定理,你不用过于关注其中的细节。

如果训练Vanilla神经网络是对函数进行优化,那么训练循环网络就是对程序进行优化。

序列缺失下的序列化处理。你可能会想,将序列作为输入或输出是相当少见的,但重要的是要认识到,即使输入或输出是固定向量,你仍然可以使用这种强大的形式体系以序列化的方式对它们进行处理。例如,下图显示的结果来自DeepMind的两篇非常不错的论文。在左边,算法学习一种循环网络策略,可以将它的注意力集中在图像周围。具体地说,就是它学习从左到右阅读门牌号码(Ba et al.)。在右边,循环网络通过学习在画布上序列化地添加颜色来生成数字图像(Gregor et al.)。

house-read house-generate

左边:RNN学习阅读门牌号码。右边:RNN学习绘制门牌号码。

言外之意就是,即使数据不是序列的形式,你仍然可以制定和训练出强大的模型来学习序列化地处理它。你是在学习处理固定大小数据的有状态程序。

RNN计算。那么这些是如何工作的呢?主要是RNN有个看似简单的API:它接收输入向量x,然后给出输出向量y。然而最重要的是,该输出向量的内容不仅受到刚才输入的影响,还受到过去整个历史输入的影响。写成类的话,RNN的API由单个step函数构成。

1
2
rnn = RNN()
y = rnn.step(x)  # x是输入向量,y是RNN的输出向量

每当step函数被调用时,RNN的某些内部状态就会被更新。在最简单的案例中,这个状态由单个隐藏向量h构成。以下是Vanilla RNN中step函数的实现:

1
2
3
4
5
6
7
8
class RNN:
  # ...
  def step(self, x):
    # update the hidden state
    self.h = np.tanh(np.dot(self.W_hh, self.h) + np.dot(self.W_xh, x))
    # compute the output vector
    y = np.dot(self.W_hy, self.h)
    return y

上面的代码详细说明了vanilla RNN的前向传播。这个RNN的参数是3个矩阵:W_hh、W_xh、W_hy。隐藏状态self.h被初始化为零向量。np.tanh函数实现一个非线性,将激活值压缩到范围[-1, 1]。注意代码是如何工作的:tanh内有两个条件,一是基于前面的隐藏状态,一是基于当前的输入。在NumPy中,np.dot是矩阵乘法。两个中间变量相加,然后被tanh压缩为新的状态向量。如果你更享受数学公式,我们也可以将隐藏状态写成:equation

我们用随机数初始化RNN的矩阵,训练中的大部分工作是寻找那些能够产生期望行为的矩阵,通过一些损失函数来度量,这些函数表示对于输入序列x你偏好什么类型的输出y。

深度网络。RNN是神经网络,如果你进行深度学习并且开始像叠煎饼一样堆叠模型,它将会工作得越来越好(如果做得正确的话)。例如,我们可以通过以下方式建立一个2层的循环网络:

1
2
y1 = rnn1.step(x)
y = rnn2.step(y1)

换句话说,我们有两个独立的RNN:一个RNN接收输入向量,而第二个RNN以第一个RNN的输出作为其输入。RNN其实并不关心这些——它们都只是向量的进出,以及在反向传播期间某些梯度流经每个模块。

需要指明的是,在实践中我们大多数人使用略有不同的长短期记忆(LSTM)网络。LSTM是一种特殊类型的循环网络,由于其强大的更新方程和一些吸引人的动态反向传播机制,它在实践中的效果略好一些。除了用于更新计算(self.h=...)的数学形式变得有点复杂外,其它所有都与本文介绍的RNN的内容完全相同。从这里开始,我会混合使用术语RNN和LSTM,但是本文中的所有实验都是用LSTM完成的。

字符级别的语言模型

现在我们已经知道RNN是什么,为什么它如此令人兴奋,以及它是如何工作的。我们现在就用它来实现一个有趣的应用:我们将训练RNN字符级别的语言模型。也就是说,我们提供RNN巨量的文本,然后让其建模,根据序列中以前的字符序列给出下一个字符的概率分布。这将允许我们一次一个字符地生成新文本。

作为示例,假设我们拥有只有四个字符“helo”的词汇表,想用训练序列“hello”训练一个RNN。这个训练序列实际上是4个独立的训练示例的来源:

  1. “h”出现时下一个字符最有可能是“e”。
  2. “he”出现时下一个字符最有可能是“l”。
  3. “hel”出现时下一个字符最有可能是“l”。
  4. “hell”出现时下一个字符最有可能是“o”。

具体来说,我们会使用1-of-k编码方式(即除对应字符为1外其余都为0)将每个字符编码成一个向量,并且使用step函数将它们一次一个地喂给RNN。然后,我们观察四维输出向量(每个字符一维)的序列,我们将其解释为RNN当前分配给序列中下次到来的每个字符的置信度。以下是示意图:

charseq

这个RNN示例具有4维输入和输出层,以及3个单位(神经元)的隐藏层。该示意图显示了当RNN把字符“hell”当作输入时前向传播中的激活值。输出层包含RNN为下一个字符分配的置信度(词汇表是“h,e,l,o”)。我们希望绿色数值尽可能高,红色数值尽可能低。

例如,我们可以看到,在第1个时间步骤中,当RNN看到字符“h”时,它将下一个可能出现字符的置信度分别设成“h”为1,“e”为2.2,“l”为-3.0,“o”为4.1。因为在训练数据(字符串“hello”)中,下一个正确的字符是“e”,所以我们希望增加其置信度(绿色)并降低所有其它字符的置信度(红色)。同样,在4个时间步骤中的每个步骤都有理想的目标字符需要网络给予更大的置信度。由于RNN完全由可微分的操作组成,我们可以运行反向传播算法(这只是微积分链式法则的递归应用)以计算出在哪个方向上我们应该调整其每个权重以增加正确目标(绿色粗体数值)的分数。我们然后可以执行参数更新,即在这个梯度方向上微调每个权重。如果我们在参数更新之后将相同的输入喂给RNN,我们会发现正确字符的分数(例如,第一个时间步骤中的“e”)将会略微变高(例如,从2.2变成2.3),而不正确字符的分数将会略微变低。然后我们重复这个过程多次直到网络收敛,并且它的预测最终与训练数据一致,即总能正确预测下一个字符。

更技术的解释是我们对每个输出向量同时使用标准的Softmax分类器(通常也称为交叉熵损失)。使用迷你批量的随机梯度下降训练RNN,并且我喜欢使用RMSProp或Adam(每个参数的自适应学习速率方法)来稳定参数的更新。

另外要注意的是,输入字符“l”第一次的目标为“l”,但第二次为“o”。因此,RNN不能单独依赖输入,必须使用其循环连接来跟踪上下文以实现此任务。

在测试的时候,我们喂给RNN一个字符,并得到下次可能到来的字符的分布。我们从这个分布中取样,然后将其反馈给RNN以获得下一个字符。重复这个过程你就会得到文本!现在让我们在不同的数据集上训练RNN,看看会发生什么。

为了进一步说明,出于教育目的我还写过使用Python/NumPy的最小字符级别的RNN语言模型。它只有大约100行左右,如果你更擅长阅读代码而不是文本,希望它能对上述内容给出一个简洁、具体和有用的总结。现在我们将深入实例结果,它由更高效的Lua/Torch代码库产生。

RNN的乐趣

以下5个示例的字符模型都使用我在GitHub上发布的代码进行训练。每个案例中的输入都是单个文本文件,我们将训练RNN来预测序列中的下一个字符。

Paul Graham生成器

让我们先尝试用一个小的英文数据集作为完整性检查。我最喜欢的数据集是Paul Graham的文集。基本想法是,这些文章中有很多的智慧,但不幸的是,Paul Graham的写作速度比较慢。如果我们可以根据需要生成创业智慧的样本,岂不美哉?这时就轮到RNN出场了。

合并Paul Graham过去5年的所有文章,我们可以得到大约1MB的文本文件,或者说大约100万个字符(顺便提一句,这是个非常小的数据集)。技术:训练一个2层的LSTM,含有512个隐藏节点(约350万个参数),每层之后有0.5的dropout。我们将通过每批次100个实例和长度为100个字符的截断式沿时间反向传播来训练。使用这些设置,每个批次在TITAN Z GPU上耗时大约0.46秒(这可以通过性能代价微不足道的50个字符的BPTT,即Backpropagation Through Time让耗时减半)。言归正传,让我们看看来自RNN的样本:

The surprised in investors weren’t going to raise money. I’m not the company with the time there are all interesting quickly, don’t have to get off the same programmers. There’s a super-angel round fundraising, why do you can do. If you have a different physical investment are become in people who reduced in a startup with the way to argument the acquirer could see them just that you’re also the founders will part of users’ affords that and an alternation to the idea. [2] Don’t work at first member to see the way kids will seem in advance of a bad successful startup. And if you have to act the big company too.

好吧,显然上面的样本暂时还无法取代Paul Graham,但是别忘了,RNN必须使用一个小的数据集(包括逗号,撇号和空格)完全从零开始学习英语。我也很喜欢它学会通过引用来支持自己的论点(例如上面的[2])。有时候它会说一些有洞见的话,比如“a company is a meeting to think to investors”。如果你想看到更多,这里有5万个字符的样本。

温度。我们还可以在取样的时候调节Softmax的温度。将温度从1降至更低的数值(例如0.5)可以让RNN有更高的置信度,但其样本也会更保守。相反地,较高的温度将会带来更多的多样性,但是会以更多的错误为代价(例如拼写错误等)。特别是,将温度设置得非常接近于0时就会得到与Paul Graham更相似的言论:

is that they were all the same thing that was a startup is that they were all the same thing that was a startup is that they were all the same thing that was a startup is that they were all the same

看起来我们已经陷入了创业的死循环。

莎士比亚

看起来RNN可以学习拼写英文单词。但是如果数据中有更多的结构和风格呢?为检验这一点,我下载了莎士比亚的所有作品,并将它们合并成单个(4.4MB)文件。现在我们可以训练一个更大的网络,在这个案例中,我们将尝试一个3层的RNN,每层有512个隐藏节点。在训练网络几个小时之后,我们得到如下的样本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PANDARUS:
Alas, I think he shall be come approached and the day
When little srain would be attain'd into being never fed,
And who is but a chain and subjects of his death,
I should not sleep.

Second Senator:
They are away this miseries, produced upon my soul,
Breaking and strongly should be buried, when I perish
The earth and thoughts of many states.

DUKE VINCENTIO:
Well, your wit is in the care of side and that.

Second Lord:
They would be ruled after this chamber, and
my fair nues begun out of the fact, to be conveyed,
Whose noble souls I'll have the heart of the wars.

Clown:
Come, sir, I will make did behold your worship.

VIOLA:
I'll drink it.

记住,RNN只知道字符,所以它会对发言者的名字和内容进行取样。有时候我们也会得到相对延伸的独白短文,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
VIOLA:
Why, Salisbury must find his flesh and thought
That which I am not aps, not a man and in fire,
To show the reining of the raven and the wars
To grace my hand reproach within, and not a fair are hand,
That Caesar and my goodly father's world;
When I was heaven of presence and our fleets,
We spare with hours, but cut thy council I am great,
Murdered and by thy master's ready there
My power to give thee but so much as hell:
Some service in the noble bondman here,
Would show him to her wine.

KING LEAR:
O, if you were a feeble sight, the courtesy of your law,
Your sight and several breath, will wear the gods
With his heads, and my hands are wonder'd at the deeds,
So drop upon your lordship's head, and your opinion
Shall be against your honour.

我几乎不能把这些样本从莎士比亚的原作中辨别出来:)如果你喜欢莎士比亚,你可能会喜欢这10万个字符的样本。当然,你也可以使用我提供的代码在不同温度下生成无限数量的你自己的样本。

维基百科

我们看到,LSTM可以学习拼写单词和复制一般的句法结构。让我们进一步增加难度,在结构化的markdown上面训练它。具体地说,就是使用Hutter Prize 的维基百科原始数据集(100MB)训练一个LSTM。和Graves et al.一样,我使用前面的96MB来训练,剩下的用于验证以及在晚上跑几个模型。我们现在可以对维基百科的文章进行取样!以下是一些有趣的摘录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Naturalism and decision for the majority of Arab countries' capitalide was grounded
by the Irish language by [[John Clair]], [[An Imperial Japanese Revolt]], associated
with Guangzham's sovereignty. His generals were the powerful ruler of the Portugal
in the [[Protestant Immineners]], which could be said to be directly in Cantonese
Communication, which followed a ceremony and set inspired prison, training. The
emperor travelled back to [[Antioch, Perth, October 25|21]] to note, the Kingdom
of Costa Rica, unsuccessful fashioned the [[Thrales]], [[Cynth's Dajoard]], known
in western [[Scotland]], near Italy to the conquest of India with the conflict.
Copyright was the succession of independence in the slop of Syrian influence that
was a famous German movement based on a more popular servicious, non-doctrinal
and sexual power post. Many governments recognize the military housing of the
[[Civil Liberalization and Infantry Resolution 265 National Party in Hungary]],
that is sympathetic to be to the [[Punjab Resolution]]
(PJS)[http://www.humah.yahoo.com/guardian.
cfm/7754800786d17551963s89.htm Official economics Adjoint for the Nazism, Montgomery
was swear to advance to the resources for those Socialism's rule,
was starting to signing a major tripad of aid exile.]]

你可能会注意到,上面的雅虎网址实际上并不存在,是模型生造出来的。另外,还要注意到模型学会了正确地打开和关闭括号。模型学会的结构化markdown还有很多,比如有时候它会创建标题,列表等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{ { cite journal | id=Cerling Nonforest Department|format=Newlymeslated|none } }
''www.e-complete''.

'''See also''': [[List of ethical consent processing]]

== See also ==
*[[Iender dome of the ED]]
*[[Anti-autism]]

===[[Religion|Religion]]===
*[[French Writings]]
*[[Maria]]
*[[Revelation]]
*[[Mount Agamul]]

== External links==
* [http://www.biblegateway.nih.gov/entrepre/ Website of the World Festival. The labour of India-county defeats at the Ripper of California Road.]

==External links==
* [http://www.romanology.com/ Constitution of the Netherlands and Hispanic Competition for Bilabial and Commonwealth Industry (Republican Constitution of the Extent of the Netherlands)]

有时候,模型会生成随机但是有效的XML文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<page>
  <title>Antichrist</title>
  <id>865</id>
  <revision>
    <id>15900676</id>
    <timestamp>2002-08-03T18:14:12Z</timestamp>
    <contributor>
      <username>Paris</username>
      <id>23</id>
    </contributor>
    <minor />
    <comment>Automated conversion</comment>
    <text xml:space="preserve">#REDIRECT [[Christianity]]</text>
  </revision>
</page>

模型生成timestamp,id等等。另外,还要注意到它会以正确的嵌套顺序恰当地关闭相应的标签。如果你有兴趣看到更多,这里有10万个字符的维基百科样本

代数几何(Latex)

以上结果表明,该模型在学习复杂句法结构方面确实相当擅长。这些结果令人印象深刻,我和我的实验室同事(Justin Johnson)决定在结构化的领域进一步推进。我们找到关于代数叠/几何的这本书,下载它的原始Latex源文件(16MB),然后训练一个多层的LSTM。令人惊讶的是,产生的Latex样本几乎是可以编译的。在我们手动修复一些问题后,就得到了看起来似乎合理的数学推论,这是相当惊人的:

latex4

代数几何样本(假的),这里是真正的PDF文件

这是另一份样本:

latex3

更像代数几何了,还出现了图表(右)。

正如你在上面看到的,有时候这个模型试图生成Latex图表,但显然它并不明白图表的具体意思。我也很喜欢它跳过证明的部分(左上角的“Proof omitted”)。当然,Latex有着相对困难的结构化语法格式,甚至我自己都没有完全掌握。例如,这里是一份来自模型的原始样本(未编辑):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
\begin{proof}
We may assume that $\mathcal{I}$ is an abelian sheaf on $\mathcal{C}$.
\item Given a morphism $\Delta : \mathcal{F} \to \mathcal{I}$
is an injective and let $\mathfrak q$ be an abelian sheaf on $X$.
Let $\mathcal{F}$ be a fibered complex. Let $\mathcal{F}$ be a category.
\begin{enumerate}
\item \hyperref[setain-construction-phantom]{Lemma}
\label{lemma-characterize-quasi-finite}
Let $\mathcal{F}$ be an abelian quasi-coherent sheaf on $\mathcal{C}$.
Let $\mathcal{F}$ be a coherent $\mathcal{O}_X$-module. Then
$\mathcal{F}$ is an abelian catenary over $\mathcal{C}$.
\item The following are equivalent
\begin{enumerate}
\item $\mathcal{F}$ is an $\mathcal{O}_X$-module.
\end{lemma}

这份样本来自相对像样的模型,它展示了一些常见的错误。例如,模型打开一个\begin{proof}环境,但却以\end{lemma}结束。我们必须手动修复这种问题,它可能是由于依赖关系太长期的缘故:当模型完成proof的时候,它已经忘记是在做proof还是lemma。同样地,它打开一个\begin{enumerate},但却忘记关闭它。我们观察到,在使用更大/更好的模型时,这些问题会变少,但尽管如此,这些问题还是会出现。

Linux源代码

我想把结构化的数据推到极限,所以最后的挑战是我决定使用代码。首先,我从GitHub上的Linux库中提取所有的源文件和头文件,将它们合并成单个巨大的文件(474MB的C代码)(我本来只打算在内核上训练,但是其大小只有约16MB)。然后,我花几天的时间训练出几个大小刚好适合我的GPU的3层LSTM网络。这些模型有大约1000万个参数,对于RNN模型来说仍然很少。结果超级有趣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/*
 * Increment the size file of the new incorrect UI_FILTER group information
 * of the size generatively.
 */
static int indicate_policy(void)
{
  int error;
  if (fd == MARN_EPT) {
    /*
     * The kernel blank will coeld it to userspace.
     */
    if (ss->segment < mem_total)
      unblock_graph_and_set_blocked();
    else
      ret = 1;
    goto bail;
  }
  segaddr = in_SB(in.addr);
  selector = seg / 16;
  setup_works = true;
  for (i = 0; i < blocks; i++) {
    seq = buf[i++];
    bpf = bd->bd.next + i * search;
    if (fd) {
      current = blocked;
    }
  }
  rw->name = "Getjbbregs";
  bprm_self_clearl(&iv->version);
  regs->new = blocks[(BPF_STATS << info->historidac)] | PFMR_CLOBATHINC_SECONDS << 12;
  return segtable;
}

这些代码整体上看起来非常棒。当然,这些代码是不能够编译的,但是当你滚动这些生成的代码的时候,它给你的感觉非常像是一个巨大的C代码库。注意,RNN会随机地生成带有注释的代码。它很少会犯语法错误。比如,它能正确地使用字符串、指针表示法等。它也能正确地打开和关闭括号{[,学会很好地缩进代码。一个常见的错误是它不能跟踪变量:它经常使用未定义的变量(例如上面的rw),声明从不使用的变量(例如int error),或者返回不存在的变量。让我们看看更多的示例。这是另外一段代码,显示RNN学到的更多操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/*
 * If this error is set, we will need anything right after that BSD.
 */
static void action_new_function(struct s_stat_info *wb)
{
  unsigned long flags;
  int lel_idx_bit = e->edd, *sys & ~((unsigned long) *FIRST_COMPAT);
  buf[0] = 0xFFFFFFFF & (bit << 4);
  min(inc, slist->bytes);
  printk(KERN_WARNING "Memory allocated %02x/%02x, "
    "original MLL instead\n"),
    min(min(multi_run - s->len, max) * num_data_in),
    frame_pos, sz + first_seg);
  div_u64_w(val, inb_p);
  spin_unlock(&disk->queue_lock);
  mutex_unlock(&s->sock->mutex);
  mutex_unlock(&func->mutex);
  return disassemble(info->pending_bh);
}

static void num_serial_settings(struct tty_struct *tty)
{
  if (tty == tty)
    disable_single_st_p(dev);
  pci_disable_spool(port);
  return 0;
}

static void do_command(struct seq_file *m, void *v)
{
  int column = 32 << (cmd[2] & 0x80);
  if (state)
    cmd = (int)(int_state ^ (in_8(&ch->ch_flags) & Cmd) ? 2 : 1);
  else
    seq = 1;
  for (i = 0; i < 16; i++) {
    if (k & (1 << 1))
      pipe = (in_use & UMXTHREAD_UNCCA) +
        ((count & 0x00000000fffffff8) & 0x000000f) << 8;
    if (count == 0)
      sub(pid, ppc_md.kexec_handle, 0x20000000);
    pipe_set_bytes(i, 0);
  }
  /* Free our user pages pointer to place camera if all dash */
  subsystem_info = &of_changes[PAGE_SIZE];
  rek_controls(offset, idx, &soffset);
  /* Now we want to deliberately put it to device */
  control_check_polarity(&context, val, 0);
  for (i = 0; i < COUNTER; i++)
    seq_puts(s, "policy ");
}

注意,在第二个函数中,模型会比较tty == tty,这永远为真。另一方面,至少变量tty这次在函数范围内存在!在最后一个函数中,代码没有返回任何值,这是正确的,因为函数签名是void。但是,前面两个函数同样声明为void,确有返回值。这又是由于长期相互作用导致的常见错误。

有时候模型会决定是时候对新文件进行取样。这通常是非常有趣的部分:模型首先一个字符一个字符地复述GNU许可证,包含几个头文件,声明一些宏,然后就开始生成代码部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/*
 *  Copyright (c) 2006-2010, Intel Mobile Communications.  All rights reserved.
 *
 *   This program is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 as published by
 * the Free Software Foundation.
 *
 *        This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *
 *  GNU General Public License for more details.
 *
 *   You should have received a copy of the GNU General Public License
 *    along with this program; if not, write to the Free Software Foundation,
 *  Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */

#include <linux/kexec.h>
#include <linux/errno.h>
#include <linux/io.h>
#include <linux/platform_device.h>
#include <linux/multi.h>
#include <linux/ckevent.h>

#include <asm/io.h>
#include <asm/prom.h>
#include <asm/e820.h>
#include <asm/system_info.h>
#include <asm/setew.h>
#include <asm/pgproto.h>

#define REG_PG    vesa_slot_addr_pack
#define PFM_NOCOMP  AFSR(0, load)
#define STACK_DDR(type)     (func)

#define SWAP_ALLOCATE(nr)     (e)
#define emulate_sigs()  arch_get_unaligned_child()
#define access_rw(TST)  asm volatile("movd %%esp, %0, %3" : : "r" (0));   \
  if (__type & DO_READ)

static void stat_PC_SEC __read_mostly offsetof(struct seq_argsqueue, \
          pC>[1]);

static void
os_prefix(unsigned long sys)
{
#ifdef CONFIG_PREEMPT
  PUT_PARAM_RAID(2, sel) = get_state_state();
  set_pid_sum((unsigned long)state, current_state_str(),
           (unsigned long)-1->lr_full; low;
}

这里面有太多有趣的地方需要涉及——仅仅这部分我大概就能写一整篇文章。现在我就不再多说,这里有1MB的Linux代码样本供你欣赏。

生成婴儿名字

让我们尝试一个更好玩的。给RNN提供一个包含8000个婴儿名字的文本文件,每个名字一行(名字从这里获得)。 我们可以把这些名字喂给RNN,然后生成新的名字!以下是一些示例名字,只显示训练数据中没有出现的(90%不会):

Rudi Levette Berice Lussa Hany Mareanne Chrestina Carissy Marylen Hammine Janye Marlise Jacacrie Hendred Romand Charienna Nenotto Ette Dorane Wallen Marly Darine Salina Elvyn Ersia Maralena Minoria Ellia Charmin Antley Nerille Chelon Walmor Evena Jeryly Stachon Charisa Allisa Anatha Cathanie Geetra Alexie Jerin Cassen Herbett Cossie Velen Daurenge Robester Shermond Terisa Licia Roselen Ferine Jayn Lusine Charyanne Sales Sanny Resa Wallon Martine Merus Jelen Candica Wallin Tel Rachene Tarine Ozila Ketia Shanne Arnande Karella Roselina Alessia Chasty Deland Berther Geamar Jackein Mellisand Sagdy Nenc Lessie Rasemy Guen Gavi Milea Anneda Margoris Janin Rodelin Zeanna Elyne Janah Ferzina Susta Pey Castina

你可以在这里看到更多。我最喜欢的名字包括“Baby”(哈哈)、“Killie”、“Char”、“R”、“More”、“Mars”、“Hi”、“Saddie”、“With”和“Ahbort”。这确实很有意思。当然,你还可以在写小说、命名或者给创业公司起名字的时候把它当作相当有用的灵感来源:)

理解到底发生了什么

我们看到,训练结束时的结果令人印象深刻,但是这些工作是如何进行的呢?让我们跑两个快速的实验来简单地窥视一下。

训练中样本的演变

首先,看看样本文本在模型训练的过程中是如何演变的。例如,我用列夫·托尔斯泰的《战争与和平》来训练LSTM,然后每100次训练就产生一次样本。在第100次迭代时,模型取样的文本是随机杂凑的:

1
2
tyntd-iafhatawiaoihrdemot  lytdws  e ,tfti, astai f ogoh eoase rrranbyne 'nhthnee e
plia tklrgd t o idoe ns,smtt   h ne etie h,hregtrs nigtike,aoaenns lng

但是请注意,至少它开始意识到单词要用空格分开。有时候它偶尔会插入两个空格。它也不知道逗号后面几乎总是有个空格。在迭代300次后,我们看到模型开始学会使用引号和句号:

1
2
3
4
"Tmont thithey" fomesscerliund
Keushey. Thom here
sheulke, anmerenith ol sivh I lalterthend Bleipile shuwy fil on aseterlome
coaniogennc Phe lism thond hon at. MeiDimorotion in ther thize."

这些单词同样被空格分开,模型开始在句子结尾处使用句号。在第500次迭代时:

1
2
we counter. He stutn co des. His stanted out one ofler that concossions and was
to gearang reay Jotrets and with fre colt otf paitt thin wall. Which das stimn

模式已经学会拼写最短和最常见的单词,如“we”、“He”、“His”、“Which”、“and”等。在第700次迭代时,我们开始看到越来越像英语的文本出现:

1
2
3
Aftair fall unsuch that the hall for Prince Velzonski's that me of
her hearly, and behs to so arwage fiving were to it beloge, pavu say falling misfort
how, and Gogition is so overelical and ofter.

在第1200次迭代时,我们看到引号、问号和感叹号的使用。同时还出现了更长的单词:

1
2
"Kite vouch!" he repeated by her
door. "But I would be done and quarts, feeling, then, son is people...."

直到大约第2000次迭代时,我们才开始得到正确拼写的单词、引述、名字等等:

1
2
3
"Why do what that day," replied Natasha, and wishing to himself the fact the
princess, Princess Mary was easier, fed in had oftened him.
Pierre aking his soul came to the packs and drove up his father-in-law women.

从上面的描述可以知道,模型首先是发现单词-空格这样普遍的结构,然后迅速开始学习单词:首先从简短的单词开始,然后是更长的单词。跨越多个单词的话题和主题(以及一般的长期依赖关系)要到很久以后才会出现。

可视化RNN中的预测与神经元激活

另一个有趣的可视化是看看字符的预测分布。在下面的可视化中,我们把验证集的字符数据(蓝色/绿色的行)喂给维基百科RNN模型,然后在每个字符下面可视化(用红色)前5个最有可能的下一个字符。颜色深浅由它们的概率大小决定(所以暗红色被认为是非常可能的,白色是不太可能的)。注意,有时候模型对下一个字符的预测是非常有信心的(例如,模型对http://www.序列中的字符就是)。

输入字符序列(蓝/绿)的颜色深浅取决于RNN隐藏层中随机选择的神经元的激活情况。绿色表示非常兴奋,蓝色表示不太兴奋(对于那些熟悉LSTM细节的人来说,这些是隐藏状态向量中[-1,1]之间的值,也就是经过门限操作和tanh计算的LSTM单元状态)。直观地说,这是在RNN读取输入序列的时候,将它的“大脑”中的一些神经元的激活率可视化。不同的神经元可能在寻找不同的模式。下面我们来看看4个不同的神经元,我认为它们是有趣的或者可解释的:

under1

此图中高亮的神经元似乎对URL感到非常兴奋,在URL之外则不太兴奋。LSTM很可能使用这个神经元来记住它是否在URL内部。

under2

当RNN在[[]]环境内时,此处高亮的神经元变得非常兴奋,在其外部则不太兴奋。有趣的是,神经元在看到字符“[”后不会兴奋,必须等待第二个“[”才能激活。计算模型是否已经看到一个或两个“[”的任务很可能用不同的神经元来完成。

under3

在这里,我们看到神经元在跨越[[]]环境时似乎是线性变化的。换句话说,它的激活值给RNN提供了一个跨越[[]]范围的时间对齐的坐标系统。RNN可以使用这些信息来生成不同的字符,这或多或少可能取决于字符在[[]]范围内出现的早/晚(也许?)。

under4

这里是另一个具有非常局部行为的神经元:它是相对安静的,但在碰到“www”序列中的第一个“w”之后立即变得不太兴奋。RNN可以使用这个神经元来计算它在“www”序列中有多远,以便它可以知道是否应该输出另一个“w”,或者是否应该开始URL。

当然,由于RNN的隐藏状态是极多的、高维的和分散的,所以这些结论有些需要手动调整。这些可视化是由自定义的HTML/CSS/JavaScript生成的,如果你想创建类似的东西,你可以看这里的模板。

我们也可以通过排除最有可能的预测来精简这个可视化,仅仅显现文本,通过单元的激活值来决定颜色深浅。我们可以看到,除了大部分没有做任何解释的单元之外,大约5%的单元最终学会了相当有趣和可解释的算法:

pane1 pane2

此外,在试图预测下一个字符(例如,它可能有助于跟踪你目前是否在引号内)的任何时候我们都不必硬编码,这是多么美妙的一件事情!我们刚刚使用原始数据训练LSTM,它就决定这是个有用的东西需要跟踪。换句话说,其中一个单元在训练中逐渐把自己调整成为引号检测单元,因为这有助于它更好地完成最终任务。这是深度学习模型(更普遍的端到端训练)的能力来自哪里的最干净和最引人注目的示例之一。

源代码

希望我已经让你深信,训练字符级别的语言模型是一个非常有趣的练习。你可以使用我在GitHub上发布的字符RNN代码(采用MIT许可证)来训练自己的模型。它需要一个大的文本文件来训练字符级别的模型,然后你就可以从中取样。此外,如果你有一个GPU的话会更好,否则在CPU上训练会多花大约10倍的时间。不管怎样,如果你用某些数据进行训练并最终得到有趣的结果,请告诉我!如果你迷失在Torch/Lua的代码库中,请记住,它只是这100行要点的更高级版本。

题外话。代码是用Torch 7编写的,它最近已经成为我最喜欢的深度学习框架。我在最近的几个月才开始使用Torch/Lua,它们并不简单(我花了很多时间在GitHub上挖掘原始的Torch代码,并在gitter上询问他们问题以完成工作),但是一旦你掌握了足够的知识,它就会给你带来很大的灵活性和速度提升。我以前同样使用过Caffe和Theano,我认为,虽然Torch还不完美,但是它的抽象层次和哲学要比其它的好。在我看来,一个高效的框架应该具有以下特征:

  1. 具有许多功能的CPU/GPU透明张量库(切片、数组/矩阵操作等)
  2. 一套基于脚本语言(最好是Python)的完全独立的代码库,能够对张量进行操作,实现所有深度学习的东西(前向/反向传播、图计算等)
  3. 可以轻松地分享预训练的模型(Caffe做的很好,其它的不行)
  4. 最关键的:没有编译步骤(或者至少不要像Theano现在这样)。深度学习的趋势是更大更复杂的网络,它们在复杂图中花费的时间会成倍地增加。不需要长时间编译或开发是非常重要的。其次,编译会丢失可解释性和有效日志/调试的能力。如果有选项可以在图被开发完成后选择是否编译,那是相当好的。

进一步阅读

在结束这篇文章前,我还想把RNN放到更广泛的背景中,并提供当前研究方向的概述。RNN最近在深度学习领域颇受欢迎。和卷积网络类似,它已经存在了几十年,但是它的全部潜力最近才开始得到广泛的认可,这在很大程度上是由于我们不断增长的计算资源。这里简要概述一些最近的发展情况(肯定不是完整的清单,很多这样的工作可以追溯到20世纪90年代的研究,请参阅相关的研究部分):

在NLP/语音领域,RNN将语音转录为文本,执行机器翻译生成手写文本,当然,它也被用作强大的语言模型(Sutskever et al.)(Graves)(Mikolov et al.)(都是在字符和单词层面)。目前看来,单词级别的模型比字符级别的模型更好,但这肯定是暂时的。

计算机视觉。RNN在计算机视觉中也很快变得普及。例如,将RNN用于帧级别的视频分类图像标注(也包括我自己的工作以及其它许多内容),视频标注以及最近的视觉问答。在计算机视觉论文中,我个人最喜欢的RNN是可视化注意力的循环模型,这是由于其高层次的方向(对图像扫视后的序列化处理)和低层次的建模(REINFORCE学习规则是强化学习中策略梯度方法的一个特例,可以训练出执行不可微分计算的模型(在这种情况下对图像周围进行扫视))。我相信,这种由CNN做原始感知加上RNN在顶部做扫视策略的混合模型将变得普及,特别是在那些不仅仅是对普通视图中某些对象进行分类的更复杂的任务中。

归纳推理、记忆和注意力。另一个极其令人兴奋的研究方向是面向解决Vanilla循环网络的局限性。RNN的一个问题是不具有归纳性:它能很好地记忆序列,但不一定总是以正确的方式显示令人信服的泛化符号。第二个问题是它不必要地将表征大小与每个步骤的计算量相结合。例如,如果将隐藏状态矢量的大小加倍,由于矩阵乘法的原因,每个步骤的FLOPS(译者注:浮点运算时间)数量会增加四倍。理想情况下,我们希望在保持巨大的表征/记忆(例如,包含所有维基百科或许多中间状态变量)的同时,能够保持每个时间步骤的计算量固定不变。

在这些方向上,第一个有说服力的示例已经在DeepMind的神经图灵机论文中被建立。论文勾勒出一个模型的路径,该模型可以在大型外部存储阵列和较小的记忆寄存器集(运算发生的地方,可以将其视作我们的工作记忆)之间执行读/写操作。至关重要的是,该论文还特别提出一个非常有趣的记忆寻址机制,该机制是通过(“软”的和完全可微分的)注意力模型来实现的。“软”注意力的概念已被证明是一个强大的建模特性,也被通过共同学习对齐和翻译的神经机器翻译提出用于机器翻译和被记忆网络提出用于(玩具)问答。事实上,我可以这么说:

注意力的概念是近期神经网络中最有趣的架构创新。

现在,我不想讲太多的细节,但是记忆寻址的“软”注意力方案是很方便的,因为它使得模型完全可微分的,但不幸的是会牺牲一些效率,因为所有可以被注意的东西都被注意到了。可以将其视作C语言中的指针,它不指向特定地址,而是定义了整个记忆地址,并且间接引用指针,返回指向内容的权重和(这是非常昂贵的操作!)。这让很多研究者从“软”注意力模型转向“硬”注意力模型,以便对某个特定的需要注意的记忆块进行采样(例如,在某种程度上对某些记忆单元读/写而不是对所有单元读/写)。这个模型在哲学上更有吸引力、可扩展和高效,但不幸的是它也是不可微分的。这就要求使用来自强化学习文献(例如REINFORCE)的技术,其中人们完全习惯于不可微分的相互作用的概念。这项工作现在还在进展中,但是这些“硬”注意力模型已经被探索过,例如,使用栈增强循环网络的推理算法模式强化学习神经图灵机Show Attend and Tell

研究者。如果你想详细研究RNN,我推荐Alex GravesIlya SutskeverTomas Mikolov的论文。想要知道更多关于REINFORCE和更通用的强化学习和策略梯度方法(REINFORCE是它的一个特例)的内容,可以学习David Silver或者Pieter Abbeel的公开课。

代码。如果你想训练RNN,Theano上的KerasPassage很不错,或者是本文配套的Torch代码,或者是我不久以前写的原始NumPy代码的要点,它实现了一个高效、批量的LSTM前向和反向传播。你也可以看看我的基于NumPy的NeuralTalk,它使用RNN/LSTM来标注图像,或者看看Jeff Donahue的这个Caffe实现

总结

我们已经学习了RNN,它是如何工作的,以及为什么它如此重要。我们还使用几个有趣的数据集来训练RNN字符级别的语言模型,并且我们已经看到RNN是如何进行这个过程的。你可以自信地期待RNN领域的大量创新,我相信它将成为智能系统的普遍和关键组成部分。

最后,为给这篇文章增加一些元素,我使用这篇博文的源文件来训练一个RNN。不幸的是,文章的长度不足以很好地训练RNN。以下是返回的样本(使用低温生成以获得更典型的样本):

1
2
I've the RNN with and works, but the computed with program of the
RNN with and the computed of the RNN with with and the code

是的,这篇文章讲的是RNN以及它的效果如何,很明显它工作良好:)下次再见!

额外链接:

视频:

讨论:

回复:

如何验证邮箱是否存在?

事情的起因是想要注销某个免费的163邮箱,但是查找资料后发现:

163免费邮箱不支持直接注销。

如果连续180天没登录过网易任何产品的帐号,系统将自动清空所有信息和资料,并删除帐号。

那么问题来了,超过上述期限没有登录的话,如何才能验证该邮箱是否真的被销号?直接登录肯定不行,如果那时邮箱正处于冻结状态,你登录不就相当于重新激活吗!或者向该邮箱发送测试邮件,如果投递成功就说明邮箱还没被注销,但这种方法太过麻烦,尤其对于注销多个邮箱的情况。要想优雅地解决这个问题,我们需要从了解电子邮件传输协议开始。

电子邮件传输协议

电子邮件传输协议主要包括SMTP协议、ESMTP协议、POP协议、IMAP协议等,具体的标准可以参考RFC Editor网站下的相关RFC文档。POP3是Post Office Protocol 3的缩写,IMAP是Internet Message Access Protocol的缩写,两者的作用都是收取邮件,关键的区别在于邮件是放在服务器上(IMAP)还是放在本地电脑上(POP3)。SMTP是Simple Mail Transfer Protocol的缩写,ESMTP即Extension-SMTP,是为对付滥用SMTP服务器发邮件而引入的,它和SMTP的主要区别在于增加了发信认证机制。

下图是一个典型的电子邮件收发路径:

email-architecture

在你写完邮件点击“发送”时,你的邮件客户端会将消息发送给SMTP服务器(就如同我们将信交给本地的邮局),然后SMTP服务器根据收信人的地址向DNS服务器查询邮箱地址后缀的MX记录,找到目的邮件服务器(就好像邮局根据收信人的地址选择邮递路线,经过飞机或火车等交通工具到达收信人所在地邮局),然后收信人就可以使用自己的帐号登录POP3服务器收取邮件(就像收信人从邮箱取信)。

由此推断,通过SMTP服务器或者MX服务器能够验证邮箱是否存在。下面我们就来进行测试。

实践

我们先从SMTP服务器开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
C:\>telnet smtp.163.com 25
220 163.com Anti-spam GT for Coremail System (163com[20141201])

hello    # SMTP协议没有这个指令
502 Error: command not implemented

helo hi  # 因为每次按键都会被传送到服务器,所以输入错误时不能使用退格键删除,只能换行重新输入
500 Error: bad syntax

helo hi
250 OK

vrfy noname@163.com  # 现在的邮件服务器都使用ESTMP协议,VRFY、EXPN这些指令都已经被禁用或不被支持
502 Error: command not implemented

mail from: <noname@example.com>  # 必须是同域的邮箱才能发邮件
553 Local user only,163 smtp7,C8CowAA3eUZ7jEBaXE8hDg--.38995S2 1514179735

mail from: <noname@163.com>      # 必须登录服务器才能发邮件
553 authentication is required,163 smtp7,C8CowAA3eUZ7jEBaXE8hDg--.38995S3 1514179751

quit
221 Bye

因为邮件服务器使用ESMTP协议的缘故,必须使用同域的其它邮箱登录SMTP服务器后才能验证邮箱是否存在,这显然不是什么好的方法。

再来试试MX服务器,希望不要如此麻烦,否则的话就完蛋了~

首先找到163免费邮箱的MX服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
C:\>nslookup -q=mx 163.com
Non-authoritative answer:
163.com MX preference = 10, mail exchanger = 163mx03.mxmail.netease.com
163.com MX preference = 10, mail exchanger = 163mx01.mxmail.netease.com
163.com MX preference = 10, mail exchanger = 163mx02.mxmail.netease.com
163.com MX preference = 50, mail exchanger = 163mx00.mxmail.netease.com

163.com nameserver = ns6.nease.net
163.com nameserver = ns3.nease.net
163.com nameserver = ns4.nease.net
163.com nameserver = ns1.nease.net
163.com nameserver = ns8.166.com
163.com nameserver = ns2.166.com
163.com nameserver = ns5.nease.net
163mx01.mxmail.netease.com      internet address = 220.181.14.138
163mx01.mxmail.netease.com      internet address = 220.181.14.139
163mx01.mxmail.netease.com      internet address = 220.181.14.140
163mx01.mxmail.netease.com      internet address = 220.181.14.141
163mx01.mxmail.netease.com      internet address = 220.181.14.142
163mx01.mxmail.netease.com      internet address = 220.181.14.143
163mx01.mxmail.netease.com      internet address = 220.181.14.135
163mx01.mxmail.netease.com      internet address = 220.181.14.136
163mx01.mxmail.netease.com      internet address = 220.181.14.137

找到MX服务器后,我们就可以像对待SMTP服务器那样对待它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
C:\>telnet 163mx03.mxmail.netease.com 25
220 163.com Anti-spam GT for Coremail System (163com[20141201])

helo hi
250 OK

vrfy noname@163.com              # VRFY指令也处于禁用
502 Error: command not implemented

mail from: <noname@example.com>  # 注意:不同域的邮箱也能发邮件啦
250 Mail OK

rcpt to: <fewfwe>
550 Invalid User: fewfwe

rcpt to: <fewfwe@163.com>        # 邮箱不存在
550 User not found: fewfwe@163.com

rcpt to: <fake@163.com>          # 邮箱存在
250 Mail OK

quit
221 Bye

总结

  • 验证邮箱是否存在的最好方法是在MX服务器上使用RCPT TO指令。
  • 连续180天没登录过的163免费邮箱不会被注销。

写到这里才想起我手里还有以前收集的大批邮箱,即使按照上面的步骤逐个验证也不现实,看来需要找个时间码个程序来自动化批量处理它们。这次就暂时先这样子吧。

使用Scrapy爬取小说(6)

使用“下一页”链接抓取小说

在前文中,我们首先抓取小说目录页面的所有章节链接,然后再使用这些链接分别抓取各个章节的正文内容。这样需要分析两个不同页面的结构。其实,还有个更简单点的方法,只要分析内容页面的结构即可。

在小说的内容页面中通常都会有“上一页”和“下一页”这样的链接。我们只需要先抓取小说的首个章节,然后抽取出它的“下一页”链接,接着抓取这个链接对应的章节,再抽取出这个章节的“下一页”链接,重复这个循环直到不再有“下一页”链接为止。

抽取“下一页”链接的XPath表达式是:

1
//a[text()='下一页']/@href

修改后的novel_spider.py如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class NovelSpider(scrapy.Spider):
  name = 'novelspider'
  allowed_domains = ['example.com']
  # 把原来的目录页链接改成第一章的链接
  start_urls = ['http://example.com/wuxia/hzlz/ssjx/001.htm']

  def parse(self, response):
    # 有些小说网站的页眉和页脚都有“下一页”链接,我们
    # 不需要管它有几个,只要获取第一个出现的就行。
    link = response.xpath("//a[text()='下一页']/@href").extract_first()
    next_url = response.urljoin(link)
    yield scrapy.Request(next_url, callback=self.parse)  # 注意callback的值

    item = NovelItem()
    title = response.xpath('//center/table/tr[2]/td/text()').extract()
    content = response.xpath('//center/table/tr[4]/td/text()').extract()
    item['title'] = title
    item['content'] = content
    return item

满怀愉悦地运行Spider,结果什么都没有,抓取失败。这是怎么回事?百思不得其解。

郁闷之旅由此开始~

把yield关键词删掉试试?!修改后的代码如下:

1
2
3
link = response.xpath("//a[text()='下一页']/@href").extract_first()
next_url = response.urljoin(link)
scrapy.Request(next_url, callback=self.parse)

结果只输出第一章的内容。

把parse方法改成下面这样呢?

1
2
3
4
5
6
7
8
9
10
11
def parse(self, response):
  item = NovelItem()
  title = response.xpath('//center/table/tr[2]/td/text()').extract()
  content = response.xpath('//center/table/tr[4]/td/text()').extract()
  item['title'] = title
  item['content'] = content
  yield item

  link = response.xpath("//a[text()='下一页']/@href").extract_first()
  next_url = response.urljoin(link)
  return scrapy.Request(next_url, callback=self.parse)

也只能输出第一章的内容。

读到这里可能有看官会疑惑,为什么我会做出上面这两种修改?首先,我知道问题是在yield语句那里。其次,因为在我的印象中,yield的作用是延迟执行后面的语句。但现在从实际执行的情况来看显然不是如此。

真实的yield

查阅资料以后发现,包含yield的函数不再是普通函数,Python解释器会将其视为生成器。我们用个例子来详细说明下。

Fibonacci数列是个非常简单的递归数列,除第1和第2个数外,任何一个数都可以由前两个数相加得到。下面是输出Fibonacci数列前N个数的测试代码:

1
2
3
4
5
6
7
8
9
def fab(max):
  n = 0
  a, b = 0, 1
  while n < max:
    yield b
    a, b = b, a + b
    n = n + 1

print(fab(5))

猜猜会输出什么?有人以为会打印出Fibonacci数列,但实际上输出的是生成器对象的信息:

1
<generator object fab at 0x7fd41edfcd58>

fab(5)看起来像函数调用,但它其实是返回一个生成器对象。在对其调用next函数之前,它不会执行任何函数代码。虽然仍按函数的流程执行,但每执行到一条yield语句就会中断,并返回一个迭代值,下次执行时从yield处继续执行。看起来就好像一个函数在正常执行的过程中被yield中断了数次,每次中断都会通过yield返回当时的迭代值。

所以正确的打印代码应该这样写(for循环会自动对生成器对象调用next函数):

1
2
for n in fab(5)
  print(n)

也可以手动调用next函数,这样我们就可以更清楚地看到它的执行流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> f = fab(5)
>>> next(f)
1
>>> next(f)
1
>>> next(f)
2
>>> next(f)
3
>>> next(f)
5
>>> next(f)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration

当函数执行结束时,生成器自动抛出StopIteration异常,表示迭代完成。在for循环中,无需处理StopIteration异常,循环会正常结束。

正确的解决方案

明白yield到底起什么作用后,再回过头来看前面的修改。

第2次修改明显是没有思考,胡乱盲目地尝试。它只是创建一个Request对象而已,能有什么用!代码也清楚地表明,parse方法仅会被回调1次,这也是仅能输出第一章内容的原因。现在回想起来 怎么也弄不明白当初为什么会这么写?!

第3次修改的代码首先是解析Response对象,把第一章的标题和内容封装起来返回给Scrapy。因为这个函数是生成器函数,所以Scrapy在输出第一章内容后会继续执行yield item后面的语句,直到函数结束,抛出StopIteration异常。因为有return语句,所以会把返回的值当作StopIteration异常的属性。

第1次修改和第3次修改类似,不同的是它返回“下一页”的Request对象给Scrapy,所以parse方法会被不断地循环回调,直到没有“下一页”为止。

归根结底,第1和第3次修改不能输出所有章节内容的原因是因为StopIteration异常吞掉了return返回的值,所以也要把它改成yield。修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
def parse(self, response):
  item = NovelItem()
  title = response.xpath('//center/table/tr[2]/td/text()').extract()
  content = response.xpath('//center/table/tr[4]/td/text()').extract()
  item['title'] = title
  item['content'] = content
  yield item

  link = response.xpath("//a[text()='下一页']/@href").extract_first()
  next_url = response.urljoin(link)
  yield scrapy.Request(next_url, callback=self.parse)

给代码做个美容:

1
2
3
4
5
6
7
8
9
def parse(self, response):
  yield {
    'title': response.xpath('//center/table/tr[2]/td/text()').extract(),
    'content': response.xpath('//center/table/tr[4]/td/text()').extract(),
  }

  link = response.xpath("//a[text()='下一页']/@href").extract_first()
  next_url = response.urljoin(link)
  yield scrapy.Request(next_url, callback=self.parse)

打完收工。写文章比写代码累多了!

使用Scrapy爬取小说(5)

重构代码以提升健壮性

今天的任务是重构TxtPipeline。

先看下TxtPipeline中负责写文件的代码片段:

1
2
3
4
f = open(filename, 'w')
f.write(title)
f.write(content)
f.close()

我们经常会看到这样的代码,但它存在严重的问题,你能把它找出来吗?

首先,open函数打开文件,并返回文件句柄,该句柄是由操作系统分配的。接着就是调用write方法写文件。最后是调用close方法关闭文件。文件使用完毕后必须关闭,因为文件句柄会占用操作系统的资源,并且操作系统同一时间能打开的文件数量也是有限的。

由于文件读写时都有可能产生IOError,一旦出错,后面的close方法就不会被调用。所以,为确保无论是否出错都能正常地关闭文件和释放文件句柄,我们需要使用如下方法来实现:

1
2
3
4
5
6
7
try:
  f = open(filename, 'w')
  f.write(title)
  f.write(content)
finally:
  if f:
    f.close()

但每次都这么写实在太繁琐。其实,我们可以使用with语句来帮我们自动管理文件资源:

1
2
3
with open(filename, 'w') as f:
  f.write(title)
  f.write(content)

这和前面的try-finally是一样的,但是代码更加简洁,而且不必调用close方法。

使用Scrapy爬取小说(4)

重构代码以提升可移植性

在前文中,我们使用range(1, 310)来确定小说章节链接的范围,这很不好。我们编程的时候应该要注意尽量避免代码中的硬编码和魔数,提高代码的可移植性。如果小说章节的链接不是这种连续的数字,或者章节的数量是在逐步增加的,那么这段代码就是无效的,或者会慢慢变得无效。

如何才能把这段代码写得更具可移植性呢?

novel-chapter-urls

我们不必关心章节链接的格式,我们只要知道它是个链接,一定是以<a href="url">text</a>这种形式呈现(如上图所示)。我们也不必关心章节数量是否变化,只要把所有这种形式的链接抓取下来即可。与此对应的XPath表达式是:

1
//center/table[@bordercolorlight]//a/@href

因为页面文档中可能有多个表格,所以要在table后面添加@bordercolorlight属性来指定我们要查找的那个。

那么在Scrapy中如何实现这样的能力呢?以下是具体的代码:

1
2
3
4
5
def parse(self, response):
  links = response.xpath('//center/table[@bordercolorlight]//a/@href').extract()
  for link in links:
    next = response.urljoin(link)
    yield scrapy.Request(next, callback=self.parse_chapter)

使用Scrapy爬取小说(3)

将小说保存到MongoDB

在前文中,我们将小说的每个章节保存为独立的文本文件。今天我们准备把小说内容输出到数据库。对于数据存储,我选择MongoDB。为什么是MongoDB而不是其它?原因是以前没用过,想尝试下。

现在我们已经知道,要把抓取来的数据保存到数据库,只需实现Item Pipeline即可。我们可以仿照前面的实现依葫芦画瓢。

以下是将小说内容保存到MongoDB的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pymongo

class MongoPipeline(object):
  def open_spider(self, spider):
    self.client = pymongo.MongoClient('localhost', 27017)
    self.novel = self.client['novel']
    self.ssjx = self.novel['ssjx']

  def process_item(self, item, spider):
    data = {
      # 标题和内容都是列表类型,必须先转换成字符串
      'title' : ''.join(item['title']),
      'content' : ''.join(item['content']),
    }
    self.ssjx.insert(data)
    return item

  def close_spider(self, spider):
    self.client.close()

将组件添加到novel/settings.py的ITEM_PIPELINES配置中以启用它:

1
2
3
4
ITEM_PIPELINES = {
  'novel.pipelines.TxtPipeline' : 300,
  'novel.pipelines.MongoPipeline' : 400,
}

在项目的根目录中使用下面的指令运行Spider:

1
scrapy crawl novelspider

如果没有问题的话,爬虫会不停地运行,小说的章节内容也会被一个个地保存到数据库。下面的截图是最终的抓取结果:

novel-mongo-gui

使用Scrapy爬取小说(2)

将小说按章节保存为文本文件

在前文中,我们通过-o选项将抓取的小说内容保存成本地文件。虽然它工作的很好,但是有两个缺点:一是把所有小说内容保存到单个文件会导致该文件太大,用文本编辑器打开随机浏览的速度非常慢;二是小说章节不是按照顺序保存的,导致阅读指定的章节内容很不方便。

再写个小工具按章节内容分割小说文件?无需如此麻烦。我们可以在Scrapy中直接将每个章节保存为单独的文本文件。Scrapy中的Item Pipeline就是干这类事情的。看下面的Scrapy架构图:

scrapy-architecture

当Item在Spider中被收集之后,它们会被传递到Item Pipeline,这些Pipeline组件按照一定的顺序执行对Item的处理,同时也决定此Item是否继续通过,或是被丢弃而不再进行处理。

以下是Item Pipeline的一些典型应用:

  • 清理HTML数据
  • 验证爬取的数据
  • 查重
  • 将爬取结果保存到数据库中

编写Item Pipeline

编写自己的Item Pipeline非常简单,每个Item Pipeline都是实现以下方法的Python类:

1
process_item(self, item, spider)

此外,下面的方法是可选实现的:

1
2
open_spider(self, spider)  # 该方法在Spider被开启时调用
close_spider(spider)       # 该方法在Spider被关闭时调用

明白原理后,我们就可以开始编写自己的Item Pipeline。以下就是将小说的每个章节写成单独文本文件的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TxtPipeline(object):
  def process_item(self, item, spider):
    # 标题和内容都是列表类型,必须先转换成字符串
    title = ''.join(item['title'])
    content = ''.join(item['content'])
    # 使用章节名来创建文件
    # 使用strip()来过滤非法字符r'\/:*?"<>|'
    filename = '{}.txt'.format(title.strip())
    f = open(filename, 'w')
    f.write(title)
    f.write(content)
    f.close()
    return item

启用Item Pipeline

要启用Pipeline组件,你必须将它添加到novel/settings.py的ITEM_PIPELINES配置中,就像下面这样:

1
2
3
ITEM_PIPELINES = {
  'novel.pipelines.TxtPipeline' : 300,
}

Pipeline后面的整数值确定它们的运行顺序,Item按数字从低到高通过每个Pipeline。通常将这些值定义在0-1000范围内。

运行Spider

在项目的根目录中执行如下的命令(因为不再把所有的小说内容保存为单个文件,所有不需要指定-o选项):

1
scrapy crawl novelspider

没有报错的话,等个几分钟,就能看到很多文本文件躺在自己的电脑上面。

novel-txt-list

使用Scrapy爬取小说(1)

这几天正在看《Python网络数据采集》,在这过程中觉得有必要写个爬虫来实践学到的知识。便给自己定个小目标:试着用Scrapy爬取小说《蜀山剑侠传》,并把内容保存到本地文件中。

Scrapy是一个开源的Python数据抓取框架,速度快且强大,而且使用简单,可以很方便地抓取网站页面并从中提取结构化的数据。Scrapy用途广泛,可以用于数据挖掘、监测和自动化测试。

好吧,废话不多说,让我们直接开干!

创建项目

在抓取之前,必须先创建一个Scrapy项目,可以直接用以下命令生成:

1
scrapy startproject novel

这是新建项目的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
.
├── novel                # 项目模块
│   ├── __init__.py
│   ├── items.py         # 定义爬取的数据
│   ├── middlewares.py   # 定义爬取时的中间件
│   ├── pipelines.py     # 定义数据管道
│   ├── __pycache__
│   ├── settings.py      # 项目的设置文件
│   └── spiders          # 放置爬虫代码的文件夹
│       ├── __init__.py
│       └── __pycache__
└── scrapy.cfg           # Scrapy部署时的配置文件

分析页面结构

主要分析两个页面。一是小说的目录页面,目的是获取小说所有章节的链接以备抓取。二是任意章节页面,用于爬取其中的标题和正文。

通过观察目录页面的源码可以发现,所有章节的链接都类似NUMBER.htm。其中,NUMBER是3位整数,从001到309。

novel-chapter-urls

使用Firefox浏览器的检查器(Inspector)查看章节页面,尝试把光标放在正文上,你应该可以看到正文周围的蓝色方块(如下图左侧所示),如果你点击这个方块,就可以选中检查器面板中相对应的HTML代码。可以看到小说的标题和正文都在td标签内。

novel-page-inspector

与此对应的XPath表达式分别是:

1
2
//center/table/tr[2]/td/text()  # 标题的XPath路径
//center/table/tr[4]/td/text()  # 正文的XPath路径

需要注意的是,上面XPath表达式里的中括号内的数字为节点索引,是从1开始的,而不是0。

定义爬取的数据

当需要从某个网站抓取信息时,首先是定义我们要爬取的数据。在Scrapy中,可以通过Item来完成。以下是我们定义的Item:

1
2
3
4
5
import scrapy

class NovelItem(scrapy.Item):
  title = scrapy.Field()
  content = scrapy.Field()

编写爬取数据的Spider

现在我们需要添加一个爬虫来真正做点什么。创建文件novel/spiders/novel_spider.py,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import scrapy
from novel.items import NovelItem

class NovelSpider(scrapy.Spider):
  name = 'novelspider'
  allowed_domains = ['example.com']
  start_urls = ['http://example.com/wuxia/hzlz/ssjx/']

  def parse(self, response):
    # 还记得前面分析目录页面时的结果吗:000、001...309。
    for i in range(1, 310):
      # 生成每个章节的绝对链接
      next = response.urljoin('{0:03d}.htm'.format(i))
      # 生成新的请求对象解析小说的标题和正文
      yield scrapy.Request(next, callback=self.parse_chapter)

  def parse_chapter(self, response):
    item = NovelItem()
    title = response.xpath('//center/table/tr[2]/td/text()').extract()
    print('Title is', title)
    content = response.xpath('//center/table/tr[4]/td/text()').extract()
    print('Content is', content)
    item['title'] = title
    item['content'] = content
    return item

运行Spider

完成爬虫后,如何通过它来得到我们想要的结果呢?在项目的根目录中执行如下的命令:

1
scrapy crawl novelspider -o novel.json

没有报错的话,等个几分钟,就能看到一个完整的JSON数据文件躺在自己的电脑上面。

不过如果打开的话,可能只会看到“\uXXXX”这样的乱码,它们都是中文字符的Unicode编码。要直接显示成中文的话,需要在novel/settings.py中添加以下设置:

1
FEED_EXPORT_ENCODING = 'utf-8'

最终的结果如图:

novel-json-chinese

软件版本号的思考

software-version

产品的名称用来表明产品目的,通常在产品的整个生命周期中都使用它。软件产品的版本号则用来表明产品在特定时间段内所拥有的功能集。它们关注的是以何种方式对提供给用户的软件版本进行识别。

版本号用于捕获相关产品修订和变种(通常与可移植性、国际化或者性能特征相关)的信息。目标是采用尽量少的标识符去收集所有信息。遗憾的是,在工业生产中还没有一种标准的版本编号机制,或者说,不存在单一的、通用的算法。更多情况是,需要你针对发布的内容和目标群体做些细微不同的调整。

但不管你的目标群体是什么,完全发布最好通过以下方式进行标识。这是经过不断地反复实践,被各种封闭、开源软件所广泛使用的惯例。

  • 软件的名称。
  • 用x.y.z.build四位元组去捕捉修订信息。
  • 根据需要生成的任意变种信息。

版本编号中的元组定义:

元组 定义
x 主版本号。表示产品的当前主要版本。用来表示提供给客户的产品功能的主要增强。在一个极端的例子中,主版本号的增加用来说明产品现在已经拥有一个全新的功能类。
y 次版本号。表示给产品新增了一些特征,或者是在原来文档中描述的特征上做了重要的修改。用来确定次版本号什么时候需要修改的一个衡量标准就是产品功能说明书。
z 修订版本号。用来表示给产品所做的缺陷维护行为的等级。产品缺陷是在产品的功能说明书中没有定义,并且已经或者可能对产品的使用者造成不利影响的任何行为。缺陷维护可以看作是支持该版本功能说明的一切活动。
build 构建版本号。一般是编译器在编译过程中自动生成。

除z和build的意义比较明确外,对x和y的解释都太笼统。我们需要更加详细的说明以指导我们的开发工作。

修订过的版本编号中的元组定义:

元组 定义
x 主版本号。用于有扩展性的、客户可见的架构上或特性上的改变。以一个管理大型数据库的系统为例,你可能在以下情况时需要定义一个主要的版本发布:
* 改变数据库的结构,导致单纯的升级系统对客户产生比较严重的影响。
* 改变已经发布的API,导致它与前一版本不兼容。
* 删除功能(好的架构师应该删除一些不需要的功能)。
* 持续增加新的功能,例如对新的操作系统的支持。
x的增加也可以是出于纯粹的商业理由。例如,客户的技术支持合同标明,在下个主要版本发布以后,软件可以得到18个月的技术支持。通过增加x,你将强迫客户去升级。
y 次版本号。通常与期望的功能或其它改进措施相关。当市场部门认为这个版本的一系列特性已经通过证实,次要版本号就会增加。决定增加x或y可能会比较随意。市场架构师应该定义触发任何一个增加的事件(定义与x相关的触发事件比定义y要更容易)。
z 修订版本号。主要版本号和次要版本号都相同的维护版本应该彼此兼容。
build 构建版本号。一般是编译器在编译过程中自动生成。

注意:这里的版本编号规则仅适用于发布周期较长的软件产品。如果发布周期很短,像Chrome和Firefox那样,可能就不太适用。

参考资料

  • 语义化版本
  • 《软件发布方法》
  • 《超越软件架构:创建和维护优秀解决方案》

追逐时髦的技术

英文原文:https://www.nemil.com/musings/shinyandnew.html

有关当前最好的框架或编程语言的争论经常发生在Web开发中。就这点而言,Scribd的联合创始人Jared Friedman在2015年写了一篇文章推荐创业公司使用Node.js代替Rails。

他提出几个关键点:

  • Rails很慢。
  • 黑客学院的毕业生都在使用Rails,贬低了它对高级工程师的价值,并减少了它的未来前景。
  • 创业公司应该使用那些前瞻性工程师今后将使用的技术,以保证它们的应用不过时。
  • 在Scribd,过去几年里它们已经从Prototype转换到jQuery,再到CoffeeScript,再到Angular,再到React。

Node.js是创业公司的绝佳选择,但它饱受批评的两个部分令我担忧。首先,一名创业公司的工程师应该了解什么技术将会在几年后流行,以保证它们的技术栈不过时。第二,杰出的软件工程师将被时髦的技术栈吸引到创业公司,而不是有趣的技术问题。在过去我还听到过更恶劣的传闻,创业公司的开发者拒绝接受使用ES5 JavaScript编程的工作(那时CoffeeScript刚出来),Mongo发布不久工程师就执意在生产环境下使用Mongo替代Postgres,渴望用最新的前端框架不断重构项目。

我担心有些程序员(和他们的雇主)有这种倾向,即把注意力放在转换技术栈到最新上。他们主要基于框架选择公司,力求在工作中使用最新而不是最好的工具。他们把时间花在新的库和框架上,而不是提高他们的核心技术能力。我们把他们称为技术栈追逐者——他们奋力追求在创业公司的技术栈中使用那些对核心输出(用户重视的软件功能、开发团队的生产力)提升有限的新技术(或者他们自己喜欢的技术)。

“时髦的”Web开发

很同情那些在Hacker News上的时髦的Web或移动应用开发者。作为在2012年的全栈创业公司的开发者,你正在构建后端使用Ruby/Rails,前端使用Backbone/CoffeeScript/Underscore的网站,同时使用Capistrano(或相关的Python类似物)部署你的应用。到2013年,你已经将后端转换到Node/Express/Mongo,前端为Grunt/Ember。在2014年,你已经彻底切换到MEAN技术栈,但在尝试过Koa以后考虑转移到Go(在Express核心贡献者告别Node.js转向Go以后)。在2015年,你在后端使用Express/Go,前端使用Gulp/ES2015/React,使用React Native代替原生移动语言,并且慢慢地将系统转换为使用Docker的微服务。很快,你将会被转换到Phoenix,如果Angular 2是正确的选择也会转换过去——甚至可能创造一个Go可以工作在Android上以及开源Swift可以适合你的技术栈的世界。(我显然是夸大效果,尽管这是HN头条新闻流行什么的一个合理表示。)

有几个原因表明这可能是合理的。时髦的Web工程师需要“时尚”才能获得未来的工作或合同。雇主使用框架或语言作为过滤器,而不是测试批判性思维和技能。雇主没有意识到有实力的开发者如果有正确的支持,可以在几个星期,通常是几天内成为许多语言或者框架的专家。有时趋势是无法阻止的:Swift正在取代Objective-C,世界正在转向更薄、更小的单体后端和更重、反应更灵敏的前端。通常,转变有着巨大的优势:生产力大幅上升,或者新的用户功能突然变得可能。然而,所有的变化都不会导致早期到中期的公司不采用就死,而为了乐趣或业余项目学习技术和认为它是生产环境的关键是迥然不同的。

我们可以用创业公司的时髦的Web或移动开发者与我们的计算机科学家作为对比。我的一个朋友是一家顶级科技公司的计算机神经学家——跟几乎所有从事技术工作的人一样,他的世界每隔几个月就会被重塑——得益于计算能力、脑成像和深度学习算法的快速发展。基本的编程工具其实变化不大。公平地说,只有C++从 11转换到14引起了一些焦虑。还有分布式计算系统、键/值存储和其它外部服务,但这些都是使用稳定的API构建的。他的大部分时间都花在单个DSL中的架构和算法上,而不是重写功能相似的代码或者快速学习提供有争议的好处和改变的库。

选择工具

人们可能会建议创业公司选择时髦的技术栈,因为它是招聘杰出的工程师的关键工具。我自己的观察是,杰出的工程师注重其它的东西。到目前为止,最重要的是提供有趣的问题去解决——有趣的人与他们合作。吸引力和强大的使命感是吸引优秀人才(工程师或者其他)的其它途径。

我并不是在抱怨技术发展太快,也不是说我们都应该用汇编语言或者C++或者Ruby编程。软件工程师清楚他们的目标——我们的领域以令人目眩的速度发展,但对于我们拥有的影响力这都是值得的,因为有10亿人上网。我认为你需要有能力快速地学会新的框架、语言或库(如何完成它的Ask HN)——依靠周围那些经验丰富的工程师,你的目标应该是尽快地具有生产力。除此之外,你应该深刻理解多种语言,而不仅仅是一种(但是同样的态度,不应盲目地扩展到框架或者轻量级的DSL)。

对于创业公司而言,Paul Graham在2013年被问到关于理想的语言:“我的意思是,我们有的创业公司在用PHP编写代码——这让我有点担心,但这并不像其它事情那么让我担心。”GitHub的技术主管Sam Lambert在最近的一次采访中谈到,他在2013年被GitHub的CTO面试时,对GitHub的技术栈是Rails、C和Bash脚本感到惊讶:“随着面试的继续,我发现他们实际上是一群非常务实的黑客,他们只钻研Ruby和C,使用更稳定的技术栈以便花时间工作在更有趣的事情上,而不是追逐最新最酷炫的技术。”GitHub的方法在我看来是Web和移动开发者的合理的平衡:广泛地探索工具,然后务实地选择解决你所面临的问题的工具(YAGNI适用于更多的地方,而不仅仅是面向用户的功能开发)。

令我担心的是,某些开发者,特别是在职业生涯早期的开发者,可能会以为创业公司的工程师不是问题解决者或计算机科学家,而是一个荣誉查找表——他们的任务是每隔几个月记住一个新的DSL——只能获得有限的好处。这使我们这些早期的工程师贬值——构建人们想要的东西,从事有趣的技术问题,快速交付代码。

无论如何,要在额外的时间里广泛地实践。如果好处是压倒性的,则切换生产环境中的语言/框架,但要考虑是哪些好处。警惕那些追求新技术却不考虑它对团队的预期优势的人。花时间学习概念和解决有趣的技术或用户问题。如果你有正确的应用边界,并选择你有现成生产力的框架,一旦你这样做了,你将具有一定的灵活性,但需要足够坚持才能达到产品与市场的匹配和超越。

任何一天打开Hacker News,你都能看到有帖子诱惑你使用某个框架、语言、类库或者服务去贡献和构建应用(包括一些像Mongo这样有大笔现金的公司,因此在它们的平台后面有营销预算)。有些工具拥有改变游戏规则的能力,其余的只有一些关键的不同功能,但是它们都需要时间才能成为专家。有些工具会大声宣告它们才是未来,并且嘲笑你所学到的东西——但是它们需要你的技能和意识与现有的技术真正地竞争。你会如何选择?