6.1 深度學習之文本處理
文本是序列數據傳播最廣泛的形式之一,它可以理解成一個字母序列或者詞序列,但是最常見的形式是詞序列。后面章節介紹的深度學習序列處理模型有文檔分類、情感分析、作者識別和限制語境問答(QA)。當然了,要記住的是:這些深度學習模型并不是真正意義上以人的思維去理解文字,而只是書面語的統計結構映射而已。基于深度學習的自然語言處理可以看作對字詞、句子和段落的模式識別,這有點像計算機視覺中對像素的模式識別。
跟其它所有神經網絡一樣,深度學習模型并不是以原始文本為輸入,而是數值型張量。向量化文本是將文本轉換成數值張量的過程。有以下幾種方式可以做向量化文本:
- 將文本分割為詞,轉換每個詞為向量;
- 將文本分割為字(字母),轉換每個字為向量;
- 抽取詞或者字的n-gram,轉換每個n-gram轉換為向量。n-gram是多個連續詞或者字的元組。
將文本分割為字、詞或者n-gram的過程稱為分詞(tokenization),拆分出來的字、詞或者n-gram稱為token。所有文本向量化的過程都包含分詞和token轉換為數值型向量。這些向量封裝成序列張量“喂入”神經網絡模型。有多種方式可以將token轉換為數值向量,但是本小節介紹兩種方法:one-hot編碼和詞嵌入。
圖6.1 文本向量化過程
n-gram和詞袋的理解
n-gram是指從句子中抽取的N個連續詞的組合。對于字也有相同的概念。
下面是一個簡單的例子。句子“the cat sat on the mat”拆分成2-gram的集合如下:
{"The", "The cat", "cat", "cat sat", "sat", "sat on", "on", "on the", "the", "the mat", "mat"}
拆分成3-gram的集合如下:
{"The", "The cat", "cat", "cat sat", "The cat sat", "sat", "sat on", "on", "cat sat on", "on the", "the", "sat on the", "the mat", "mat", "on the mat"}
上面這些集合相應地稱為2-gram的詞袋,3-gram的詞袋。術語詞袋(bag)是指token的集合,而不是一個列表或者序列:token是無序的。所有分詞方法的結果統稱為詞袋。
詞袋是一個無序的分詞方法,其丟失了文本序列的結構信息。詞袋模型用于淺語言處理模型中,而不是深度學習模型。抽取n-gram是一種特征工程,但是深度學習是用一種簡單粗暴的方法做特征工程,去代替復雜的特征工程。本章后面會講述一維卷積和RNN,它們能從字、詞的組合中學習表征。所以本書不再進一步展開介紹n-gram。但是記住,在輕量級模型或者淺文本處理模型(邏輯回歸和隨機森林)中,n-gram是一個強有力、不可替代的特征工程工具。
6.1.1 字詞的one-hot編碼
one-hot編碼是最常見、最基本的文本向量化方法。在前面第三章的IMDB和Reuter例子中有使用過。one-hot編碼中每個詞有唯一的數值索引,然后將對應的索引轉成大小為N的二值向量(N為字典的大小):詞所對應的索引位置的值為1,其它索引對應的值為0。
當然,字級別也可以做one-hot編碼。為了予以區分,列表6.1和6.2分別展示詞和字的one-hot編碼。
#Listing 6.1 Word-level one-hot encoding
import numpy as np
'''
Initial data: one entry per sample (in this example,
a sample is a sentence,
but it could be an entire document)
'''
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
'''
Builds an index of all tokens in the data
'''
token_index = {}
for sample in samples:
'''
Tokenizes the samples via the split method.
In real life, you’d also strip punctuation
and special characters from the samples.
'''
for word in sample.split():
if word not in token_index:
'''
Assigns a unique index to each unique word.
Note that you don’t attribute index 0 to anything.
'''
token_index[word] = len(token_index) + 1
'''
Vectorizes the samples. You’ll only consider
the first max_length words in each sample.
'''
max_length = 10
'''
This is where you store the results.
'''
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.
#Listing 6.2 Character-level one-hot encoding
import string
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
'''
All printable ASCII characters
'''
characters = string.printable
token_index = dict(zip(range(1, len(characters) + 1), characters))
max_length = 50
results = np.zeros((len(samples), max_length, max(token_index.keys()) + 1))
for i, sample in enumerate(samples):
for j, character in enumerate(sample):
index = token_index.get(character)
results[i, j, index] = 1.
Keras有內建工具處理文本的one-hot編碼。建議你使用這些工具,因為它們有不少功能,比如,刪除指定字符,考慮數據集中最常用的N個字(嚴格來講,是避免向量空間過大)。
#Listing 6.3 Using Keras for word-level one-hot encoding
from keras.preprocessing.text import Tokenizer
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
'''
Creates a tokenizer, configured to only take into account the 1,000 most common words
'''
tokenizer = Tokenizer(num_words=1000)
'''
Builds the word index
'''
tokenizer.fit_on_texts(samples)
'''
Turns strings into lists of integer indices
'''
sequences = tokenizer.texts_to_sequences(samples)
'''
You could also directly get the one-hot binary representations. Vectorization modes other than one-hot encoding are supported by this tokeniser.
'''
one_hot_results = tokenizer.texts_to_matrix(samples, mode='binary')
'''
How you can recover the word index that was computed
'''
word_index = tokenizer.word_index
print('Found %s unique tokens.' % len(word_index))
one-hot 哈希(hash)編碼是one-hot編碼的一個變種,它主要用在字典太大難以處理的情況。one-hot 哈希編碼是將詞通過輕量級的哈希算法打散成固定長度的向量,而不是像one-hot編碼將每個詞分配給一個索引。one-hot 哈希編碼最大的優勢是節省內存和數據的在線編碼。同時這種方法的一個缺點是碰到哈希碰撞沖突(hash collision),也就是兩個不同詞的哈希值相同,導致機器學習模型不能分辨這些詞。哈希碰撞沖突的 可能性會隨著哈希空間的維度越大而減小。
#Listing 6.4 Word-level one-hot encoding with hashing trick
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
'''
Stores the words as vectors of size 1,000. If you have close to 1,000 words (or more), you’ll see many hash collisions, which will decrease the accuracy of this encoding method.
'''
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]:
'''
Hashes the word into a random integer index
between 0 and 1,000
'''
index = abs(hash(word)) % dimensionality
results[i, j, index] = 1.
6.1.2 詞嵌入
另外一種常用的、高效的文本向量化方法是稠密詞向量,也稱為詞嵌入。one-hot編碼得到的向量是二值的、稀疏的(大部分值為0)、高維度的(與字典的大小相同),而詞嵌入是低維度的浮點型向量(意即,稠密向量),見圖6.2。前面的向量是通過one-hot編碼得到的,而詞嵌入是由數據學習得到,最常見的詞嵌入是256維、512維或者1024維。one-hot編碼會導致向量的維度甚至超過20,000維(此處以20,000個詞的字典舉例)。所以詞嵌入能夠用更少的維度表示更多的信息。
圖6.2 one-hot編碼和詞嵌入得到的向量對比
有兩種獲得詞嵌入的方式:
- 在解決文檔分類或者情感預測的任務中學習詞嵌入。一般以隨機詞向量維開始,然后在訓練神經網絡模型權重的過程中學習到詞向量。
- 加載預訓練的詞向量。預訓練的詞向量一般是從不同于當前要解決的機器學習任務中學習得到的。
下面學習前面的兩種方法。
學習詞嵌入:Embedding layer
詞與稠密向量相關聯的最簡單方法是隨機向量化。但是,這種方法使得嵌入空間變得毫無結構:比如,單詞accurate和exact在大部分句子里是可互換的,但得到的嵌入可能完全不同。深度神經網絡很難識別出這種噪音和非結構嵌入空間。
更抽象一點的講,詞與詞之間的語義相似性在詞向量空間中應該以幾何關系表現出來。詞嵌入可以理解成是人類語言到幾何空間的映射過程。例如,你會期望同義詞被嵌入為相似的詞向量;更一般地說,你期望任意兩個詞向量的幾何距離(比如,L2距離)和相關詞的語義距離是有相關性。除了距離之外,詞向量在嵌入空間的方向也應該是有意義的。下面舉個具體的例子來說明這兩點。
圖6.3 詞嵌入空間的實例
在圖6.3中,cat、dog、wolf和tiger四個詞被嵌入到二維平面空間。在這里選擇的詞向量表示時,這些詞的語義關系能用幾何變換來編碼表示。比如,從cat到tiger和從dog到wolf有著相同的向量,該向量可以用“從寵物到野生動物”來解釋。同樣,從dog到cat和從wolf到tiger有相同的向量,該向量表示“從犬科到貓科動物”。
在實際的詞嵌入空間中,常見的幾何變換例子是“gender”詞向量和“plural”詞向量。比如,將“female”詞向量加到“king”詞向量上,可以得到“queen”詞向量;將“plural”詞向量加到“king”詞向量上,可以得到“kings”詞向量。
那接下來就要問了,有完美的詞向量空間能匹配人類語言嗎?能用來解決任意種類的自然語言處理任務嗎?答案是可能有,但是現階段暫時沒有。也沒有一種詞向量可以向人類語言一樣有很多種語言,并且是不同形的,因為它們都是在特定文化和特定環境下形成的。但是,怎么才能得到一個優秀的詞嵌入空間呢?從程序實現上講是因任務而異:英文影評情感分析模型對應完美詞嵌入空間與英文文檔分類模型對應的完美詞嵌入空間可能不同,因為不同任務的語義關系重要性是變化的。
因此,對每個新任務來說,最好重新學習的詞嵌入空間。幸運的是,反向傳播算法和Keras使得學習詞嵌入變得容易。下面學習Keras的Embedding layer權重。
#Listing 6.5 Instantiating an Embedding layer
from keras.layers import Embedding
'''
The Embedding layer takes at least two arguments: the number of possible tokens (here, 1,000: 1 + maximum word index) and the dimensionality of the embeddings (here, 64).
'''
embedding_layer = Embedding(1000, 64)
Embedding layer把詞的整數索引映射為稠密向量。它輸入整數,在中間字典中查找這些整數對應的向量。Embedding layer是一個高效的字典查表(見圖6.4)。
圖6.4 Embedding layer
Embedding layer的輸入是一個形狀為(樣本,序列長度)[^(sample,sequence_length)]的 2D 整數型張量,該張量的每項都是一個整數序列。Embedding layer能嵌入變長序列:比如,可以“喂入”形狀為(32,10)(長度為10的序列數據,32個為一個batch)或者(64,15)(長度為15的序列數據64個為一個batch)。同一個batch中的所有序列數據必須有相同的長度,因為它們會被打包成一個張量。所以比其它序列數據短的序列將用“0”填充,另外,太長的序列會被截斷。
Embedding layer返回一個形狀為(樣本,序列長度,詞向量大小)[^(samples,sequence_ length,embedding_dimensionality)]的3D浮點型張量,該張量可以被RNN layer或者1D 卷積layer處理。
當你實例化一個Embedding layer時,它的權重(詞向量的中間字典)是隨機初始化,和其它layer一樣。隨著模型的訓練,這些詞向量通過反向傳播算法逐漸調整,傳入下游模型使用。一旦模型訓練完,嵌入空間會顯現出許多結構,不同的模型會訓練出不同的特定結構。
下面用熟悉的IMDB影評情感預測任務來說明上面的想法。首先,準備數據集。限制選取詞頻為top 10,000的常用詞,只考慮影評前20個詞。神經網絡模型將學習8維的詞嵌入,把輸入的整數序列(2D整數張量)轉化為嵌入序列(3D浮點張量)
#Listing 6.6 Loading the IMDB data for use with an Embedding layer
from keras.datasets import imdv
from keras import preprocessing
'''
Number of words to consider as features
'''
max_features = 10000
'''
Cuts off the text after this number of words (among the max_features most common words)
'''
maxlen = 20
'''
Loads the data as lists of integers
'''
(x_train, y_train), (x_test, y_test) = imdb.load_data( num_words=max_features)
'''
Turns the lists of integers into a 2D integer tensor of shape (samples, maxlen)
'''
x_train = preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = preprocessing.sequence.pad_sequences(x_test, maxlen=maxlen)
#Listing 6.7 Using an Embedding layer and classifier on the IMDB data
from keras.models import Sequential
from keras.layers import Flatten, Dense
model = Sequential()
'''
Specifies the maximum input length to the Embedding layer so you can later flatten the embedded inputs. After the Embedding layer, the activations have shape (samples, maxlen, 8).
'''
model.add(Embedding(10000, 8, input_length=maxlen))
'''
Flattens the 3D tensor of embeddings into a 2D tensor of shape (samples, maxlen * 8)
'''
model.add(Flatten())
'''
Adds the classifier on top
'''
model.add(Dense(1, activation='sigmoid'))
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
model.summary()
history = model.fit(x_train, y_train,
epochs=10,
batch_size=32,
validation_split=0.2)
上面的代碼得到了約76%的驗證準確度,這對于只考慮每個影評的前20個詞來說效果已經不錯了。注意,僅僅攤平嵌入序列,用單個Dense layer訓練模型,會將輸入序列的每個詞隔離開,并沒有考慮詞之間的關系和句子結構(例如,該模型可能認為“this movie is a bomb”和“this movie is the bomb” 兩句話都是負面影評)。所以在嵌入序列之上加入RNN layer或者1D卷積layer會將句子當做整體來學習特征,后續小節會詳細講解這些。
預訓練的詞嵌入
有時,你只有很少的訓練數據集來學習詞嵌入,那怎么辦呢?
你可以加載預計算好的詞嵌入向量,而不用學習當前待解決任務的詞嵌入。這些預計算好的詞嵌入是高結構化的,具有有用的特性,其學習到了語言結構的泛化特征。在自然語言處理中使用預訓練的詞嵌入的基本理論,與圖像分類中使用預訓練的卷積網絡相同:當沒有足夠的合適數據集來學習當前任務的特征時,你會期望從通用的視覺特征或者語義特征中學到泛化特征。
一些詞嵌入是用詞共現矩陣統計計算,用各種技術,有些涉及神經網絡,有些沒有。用非監督的方法計算詞的稠密的、低維度的嵌入空間是由Bengio在2000年提出的,但是直到2013年Google的Tomas Mikolov開發出著名的Word2vec算法才開始在學術研究和工業應用上廣泛推廣。Word2vec可以獲取語義信息。
Keras的Embedding layer有各種預訓練詞嵌入數據可以下載使用,Word2vec是其中之一。另外一個比較流行的詞表示是GloVe(Global Vector),它是由斯坦福研究組在2014開發。GloVe是基于詞共現矩陣分解的一種詞嵌入技術,它的開發者預訓練好了成千上萬的詞嵌入。
下面開始學習如何在Keras模型中使用GloVe詞嵌入。其實它的使用方法與Word2vec詞嵌入或者其它詞嵌入數據相同。
6.1.3 從原始文本到詞嵌入
這里的模型網絡和上面的類似,只是換作預訓練詞嵌入。同時,直接從網上下載原始文本數據,而不是使用Keras分詞好的IMDB數據。
下載IMDB原始文本
首先,前往http://mng.bz/0tIo下載原IMDB數據集,并解壓。
接著,將單個訓練影評裝載為字符串列表,同時影評label裝載為label的列表。
#Listing 6.8 Processing the labels of the raw IMDB data
import os
imdb_dir = '/Users/fchollet/Downloads/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):
if fname[-4:] == '.txt':
f = open(os.path.join(dir_name, name))
texts.append(f.read())
f.close()
if label_type == 'neg':
labels.append(0)
else:
labels.append(1)
分詞
開始向量化文本,準備訓練集和驗證集。因為預訓練的詞嵌入是對訓練集較少時更好,這里加入步驟:取前200個樣本數據集。所以你相當于只看了200條影評就開始做影評情感分類。
#Listing 6.9 Tokenizing the text of the raw IMDB data
from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
import numpy as np
'''
Cuts off reviews after 100 words
'''
maxlen = 100
'''
Trains on 200 samples
'''
training_samples = 200
'''
Validates on 10,000 samples
'''
validation_samples = 10000
'''
Considers only the top 10,000 words in the dataset
'''
max_words = 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 tokens.' % 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)
'''
Splits the data into a training set and a validation set, but first shuffles the data, because you’re starting with data in which samples are ordered (all negative first, then all positive)
'''
indices = np.arange(data.shape[0])
np.random.shuffle(indices)
data = data[indices]
labels = labels[indices]
x_train = data[:training_samples]
y_train = labels[:training_samples]
x_val = data[training_samples: training_samples + validation_samples]
y_val = labels[training_samples: training_samples + validation_samples]
下載GloVe詞嵌入
前往https://nlp.stanford.edu/projects/glove下載預訓練的2014年英文維基百科的GloVe詞嵌入。它是一個822 MB的glove.6B.zip文件,包含400,000個詞的100維嵌入向量。
預處理GloVe嵌入
下面解析解壓的文件(a.txt)來構建索引,能將詞映射為向量表示。
#Listing 6.10 Parsing the GloVe word-embeddings file
glove_dir = '/Users/fchollet/Downloads/glove.6B'
embeddings_index = {}
f = open(os.path.join(glove_dir, 'glove.6B.100d.txt'))
for line in f:
values = line.split()
word = values[0]
coefs = np.asarray(values[1:], dtype='float32')
embeddings_index[word] = chefs
f.close()
print('Found %s word vectors.' % len(embeddings_index))
接著,構建能載入Embedding layer的嵌入矩陣。它的矩陣形狀為(max_words, embedding_dim),其每項i是在參考詞索引中為i的詞對應的embedding_dim維向量。注意,索引0不代表任何詞,只是個占位符。
#Listing 6.11 Preparing the GloVe word-embeddings matrix
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)
if embedding_vector is not None:
'''
Words not found in the embedding index will be all zeros.
'''
embedding_matrix[i] = embedding_vector
定義模型
使用前面相同的模型結構。
#Listing 6.12 Model definition
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'))
model.summary()
加載GloVe詞嵌入
Embedding layer有一個權重矩陣:2D浮點型矩陣,每項i表示索引為i的詞對應的詞向量。在神經網絡模型中加載GloVe詞嵌入到Embedding layer
#Listing 6.13 Loading pretrained word embeddings into the Embedding layer
model.layers[0].set_weights([embedding_matrix])
model.layers[0].trainable = False
此外,設置trainable為False,凍結Embedding layer。當一個模型的部分網絡是預訓練的(像Embedding layer)或者隨機初始化(像分類),那該部分網絡在模型訓練過程中不能更新,避免模型忘記已有的特征。隨機初始化layer會觸發大的梯度更新,導致已經學習的特征丟失。
訓練和評估模型
編譯和訓練模型。
#Listing 6.14 Training and evaluation
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')
現在繪制模型隨時間的表現,見圖6.5和6.6。
#Listing 6.15 Plotting the results
import matplotlib.pyplot as pet
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()
圖6.5 使用預訓練詞嵌入時的訓練損失和驗證損失曲線
圖6.6 使用預訓練詞嵌入時的訓練準確度和驗證準確度曲線
模型訓練在開始不久即出現過擬合,這在訓練集較少的情況下很常見。驗證準確度有高的variance,不過也到50%了。
可能你的結果不同:因為訓練集太少,導致模型效果嚴重依賴被選擇的200個樣本(這里選擇是隨機的)。
你也可以在不加載預訓練詞嵌入和不凍結embedding layer的情況下訓練相同的網絡模型。訓練集也使用前面相同的200個樣本,見圖6.7和6.8。
#Listing 6.16 Training the same model without pretrained word embeddings
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'))
model.summary()
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))
圖6.7 未使用預訓練詞嵌入時的訓練損失和驗證損失曲線
圖6.8 未使用預訓練詞嵌入時的訓練準確度和驗證準確度曲線
這次的結果顯示驗證準確度不到50%。所以樣本量較少的情況下,預訓練詞嵌入效果更優。
最后,在測試數據集上評估模型。首先,對測試數據進行分詞。
#Listing 6.17 Tokenizing the data of the test set
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, name))
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)
接著,加載并評估第一個模型。
#Listing 6.18 Evaluating the model on the test set
model.load_weights('pre_trained_glove_model.h5') model.evaluate(x_test, y_test)
返回測試準確度56%的結果。
6.1.4 小結
你學到的知識有:
- 文本分詞
- 使用Keras的Embedding layer學習特定的詞嵌入
- 使用預訓練的詞嵌入提升自然語言處理問題
未完待續。。。
Enjoy!
翻譯本書系列的初衷是,覺得其中把深度學習講解的通俗易懂。不光有實例,也包含作者多年實踐對深度學習概念、原理的深度理解。最后說不重要的一點,Fran?ois Chollet是Keras作者。
聲明本資料僅供個人學習交流、研究,禁止用于其他目的。如果喜歡,請購買英文原版。
俠天,專注于大數據、機器學習和數學相關的內容,并有個人公眾號分享相關技術文章。
若發現以上文章有任何不妥,請聯系我。