Python深度學習(處理文字資料)--學習筆記(十二)

呆萌的小透明發表於2020-11-12

第6章 深度學習用於文字和序列

  • 用於處理序列的兩種基本的深度學習演算法分別是迴圈神經網路(recurrent neural network)和一維卷積神經網路(1D convnet),這些演算法的應用包括:(1)文件分類和時間序列分類,比如識別文字的主題或書的作者;(2)時間序列對比,比如估測兩個文件或兩支股票行情的相關程度;(3)序列到序列的學習,比如將英語翻譯成法語;(4)情感分析,比如將推文或電影評論的情感劃分為正面或負面;(5)時間序列預測,比如根據某地最近的天氣資料來預測未來天氣。

6.1 處理文字資料

  • 文字是最常用的序列資料之一,可以理解為字元序列或單詞序列,但最常見的是單詞級處理。後面幾節介紹的深度學習序列處理模型都可以根據文字生成基本形式的自然語言理解,並可用於文件分類、情感分析、作者識別甚至問答(QA,在有限的語境下)等應用。當然,請記住,本章的這些深度學習模型都沒有像人類一樣真正地理解文字,而只是對映出書面語言的統計結構,但這足以解決許多簡單的文字分類任務。深度學習用於自然語言處理是將模式識別應用於單詞、句子和段落,這與計算機視覺將模式識別應用於畫素大致相同。
  • 與其他所有神經網路一樣,深度學習模型不會接收原始文字作為輸入,它只能處理數值張量。文字向量化(vectorize)是指將文字轉換為數值張量的過程。它有多種實現方法:(1)將文字分割為單詞,並將每個單詞轉換為一個向量。(2)將文字分割為字元,並將每個字元轉換為一個向量。(3)提取單詞或字元的n-gram,並將每個n-gram轉換為一個向量。n-gram是多個連續單詞或字元的集合(n-gram之間可重疊)。
  • 將文字分解而成的單元(單詞、字元或n-gram)叫作標記(token),將文字分解成標記的過程叫作分詞(tokenization)。所有文字向量化過程都是應用某種分詞方案,然後將數值向量與生產的標記相關聯。這些向量組合成序列張量,被輸入到深度神經網路中。將向量與標記相關聯的方法有很多種。本節將介紹兩種主要方法:對標記做one-hot編碼(one-hot encoding)與標記嵌入(token embedding,通常只用於單詞,叫作詞嵌入(word embedding))。

理解n-gram和詞袋

  • n-gram是從一個句子中提取的N個(或更少)連續單詞的集合。這一概念中的“單詞”也可以替換為“字元”。
  • 袋(bag)這一術語指的是標記組成的集合,而不是一個列表或序列,即標記沒有特定的順序。
  • 詞袋是一種不儲存順序的分詞方法(生成的標記組成一個集合,而不是一個序列,捨棄了句子的總體結構),因此它往往被用於淺層的語言處理模型,而不是深度學習模型。提取n-gram是一種特徵工程,深度學習不需要這種四班而又不穩定的方法,並將其替換為分層特徵學習。本章後面將介紹的一維卷積神經網路和迴圈神經網路,都能夠通過觀察連續的單詞序列或字元序列來學習單片語合字元組的資料表示,而無須明確知道這些組的存在。在使用輕量級的淺層文字處理模型時(比如logistic迴歸和隨機森林),n-gram是一種功能強大、不可或缺的特徵工程工具。

6.1.1 單詞和字元的one-hot編碼

  • one-hot編碼是將標記轉換為向量最常用、最基本的方法。它將每個單詞與一個唯一的整數索引相關聯,然後將這個整數索引 i i i轉為為長度為 N N N的二進位制向量( N N N是詞表大小),這個向量只有第 i i i個元素是1,其餘元素都為0。當然,也可以進行字元級的one-hot編碼。
# 單詞級的one-hot編碼
import numpy as np

