はじめに
頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週はここまでの総決算として、日本語テキストを分類するタスクにTensorFlowを使って取り組みました。
目次
- はじめに
- 目次
- 動作環境
- TensorFlowを使ったテキストの扱い
- 1. テキストのダウンロード:ライブドアニュースのデータセット
- 2. テキストを tf.data.Dataset に読み込む
- 追加:Dataset中のテキストを形態素解析する
- 3. テキスト中の単語を数字にエンコードする
- 4. データセットを、訓練用、バリデーション用、テスト用のバッチに分割する
- 5. モデルの構築・訓練
- コードの書き方が悪くて耐えがたい遅さ
- 感想
動作環境
$ sw_vers ProductName: Mac OS X ProductVersion: 10.14.6 BuildVersion: 18G103 $ # 注:コードを動かすのにGPUは使っていません $ python -V Python 3.7.3 $ pip list # grepを使って抜粋して表示 tensorflow 2.1.0 tensorflow-datasets 2.0.0 matplotlib 3.1.2 numpy 1.18.1 Janome 0.3.10
tensorflow-datasets
のバージョンが2系に上がっていました。
TensorFlowを使ったテキストの扱い
方法はいくつかあると思いますが、前回のブログ(英語テキストの場合)にならって tf.data
を使う方法で進めます。
- テキストのダウンロード
- テキストを
tf.data.Dataset
に読み込む - テキスト中の単語を数字にエンコードする
- データセットを、訓練用、バリデーション用、テスト用のバッチに分割する
- モデルの構築・訓練
日本語テキストで必要なステップ:形態素解析
日本語テキストと英語テキストの扱いの違いは形態素解析です。
英語は単語が半角スペースで区切られています(例:This is a pen)が、日本語は一続き(例:これはペンです)です。
単語の区切りを見つける必要があり、その方法の1つが形態素解析です。
形態素解析で単語の区切りを見つけることで、英語のように半角スペース区切りで単語を並べることもできます(これ は ペン です)
形態素解析は2と3の間に必要です。
今回は取り組みの中で触ったことのあるjanome
を使用しました。
今週のネタ探しで参照した記事1にjanome
を使ったコード例があったのも採用理由です。
1. テキストのダウンロード:ライブドアニュースのデータセット
コードでダウンロードするのではなく、手動でダウンロードします。
https://www.rondhuit.com/download.html からダウンロードした「livedoor ニュースコーパス」2を使いました。3
展開方法はこちら。
カレントディレクトリにtext
ディレクトリができます
$ tar xzf ~/Downloads/ldcc-20140209.tar.gz $ tree -L 1 text/ text/ ├── CHANGES.txt ├── README.txt ├── dokujo-tsushin ├── it-life-hack ├── kaden-channel ├── livedoor-homme ├── movie-enter ├── peachy ├── smax ├── sports-watch └── topic-news
tar --help
を見たところ、fオプションによりアーカイブを指定すればいいことに気づきました(以下に抜粋)。
First option must be a mode specifier: -c Create -r Add/Replace -t List -u Update -x Extract Common Options: -f <filename> Location of archive -z, -j, -J, --lzma Compress archive with gzip/bzip2/xz/lzma
2. テキストを tf.data.Dataset
に読み込む
def labeler(example, index): return example, tf.cast(index, tf.int64) text_datasets = [] label_count = 0 text_dir = os.path.join(os.getcwd(), "text") text_subdir_names = tf.compat.v1.gfile.ListDirectory(text_dir) for subdir in text_subdir_names: data_dir = os.path.join(text_dir, subdir) if os.path.isdir(data_dir): print(f"{label_count}: {subdir}") text_file_names = tf.compat.v1.gfile.ListDirectory(data_dir) text_tensors = [] for file_name in text_file_names: text_file = os.path.join(data_dir, file_name) lines_dataset = tf.data.TextLineDataset(text_file) # 1行1行がTensorとなるので、ファイルの文章全体をつないでTensorとする sentences = [ line_tensor.numpy().decode("utf-8") for line_tensor in lines_dataset ] concatenated_sentences = " ".join(sentences) # subdirのファイルごとにTensorを作り、Datasetとする text_tensor = tf.convert_to_tensor(concatenated_sentences) text_tensors.append(text_tensor) text_dataset = tf.data.Dataset.from_tensor_slices(text_tensors) text_dataset = text_dataset.map(lambda ex: labeler(ex, label_count)) text_datasets.append(text_dataset) label_count += 1
text
ディレクトリ以下のサブディレクトリを取得4し、その中のファイルの一覧も取得、1つ1つのファイルについてTextLineDataset
で読み込みます。
今回の場合、サブディレクトリはクラスを表し、その中に複数のテキストファイルがあります。
ここで苦労したのは、TextLineDataset
は1行ずつ読み込まれるという挙動5。
- やりたいこと:テキストファイル全体に含まれる複数行のテキストを最小単位として
tf.data.Dataset
に読み込む TextLineDataset
では、テキストファイルの1行ごとに分かれて読み込まれる
ひねり出した解決策
- 1ファイルを
TextLineDataset
として読み込んだら、すべての行を一度取り出し、つないだtf.Tensor
(以下テンソル)とする(1ファイルに対応するテンソル):tf.convert_to_tensor
- 1のテンソルをリストに入れる(クラスごとのファイルに対応するテンソルが1つのリストに入る。リストはクラスを表す)
- 2のリストから
tf.data.Dataset
を作る:tf.data.Dataset.from_tensor_slices
6
これで前回のブログのホメロスのケースと同じ扱いになったので、concatenate
で1つのDataset
にまとめ、shuffle
します
all_labeled_data = text_datasets[0] for labeled_data in text_datasets[1:]: all_labeled_data = all_labeled_data.concatenate(labeled_data) all_labeled_data = all_labeled_data.shuffle( BUFFER_SIZE, seed=RANDOM_SEED, reshuffle_each_iteration=False )
追加:Dataset
中のテキストを形態素解析する
Analyzer
はこれまでで学んだことが活きました。
tf.data.Dataset apiでテキスト (自然言語処理) の前処理をする方法をまとめる - Qiita (☆)にならって、関数_tokenizer
を返します
def janome_tokenizer(): token_filters = [ CompoundNounFilter(), POSStopFilter(["記号", "助詞", "助動詞", "名詞,非自立", "名詞,代名詞"]), LowerCaseFilter(), ExtractAttributeFilter("surface"), ] tokenizer = Tokenizer() analyzer = Analyzer(tokenizer=tokenizer, token_filters=token_filters) analyze_function = analyzer.analyze def _tokenizer(text_tensor, label): text_str = text_tensor.numpy().decode("utf-8") tokenized_text = " ".join(list(analyze_function(text_str))) return tokenized_text, label return _tokenizer
map
で適用します。
all_tokenized_data = all_labeled_data.map(tokenize_map_fn(janome_tokenizer()))
3. テキスト中の単語を数字にエンコードする
前回の記事と同じです。
tokenizer = tfds.features.text.Tokenizer() vocabulary_set = set() for text_tensor, _ in tqdm(all_tokenized_data): tokens = tokenizer.tokenize(text_tensor.numpy().decode("utf-8")) vocabulary_set.update(tokens) vocab_size = len(vocabulary_set) print(f"vocabulary size: {vocab_size}") encoder = tfds.features.text.TokenTextEncoder(vocabulary_set) all_encoded_data = all_tokenized_data.map(encode_map_fn)
4. データセットを、訓練用、バリデーション用、テスト用のバッチに分割する
これも前回の記事のとおりです。
(☆)にならって関数化してもいいかも
output_shapes = tf.compat.v1.data.get_output_shapes(all_encoded_data) test_data = all_encoded_data.take(TAKE_SIZE) test_data = test_data.padded_batch(BATCH_SIZE, output_shapes) train_data = all_encoded_data.skip(TAKE_SIZE).shuffle( BUFFER_SIZE, seed=RANDOM_SEED ) val_data = train_data.take(TAKE_SIZE) val_data = val_data.padded_batch(BATCH_SIZE, output_shapes) train_data = train_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE, seed=RANDOM_SEED) train_data = train_data.padded_batch(BATCH_SIZE, output_shapes) vocab_size += 1 # padded_batchした際に0という番号を追加している
vocab_size += 1
を忘れたところ、学習中にインデックス範囲外のインデックス参照が発生して、落ちました。
5. モデルの構築・訓練
前回の記事で知ったモデルを使います。
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(9, activation="softmax"), ] ) model.compile( optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"], )
コードの書き方が悪くて耐えがたい遅さ
Datasetはステップ3のfor文の箇所で初めて評価されるようです(遅延評価という理解)。
ここの処理が遅く、扱うテキスト量の問題と考え、分類に使うテキストを減らしていきました。
最終的にit-life-hackとdokujo-tsushinの2クラス分類になりました。
テキスト数の概要としては1700件程度です(これでも件のfor文に2分半ほどかかります)。
$ ls -l text/dokujo-tsushin/ | wc -l 872 $ ls -l text/it-life-hack/ | wc -l 872
訓練用:バリデーション用:テスト用の分割は8:1:1としています。
遅さはどうすれば解消するか
今回のコードには問題があるので、全クラスを対象にできるように今後コードを書き換えます。
今の段階で考えているのは以下のような案です:
- (☆)の記事のように
tf.py_function
を活用して実行する回数を減らすtf.py_function
が何をやっているのかがいま一つわからず、まだ使いこなせていない課題- データの分割で
shuffle
の回数が減らせそう
- 形態素解析器を
janome
からmecab
に変えてみる - 1つのスクリプトで全部やろうとせずに中間ファイルを作ってはどうだろう
モデルの評価:2クラス分類させたら
On test data: loss=0.07063097149754564, acc=0.9733333587646484
ITの記事と女性向けの記事は違いが顕著なようで、また、MLPと比べたら多少はいいモデルを使っているので、実運用できそうなモデルができあがりました。
問題は実行時間がだいぶかかる(毎回コーヒー飲める)ことですね。。
感想
動くようにしたコードはGitHubに上げました:
今週は木曜の登壇や日曜のリジェクトコン開催で、この取り組みの時間を作れずめちゃくちゃ厳しい週でした。
今回のスクリプトの実行に10分程度かかり、タイムリミットは迫り、「機械学習の取り組みは時間に追われてやるものではないな」と心底感じました。
見るコードによってbytes.decode()
の引数がまちまちだなと思ったのですが、これは第1引数encoding
のデフォルトが'utf-8'
だから書かなくてもいいということでした。8
この取り組みをそろそろモデルの方にシフトしこうと思っていたのですが、今週残した宿題は結構大きい認識なので、もう少しだけ続きに取り組みます(来週の目標は宿題を解消してBERTに触る!)。9
-
データセットの説明は今回参照した Mecabとtf.dataを使ってlivedoorニュースコーパスを分かち書きする - Qiita にあります↩
-
これを使って過去にこんなこともしています:イベントレポート | #pyhack にて旦那と彼氏の間に何があるのかをword2vecに聞いてみました - nikkie-ftnextの日記↩
-
使ったのが
tf.compat.v1.gfile.ListDirectory
。これはpathlib.Path
を受け付けなくてがっかりでした↩ -
前回のホメロスの翻訳の分類のように、クラスごとにテキストが1ファイルで1つ1つの文をクラスに分類する際に重宝すると思います。他にはポジティブな感情を表すテキストファイルとネガティブな感情を表すテキストファイルの場合が考えられます↩
-
似たメソッドの
tf.data.Dataset.from_tensors
と結果を比較して選択しました↩ -
ref: https://docs.python.org/ja/3/library/stdtypes.html#bytes.decode↩
-
リジェクトコンで知った
nagisa
や最近耳にするGiNZA
と前処理の新しい潮流も触っておきたいなという思いも出てきています。悩ましいです↩