はじめに
頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週はTensorFlowにおける新しめのデータの扱い方のチュートリアルに取り組みました。
チュートリアル「tf.data を使ったテキストの読み込み」
問題設定は、英文テキストの多クラス分類です。
以下の手順で進みました。
動作環境
$ 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_seed
、shuffle
メソッドのseed
引数を指定することになると考えています(未実施)
そもそも、tf.data
とは
2019年10月に行ったPyCon SingaporeのTensorFlow 2.0 チュートリアルでtf.data
についてインプットしていました1。
チュートリアルによると、tf.data
の目的は、深層学習に取り組む上でCPUとGPUが交互に遊んでしまう(idle)状態の解決とのことでした。
tf.data
を使わない場合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にないため、ダウンロードする
- 必須引数
fname
:xxx.txt
- 必須引数
origin
:テキストのURL - データのcache先は
cache_dir
引数とcache_subdir
引数で指定 - 今回はどちらもデフォルト値(
cache_dir
引数がNone
、cache_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.TextLineDataset
3として読み込みます。
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)から取り出したex
はtuple
でした。
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ステップでした。
ボキャブラリーの構築
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_set
をtfds.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
ortf.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 atf.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_shapes
でall_encoded_data
のoutput shapesを取得し、padded_batch
のpadded_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を使って、学習状況を可視化します。
訓練用データにおける正解率がバリデーション用データにおける正解率を下回っているため、学習不足という印象です。
epochs
はもう少し増やせそうです。
感想
初めてしっかり触ったtf.data
。
この取り組みの中ではこれまで、tf.keras.preprocessing.text.Tokenizer
でテキストをnumpyのarrayに変換して扱いました。
tf.data
にはTensor
やtf.function
など関係するものが多くまだ掴みきれていないのですが、データの扱いが揃うのは便利そうなので、引き続き触っていきたいです(読めるコードも増えそうですし)。
kerasのTokenizerとtfdsのTokenizer, TokenTextEncoderは役割がかぶっている印象ですが、どちらを使ったほうがいいという指針があるのか、私、気になります!
学んだこと
- TensorFlowだけの利用で英語のテキストを数値にエンコードする方法
- テキストを扱うときに有効と聞くEmbeddingやLSTMのlayerの使い方(の初めの一歩)
Future Works(今後のネタ帳)
tf.data
で日本語を扱ってみる- tf.data.Dataset apiでテキスト (自然言語処理) の前処理をする方法をまとめる - Qiita から始めてアドカレをなぞる
- ドキュメントにある他のチュートリアル
- Tensorの扱いを調べる
tf.function
を調べる- ロイター通信のアウトプットを更新(Datasetとして扱う、モデルをアップデート)
- このチュートリアルでラベルのone hot encodingを試したい
-
スライド中の図はドキュメントから更新されたようです。ドキュメント内の説明はこちら:Better performance with the tf.data API | TensorFlow Core↩
-
get_file
のドキュメントより「By default the file at the urlorigin
is downloaded to the cache_dir~/.keras
, placed in the cache_subdirdatasets
, and given the filenamefname
.」↩ -
issubclass(tf.data.TextLineDataset, tf.data.Dataset)
がTrueなので、tf.data.TextLineDataset
はtf.data.Dataset
のサブクラスです(tf.data.Dataset
と同様にパイプラインでデータを扱えるということです)ref:ドキュメント 組み込み関数issubclass
↩ -
[前処理編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita↩
-
ref: [モデル構築編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita↩