nikkie-ftnextの日記

イベントレポートや読書メモを発信

tf.dataを使って英文テキストを読み込み、分類するモデルを作るTensorFlowのチュートリアルに取り組みました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週はTensorFlowにおける新しめのデータの扱い方チュートリアルに取り組みました。

チュートリアル「tf.data を使ったテキストの読み込み」

問題設定は、英文テキストの多クラス分類です。

  • データ:ホメロス(ホーマー)の『イリアス』(『イリアッド』)の英語翻訳版3通り
    • cowper, derby, butler
  • 推論:与えられたテキストがどの翻訳版のものか

以下の手順で進みました。

  1. テキストのダウンロード
  2. テキストをデータセットに読み込む
  3. テキスト行を数字にエンコードする
  4. データセットを、テスト用と訓練用のバッチに分割する(ハマりました😢)
  5. モデルの構築・訓練

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ # 注:コードを動かすのにGPUは使っていません
$ python -V  # venvによる仮想環境を使用
Python 3.7.3
$ pip list  # grepを使って抜粋して表示
tensorflow               2.1.0
tensorflow-datasets      1.3.2
matplotlib               3.1.2
  • ipythonのコードでセルの番号が前後するのは、ブログを書くに当たり実行したコードがあるためです
  • 乱数のシードを固定せずに実施しました。再現性を担保する場合、numpyのシードの固定やtensorflow.random.set_seedshuffleメソッドのseed引数を指定することになると考えています(未実施)

そもそも、tf.dataとは

2019年10月に行ったPyCon SingaporeのTensorFlow 2.0 チュートリアルtf.dataについてインプットしていました1

