Tensorflow 2.4 完成 StackOverflow 的文字分類

語言: CN / TW / HK

本文正在參加「金石計劃 . 瓜分6萬現金大獎」

前言

本文使用 cpu 版本的 tensorflow 2.4 來完成 Stack Overflow 的文字分類,為了順利進行要保證安裝了 2.8 版本以上的 tensorflow_text 。

本文大綱

  1. 獲取 Stack Overflow 資料
  2. 兩種向量化方式處理資料
  3. 訓練和評估兩種模型
  4. 完整模型的搭建和評估
  5. 模型預測

實現思路

1. 獲取 Stack Overflow 資料

(1)我們用到的資料是 Stack Overflow 中收集到的關於程式設計問題的資料集,每個資料樣本都是由一個問題和一個標籤標籤組成,問題是對不同程式語言的技術提問,標籤是對應的程式語言標籤,分別是 CSharp、Java、JavaScript 或 Python ,表示該問題所屬的技術範疇,在資料中分別用 0、1、2、3 四個數字來表示。

(2)我們的資料需要使用 tensorflow 內建函式,從網路上進行下載,下載好的資料分為兩個資料夾 train 和 test 表示存放了訓練資料和測試資料,而 train 和 test 都一樣擁有 CSharp、Java、JavaScript 和 Python 四個資料夾,裡面包含了四類程式語言對應的樣本,如 train 目錄如下所示,test 目錄類似。

train/ 
    ...csharp/ 
        ......1.txt  
        .......... 
     ...java/ 
         ......1.txt  
         .......... 
     ...javascript/ 
         ......1.txt  
         ..........
     ...python/ 
          ......1.txt  
          ..........

(3)在模型訓練過程中要將資料分三份,分別是訓練集、驗證集和測試集,將訓練集中的 20% 當做驗證集,也就是說我們最後訓練集中有 6400 個樣本,驗證集中有 1600 個樣本,測試集中有 8000 個樣本。

(4)為了增加分類的難度,問題中出現的單詞 Python、CSharp、JavaScript 或 Java 都被替換為 blank 字串。

import collections
import pathlib
import tensorflow as tf
from tensorflow.keras import layers
from tensorflow.keras import losses
from tensorflow.keras import utils
from tensorflow.keras.layers.experimental.preprocessing import TextVectorization
import tensorflow_datasets as tfds
import tensorflow_text as tf_text

url = 'http://storage.googleapis.com/download.tensorflow.org/data/stack_overflow_16k.tar.gz'
batch_size = 32
seed = 1

dataset_dir = utils.get_file( origin=url, untar=True, cache_dir='stack_overflow', cache_subdir='')
dataset_dir = pathlib.Path(dataset_dir).parent
train_dir = dataset_dir/'train'
test_dir = dataset_dir/'test'

train_datas = utils.text_dataset_from_directory( train_dir, batch_size=batch_size, validation_split=0.2, subset='training', seed=seed)
val_datas = utils.text_dataset_from_directory( train_dir, batch_size=batch_size, validation_split=0.2, subset='validation', seed=seed)
test_datas = utils.text_dataset_from_directory( test_dir, batch_size=batch_size)

2. 資料處理

(1)我們在使用資料之前,要進行預處理、分詞、向量化。預處理指預處理文字資料,通常就是去掉文字中用不到的標點符號、HTML 元素或者停用詞等,精簡樣本資料內容 。分詞就是按空格將一個句子拆分為若干個單詞。向量化是指將每個詞都轉化成對應的整數,以便將它們輸入到神經網路中進行運算。

(2)上面說的三個操作我們可以使用 TextVectorization 來一次性搞定,需要注意的是使用 TextVectorization 會將文字預設地轉換為小寫字母並移除標點符號。在進行分詞的時候如果沒有明確要求預設會將文字按空格分割。向量化的預設模式為 'int' ,這會為每個單詞生成一個對應的整數。還可以選擇使用模式 'binary' 來構建詞袋模型,也就是用一個長度為 VOCAB_SIZE 的向量表示一個樣本,只有該樣本中出現的單詞對應的索引位置為 1 ,其他位置都為 0 。