samples = ['The cat sat on the mat.', 'The dog ate my homework.']

token_index = {} # 構建資料中所有標記的索引
# 為每個唯一單詞指定一個唯一索引。注意,沒有為索引編號0指定單詞。
for sample in samples:
    for word in sample.split():
        if word not in token_index:
            token_index[word] = len(token_index) + 1

# 對樣本進行分詞。只考慮每個樣本前max_length個單詞
max_length = 10

results = np.zeros(shape=(len(samples), max_length, max(token_index.values()) + 1))

for i, sample in enumerate(samples):
    for j, word in list(enumerate(sample.split()))[:max_length]:
        index = token_index.get(word)
        results[i, j, index] = 1.

print(results)
# 字元級的one-hot編碼
import string
import numpy as np

samples = ['The cat sat on the mat.', 'The dog ate my homework.']
characters = string.printable # 所有可列印的ASCII字元
token_index = dict(zip(characters, range(1, len(characters) + 1)))

max_length = 50

results = np.zeros((len(samples), max_length, max(token_index.values()) + 1))

for i, sample in enumerate(samples):
    for j, character in enumerate(sample):
        index = token_index.get(character)
        results[i, j, index] = 1.

print(results)
  • 注意,Keras的內建函式可以對原始文字資料進行單詞級或字元級的one-hot編碼。你應該使用這些函式,因為它們實現了許多重要的特性,比如從字串中去除特殊字元、只考慮資料集中前 N N N個最常見的單詞(這是一種常用的限制,以避免處理非常大的輸入向量空間)。
# 用Keras實現單詞級的one-hot編碼

from keras.preprocessing.text import Tokenizer

samples = ['The cat sat on the mat.', 'The dog ate my homework.']

tokenizer = Tokenizer(num_words=1000) # 建立一個分詞器(tokenizer),設定為只考慮前1000個最常見的單詞。
tokenizer.fit_on_texts(samples) # 構建單詞索引

sequences = tokenizer.texts_to_sequences(samples) # 將字串轉換為整數索引組成的列表

# 得到one-hot二進位制表示。這個分詞器也支援除one-hot編碼外的其他向量化模式。
one_hot_results = tokenizer.texts_to_matrix(samples, mode='binary')

word_index = tokenizer.word_index # 找回單詞索引
print('Found %s unique tokens.' % len(word_index))
  • one-hot編碼的一種變體是所謂的one-hot雜湊技巧(one-hot hasing trick),如果詞表中唯一標記的數量太大而無法直接處理,就可以使用這種技巧。這種方法沒有為每個單詞顯示分配一個索引並將這些索引儲存在一個字典中,而是將單詞雜湊編碼為固定長度的向量,通常用一個非常簡單的雜湊函式來實現。這種方法的主要優點在於,它避免了維護一個顯式的單詞索引,從而節省記憶體並允許資料的線上編碼(在讀取完所有資料之前,你就可以立刻生成標記向量)。這種方法有一個缺點,就是可能會出現雜湊衝突(hash collision),即兩個不同的單詞可能具有相同的雜湊值,隨後任何機器學習模型觀察這些雜湊值,都無法區分它們所對應的單詞。如雜湊空間的維度遠大於需要雜湊的唯一標記的個數,雜湊衝突的可能性會減小。
# 使用雜湊技巧的單詞級的one-hot編碼
import numpy as np

samples = ['The cat sat on the mat.', 'The dog ate my homework.']

# 將單詞儲存為長度1000的向量。如果單詞數量接近1000個(或更多),那麼會遇到很多雜湊衝突,這會降低這種編碼方法的準確性。
dimensionality = 1000
max_length = 10

results = np.zeros((len(samples), max_length, dimensionality))

for i, sample in enumerate(samples):
    for j, word in list(enumerate(sample.split()))[:max_length]:
        index = abs(hash(word)) % dimensionality
        results[i, j, index] = 1.

print(results)

6.1.2 使用詞嵌入

  • 將單詞與向量相關聯還有另一種常用的強大方法,就是使用密集的詞向量(word vector),也叫詞嵌入(word embedding)。one-hot編碼得到的向量是二進位制的、稀疏的(絕大部分元素都是0)、維度很高的(維度大小等於詞表中的單詞個數),而詞嵌入是低維的浮點數向量(即密集向量,與稀疏向量相對)。與one-hot編碼得到的詞向量不同,詞嵌入是從資料中學習得到的。常見的詞向量維度是256、512或1024(處理非常大的詞表時)。與此相對,one-hot編碼的詞向量維度通常為20000或更高(對應包含20000個標記的詞表)。因此,詞嵌入可以將更多的資訊塞入更低的維度中。
  • 獲取詞嵌入有兩種方法:(1)在完成主任務(比如文件分類或情感預測)的同時學習詞嵌入。在這種情況下,一開始是隨機的詞向量,然後對這些詞向量進行學習,其學習方式與學習神經網路的權重相同。
  • 在不同於待解決問題的機器學習任務上預計算好詞嵌入,然後將其載入到模型中。這些詞嵌入叫作預訓練詞嵌入(pretrained word embedding)。
1.利用Embedding層學習詞嵌入
  • 要將一個詞與一個密集向量相關聯,最簡單的方法就是隨機選擇向量。這種方法的問題在於,得到的嵌入空間沒有任何結構。例如,accurate和exact兩個詞的嵌入可能完全不同,儘管它們在大多數句子裡都是可以互換的。深度神經網路很難對這種雜亂的、非結構化的嵌入空間進行學習。
  • 說得更抽象一點,詞向量之間的幾何關係應該表示這些詞之間的語義關係。詞嵌入的作用應該是將人類的語言對映到幾何空間中。例如,在一個合理的嵌入空間中,同義詞應該被嵌入到相似的詞向量中,一般來說,任意兩個詞向量之間的幾何距離(比如L2距離)應該和這兩個詞的語義距離有關(表示不同事物的詞被嵌入到相隔很遠的點,而相關的詞則更加靠近)。除了距離,你可能還希望嵌入空間中的特定方法也是有意義的。
  • 在真實的詞嵌入空間中,常見的有意義的幾何變換的例子包括“性別”向量和“複數”向量。例如,將king(國王)向量加上female(女性)向量,得到的是queen(女王)向量。將king(國王)向量加上plural(複數)向量,得到的是kings向量。詞嵌入空間通常具有幾千個這種可解釋的、並且可能很有用的向量。
  • 有沒有一個理想的詞嵌入空間,可以完美地對映人類語言,並可用於所有的自然語言處理任務?可能有,但我們尚未發現。此外,也不存在人類語言(human language)這種東西。世界上,有許多種不同的語言,而且它們不是同構的,因為語言是特定文化和特定環境的反射。但從更實際的角度來說,一個好的詞嵌入空間在很大程度上取決於你的任務。英語電影評論情感分析模型的完美詞嵌入空間,可能不同於法律文件分類模型的完美詞嵌入空間,因為某些語義關係的重要性因任務而異。
  • 因此,合理的做法是對每個新任務都學習一個新的嵌入空間。幸運的是,反向傳播讓這種學習變得很簡單,而Keras使其變得更簡單。完美要做的就是學習一個層的權重,這個層就是Embedding層。
# 將一個Embedding層例項化
from keras.layers import Embedding

# Embedding層至少需要兩個引數:標記的個數(這裡是1000,即最大單詞索引+1)和嵌入的維度(這裡是64)
embedding_layer = Embedding(1000, 64)
  • 最好將Embedding層理解為一個字典,將整數索引(表示特定單詞)對映為密集向量。它接收整數作為輸入,並在內部字典中查詢這些整數,然後返回相關聯的向量。Embedding層實際上是一種字典查詢:單詞索引->Embedding層->對應的詞向量。
  • Embedding層的輸入是一個二維整數張量,其形狀為(samples, sequence_length),每個元素是一個整數序列。它能夠嵌入長度可變的序列,例如,對於前一個例子中的Embedding層,你可以輸入形狀為(32,10)(32個長度為10的序列組成的批量)或(64,15)(64個長度為15的序列組成的批量)的批量。不過一批資料中的所有序列必須具有相同的長度(因為需要將它們打包成一個張量),所以較短的序列應該用0填充,較長的序列應該被截斷。
  • 這個Embedding層返回一個形狀為(samples,sequence_length,embedding_dimensionality)的三維浮點數張量。然後可以用RNN層或一維卷積層來處理這個三維張量。
  • 將一個Embedding層例項化時,它的權重(即標記向量的內部字典)最開始是隨即的,與其他層一樣。在訓練過程中,利用反向傳播來逐漸調節這些詞向量,改變空間結構以便下游模型可以利用。一旦訓練完成,嵌入空間將會展示大量結構,這種結構專門針對訓練模型所要解決的問題。
  • 接下來將這個想法應用於IMDB電影評論情感預測任務。首先,將電影評論限制為前10000個最常見的單詞,然後將評論長度限制為只有20個單詞。對於這10000個單詞,網路將對每個單詞學習一個8維嵌入,將輸入的整數序列(二維整數張量)轉換為嵌入序列(三維浮點數張量),然後將這個張量展平為二維,最後在上面訓練一個Dense層用於分類。
# 載入IMDB資料,準備用於Embedding層
from keras.datasets import imdb
from keras import preprocessing

max_features = 10000 # 作為特徵的單詞個數
maxlen = 20 # 在這麼多單詞後截斷文字(這些單詞都屬於前max_features個最常見的單詞)

# 將資料載入為整數列表
(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=max_features)

x_train = preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = preprocessing.sequence.pad_sequences(x_test, maxlen=maxlen)

# 在IMDB資料上使用Embedding層和分類器
from keras.models import Sequential
from keras.layers import Flatten, Dense, Embedding

model = Sequential()
# 指定Embedding層的最大輸入長度,以便後面將嵌入展平。Embedding層啟用的形狀為(samples, maxlen, 8)
model.add(Embedding(10000, 8, input_length=maxlen))

# 將三維的嵌入張量展平成形狀為(samples, maxlen * 8)的二維張量
model.add(Flatten())

# 在上面新增分類器
model.add(Dense(1, activation='sigmoid'))
print(model.summary())

model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])

history = model.fit(x_train, y_train, epochs=10, batch_size=32, validation_split=0.2)
  • 僅僅將嵌入序列展開並在上面訓練一個Dense層,會導致模型對輸入序列中的每個單詞單獨處理,而沒有考慮單詞直接的關係和句子結構(模型可能會將this movie is a bomb和this movie is the bomb兩條都歸為負面評論)。更好的做法是在嵌入序列上新增迴圈層或一維卷積層,將每個序列作為整體來學習特徵。
