1. 程式人生 > 其它 >在深度學習TensorFlow 框架上使用 LSTM 進行情感分析

在深度學習TensorFlow 框架上使用 LSTM 進行情感分析

在這篇教程中,我們將介紹如何將深度學習技術應用到情感分析中。該任務可以被認為是從一個句子,一段話,或者是從一個文件中,將作者的情感分為積極的,消極的或者中性的。

這篇教程由多個主題組成,包括詞向量,迴圈神經網路和 LSTM。文章的最後給出完整的程式碼可以通過回覆公眾號"LSTM"獲取。

在討論具體的概念之前,讓我們先來談談為什麼深度學習適合應用在自然語言處理中。

深度學習在自然語言處理中的應用

自然語言處理是教會機器如何去處理或者讀懂人類語言的系統,目前比較熱門的方向,包括如下幾類:

  • 對話系統 - 比較著名的案例有:Siri,Alexa 和 Cortana。
  • 情感分析 - 對一段文字進行情感識別。
  • 圖文對映 - 用一句話來描述一張圖片。
  • 機器翻譯 - 將一種語言翻譯成另一種語言。
  • 語音識別 - 讓電腦識別口語。

在未進入深度學習時代,NLP也是一個蓬勃發展的領域。然而,在所有的上述任務中,我們都需要根據語言學的知識去做大量的,複雜的特徵工程。如果你去學習這個領域,那麼整個四年你都會在從事這方面的研究,比如音素,語素等等。在過去的幾年中,深度學習的發展取得了驚人的進步,在一定程度上我們可以消除對語言學的依賴性。由於進入的壁壘降低了,NLP 任務的應用也成為了深度學習研究的一個重大的領域之一。

詞向量

為了瞭解深度學習是如何被應用的,我們需要考慮不同形式的資料,這些資料被用來作為機器學習或者深度學習模型的輸入資料。卷積神經網路使用畫素值作為輸入,logistic迴歸使用一些可以量化的特徵值作為輸入,強化學習模型使用獎勵訊號來進行更新。通常的輸入資料是需要被標記的標量值。所有當你處理 NLP 任務時,可能會想到利用這樣的資料管道。

但是,如果這樣設計管道,那麼是存在很多問題的。我們不能像點積或者反向傳播那樣在一個字串上執行普通的運算操作。所有,我們不需要將字串作為輸入,而是將句子中的每個詞轉換成向量。

你可以將輸入資料看成是一個 16*D 的一個矩陣。

我們希望這些向量以某種方式建立,而這種方式可以表示單詞及其上下文意義。例如,我們希望單詞 “love” 和 “adore” 這兩個詞在向量空間中是有一定的相關性的,因為他們有類似的定義,他們都在類似的上下文中使用。單詞的向量表示也被稱之為詞嵌入。

Word2Vec

為了去得到這些詞嵌入,我們使用一個很著名的模型 “Word2Vec”。簡單的說,這個模型根據上下文的語境來推斷出每個詞的詞向量。如果兩個個詞在上下文的語境中,可以被互相替換,那麼這兩個詞的距離就非常近。在自然語言中,上下文的語境對分析詞語的意義是非常重要的。比如,之前我們提到的 “adore” 和 “love” 這兩個詞,我們觀察如下上下文的語境。

從句子中我們可以看到,這兩個詞通常在句子中是表現積極的,而且一般比名詞或者名詞組合要好。這也說明了,這兩個詞可以被互相替換,他們的意思是非常相近的。對於句子的語法結構分析,上下文語境也是非常重要的。所有,這個模型的作用就是從一大堆句子(以 Wikipedia 為例)中為每個獨一無二的單詞進行建模,並且輸出一個唯一的向量。Word2Vec 模型的輸出被稱為一個嵌入矩陣。

這個嵌入矩陣包含訓練集中每個詞的一個向量。傳統來講,這個嵌入矩陣中的詞向量資料會超過三百萬。

Word2Vec 模型根據資料集中的每個句子進行訓練,並且以一個固定視窗在句子上進行滑動,根據句子的上下文來預測固定視窗中間那個詞的向量。然後根據一個損失函式和優化方法,來對這個模型進行訓練。這個訓練的詳細過程有點複雜,所有我們這裡就先不討論細節方面的事。但是,對於深度學習模型來說,我們處理自然語言的時候,一般都是把詞向量作為模型的輸入。

如果你想了解更多有關 Word2Vec 的理論,那麼你可以學習這個教程。

https://www.tensorflow.org/tutorials/word2vec

迴圈神經網路(RNN)

現在,我們已經得到了神經網路的輸入資料 —— 詞向量,接下來讓我們看看需要構建的神經網路。NLP 資料的一個獨特之處是它是時間序列資料。每個單詞的出現都依賴於它的前一個單詞和後一個單詞。由於這種依賴的存在,我們使用迴圈神經網路來處理這種時間序列資料。

迴圈神經網路的結構和你之前看到的那些前饋神經網路的結構可能有一些不一樣。前饋神經網路由三部分組成,輸入層,隱藏層和輸出層。

前饋神經網路和 RNN 之前的主要區別就是 RNN 考慮了時間的資訊。在 RNN 中,句子中的每個單詞都被考慮上了時間步驟。實際上,時間步長的數量將等於最大序列長度。

與每個時間步驟相關聯的中間狀態也被作為一個新的元件,稱為隱藏狀態向量 h(t) 。從抽象的角度來看,這個向量是用來封裝和彙總前面時間步驟中所看到的所有資訊。就像 x(t) 表示一個向量,它封裝了一個特定單詞的所有資訊。

隱藏狀態是當前單詞向量和前一步的隱藏狀態向量的函式。並且這兩項之和需要通過啟用函式來進行啟用。

上面的公式中的2個W表示權重矩陣。如果你需要仔細研究這兩個矩陣,你會發現其中一個矩陣是和我們的輸入 x 進行相乘。另一個是隱藏的裝填向量,用來和前一個時間步驟中的隱藏層輸出相乘。W(H) 在所有的時間步驟中都是保持一樣的,但是矩陣 W(x) 在每個輸入中都是不一樣的。

這些權重矩陣的大小不但受當前向量的影響,還受前面隱藏層的影響。舉個例子,觀察上面的式子,h(t) 的大小將會隨著 W(x) 和 W(H) 的大小而改變。

讓我們來看一個快速例子。當 W(H) 非常大,W(X) 非常小的時候,我們知道 h(t) 受 h(t-1) 的影響比 x(t) 的影響大。換句話說,目前的隱藏狀態向量更關心前面句子的一個總和,而不是當前的一個句子。

權重的更新,我們採用 BPTT 演算法來進行跟新。

在最後的時刻,隱藏層的狀態向量被送入一個 softmax 分類器,進行一個二分類,即判斷文字是否是積極情緒或者xiao'ji消極情緒。

長短期記憶網路(LSTM)

長短期記憶網路單元,是另一個 RNN 中的模組。從抽象的角度看,LSTM 儲存了文字中長期的依賴資訊。正如我們前面所看到的,H 在傳統的RNN網路中是非常簡單的,這種簡單結構不能有效的將歷史資訊連結在一起。舉個例子,在問答領域中,假設我們得到如下一段文字,那麼 LSTM 就可以很好的將歷史資訊進行記錄學習。

在這裡,我們看到中間的句子對被問的問題沒有影響。然而,第一句和第三句之間有很強的聯絡。對於一個典型的RNN網路,隱藏狀態向量對於第二句的儲存資訊量可能比第一句的資訊量會大很多。但是LSTM,基本上就會判斷哪些資訊是有用的,哪些是沒用的,並且把有用的資訊在 LSTM 中進行儲存。

我們從更加技術的角度來談談 LSTM 單元,該單元根據輸入資料 x(t) ,隱藏層輸出 h(t) 。在這些單元中,h(t) 的表達形式比經典的 RNN 網路會複雜很多。這些複雜元件分為四個部分:輸入門,輸出門,遺忘門和一個記憶控制器。

每個門都將 x(t) 和 h(t-1) 作為輸入(沒有在圖中顯示出來),並且利用這些輸入來計算一些中間狀態。每個中間狀態都會被送入不同的管道,並且這些資訊最終會彙集到 h(t) 。為簡單起見,我們不會去關心每一個門的具體推導。這些門可以被認為是不同的模組,各有不同的功能。輸入門決定在每個輸入上施加多少強調,遺忘門決定我們將丟棄什麼資訊,輸出門根據中間狀態來決定最終的 h(t) 。為了瞭解更多有關 LSTM 的資訊,你可以檢視 Christopher Olah 的部落格。

http://colah.github.io/posts/2015-08-Understanding-LSTMs/

我們再來看看第一個問題,“What is the sum of the two numbers?",該模型必須接受類似的問題和答案來進行訓練。LSTM 就會認為任何沒有數字的句子都是沒有意義的,因此遺忘門就會丟棄這些不必要的資訊。

情感分析框架

如前所述,情感分析的任務是去分析一個輸入單詞或者句子的情緒是積極的,消極的還是中性的。我們可以把這個特定的任務(和大多數其他NLP任務)分成 5個不同的元件。

1) Training a word vector generation model (such as Word2Vec) or loading pretrained word vectors 2) Creating an ID's matrix for our training set (We'll discuss this a bit later) 3) RNN (With LSTM units) graph creation 4) Training 5) Testing

匯入資料

首先,我們需要去建立詞向量。為了簡單起見,我們使用訓練好的模型來建立。

作為該領域的一個最大玩家,Google 已經幫助我們在大規模資料集上訓練出來了 Word2Vec 模型,包括 1000 億個不同的詞!在這個模型中,谷歌能建立 300 萬個詞向量,每個向量維度為 300。

https://code.google.com/archive/p/word2vec/#Pre-trained_word_and_phrase_vectors

在理想情況下,我們將使用這些向量來構建模型,但是因為這個單詞向量矩陣相當大(3.6G),我們將使用一個更加易於管理的矩陣,該矩陣由 GloVe 進行訓練得到。矩陣將包含 400000 個詞向量,每個向量的維數為 50。

我們將匯入兩個不同的資料結構,一個是包含 400000 個單詞的 Python 列表,一個是包含所有單詞向量值得 400000*50 維的嵌入矩陣。

import numpy as np
wordsList = np.load('wordsList.npy')
print('Loaded the word list!')
wordsList = wordsList.tolist() #Originally loaded as numpy arraywordsList = [word.decode('UTF-8') for word in wordsList] #Encode words as UTF-8wordVectors = np.load('wordVectors.npy')
print ('Loaded the word vectors!')

請確保上面的程式能正常執行,我們可以檢視詞彙表的維度和詞向量的維度。

print(len(wordsList))
print(wordVectors.shape)

我們也可以在詞庫中搜索單詞,比如 “baseball”,然後可以通過訪問嵌入矩陣來得到相應的向量,如下:

baseballIndex = wordsList.index('baseball')
wordVectors[baseballIndex]

現在我們有了向量,我們的第一步就是輸入一個句子,然後構造它的向量表示。假設我們現在的輸入句子是 “I thought the movie was incredible and inspiring”。為了得到詞向量,我們可以使用 TensorFlow 的嵌入函式。這個函式有兩個引數,一個是嵌入矩陣(在我們的情況下是詞向量矩陣),另一個是每個詞對應的索引。接下來,讓我們通過一個具體的例子來說明一下。

import tensorflow as tfmaxSeqLength = 10 #Maximum length of sentence
numDimensions = 300 #Dimensions for each word vector
firstSentence = np.zeros((maxSeqLength), dtype='int32')
firstSentence[0] = wordsList.index("i")
firstSentence[1] = wordsList.index("thought")
firstSentence[2] = wordsList.index("the")
firstSentence[3] = wordsList.index("movie")
firstSentence[4] = wordsList.index("was")
firstSentence[5] = wordsList.index("incredible")
firstSentence[6] = wordsList.index("and")
firstSentence[7] = wordsList.index("inspiring")
#firstSentence[8] and firstSentence[9] are going to be 0print(firstSentence.shape)print(firstSentence) #Shows the row index for each word

資料管道如下圖所示:

輸出資料是一個 10*50 的詞矩陣,其中包括 10 個詞,每個詞的向量維度是 50。

with tf.Session() as sess:
    print(tf.nn.embedding_lookup(wordVectors,firstSentence).eval().shape)

在整個訓練集上面構造索引之前,我們先花一些時間來視覺化我們所擁有的資料型別。這將幫助我們去決定如何設定最大序列長度的最佳值。在前面的例子中,我們設定了最大長度為 10,但這個值在很大程度上取決於你輸入的資料。