チュートリアルによると、tf.dataの目的は、深層学習に取り組む上でCPUとGPUが交互に遊んでしまう(idle)状態の解決とのことでした。

  • tf.dataを使わない場合
    • CPUでデータの準備をする間、GPUは遊ぶ
    • GPUで学習をする間、CPUは遊ぶ
    • これが交互に繰り返される
  • tf.dataを使う場合
    • CPUでのデータの準備とGPUでの学習を同時に進める(パイプライン

データをtf.data.Datasetとして扱うことで、CPUとGPUが交互に遊ぶ状態は解決されます。
さらにデータの扱い方(インターフェース)が揃うのもメリットだと考えます。

1. テキストのダウンロード

tf.keras.utils.get_fileを用いて、テキスト(『イリアス』の3種の英語翻訳)のURLを指定し、ダウンロードします。

In [4]: DIRECTORY_URL = 'https://storage.googleapis.com/download.tensorflow.org/data/illiad/'

In [5]: FILE_NAMES = ['cowper.txt', 'derby.txt', 'butler.txt']

In [6]: for name in FILE_NAMES:
   ...:     text_dir = tf.keras.utils.get_file(name, origin=DIRECTORY_URL+name)
   ...:
  • 挙動:データがcacheにないため、ダウンロードする
    • 必須引数fnamexxx.txt
    • 必須引数origin:テキストのURL
    • データのcache先はcache_dir引数とcache_subdir引数で指定
    • 今回はどちらもデフォルト値(cache_dir引数がNonecache_subdir引数が'datasets'
    • $HOME/.keras/datasets/2 にダウンロードされる
  • 返り値:ダウンロードされたファイルを指すパス(手元のマシンの中のパス)
    • 上記のcacheにダウンロードされたことを確認できます

このチュートリアルで使われているテキストファイルは、ヘッダ、フッタ、行番号、章のタイトルの削除など、いくつかの典型的な前処理を行ったものです。

との記載から、テキストの前処理はtf.dataを使う前に行うようです(前処理してbutler.txtなどと同じ形式(単語の半角スペース区切り)にする想定)。

$ head -n3 .keras/datasets/butler.txt  # 注:カレントディレクトリはホームディレクトリ
Sing, O goddess, the anger of Achilles son of Peleus, that brought
countless ills upon the Achaeans. Many a brave soul did it send
hurrying down to Hades, and many a hero did it yield a prey to dogs and
$ wc -l .keras/datasets/*.txt
   12131 .keras/datasets/butler.txt  # 24%
   19142 .keras/datasets/cowper.txt  # 39%
   18333 .keras/datasets/derby.txt  # 37%
   49606 total

1では、まだtf.dataは使っていません。

2. テキストをデータセットに読み込む

テキストは、tf.data.TextLineDataset3として読み込みます。

TextLineDataset は、テキストファイルからデータセットを作成するために設計されています。この中では、元のテキストファイルの一行一行がサンプルです。(チュートリアル冒頭より)

In [9]: def labeler(example, index):
   ...:     return example, tf.cast(index, tf.int64)
   ...:

In [10]: labeled_data_sets = []

In [11]: for i, file_name in enumerate(FILE_NAMES):
    ...:     lines_dataset = tf.data.TextLineDataset(os.path.join(parent_dir, file_name))
    ...:     labeled_dataset = lines_dataset.map(lambda ex: labeler(ex, i))
    ...:     labeled_data_sets.append(labeled_dataset)
    ...:

必須引数filenamesを指定してTextLineDatasetを作成します(for文の節の1行目)。

テキストファイルの1行1行についてラベルを付けます(for文の節の2行目)。
ラベルと翻訳の対応は以下の通りです。

ラベル 翻訳
0 cowper
1 derby
2 butler

(リストFILE_NAMESにおけるインデックスがラベルです)

TextLineDataset.mapを使って、lines_datasetの1件1件について、labeler関数を適用します。
labeler関数はラベルの型tf.int64変換します。

labeled_data_sets

  • [0]:cowperによる翻訳テキスト
  • [1]:derbyによる翻訳テキスト
  • [2]:butlerによる翻訳テキストの順に並んでいます。
In [12]: len(labeled_data_sets)
Out[12]: 3

クラスごとに分かれているので、TextLineDataset.concatenateで一続きのDataset(all_labeled_data)になるように繋げます(concatenateした結果でall_labeled_dataを更新して繋げていく)。

In [16]: all_labeled_data = labeled_data_sets[0]

In [17]: for labeled_dataset in labeled_data_sets[1:]:
    ...:     all_labeled_data = all_labeled_data.concatenate(labeled_dataset)
    ...:

繋げた状態だとラベルが0→1→2と順番に並んでいるので、TextLineDataset.shuffleして並べ替えます。

In [23]: all_labeled_data = all_labeled_data.shuffle(
    ...:     BUFFER_SIZE, reshuffle_each_iteration=False)

並べ替えた後、all_labeled_data先頭3件を見ます。

In [18]: for ex in all_labeled_data.take(3):
    ...:     print(ex)
    ...:
(<tf.Tensor: shape=(), dtype=string, numpy=b'when his true comrade fell at the hands of the Trojans, and he now lies'>, <tf.Tensor: shape=(), dtype=int64, numpy=2>)
(<tf.Tensor: shape=(), dtype=string, numpy=b"On his broad palm, and darkness veil'd his eyes.">, <tf.Tensor: shape=(), dtype=int64, numpy=0>)
(<tf.Tensor: shape=(), dtype=string, numpy=b'All-bounteous Mercury clandestine there'>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)

TextLineDataset.takeの返り値(Dataset)から取り出したextupleでした。
tupleのインデックス1の要素が、ラベルを表しています(tf.Tensorオブジェクトなるもの)。
ラベルの値(numpy)を見ると、先頭のテキストのラベルが2なので、シャッフルされていますね。

In [23]: type(ex)
Out[23]: tuple

In [26]: ex[0]  # テキストを表す
Out[26]: <tf.Tensor: shape=(), dtype=string, numpy=b'All-bounteous Mercury clandestine there'>

In [27]: ex[1]  # ラベルを表す
Out[27]: <tf.Tensor: shape=(), dtype=int64, numpy=0>

ここでは、テキスト行にクラスを表すラベルを付け、1つのDatasetとしてまとめることをしました。

3. テキスト行を数字にエンコードする

機械学習モデルが扱うのは単語ではなくて数字であるため、文字列は数字のリストに変換する必要があります。

ロイター通信のデータセットのように4、整数の並びにするということですね。
以下の2ステップでした。

  1. ボキャブラリーの構築
  2. テキストのエンコード

ボキャブラリーの構築

tfds.features.text.Tokenizerを使って、テキストに含まれる単語を取り出します。

In [28]: sample_tokenizer = tfds.features.text.Tokenizer()

In [29]: ex[0].numpy()
Out[29]: b'All-bounteous Mercury clandestine there'

In [30]: sample_tokenizer.tokenize(ex[0].numpy())
Out[30]: ['All', 'bounteous', 'Mercury', 'clandestine', 'there']

TextLineDatasetの1件1件(タプル)では、インデックス0がテキストを表します。
テキスト自体はnumpy()メソッドで取得できます。
これをTokenizer.tokenizeに与えると、単語を格納したリストが返ります。

全てのテキストについて上記の処理を繰り返し、入手した単語をボキャブラリーとします。
tokenizeの返り値をセットに入れることで、重複を除きます。

In [32]: tokenizer = tfds.features.text.Tokenizer()

In [33]: vocabulary_set = set()

In [34]: for text_tensor, _ in all_labeled_data:
    ...:     some_tokens = tokenizer.tokenize(text_tensor.numpy())
    ...:     vocabulary_set.update(some_tokens)
    ...:

In [35]: vocab_size = len(vocabulary_set)

In [36]: vocab_size
Out[36]: 17178

日本語テキストを扱う場合は、Tokenizer初期化時にalphanum_only引数をFalseに指定することになりそうです。

テキストのエンコード

vocabulary_settfds.features.text.TokenTextEncoder に渡してエンコーダーを作成します。

In [37]: encoder = tfds.features.text.TokenTextEncoder(vocabulary_set)

エンコーダTokenTextEncoderは、

  • 必須引数vocab_listを渡して初期化
  • encodeメソッドにテキストを渡すと、変換した整数のリスト(a list of integers)を返す

これを使って、all_labeled_dataの各テキストを、整数が並んだリストに置き換えます。

In [43]: def encode(text_tensor, label):
    ...:     encoded_text = encoder.encode(text_tensor.numpy())
    ...:     return encoded_text, label
    ...:

In [44]: def encode_map_fn(text, label):
    ...:     encoded_text, label = tf.py_function(
    ...:         encode, inp=[text, label], Tout=(tf.int64, tf.int64))
    ...:     encoded_text.set_shape([None])
    ...:     label.set_shape([])
    ...:     return encoded_text, label
    ...:

In [45]: all_encoded_data = all_labeled_data.map(encode_map_fn)

tf.py_functionについては今回掘り下げられていないのですが、ドキュメントの中に

tf.function が呼び出されるたびに Python のコードを実行したいのであれば、tf.py_function がぴったりです。

という記載を見つけました5
all_labeled_dataの1件1件にmapメソッドを介してencode関数を適用するために使っているようです。
set_shapeのあたりは言語化できるレベルで理解できていないのですが、tf.functionとの関係の中でとらえるとよさそうです。

エンコードしたテキストの確認

In [40]: for ex in all_encoded_data.take(3):
    ...:     print(ex)
    ...:
(<tf.Tensor: shape=(15,), dtype=int64, numpy=
array([ 7516,  2349, 11186, 13393,  4276,  1931,  1495,  8889, 16159,
        1495,  6530,   423,  5728,  1429, 12401])>, <tf.Tensor: shape=(), dtype=int64, numpy=2>)
(<tf.Tensor: shape=(10,), dtype=int64, numpy=
array([ 4287,  2349,   384,  3282,   423,  8741, 14597,  2713,  2349,
        5192])>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)
(<tf.Tensor: shape=(5,), dtype=int64, numpy=array([14260, 10012,  1468,   907,  9474])>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)

4. データセットを、テスト用と訓練用のバッチに分割する

小さなテスト用データセットと、より大きな訓練用セットを作成します。

  • テスト用データはTextLineDataset.takeで先頭から取り出す
  • 訓練用データはTextLineDataset.skipで、先頭からテスト用データを避けて取り出す

訓練用データとテスト用データを「バッチ化」します。
先ほど見たように、テキストを変換した整数のリストの長さは異なります(元のテキストの単語数が異なるため)。
バッチ化にあたり、短いリストには0を埋めて、同じサイズにします。

ハマった:TextLineDataset.padded_batch

チュートリアルのコードのとおりに実行すると、必須引数2つが指定されていないためにエラーが発生します。

In [41]: train_data = all_encoded_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [42]: train_data = train_data.padded_batch(BATCH_SIZE)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-42-8afff0f1d82a> in <module>
----> 1 train_data = train_data.padded_batch(BATCH_SIZE)

TypeError: padded_batch() missing 1 required positional argument: 'padded_shapes'

padded_shapes引数について

A nested structure of tf.TensorShape or tf.int64 vector tensor-like objects representing the shape to which the respective component of each input element should be padded prior to batching. Any unknown dimensions (e.g. tf.compat.v1.Dimension(None) in a tf.TensorShape or-1 in a tensor-like object) will be padded to the maximum size of that dimension in each batch.

解決の参考になったのは、以下のIssueのコード:

output_shapes_train = tf.compat.v1.data.get_output_shapes(ds_train)

tf.compat.v1.data.get_output_shapesall_encoded_dataのoutput shapesを取得し、padded_batchpadded_shapes引数に渡します。

In [45]: output_shapes = tf.compat.v1.data.get_output_shapes(all_encoded_data)

In [46]: output_shapes
Out[46]: (TensorShape([None]), TensorShape([]))

In [47]: train_data = train_data.padded_batch(BATCH_SIZE, output_shapes)

train_dataのバッチのうち、先頭1件を確認します。

In [52]: for sample_text, sample_label in train_data.take(1):
    ...:     print(sample_text)
    ...:     print('-' * 40)
    ...:     print(sample_label)
    ...:
tf.Tensor(
[[ 9310   722 10783 ...     0     0     0]
 [ 5766 12211 13137 ...     0     0     0]
 [ 2584 10652  4521 ...     0     0     0]
 ...
 [ 7497  8881  8781 ...     0     0     0]
 [ 6450  7516  1495 ...     0     0     0]
 [ 1785  2713 15027 ...     0     0     0]], shape=(64, 17), dtype=int64)
----------------------------------------
tf.Tensor(
[1 0 0 0 2 2 2 1 0 0 0 0 2 2 0 0 0 0 2 1 1 0 1 0 1 1 1 0 1 1 1 1 1 2 2 0 2
 0 1 0 2 2 2 2 1 2 1 0 2 1 0 0 0 0 0 2 1 1 0 1 0 2 0 0], shape=(64,), dtype=int64)

sample_textの整数の並びは終わりが0となって、長さが揃っていますね。
BATCH_SIZEでまとまっています(これがバッチ化という認識です)。

テスト用データも同様にバッチ化します。

In [55]: test_data = all_encoded_data.take(TAKE_SIZE)

In [56]: test_data = test_data.padded_batch(BATCH_SIZE, output_shapes)

0という新しい番号で埋めたので、ボキャブラリーサイズを1増やします。

In [57]: vocab_size += 1

5. モデルの構築・訓練

チュートリアルではテスト用データを訓練中のバリデーションに使っているのが気になったので、テスト用・バリデーション用・訓練用に分けました。

In [82]: test_data = all_encoded_data.take(TAKE_SIZE)

In [83]: test_data = test_data.padded_batch(BATCH_SIZE, output_shapes)

In [84]: train_data = all_encoded_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [85]: val_data = train_data.take(TAKE_SIZE)

In [86]: val_data = val_data.padded_batch(BATCH_SIZE, output_shapes)

In [87]: train_data = train_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [88]: train_data = train_data.padded_batch(BATCH_SIZE, output_shapes)

チュートリアルに沿ったモデルとします。

In [89]: model = tf.keras.Sequential(
    ...:     [
    ...:         tf.keras.layers.Embedding(vocab_size, 64),
    ...:         tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(64)),
    ...:         tf.keras.layers.Dense(64, activation='relu'),
    ...:         tf.keras.layers.Dense(64, activation='relu'),
    ...:         tf.keras.layers.Dense(3, activation='softmax')
    ...:     ]
    ...: )
  • Embedding layer
  • LSTM
  • Dense 1
  • Dense 2
  • Dense 3(softmaxをとって、3クラスのどれかに分類)
In [90]: model.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
    ...:  metrics=['accuracy'])

In [91]: history = model.fit(train_data, epochs=3, validation_data=val_data)
Epoch 1/3
    619/Unknown - 21s 33ms/step - loss: 0.5528 - accuracy: 0.72992020-01-19 13:15:39.947590: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
2020-01-19 13:15:48.626987: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 29s 47ms/step - loss: 0.5528 - accuracy: 0.7299 - val_loss: 0.3283 - val_accuracy: 0.8696
Epoch 2/3
617/619 [============================>.] - ETA: 0s - loss: 0.3209 - accuracy: 0.85992020-01-19 13:16:15.284238: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 27s 43ms/step - loss: 0.3207 - accuracy: 0.8599 - val_loss: 0.2153 - val_accuracy: 0.9172
Epoch 3/3
615/619 [============================>.] - ETA: 0s - loss: 0.2440 - accuracy: 0.89502020-01-19 13:16:41.786026: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 27s 43ms/step - loss: 0.2438 - accuracy: 0.8951 - val_loss: 0.1813 - val_accuracy: 0.9298

テスト用データに対しての正解率は84.0%でした(チュートリアル通り)

In [92]: eval_loss, eval_acc = model.evaluate(test_data)
     79/Unknown - 2s 23ms/step - loss: 0.3762 - accuracy: 0.83962020-01-19 13:21:13.484423: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
     79/Unknown - 2s 23ms/step - loss: 0.3762 - accuracy: 0.8396
In [93]: f'Eval loss: {eval_loss}, Eval accuracy: {eval_acc}'
Out[93]: 'Eval loss: 0.37618066295038294, Eval accuracy: 0.8396000266075134'

過去に作った plot_accuracy 関数6を使って、学習状況を可視化します。

f:id:nikkie-ftnext:20200119141000p:plain

訓練用データにおける正解率がバリデーション用データにおける正解率を下回っているため、学習不足という印象です。
epochsはもう少し増やせそうです。

感想

初めてしっかり触ったtf.data
この取り組みの中ではこれまで、tf.keras.preprocessing.text.Tokenizerでテキストをnumpyのarrayに変換して扱いました。
tf.dataにはTensortf.functionなど関係するものが多くまだ掴みきれていないのですが、データの扱いが揃うのは便利そうなので、引き続き触っていきたいです(読めるコードも増えそうですし)。
kerasのTokenizerとtfdsのTokenizer, TokenTextEncoderは役割がかぶっている印象ですが、どちらを使ったほうがいいという指針があるのか、私、気になります!

学んだこと

  • TensorFlowだけの利用で英語のテキストを数値にエンコードする方法
  • テキストを扱うときに有効と聞くEmbeddingやLSTMのlayerの使い方(の初めの一歩)

Future Works(今後のネタ帳)


  1. スライド中の図はドキュメントから更新されたようです。ドキュメント内の説明はこちら:Better performance with the tf.data API  |  TensorFlow Core

  2. get_fileのドキュメントより「By default the file at the url origin is downloaded to the cache_dir ~/.keras, placed in the cache_subdir datasets, and given the filename fname.」

  3. issubclass(tf.data.TextLineDataset, tf.data.Dataset)がTrueなので、tf.data.TextLineDatasettf.data.Datasetのサブクラスです(tf.data.Datasetと同様にパイプラインでデータを扱えるということです)ref:ドキュメント 組み込み関数issubclass

  4. [前処理編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita

  5. ref: tf.function で性能アップ  |  TensorFlow Core

  6. ref: [モデル構築編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita