第一部分:文字處理
歡迎來到機器學習和自然語言處理原型編碼教程系列的第一部分。 Thoughtly正在製作一個著重於理解機器學習基礎的系列教程,著重關注於在自然語言處理中的應用。
這一系列教程的目標是提供有據可查的可用程式碼,附加留言部分的深入探討。程式碼將被放到GitHub上,在一個開放的許可證下,允許你任意修改或使用——不必署名(註明來源)。這裡的程式碼為了明白起見以犧牲效能為代價寫的比較冗長。如果你有大量的資料要處理,這些工具的可擴充套件性很可能無法達到完成你目的的要求。幸運的是,我們正在計劃通過研究此處討論的演算法在當下最新的實現,來更好地對這個系列進行深入探索。這些內容都是黑盒子,是我們在初始系列中有意避免(到實用的程度)提到的內容。我們相信,在能使用這些黑盒子之前,在機器學習方面打下一個堅實的基礎是至關重要的。
第一部分的重點是如何從文字語料庫提取出資訊來。我們有意用介紹性的水平來開始教程,但是它涉及到很多不同的技巧和測量標準,這些方法都會在之後應用到更深入的機器學習任務上。
文字提取
下文介紹的以及此處程式碼中用到的工具,都假設我們將所選的語料當作一袋單詞。這是你在處理文字文件的時候常常會看到的一個基本概念。將語料當作一袋單詞是將文件向量化中的一個典型步驟,以供機器學習演算法進一步處理。把文件轉換成可處理向量通常還需要採取一些額外步驟,我們將在後面的課程中對此進行討論。本課程中介紹的概念和工具將作為後面工具的構建模組。也許更重要的是,這些工具可以幫助你通過快速檢查一個文字語料庫,從而對它所包含的內容有一個基本的瞭解。
本課程中我們所研究的程式碼及示例都是使用Python實現的。這些程式碼能夠從NLTK(Python的自然語言工具包)所提供的不同的文字語料庫中提取資料。這是個包括了ABC新聞的文字、聖經的創世紀、從古滕堡計劃中選取的部分文字、總統就職演說、國情諮文和從網路上擷取的部分文字所組成的語料庫。另外,使用者還能從他們自己提供的語料庫來提取文字。從NLTK匯入的程式碼並不是特別有趣,但我們想指出的是,要從NLTK文字語料庫中提取資料是非常簡單方便的。
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 |
def load_text_corpus(args): if args["abc"]: logging.debug("Loading the ABC corpus.") name = "ABC" words = nltk.corpus.abc.words() elif args["genesis"]: logging.debug("Loading the ABC corpus.") name = "Genesis" words = nltk.corpus.genesis.words() elif args["gutenberg"]: logging.debug("Loading the Gutenberg corpus.") name = "Gutenberg" words = nltk.corpus.gutenberg.words() elif args["inaugural"]: logging.debug("Loading the Inaugural Address corpus.") name = "Inaugural" words = nltk.corpus.inaugural.words() elif args["stateUnion"]: logging.debug("Loading the State of the Union corpus.") name = "Union" words = nltk.corpus.state_union.words() elif args["webtext"]: logging.debug("Loading the webtext corpus.") name = "Web" words = nltk.corpus.webtext.words() elif args["custom"] != None: logging.debug("Loading a custom corpus from " + args["custom"]) name = "Custom" words = load_custom_corpus(args["custom"]) else: words = "" name = "None" logging.debug("Read " + str(len(words)) + " words: " + str(words[0:20])) return words, name |
上面的大部分程式碼只是日誌。有意思的部分在357行、362行、367行等。基於使用者選擇,每部分載入不同的語料庫。 NLTK對從現有語料庫中提取文字提供了一些非常便利的方法。這包括一些簡單的、純文字的語料庫,也包括一些已經用各種方式被標記過的語料庫 —— 語料庫中的每個文件可能被標記過類別或是語料庫中有的語音已被加過標籤,如此等等。在本課程中,我們對NLTK的使用僅限於語料庫的匯入、詞彙的切分,以及我們下面將討論兩個操作,詞根和詞形還原。雖然不會總是如此,但現在為止足夠我們需要的所有功能。值得注意的是,您還可以在指令碼中使用-custom引數匯入自定義語料庫。這應該是含有.txt檔案的資料夾。該資料夾是遞迴讀入的,所以含有.txt檔案的子資料夾也能被處理。
詞彙切分
詞彙切分是切分語料庫,使之變成各個獨立部分——通常指單詞,的行為。我們這樣做是因為大多數ML演算法無法處理任意長的文字字串。相反,他們會假設你已經分割你的語料庫為單獨的,演算法可處理的詞塊(token)。雖然我們將在後面的課程詳細討論這個話題,演算法不一定限於一次只處理一個詞塊(token)。事實上,許多演算法只在處理短序列(n-grams)時有用。本課程中我們將情況限定於一序列(1-grams),或者叫,單序列(unigram)。
對文字語料庫做詞彙切分的最簡單的方法就是僅基於空白字元。這種方法確實非常簡單,但它也有缺點。例如,它會導致位於句尾的文字包含有句尾標點符號,而一般不需要這樣。在另一方面,類似can’t和e.g.這樣帶有詞內標點的單詞就沒法被正確提取出來了。我們可以新增一步操作來刪除所有非字母數字的字元。這將解決句尾標點符號的問題,同時也能將can’t和e.g.這樣的單詞提取出來,儘管是以丟掉了他們的標點符號的方式被提取出來的。然而,這也引入了一個新的問題。對於某些應用,我們還是希望保留標點符號。在建立語言模型的時候,句尾標點能區分一個單詞是否是結尾單詞,從這方面來說,額外的標點資訊是有價值的。
對於這個任務,我們要將一些標點符號(句號)作為一個詞,使用NLTK word_tokenizer(它是基於TreebankWordTokenizer來實現的)來做詞彙切分。這個分詞器有很多針對各式各樣的詞彙做切分的規則。舉例來說,“can’t”這樣的縮寫實際上被分成了兩個詞(token) – ca和n’t。有趣的是,這意味著我們最後會得到ca這樣的詞,它理想地匹配了can(在某些任務中)。這樣的錯誤匹配是這種符號化演算法帶來的不幸後果。NLTK支援多種分詞器。這是一個及其冗長的檔案,http://www.nltk.org/api/nltk.tokenize.html,但在裡面可以找到它所支援的分詞器的細節。
詞幹提取和詞形還原
一旦取到了文字我們就可以開始處理它。指令碼提供了許多簡單的工具,它們會幫助我們檢視我們所選擇的內容。之後我們會深入談到這些工具。首先,讓我們思考一下該用什麼方法來操作我們取到的文字。通常我們需要為ML演算法提供從語料庫提取的原始文字詞彙(單詞)。在其他情況下,將這些單詞轉成原始內容的各種變形也是有道理的。
具體來說,我們經常要將原始單詞截斷到它的詞根。那麼,什麼是一個詞根呢?英語單詞有從原始單詞延伸出的通用字尾。就拿單詞”run”為例。有很多的擴充套件它的詞 – “runner”,”runs”,”running”等,即對基本定義的進一步闡述。詞幹提取是從”runner”,”runs”以及“running”中去除所有和”run”不一致的部分的過程。請注意,在上述列表中不包含”ran” —— 後面我們再對此進行闡述。下面是一個被提取詞幹的句子的具體例項。
1 |
stem(Jim is running to work.) => Jim is run to work. |
我們已經丟失了”吉姆在跑步”這個資訊,儘管此處的上下文隱含的所有其他資訊都說不通。我們不可能完全扭轉這一點 —— 我們可以猜測那裡曾經是什麼詞,但我們很可能會弄錯。
此處提供的程式碼可以讓你對你的語料庫進行詞幹提取。實際的詞幹提取是微不足道的,因為我們會使用NLTK來進行這部分工作。我們只需通過輸入陣列迭代,並返回使用NLTK Porter Stemmer所得到的各種提取後的詞幹變體。有許多不同的詞幹分析器可供選擇,還包括非英語語言的選項。Porter Stemmer常用於英語。
1 2 3 4 5 6 7 |
def stem_words_array(words_array): stemmer = nltk.PorterStemmer(); stemmed_words_array = []; for word in words_array: stem = stemmer.stem(word); stemmed_words_array.append(stem); return stemmed_words_array; |
詞形還原類似於詞幹提取,但又有著重要的區別。與使用一系列簡單的規則將一個單詞截斷成它的詞根不同,詞形還原嘗試對輸入的單詞確定一個恰當的詞根。本質上,詞形還原試圖找到一個單詞的字典項,也稱為單詞的基本形(base term)。為了使這種查詢能正確的工作,詞形還原器必須知道您尋找的這個詞在句子中的詞性。生成語料庫的詞條與詞幹提取的程式碼基本上是相同的(儘管這段程式碼有上文略為提及的缺點,我們將在下面進一步對此進行討論)。這裡我們用了WordNetLemmatizer,它使用WordNet的資料庫作為其查詢指定詞條的字典。
1 2 3 4 5 6 7 |
def lemmatize_words_array(words_array): lemmatizer = nltk.stem.WordNetLemmatizer() lemmatized_words_array = []; for word in words_array: lemma = lemmatizer.lemmatize(word) lemmatized_words_array.append(lemma) return lemmatized_words_array; |
正如上文所述,詞形還原知道單詞的詞性。NLTK WordNetLemmatizer天真地假設,所有傳入的單詞都是名詞。這種假設意味著你必須告訴詞形還原器要傳遞的詞不是一個名詞,否則它會錯誤的地將其視為一個名詞。這個行為,加上對未知的單詞(特別是當它混在一段文字中的時候)不做任何處理直接輸出的行為,使得詞形還原器處理效果很差。舉例來說,如果讓詞形還原器處理”ran”這個詞,在不指出”ran”屬於一段文字的情況下,它將直接輸出”ran”。它不知道的作為名詞的”ran”,因為很明顯”ran”不是一個名詞。但是,如果你正確地指出”ran”是動詞,那麼詞形還原器就能輸出”run”。與相對,此處詞幹分析器就會輸出”ran”。因此,如果我們要有效地利用詞形還原器,我們也必須付出在原始碼中對詞性進行標註的代價,我們將在後面的課程中對詞性標註的部分進行討論。標記單詞詞性的額外成本也是詞形還原器不像詞幹分析器那樣應用廣泛的原因之一 —— 所新增的功能抵不上所花的成本。
詞彙量
現在,使用詞幹提取或詞形還原的方法,我們已經拉取了一個語料庫並且(視情況)對它做了變形,終於可以開始檢視它的內容了。下面不是一個詳盡的清單,但作為審查文字的技術參考。有些是立刻會用到的,其他則會在以後討論到。
第一項測量是最簡單的——詞彙計數。這個指標是語料庫內所有唯一字的計數。正如你所期望的,程式碼很容易實現。唯一一個你之後還會再遇到的技巧,是我們決定使用Python裡dictionary的唯一性。即任一字典的條目在字典中不能出現超過一次。
1 2 3 4 5 |
def collect_unique_terms(corpus): unique_vocabulary = {} for term in corpus: unique_vocabulary[term] = 1; return unique_vocabulary; |
這種方法可以讓我們對我們的資料有所認知。思考我們使用詞幹提取及詞形還原來考察ABC語料庫後的如下輸出。
首先是原始語料文字:
1 2 3 4 5 |
> python words.py -vv -abc -s -vs Loading the ABC corpus. Read 766811 words: [u'PM', u'denies', u'knowledge', u'of', u'AWB', u'kickbacks', u'The', u'Prime', u'Minister', u'has', u'denied', u'he', u'knew', u'AWB', u'was', u'paying', u'kickbacks', u'to', u'Iraq', u'despite'] The corpus contains 766811 elements after processing The corpus has a total vocabulary of 31885 unique tokens. |
其次是詞形還原後的語料庫:
1 2 3 4 5 |
> python words.py -vv -abc -l -vs Loading the ABC corpus. Read 766811 words: [u'PM', u'denies', u'knowledge', u'of', u'AWB', u'kickbacks', u'The', u'Prime', u'Minister', u'has', u'denied', u'he', u'knew', u'AWB', u'was', u'paying', u'kickbacks', u'to', u'Iraq', u'despite'] The corpus contains 766811 elements after processing The corpus has a total vocabulary of 28699 unique tokens. |
最後,是詞幹提取後的語料庫:
1 2 3 4 5 |
> python words.py -vv -abc -vs Loading the ABC corpus. Read 766811 words: [u'PM', u'denies', u'knowledge', u'of', u'AWB', u'kickbacks', u'The', u'Prime', u'Minister', u'has', u'denied', u'he', u'knew', u'AWB', u'was', u'paying', u'kickbacks', u'to', u'Iraq', u'despite'] The corpus contains 766811 elements after processing The corpus has a total vocabulary of 22162 unique tokens. |
可以看到,從原始資料到詞形還原到詞幹提取後,語料庫中唯一字計數值總體在減少,從31K至28K到22K。這個模式重複於每個語料庫。在每個例項中,原始語料庫的字數統計大於詞幹提取後的,而詞幹提取後的字數統計則大於詞形還原後的。
上面的圖表是使用我們共享工程的Python程式碼生成。它對非定製語料庫列表進行遍歷,並分別計算原始、詞幹提取後、詞形還原後的唯一字數量。你可以用命令列重現這個圖表。你還可以得到一份同樣內容的文字轉儲。
1 2 3 4 5 6 |
> python words.py -v --stemVsLemma 2015-02-02 19:49:22,255 (INFO): Corpora: ['ABC', 'Genesis', 'Gutenberg', 'Inaugural', 'Union', 'Web'] 2015-02-02 19:49:22,255 (INFO): Word Counts: [31885, 25841, 51156, 9754, 14591, 21538] 2015-02-02 19:49:22,255 (INFO): Lemmatized Word Counts: [28699, 25444, 46456, 8763, 13111, 20056] 2015-02-02 19:49:22,255 (INFO): Stemmed Word Counts: [22162, 23542, 33521, 6135, 9533, 16599] 2015-02-02 19:49:22,466 (INFO): The corpus contains 0 elements after processing |
詞項存在
加入一點複雜性,我們接下來看看如何輸出上文所指出的詞項。我們生成一個CSV檔案,它包含了出現在語料中的每個唯一字。它使用了上文中collect_unique_terms這個方法,只是不同於僅僅簡單地輸出唯一字計數,它通過遍歷返回的字典會列印出每個鍵值。
1 2 3 4 5 6 7 |
def output_corpus_terms(corpus, unique_vocabulary=None): if unique_vocabulary is None: unique_vocabulary = collect_unique_terms(corpus) output_csv_file = open_csv_file("corpus_terms.csv", ["Term"]) for term in unique_vocabulary: logging.debug(term) output_csv_file.writerow([term]) |
雖然在僅僅輸出一個單詞的情況下,它可能看起來意義非常有限,但也有比統計每個詞的總計數更好的演算法(我們接下來將要將討論)。正如對微博(tweets)的情感分析一樣——在處理較短的文字序列時,我們更傾向於選擇它。
您可以在命令列中使用所提供的程式碼生成CSV。
1 2 |
python words.py -v --termPresence --gutenberg 2015-02-02 20:07:49,623 (INFO): The corpus contains 2621613 elements after processing |
詞頻
詞頻是詞項存在的延伸。不是簡單地指出一個詞的存在,而是在確定詞頻的時候,我們更關心語料庫中的每個詞所出現的例項個數。計算這個的程式碼與確定詞項存在的程式碼是非常相似的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def collect_and_output_corpus_term_frequencies(corpus, corpus_name): term_frequencies = collect_term_counts(corpus) output_csv_file = open_csv_file("term_frequencies.csv", ["Term", "Frequency"]) unsorted_array = [[key,value] for key, value in term_frequencies.iteritems()] sorted_array = sorted(unsorted_array, key=lambda term_frequency: term_frequency[1], reverse=True) for term, frequency in sorted_array: output_csv_file.writerow([term] + [frequency]) # output a bar chart illustrating the above chart_term_frequencies("term_frequencies.png", "Term Frequencies (" + corpus_name + ")", "Term Frequencies", sorted_array, [0, 1, 2, -3, -2, -1]) return term_frequencies |
詞頻,或者我們將在下一節中看到的基於它的變形,是機器學習的向量化過程中常見的主要組成部分。一般來說,ML演算法需要一組能代表需要判定的單個樣本的特徵集。但是,文字並不能自動地適應這種模式。要迫使它去適應,我們不能考慮文字本身,而是要看文字例項的數量。詞頻是將文字域對映到ML友好的實數域一個簡單的方法。
您可以在命令列中使用提供的原始碼生成包含所有詞條和其頻率的有序列表的CSV檔案以及上面的圖表。
1 2 |
> python words.py -v --termFrequency --genesis 2015-02-02 20:27:45,298 (INFO): The corpus contains 315268 elements after processing |
記錄標準化詞頻
該圖顯示了語料庫中三個最常見詞條和最不常見詞條的原始頻率。這裡提供的程式碼可以為使用者選擇的語料庫生成上述圖表。此外。執行該指令碼還會生成一個名為term_frequencies.csv的檔案,它能讓使用者看到一個包含文件中的所有唯一字及其相應詞頻的電子表格。使用美國總統就職演說來生成就是:
1 2 3 4 |
> python words.py -vv --termFrequency --inaugural 2015-02-02 20:04:09,345 (DEBUG): Loading the Inaugural Address corpus. 2015-02-02 20:04:09,464 (DEBUG): Read 145735 words: [u'Fellow', u'-', u'Citizens', u'of', u'the', u'Senate', u'and', u'of', u'the', u'House', u'of', u'Representatives', u':', u'Among', u'the', u'vicissitudes', u'incident', u'to', u'life', u'no'] 2015-02-02 20:04:09,465 (INFO): The corpus contains 145735 elements after processing |
機器學習演算法在特徵值沒有規範到相似的尺度內的時候常常就不工作了。在計算詞頻的情況下,相對於罕見的單詞來說,常用字可能會出現得非常頻繁。這將造成這些組完全不同的頻率字之間的顯著歪斜。即使演算法能處理歪斜的特徵量, 如果你認為出現率10倍以上的詞條就重要10倍以上的話,那麼特定的任務可能無法正常工作。收縮特徵值大小,同時還允許收縮後的特徵值隨著原始資料的增長而增長,一種常見的方法是取該特徵值的對數。在此例項中,我們使用下面的方程來對詞頻資料進行歸一:
1 |
LogNormalizedTF = 1 + log10(TermFrequency) |
使用對數以10為底意味著對於每10倍增加的詞頻,我們將看到對數歸一後的詞頻的一個數量點的增長。我們將對數歸一後的詞頻初始化為1,這樣一來,對於詞頻是0的詞來說值剛好也是1。用來計算歸一化後的詞頻的程式碼非常簡單,並依賴於前一節中提到的頻率採集器。請注意,此程式碼同時轉儲輸出到一個CSV檔案。上文其他的示例程式碼沒有這一步,因為它們其實是那些最終去轉儲CSV方法的輔助方法。這段程式碼恰好是在轉儲到CSV前做了少量工作(計算對數歸一化)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
if term_frequencies is None: term_frequencies = collect_term_counts(corpus) output_csv_file = open_csv_file("normalized_term_frequencies.csv", ["Term", "Log Normalized TF"]) unsorted_array = [] for term, frequency in term_frequencies.iteritems(): normalized_term_frequency = (1 + math.log(frequency, 10)) unsorted_array.append([term, normalized_term_frequency]) output_csv_file.writerow([term] + [normalized_term_frequency]) sorted_array = sorted(unsorted_array, key=lambda term_frequency: term_frequency[1], reverse=True) # output a bar chart illustrating the above chart_term_frequencies("normalized_term_frequencies.png", "Log Normalized Term Frequencies (" + corpus_name + ")", "Term Frequencies", sorted_array, [0, 1, 2, -3, -2, -1]) return term_frequencies |
下面的圖表和上節”詞頻”中使用的是相同的資料。它顯示了就職演說語料庫中的三個最常用的詞和三個不最常用的詞。令人感興趣的是詞頻值的壓縮。在語料庫中出現頻率約2000倍以上的詞,它的對數歸一化版本比文件中只出現一次得分為1(固有地、不公地、疏遠地)的詞得分只略微高了一點。出現頻率超過8000倍的時候,它的分數也只增長了5倍而已。這種壓縮用於將文字特徵尺寸保持在一個相對小的數值範圍內。
類似前面的例子,此圖也可以直接使用所提供的程式碼生成。
1 2 |
> python words.py -v --logNormalize --genesis 2015-02-02 20:42:02,538 (INFO): The corpus contains 315268 elements after processing |
詞頻頻率
不,這不是一個打字錯誤。詞頻頻率與我們到目前為止討論的指標稍有不同。這些資訊不太可能被直接當作一個ML演算法的特徵值來用。然而,它可以為檢查語料庫結構的人提供很多資訊。本質上,詞頻頻率對給定的頻率的詞項進行計數。說再多也比不上直接舉一個例子。如果你執行下面的命令列:
1 |
> python words.py --inaugural -ff |
它會生成一個名為frequency_frequencies.csv的CSV檔案,以及下面的圖表。
可以看到,它計算了一個單詞被使用特定(大約4200次)次數的計數(1次)。這是一種奇怪的指標,但它多少可以給你這個語料庫是否都是由很少使用的單詞所組成的一個大致印象。在這個例子中,整個語料庫包含145735個字。例如,讓我們認為出現四次或更少次數的單詞就是罕見詞,別的則是常用詞。我們知道,這個語料庫中有4122+1488+817+547=6,974個或約佔總字數4.7%的罕見詞。與此相比,國情諮文語料庫中有9581,總字數為399822,或約佔總字數2%的罕見詞。這似乎暗示了就職演說與國情諮文相比有著更豐富的詞彙量。這是有用的資訊嗎?可能有。取決於你想了解該文字的什麼方面。
計算詞頻頻率的程式碼是非常簡單的。它和上文利用相同的詞頻資料。該方法通過遍歷”詞/詞頻”字典,並建立一個新的frequency_frequencies字典,累計對應頻率的不同詞個數。總而言之,我們對出現在每個頻率的詞條數進行計數。
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 |
def collect_and_output_frequency_frequencies(corpus, corpus_name, term_frequencies): if term_frequencies is None: term_frequencies = collect_term_counts(corpus) frequency_frequencies = {} for term, frequency in term_frequencies.iteritems(): if frequency_frequencies.has_key(frequency): frequency_frequencies[frequency] += 1 else: frequency_frequencies[frequency] = 1 unsorted_array = [[key,value] for key, value in frequency_frequencies.iteritems()] sorted_array = sorted(unsorted_array, key=lambda frequency_frequency: frequency_frequency[1], reverse=True) frequency_frequencies_to_chart = [] frequencies_to_chart = [] output_csv_file = open_csv_file("frequency_frequencies.csv", ["Frequency Frequency", "Term Frequency"]) # we collect frequencies_to_chart and frequency_frequencies_to_chart each into their own single dimensional # array. Then we pass frequency_frequencies_to_chart in an array so that it is 2D as needed by the chart. # This means there is exactly 1 data set and 6 columns of data in the set. There is no second set to compare # it to. for index, (term_frequency, frequency_frequency) in enumerate(sorted_array): output_csv_file.writerow([frequency_frequency] + [term_frequency]) if index <= 20: frequencies_to_chart.extend([term_frequency]) frequency_frequencies_to_chart.extend([frequency_frequency]) charting.bar_chart( "frequency_frequencies.png", [frequency_frequencies_to_chart], "Frequency Frequencies (" + corpus_name + ")", frequencies_to_chart, "Frequency Frequency", None, ['#59799e', '#810CE8', '#FF0000', '#12995D', '#FD53FF', '#AA55CC'], 0.2, 0.0) return frequency_frequencies |
總結
在這一課中,我們研究了一些基本指標和文字分析的一些基礎模組。我們沒有做任何的機器學習相關的事情。別擔心,ML程式碼的乾貨即將到來。在我們進行到那裡之前,我們要先了解基礎的概率論和一些簡單的語言建模技術。將本課程、概率論和一些語言建模技術的結合起來,能帶領我們接觸第一個真正的機器學習任務——樸素貝葉斯分類器。從那裡開始,我們將接觸一系列不同的ML相關的主題,所以暫時別離開,我們保證討論ML程式碼前的主題也能引起你的興趣。
最後,我們希望這個系列是可延展且有見地的。如果我們發現更多更加清晰的材料或新的有用的例子,我們將把它們新增進來。如果你覺得有什麼是值得新增的,也請留言給我們。同樣重要的是,如果讀者發現任何我們弄錯的地方,不管是程式碼或以其他方面,不要猶豫,馬上告知我們。我們對此表示衷心的感謝。