2.使用預訓練的詞嵌入
  • 有時可用的訓練資料很少,以至於只用手頭資料無法學習適合特定任務的詞嵌入。這時可以從預計算的嵌入空間中載入嵌入向量(你知道這個嵌入空間是高度結構化的,並且具有有用的屬性,即抓住了語言結構的一般特點),而不是在解決問題的同時學習詞嵌入。在自然語言處理中使用預訓練的詞嵌入,其背後的原理與在影像分類中使用預訓練的卷積神經網路是一樣的:沒有足夠的資料來自己學習真正強大的特徵,但你需要的特徵應該是非常通用的,比如常見的視覺特徵或語義特徵。在這種情況下,重複使用在其他問題上學到的特徵,這種做法是有道理的。
  • 這種詞嵌入通常是利用詞頻統計計算得出的(觀察那些詞共同出現在句子或文件中),用到的技術很多,有些涉及神經網路,有些則不涉及。Bengio等人在21世紀初首先研究了一種思路,就是用無監督的方法計算一個密集的低維詞嵌入空間,但知道最有名且最成功的詞嵌入方案之一word2vec演算法釋出之後,這一思路才開始在研究領域和工業應用中取得成功。word2vec演算法由Google的Tomas Mikolov於2013年開發,其維度抓住了特定的語義屬性,比如性別。
  • 有許多預計算的詞嵌入資料庫,你都可以下載並在Keras的Embedding層中使用。word2vec就是其中之一。另一個常用的是GloVe(global vectors for word representation, 詞表示全域性向量),由史丹佛大學的研究人員於2014年開發。這種嵌入方法基於對詞共現統計矩陣進行因式分解。其開發者已經公開了數百萬個英文標記的預計算嵌入,它們都是從維基百科資料和Common Crawl資料得到的。

6.1.3 整合在一起:從原始文字到詞嵌入

  • 本節的模型與之前剛剛見過的那個類似:將句子嵌入到向量序列中,然後將其展平,最後在上面訓練一個Dense層。但此處將使用預訓練的詞嵌入。此外,我們將從頭開始,先下載IMDB原始文字資料,而不是使用Keras內建的已經預先分詞的IMDB資料。
1.下載IMDB資料的原始文字
  • 首先,開啟http://mng.bz/0tIo,下載原始IMDB資料集並解壓。
  • 接下來,我們將訓練評論轉換成字串列表,每個字串對應一條評論。你也可以將評論標籤(正面/負面)轉換成labels列表。
# 處理IMDB原始資料的標籤
import os

imdb_dir = "../data/aclImdb"
train_dir = os.path.join(imdb_dir, 'train')

labels = []
texts = []

for label_type in ['neg', 'pos']:
    dir_name = os.path.join(train_dir, label_type)

    for fname in os.listdir(dir_name):
        f = open(os.path.join(dir_name, fname),'r', encoding='utf-8')
        texts.append(f.read())
        f.close()
        if label_type == 'neg':
            labels.append(0)
        else:
            labels.append(1)
2.對資料進行分詞
  • 利用本節前面介紹過的概念,我們對文字進行分詞,並將其劃分為訓練集和驗證集。因為預訓練的詞嵌入對訓練資料很少的問題特別有用(否則,針對於具體任務的嵌入可能效果更好),所以我們又新增了以下限制:將訓練資料限定為前200個樣本。因此,你需要在讀取200個樣本之後學習對電影評論進行分類。
# 對IMDB原始資料的文字進行分詞
from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
import numpy as np

maxlen = 100 # 在100個單詞後截斷評論
train_samples = 200 # 在200個樣本上訓練
validation_samples = 10000 # 在10000個樣本上驗證
max_words = 10000 # 只考慮資料集中前10000個最常見的單詞

tokenizer = Tokenizer(num_words=max_words)
tokenizer.fit_on_texts(texts)
sequences = tokenizer.texts_to_sequences(texts)

word_index = tokenizer.word_index
print('Found %s unique tolens.' % len(word_index))

data = pad_sequences(sequences, maxlen=maxlen)

labels = np.asarray(labels)
print('shape of data tensor:', data.shape)
print('shape of label tensor:', labels.shape)

# 將資料劃分為訓練集和驗證集,但首先要打亂資料,因為一開始資料中的樣本是排好序的
indices = np.arange(data.shape[0])
np.random.shuffle(indices)
data = data[indices]
labels = labels[indices]

x_train = data[:train_samples]
y_train = labels[:train_samples]

