nikkie-ftnextの日記

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

tf.data でライブドアニュース(日本語テキスト)の分類に取り組んだところ、自分のコードの課題が見えました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週はここまでの総決算として、日本語テキストを分類するタスクにTensorFlowを使って取り組みました。

目次

動作環境

$ 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 を使う方法で進めます。

  1. テキストのダウンロード
  2. テキストを tf.data.Dataset に読み込む
  3. テキスト中の単語を数字にエンコードする
  4. データセットを、訓練用、バリデーション用、テスト用のバッチに分割する
  5. モデルの構築・訓練

日本語テキストで必要なステップ:形態素解析

日本語テキストと英語テキストの扱いの違いは形態素解析です。
英語は単語が半角スペースで区切られています(例:This is a pen)が、日本語は一続き(例:これはペンです)です。
単語の区切りを見つける必要があり、その方法の1つが形態素解析です。
形態素解析で単語の区切りを見つけることで、英語のように半角スペース区切りで単語を並べることもできます(これ は ペン です)

形態素解析は2と3の間に必要です。
今回は取り組みの中で触ったことのあるjanomeを使用しました。

今週のネタ探しで参照した記事1janomeを使ったコード例があったのも採用理由です。

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. 1ファイルをTextLineDatasetとして読み込んだら、すべての行を一度取り出し、つないだtf.Tensor(以下テンソル)とする(1ファイルに対応するテンソル):tf.convert_to_tensor
  2. 1のテンソルをリストに入れる(クラスごとのファイルに対応するテンソルが1つのリストに入る。リストはクラスを表す)
  3. 2のリストからtf.data.Datasetを作る:tf.data.Dataset.from_tensor_slices6

これで前回のブログのホメロスのケースと同じ扱いになったので、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に変えてみる
    • Analyzerを都度作っていて遅いというわけではなさそう(関数のローカル変数からグローバル変数に移して比較した)
    • ラッパーは今だとfugashiを使うといいらしい7
  • 1つのスクリプトで全部やろうとせずに中間ファイルを作ってはどうだろう

モデルの評価:2クラス分類させたら

On test data: loss=0.07063097149754564, acc=0.9733333587646484

ITの記事と女性向けの記事は違いが顕著なようで、また、MLPと比べたら多少はいいモデルを使っているので、実運用できそうなモデルができあがりました。
問題は実行時間がだいぶかかる(毎回コーヒー飲める)ことですね。。

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

感想

動くようにしたコードはGitHubに上げました:

今週は木曜の登壇日曜のリジェクトコン開催で、この取り組みの時間を作れずめちゃくちゃ厳しい週でした。
今回のスクリプトの実行に10分程度かかり、タイムリミットは迫り、「機械学習の取り組みは時間に追われてやるものではないな」と心底感じました。

見るコードによってbytes.decode()の引数がまちまちだなと思ったのですが、これは第1引数encodingのデフォルトが'utf-8'だから書かなくてもいいということでした。8

この取り組みをそろそろモデルの方にシフトしこうと思っていたのですが、今週残した宿題は結構大きい認識なので、もう少しだけ続きに取り組みます(来週の目標は宿題を解消してBERTに触る!)。9


  1. tf.data.Dataset apiでテキスト (自然言語処理) の前処理をする方法をまとめる - Qiita

  2. データセットの説明は今回参照した Mecabとtf.dataを使ってlivedoorニュースコーパスを分かち書きする - Qiita にあります

  3. これを使って過去にこんなこともしています:イベントレポート | #pyhack にて旦那と彼氏の間に何があるのかをword2vecに聞いてみました - nikkie-ftnextの日記

  4. 使ったのがtf.compat.v1.gfile.ListDirectory。これはpathlib.Pathを受け付けなくてがっかりでした

  5. 前回のホメロスの翻訳の分類のように、クラスごとにテキストが1ファイルで1つ1つの文をクラスに分類する際に重宝すると思います。他にはポジティブな感情を表すテキストファイルとネガティブな感情を表すテキストファイルの場合が考えられます

  6. 似たメソッドの tf.data.Dataset.from_tensors と結果を比較して選択しました

  7. ref: Mecabとtf.dataを使ってlivedoorニュースコーパスを分かち書きする - Qiita

  8. ref: https://docs.python.org/ja/3/library/stdtypes.html#bytes.decode

  9. リジェクトコンで知ったnagisaや最近耳にするGiNZAと前処理の新しい潮流も触っておきたいなという思いも出てきています。悩ましいです