(3)我們現在要將訓練資料中的標籤去掉,只保留問題描述。然後用 binary_vectorize_layer 生成訓練資料中單詞和對應詞袋中的索引,用 int_vectorize_layer 生成訓練資料中單詞和對應的整數。

(4)我們分別使用兩種不同的向量化工具生成兩類訓練集、驗證集和測試集。

VOCAB_SIZE = 10000
MAX_SEQUENCE_LENGTH = 250
binary_vectorize_layer = TextVectorization( max_tokens=VOCAB_SIZE, output_mode='binary')
int_vectorize_layer = TextVectorization( max_tokens=VOCAB_SIZE, output_mode='int', output_sequence_length=MAX_SEQUENCE_LENGTH)
train_text = train_datas.map(lambda text, labels: text)
binary_vectorize_layer.adapt(train_text)
int_vectorize_layer.adapt(train_text)

def binary_vectorize(text, label):
    text = tf.expand_dims(text, -1)
    return binary_vectorize_layer(text), label

def int_vectorize(text, label):
    text = tf.expand_dims(text, -1)
    return int_vectorize_layer(text), label

binary_train_datas = train_datas.map(binary_vectorize)
binary_val_datas = val_datas.map(binary_vectorize)
binary_test_datas = test_datas.map(binary_vectorize)

int_train_datas = train_datas.map(int_vectorize)
int_val_datas = val_datas.map(int_vectorize)
int_test_datas = test_datas.map(int_vectorize)

(5)為了保證在載入資料的時候不會出現 I/O 不會阻塞,我們在從磁碟載入完資料之後,使用 cache 會將資料儲存在記憶體中,確保在訓練模型過程中資料的獲取不會成為訓練速度的瓶頸。如果說要儲存的資料量太大,可以使用 cache 建立磁碟快取提高資料的讀取效率。另外我們還使用 prefetch 在訓練過程中可以並行執行資料的預獲取。

(6)我們對兩類向量化資料,都進行了同樣的 cache 和 prefetch 操作。

AUTOTUNE = tf.data.AUTOTUNE
def configure_dataset(dataset):
    return dataset.cache().prefetch(buffer_size=AUTOTUNE)

binary_train_datas = configure_dataset(binary_train_datas)
binary_val_datas = configure_dataset(binary_val_datas)
binary_test_datas = configure_dataset(binary_test_datas)

int_train_datas = configure_dataset(int_train_datas)
int_val_datas = configure_dataset(int_val_datas)
int_test_datas = configure_dataset(int_test_datas)

3. 訓練和評估兩種模型

(1)我們對進行了 binary_vectorize 操作的訓練資料建立一個 binary_model 模型,模型只是一個非常簡單的結構,只有一層輸入為 4 個維度向量的全連線操作,其實就是輸出該問題樣本分別屬於四種類別的概率分佈。模型的損失函式選擇 SparseCategoricalCrossentropy ,模型的優化器選擇 adam ,模型的評估指標選擇為 accuracy 。

binary_model = tf.keras.Sequential([layers.Dense(4)])
binary_model.compile(   loss=losses.SparseCategoricalCrossentropy(from_logits=True),
                        optimizer='adam',
                        metrics=['accuracy'])
binary_model.fit( binary_train_datas, validation_data=binary_val_datas, epochs=20)

訓練過程如下:

Epoch 1/20
200/200 [==============================] - 3s 12ms/step - loss: 1.1223 - accuracy: 0.6555 - val_loss: 0.9304 - val_accuracy: 0.7650
Epoch 2/20
200/200 [==============================] - 3s 12ms/step - loss: 0.7780 - accuracy: 0.8248 - val_loss: 0.7681 - val_accuracy: 0.7856
...
Epoch 19/20
200/200 [==============================] - 3s 13ms/step - loss: 0.1570 - accuracy: 0.9812 - val_loss: 0.4923 - val_accuracy: 0.8087
Epoch 20/20
200/200 [==============================] - 2s 12ms/step - loss: 0.1481 - accuracy: 0.9836 - val_loss: 0.4929 - val_accuracy: 0.8081