訓練集我們使用的是 IMDB 資料集。這個資料集包含 25000 條電影資料,其中 12500 條正向資料,12500 條負向資料。這些資料都是儲存在一個文字檔案中,首先我們需要做的就是去解析這個檔案。正向資料包含在一個檔案中,負向資料包含在另一個檔案中。下面的程式碼展示了具體的細節:

from os import listdir
from os.path import isfile, joinpositiveFiles = ['positiveReviews/' + f for f in listdir('positiveReviews/') if isfile(join('positiveReviews/', f))]
negativeFiles = ['negativeReviews/' + f for f in listdir('negativeReviews/') if isfile(join('negativeReviews/', f))]
numWords = []for pf in positiveFiles:
    with open(pf, "r", encoding='utf-8') as f:        line=f.readline()
        counter = len(line.split())
        numWords.append(counter)       
print('Positive files finished')for nf in negativeFiles:
    with open(nf, "r", encoding='utf-8') as f:        line=f.readline()
        counter = len(line.split())
        numWords.append(counter)  print('Negative files finished')

numFiles = len(numWords)print('The total number of files is', numFiles)print('The total number of words in the files is', sum(numWords))print('The average number of words in the files is', sum(numWords)/len(numWords))

我們也可以使用 Matplot 將資料進行視覺化。

import matplotlib.pyplot as plt%matplotlib inlineplt.hist(numWords, 50)plt.xlabel('Sequence Length')plt.ylabel('Frequency')plt.axis([0, 1200, 0, 8000])plt.show()

從直方圖和句子的平均單詞數,我們認為將句子最大長度設定為 250 是可行的。

maxSeqLength = 250

接下來,讓我們看看如何將單個檔案中的文字轉換成索引矩陣,比如下面的程式碼就是文字中的其中一個評論。

fname = positiveFiles[3] #Can use any valid index (not just 3)with open(fname) as f:    for lines in f:
        print(lines)        exit

接下來,我們將它轉換成一個索引矩陣。

# Removes punctuation, parentheses, question marks, etc., and leaves only alphanumeric charactersimport re
strip_special_chars = re.compile("[^A-Za-z0-9 ]+")

def cleanSentences(string):    string = string.lower().replace("<br />", " ")    return re.sub(strip_special_chars, "", string.lower())
firstFile = np.zeros((maxSeqLength), dtype='int32')with open(fname) as f:
    indexCounter = 0
    line=f.readline()
    cleanedLine = cleanSentences(line)    split = cleanedLine.split()    for word in split:        try:
            firstFile[indexCounter] = wordsList.index(word)
        except ValueError:
            firstFile[indexCounter] = 399999 #Vector for unknown words
        indexCounter = indexCounter + 1firstFile

現在,我們用相同的方法來處理全部的 25000 條評論。我們將匯入電影訓練集,並且得到一個 25000 * 250 的矩陣。這是一個計算成本非常高的過程,因此我在這邊提供了一個我處理好的索引矩陣檔案。

輔助函式

下面你可以找到幾個輔助函式,這些函式在稍後訓練神經網路的步驟中會使用到。

RNN 模型

現在,我們可以開始構建我們的 TensorFlow 圖模型。首先,我們需要去定義一些超引數,比如批處理大小,LSTM的單元個數,分類類別和訓練次數。

batchSize = 24lstmUnits = 64numClasses = 2iterations = 100000

與大多數 TensorFlow 圖一樣,現在我們需要指定兩個佔位符,一個用於資料輸入,另一個用於標籤資料。對於佔位符,最重要的一點就是確定好維度。

標籤佔位符代表一組值,每一個值都為 [1,0] 或者 [0,1],這個取決於資料是正向的還是負向的。輸入佔位符,是一個整數化的索引陣列。

import tensorflow as tftf.reset_default_graph()

labels = tf.placeholder(tf.float32, [batchSize, numClasses])
input_data = tf.placeholder(tf.int32, [batchSize, maxSeqLength])

一旦,我們設定了我們的輸入資料佔位符,我們可以呼叫 tf.nn.embedding_lookup() 函式來得到我們的詞向量。該函式最後將返回一個三維向量,第一個維度是批處理大小,第二個維度是句子長度,第三個維度是詞向量長度。更清晰的表達,如下圖所示:

data = tf.Variable(tf.zeros([batchSize, maxSeqLength, numDimensions]),dtype=tf.float32)data = tf.nn.embedding_lookup(wordVectors,input_data)

現在我們已經得到了我們想要的資料形式,那麼揭曉了我們看看如何才能將這種資料形式輸入到我們的 LSTM 網路中。首先,我們使用

tf.nn.rnn_cell.BasicLSTMCell 函式,這個函式輸入的引數是一個整數,表示需要幾個 LSTM 單元。這是我們設定的一個超引數,我們需要對這個數值進行除錯從而來找到最優的解。然後,我們會設定一個 dropout 引數,以此來避免一些過擬合。

最後,我們將 LSTM cell 和三維的資料輸入到 tf.nn.dynamic_rnn ,這個函式的功能是展開整個網路,並且構建一整個 RNN 模型。

lstmCell = tf.contrib.rnn.BasicLSTMCell(lstmUnits)
lstmCell = tf.contrib.rnn.DropoutWrapper(cell=lstmCell, output_keep_prob=0.75)
value, _ = tf.nn.dynamic_rnn(lstmCell, data, dtype=tf.float32)

堆疊 LSTM 網路是一個比較好的網路架構。也就是前一個LSTM 隱藏層的輸出是下一個LSTM的輸入。堆疊LSTM可以幫助模型記住更多的上下文資訊,但是帶來的弊端是訓練引數會增加很多,模型的訓練時間會很長,過擬合的機率也會增加。如果你想了解更多有關堆疊LSTM,可以檢視TensorFlow的官方教程。

dynamic RNN 函式的第一個輸出可以被認為是最後的隱藏狀態向量。這個向量將被重新確定維度,然後乘以最後的權重矩陣和一個偏置項來獲得最終的輸出值。

weight = tf.Variable(tf.truncated_normal([lstmUnits, numClasses]))bias = tf.Variable(tf.constant(0.1, shape=[numClasses]))value = tf.transpose(value, [1, 0, 2])last = tf.gather(value, int(value.get_shape()[0]) - 1)prediction = (tf.matmul(last, weight) + bias)

接下來,我們需要定義正確的預測函式和正確率評估引數。正確的預測形式是檢視最後輸出的0-1向量是否和標記的0-1向量相同。

correctPred = tf.equal(tf.argmax(prediction,1), tf.argmax(labels,1))
accuracy = tf.reduce_mean(tf.cast(correctPred, tf.float32))

之後,我們使用一個標準的交叉熵損失函式來作為損失值。對於優化器,我們選擇 Adam,並且採用預設的學習率。

loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=prediction, labels=labels))
optimizer = tf.train.AdamOptimizer().minimize(loss)

如果你想使用 Tensorboard 來視覺化損失值和正確率,那麼你可以修改並且執行下列的程式碼。

import datetime

tf.summary.scalar('Loss', loss)
tf.summary.scalar('Accuracy', accuracy)
merged = tf.summary.merge_all()
logdir = "tensorboard/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "/"writer = tf.summary.FileWriter(logdir, sess.graph)

超引數調整

選擇合適的超引數來訓練你的神經網路是至關重要的。你會發現你的訓練損失值與你選擇的優化器(Adam,Adadelta,SGD,等等),學習率和網路架構都有很大的關係。特別是在RNN和LSTM中,單元數量和詞向量的大小都是重要因素。

  • 學習率:RNN最難的一點就是它的訓練非常困難,因為時間步驟很長。那麼,學習率就變得非常重要了。如果我們將學習率設定的很大,那麼學習曲線就會波動性很大,如果我們將學習率設定的很小,那麼訓練過程就會非常緩慢。根據經驗,將學習率預設設定為 0.001 是一個比較好的開始。如果訓練的非常緩慢,那麼你可以適當的增大這個值,如果訓練過程非常的不穩定,那麼你可以適當的減小這個值。
  • 優化器:這個在研究中沒有一個一致的選擇,但是 Adam 優化器被廣泛的使用。
  • LSTM單元的數量:這個值很大程度上取決於輸入文字的平均長度。而更多的單元數量可以幫助模型儲存更多的文字資訊,當然模型的訓練時間就會增加很多,並且計算成本會非常昂貴。
  • 詞向量維度:詞向量的維度一般我們設定為50到300。維度越多意味著可以儲存更多的單詞資訊,但是你需要付出的是更昂貴的計算成本。