x_val = data[train_samples:train_samples + validation_samples]
y_val = labels[train_samples:train_samples + validation_samples]
3. 下載Glove詞嵌入
  • 開啟https://nlp.stanford.edu/projects/glove,下載2014年英文維基百科的預計算嵌入。這是一個822MB的壓縮檔案,檔名是glove.6B.zip,裡面包含400000個單詞(或非單詞的標記)的100位嵌入向量。
4.對嵌入進行預處理
# 解析Glove詞嵌入檔案
glove_dir = "../data"

embeddings_index = {}
f = open(os.path.join(glove_dir, 'glove.6B.100d.txt'), 'r', encoding='utf-8')
for line in f:
    values = line.strip().split()
    word = values[0]
    coefs = np.asarray(values[1:], dtype='float32')
    embeddings_index[word] = coefs
f.close()

print('Flound %s word vectors.' % len(embeddings_index))
  • 接下來,需要構建一個可以載入到Embedding層中的嵌入矩陣。它必須是一個形狀為(max_words, embedding_dim)的矩陣,對於單詞索引(在分詞時構建)中索引為 i i i的單詞,這個矩陣的元素 i i i就是這個單詞對應的embedding_dim維向量。注意,索引0不應該代表任何單詞或標記,它只是一個佔位符。
# 準備GloVe詞嵌入矩陣
embedding_dim = 100

embedding_matrix = np.zeros((max_words, embedding_dim))
for word, i in word_index.items():
    if i < max_words:
        embedding_vector = embeddings_index.get(word)
        # 嵌入索引(embedding_index)中找不到的詞,其嵌入向量全為0
        if embedding_vector is not None:
            embedding_matrix[i] = embedding_vector
5.定義模型
# 模型定義
from keras.models import Sequential
from keras.layers import Embedding, Flatten, Dense

model = Sequential()
model.add(Embedding(max_words, embedding_dim, input_length=maxlen))
model.add(Flatten())
model.add(Dense(32, activation='relu'))
model.add(Dense(1, activation='sigmoid'))
print(model.summary())
6.在模型中載入GloVe嵌入
  • Embedding層只有一個權重矩陣,是一個二維的浮點數矩陣,其中每個元素 i i i是與索引 i i i相關聯的詞向量。將準備好的GloVe矩陣載入到Embedding層中,即模型的第一層。
# 將預訓練的詞嵌入載入到Embedding層中
model.layers[0].set_weights([embedding_matrix])
model.layers[0].trainable = False
  • 此外,需要凍結Embedding層(即將其trainable屬性設為False),其原理和預訓練的卷積神經網路特徵相同。如果一個模型的一部分是經過預訓練的(如Embedding層),而另一部分是隨機初始化的(如分類器),那麼在訓練期間不應該更新預訓練的部分,以避免丟失它們所儲存的資訊。隨機初始化的層會引起較大的梯度更新,會破壞已經學到的特徵。
7.訓練模型與評估模型
# 訓練和評估
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])

history = model.fit(x_train, y_train, epochs=10, batch_size=32, validation_data=(x_val, y_val))
model.save_weights('pre_trained_glove_model.h5')

# 繪製結果
import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(acc) + 1)

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()

plt.figure()
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()

plt.show()
# 對測試集資料進行分詞
test_dir = os.path.join(imdb_dir, 'test')

labels = []
texts = []

for label_type in ['neg', 'pos']:
    dir_name = os.path.join(test_dir, label_type)
    for fname in sorted(os.listdir(dir_name)):
        if fname[-4:] == '.txt':
            f = open(os.path.join(dir_name, fname), 'r', encoding='utf-8')
            texts.append(f.read())
            f.close()
            if label_type == 'neg':
                labels.append(0)
            else:
                labels.append(1)

sequences = tokenizer.texts_to_sequences(texts)
x_test = pad_sequences(sequences, maxlen=maxlen)
y_test = np.asarray(labels)

# 在測試集上評估模型
model.load_weights('pre_trained_glove_model.h5')
print(model.evaluate(x_test, y_test))

相關文章