(2)我們對進行了 int_vectorize 操作的訓練資料建立一個 int_model 模型, 第一層是對所有的單詞都進行詞嵌入操作,將每個單詞轉換成 64 維向量,這裡因為 0 要進行 padding 操作,所以在進行詞嵌入的時候要額外加 1 。第二層使用卷積函式,輸出 64 維的向量,卷積核大小為 5 ,步長為 2,並使用了 padding 操作進行補齊,啟用函式使用的是 relu 。第三層是一個最大池化層。第四層是一個輸出 4 個維度的向量的全連線層,其實就是輸出該問題樣本分別屬於四種類別的概率分佈。

def create_model():
    model = tf.keras.Sequential([
      layers.Embedding(VOCAB_SIZE + 1, 64, mask_zero=True),
      layers.Conv1D(64, 5, padding="valid", activation="relu", strides=2),
      layers.GlobalMaxPooling1D(),
      layers.Dense(4) ])
    return model

int_model = create_model()
int_model.compile(
    loss=losses.SparseCategoricalCrossentropy(from_logits=True),
    optimizer='adam',
    metrics=['accuracy'])
int_model.fit(int_train_datas, validation_data=int_val_datas, epochs=20)

訓練過程如下:

Epoch 1/20
200/200 [==============================] - 5s 24ms/step - loss: 1.1247 - accuracy: 0.5069 - val_loss: 0.7829 - val_accuracy: 0.6756
Epoch 2/20
200/200 [==============================] - 5s 24ms/step - loss: 0.6034 - accuracy: 0.7708 - val_loss: 0.5820 - val_accuracy: 0.7681
...
Epoch 19/20
200/200 [==============================] - 5s 24ms/step - loss: 5.8555e-04 - accuracy: 1.0000 - val_loss: 0.7737 - val_accuracy: 0.8019
Epoch 20/20
200/200 [==============================] - 5s 24ms/step - loss: 4.9328e-04 - accuracy: 1.0000 - val_loss: 0.7853 - val_accuracy: 0.8019

(3)我們使用各自的測試資料分別對兩個模型進行測試。

_, binary_accuracy = binary_model.evaluate(binary_test_datas)
_, int_accuracy = int_model.evaluate(int_test_datas)

print("Binary model accuracy: {:2.2%}".format(binary_accuracy))
print("Int model accuracy: {:2.2%}".format(int_accuracy))

測試結果為:

Binary model accuracy: 81.29%
Int model accuracy: 80.67%

4. 完整模型的搭建和評估

(1)為了快速搭建模型,我們還可以將 binary_vectorize_layer 與 binary_model 進行拼接,最後再接入 sigmoid 啟用函式,這樣模型就有了能夠處理原始文字的能力。

(2)因為模型的功能完整,所以我們只需要將原始的測試文字資料傳入即可。

whole_model = tf.keras.Sequential(
    [binary_vectorize_layer, 
     binary_model,
     layers.Activation('sigmoid')])

whole_model.compile(
    loss=losses.SparseCategoricalCrossentropy(from_logits=False),
    optimizer='adam',
    metrics=['accuracy'])

_, accuracy = whole_model.evaluate(test_datas)
print("Accuracy: {:2.2%}".format(accuracy))

測試結果為:

Accuracy: 81.29%

5. 模型預測

我們分別使用兩個樣本來用模型進行預測,可以看出預測結果準確。

def get_sample(predicteds):
    int_labels = tf.math.argmax(predicteds, axis=1)
    predicted_labels = tf.gather(train_datas.class_names, int_labels)
    return predicted_labels

inputs = [
    "how do I extract keys from a dict into a list?",  
    "debug public static void main(string[] args) {...}",  
]
predicted_scores = whole_model.predict(inputs)
predicted_labels = get_sample(predicted_scores)
for t, label in zip(inputs, predicted_labels):
    print("Question: ", t)
    print("Predicted label: ", label.numpy())

預測結果:

Question:  how do I extract keys from a dict into a list?
Predicted label:  b'python'
Question:  debug public static void main(string[] args) {...}
Predicted label:  b'java'