訓練

訓練過程的基本思路是,我們首先先定義一個 TensorFlow 會話。然後,我們載入一批評論和對應的標籤。接下來,我們呼叫會話的 run 函式。這個函式有兩個引數,第一個引數被稱為 fetches 引數,這個引數定義了我們感興趣的值。我們希望通過我們的優化器來最小化損失函式。第二個引數被稱為 feed_dict 引數。這個資料結構就是我們提供給我們的佔位符。我們需要將一個批處理的評論和標籤輸入模型,然後不斷對這一組訓練資料進行迴圈訓練。

我們不在這裡對模型進行訓練(因為至少需要花費幾個小時),我們載入一個預訓練好的模型。

如果你決定使用你自己的機器去訓練這個網路,那麼你可以使用 TensorBoard 來檢視這個訓練過程。你可以開啟終端,然後在裡面執行 tensorboard --logdir=tensorboard ,之後你就可以在 http://localhost:6006/ 中檢視到整個訓練過程。

# sess = tf.InteractiveSession()# saver = tf.train.Saver()# sess.run(tf.global_variables_initializer())# for i in range(iterations):#    #Next Batch of reviews#    nextBatch, nextBatchLabels = getTrainBatch();#    sess.run(optimizer, {input_data: nextBatch, labels: nextBatchLabels})#    #Write summary to Tensorboard#    if (i % 50 == 0):#        summary = sess.run(merged, {input_data: nextBatch, labels: nextBatchLabels})#        writer.add_summary(summary, i)#    #Save the network every 10,000 training iterations#    if (i % 10000 == 0 and i != 0):#        save_path = saver.save(sess, "models/pretrained_lstm.ckpt", global_step=i)#        print("saved to %s" % save_path)# writer.close()

載入一個預訓練的模型

在訓練過程中,這個預訓練模型的正確率和損失值如下所示:

檢視上面的訓練曲線,我們發現這個模型的訓練結果還是不錯的。損失值在穩定的下降,正確率也不斷的在接近 100% 。然而,當分析訓練曲線的時候,我們應該注意到我們的模型可能在訓練集上面已經過擬合了。過擬合是機器學習中一個非常常見的問題,表示模型在訓練集上面擬合的太好了,但是在測試集上面的泛化能力就會差很多。也就是說,如果你在訓練集上面取得了損失值是 0 的模型,但是這個結果也不一定是最好的結果。當我們訓練 LSTM 的時候,提前終止是一種常見的防止過擬合的方法。基本思路是,我們在訓練集上面進行模型訓練,同事不斷的在測試集上面測量它的效能。一旦測試誤差停止下降了,或者誤差開始增大了,那麼我們就需要停止訓練了。因為這個跡象表明,我們網路的效能開始退化了。

匯入一個預訓練的模型需要使用 TensorFlow 的另一個會話函式,稱為 Server ,然後利用這個會話函式來呼叫 restore 函式。這個函式包括兩個引數,一個表示當前的會話,另一個表示儲存的模型。

sess = tf.InteractiveSession()
saver = tf.train.Saver()
saver.restore(sess, tf.train.latest_checkpoint('models'))

然後,從我們的測試集中匯入一些電影評論。請注意,這些評論是模型從來沒有看見過的。你可以通過以下的程式碼來檢視每一個批處理的正確率。

iterations = 10for i in range(iterations):
    nextBatch, nextBatchLabels = getTestBatch();    print("Accuracy for this batch:", (sess.run(accuracy, {input_data: nextBatch, labels: nextBatchLabels})) * 100)

結論

在這篇文章中,我們通過深度學習方法來處理情感分析任務。我們首先設計了我們需要哪些模型元件,然後來編寫我們的 TensorFlow 程式碼,來具體實現這些元件,並且我們需要設計一些資料管道來作為資料的流通渠道。最後,我們訓練和測試了我們的模型,以此來檢視是否能在電影評論集上面正常工作。

在 TensorFlow 的幫助下,你也可以來建立自己的情感分析模型,並且來設計一個真實世界能用的模型。

作者:chen_h 連結:http://www.jianshu.com/p/d443aab9bcb1