LivedoorニュースコーパスをUniversal Sentence Encoderでベクトル化して、マルチクラス分類をしてみる

July 21, 2024
tag icon
Universal Sentence Encoder
tag icon
multinomial classification
tag icon
machine learning
tag icon
python

Evernoteでクリップしている記事を自動でタグ付けしたい、と思い立ったので、まずはLivedoorニュースコーパスを使って、うまくいくか試してみました。

以下JupyterLabで実行しているコードを紹介します。

大きな流れ

  1. テキストの読み込み&テキストのベクトル化(エンベディング)
  2. (任意)データの可視化
  3. クラス分類の学習、推論と評価

工夫している点は、ベクトル化にUniversal Sentence Encoderを利用している点と、クラス分類にOne Vs Rest戦略を採用している点です。

ヘッダー

以下のスニペットで共通で利用しているモジュールは下記です。

from lightgbm import LGBMClassifier
import matplotlib.pyplot as plt
import numpy as np
import os
from sklearn.manifold import TSNE
from sklearn.multiclass import OneVsRestClassifier
from sklearn.naive_bayes import GaussianNB # ガウシアンナイーブベイズ
import tensorflow as tf
import tensorflow_hub as hub
import tensorflow_text # 明示的な依存関係の説明がないけどtensorflow_hubのモデルに必須
import time

テキストの読み込み&ベクトル化

1つ目のスニペットでは、フォルダ内のファイルを読み込んでいます。

工夫点はデバッグを早くできるように1つのフォルダで読み込むファイルの上限数を設定している点と、本文が始まる箇所までスキップできるようにしている点です。

この辺はBing(ChatGPT)に聞いてサクサク進みました。

# グローバル変数
LIVEDOOR_TEXT_PATH = "./livedoor_text"
LIVEDOOR_DATA_LIST = ["dokujo-tsushin","it-life-hack","kaden-channel","livedoor-homme","movie-enter","peachy","smax","sports-watch","topic-news"]

def read_text_files_in_folder(folder_path, file_header="", start_line=1, end_line=None, max_files=0):
    file_contents = []  # 空のリストを作成
    file_count = 0

    # フォルダ内のファイルを走査
    for filename in os.listdir(folder_path):
        if max_files > 0 and file_count >= max_files:
            break # ファイル数上限に達したらループを終了
            
        file_path = os.path.join(folder_path, filename)
        if os.path.isfile(file_path) and filename.lower().endswith(".txt"):
            
            # ファイルヘッダーが設定されていたらフィルタ
            if file_header and filename.startswith(file_header):
                
                # テキストファイルの場合のみ読み込む
                with open(file_path, "r", encoding="utf-8") as file:
                    lines = file.readlines()
                    
                    if end_line is None:
                      	# 特定の行から最後まで読み込む
                        content = "".join(lines[start_line - 1 :])  
                    else:
                        content = "".join(lines[start_line - 1 : end_line - 1])
                        
                    file_contents.append(content)
                    file_count += 1

    return file_contents

def split_list_by_ratio(lst, ratio):
    n = int(len(lst) * ratio)
    return lst[:n], lst[n:]

2つ目のスニペットでは上記の関数を呼び出してテキストを読み込み、ベクトル化しています。

自然言語をベクトル化して分類する際はBERTを使った例が多く、また精度も良いことが知られています。ただ分類まで行う例が多く、ベクトル化用に使うのには大変そうだったのでスルーしました。

他にもTF-IDFなども軽量で精度が良い方法として利用されています。この方法も分類したい文書で学習させるなど、ベクトル化するときにあらかじめデータが揃っている必要があります。

今回は将来にわたって使いたい=データセットが継続的に変わると言うことを踏まえて、ベクトル化にあたってデータセットに非依存でできるLLMを使ってベクトル化することにしました。

でもいきなりOpenAIのtext-embedding-ada-002とか使ってお金がかかるのは嫌なので(笑)、まずはローカルでも良い精度が出るとの情報があったUniversal Sentence Encoder(略称USE)を使ってコードを書いてみました(後でベクトル化の箇所だけ差し替えれば良い)。

def read_livedoor():
    file_label_Y = []
    file_label_test = []
    split_list_80 = []
    split_list_20 = []
    
    # Universal Sentence Encoderのモデル読み出し
    embed = hub.load("https://tfhub.dev/google/universal-sentence-encoder-multilingual/3")
    # ループで複数のフォルダの内容を結合するため、ダミーのデータで同じ形のテンソルを作っておく
    embeddings_X = embed("ダミー")
    embeddings_test = embed("ダミー")
    
    start_time = time.time()
    
    max_files = 500
    for header in LIVEDOOR_DATA_LIST:
        print(header)
        
        folder_path = LIVEDOOR_TEXT_PATH + "/" + header + "/"
        
        # フォルダ内のテキストを読み込む
        file_contents_temp = read_text_files_in_folder(folder_path,header,start_line=4,max_files=max_files)
        
        # ダミーで変数初期化
        embeddings_temp = embed("ダミー")
        for contents in file_contents_temp:
            # 読み込んだテキストをベクトル化して、これまでのテンソルにくっつける
            embeddings_temp = tf.concat([embeddings_temp, embed(contents)],0)
        
        # ダミー分を取り除く
        embeddings_temp = np.delete(embeddings_temp.numpy(),0,0)
        
        # 読み込んだデータを学習用とテスト用に分ける
        split_list_80, split_list_20 = split_list_by_ratio(embeddings_temp, 0.8)
        
        # 学習用のテンソルとテスト用のテンソルにそれぞれ追加する
        embeddings_X = tf.concat([embeddings_X, split_list_80], 0)
        embeddings_test = tf.concat([embeddings_test, split_list_20], 0)
        
        # ラベルデータも生成してくっつける
        file_label_list = [header] * len(embeddings_temp)
        split_list_80, split_list_20 = split_list_by_ratio(file_label_list, 0.8)
        file_label_Y += split_list_80
        file_label_test += split_list_20
    

    # ダミー分を取り除く
    embeddings_X = np.delete(embeddings_X.numpy(),0,0)
    embeddings_test = np.delete(embeddings_test.numpy(),0,0)
    
    interval = time.time() - start_time
    print('text read and embedding time: {}s'.format(interval))
    
    return embeddings_X, embeddings_test, file_label_Y, file_label_test

# main1:embedding
#テキストを文章ベクトルに変換

embeddings_X, embeddings_test, file_label_Y, file_label_test = read_livedoor()

Universal Sentence Encoderを使った際のTIPSです。

Universal Sentence EncoderはTensorFlow Hubからダウンロードして使うことができるので、v3の利用は上記の通り簡単です。ただtensorflow_textをimportしておかないと、エラーを吐いてこけます。

あとテキストのリストを入れると全部まとめてベクトル化してくれて、かつ早いのですが、量が多いと(GPUの?)メモリあふれを起こすので、1ファイル分のテキストずつベクトル化しています(自分の環境では4500件のデータで170秒くらいかかります)。

複数のフォルダのテキストをループでベクトル化するのですが、その時に空の変数だと(テンソルの)形が合ってない!と怒られるので、ダミーのデータをベクトル化して、くっつけた後に取り除いています(もっといいやり方があると思うのですが、エンコーダー差し替えた時もそのまま使えるので)。

データの可視化

せっかくなので、t-SNEでベクトル化したテキストを可視化してみました。

def view_tsne_with_label(tsne_data, data_label):
    start_time = time.time()
    
    # TSNEモジュールを呼び出して、ベクトル化したデータを2次元に表現する
    embedding = TSNE(n_components=2).fit_transform(tsne_data)
    
    interval = time.time() - start_time
    print('t-sne time: {}s'.format(interval))
    
    # ラベルごとにマスクした結果を系列にして表示する
    for label in np.unique(data_label):
        mask = [ True if l == label else False for l in data_label ]
        plt.scatter(embedding[mask, 0], embedding[mask, 1], label=label)
        
    plt.legend(bbox_to_anchor=(1.05,1), loc='upper left')
    plt.show()

# main2:データ可視化
print('学習用データ件数:',len(embeddings_X))
view_tsne_with_label(embeddings_X, file_label_Y)
print('テスト用データ件数:',len(embeddings_test))
view_tsne_with_label(embeddings_test, file_label_test)

きれいに分かれているラベル(smaxとか)とそうでないラベル(livedoor-hommeとか)がありますね。当然ですが精度にも影響が出てました。最後にラベルごとの調和平均も記載しました。

t-sne image

クラス分類の学習、推論と評価

1つ目のスニペットでは、文字列のラベルを数値のラベルに置き換えています。ナイーブベイズの場合は文字列のままでも動くのですが、LightGBMは数値でないと扱えないためです。

def label_to_int_def(label_def):
    replace_dict = {"none": 0}
    reverse_dict = {0: "none"}
    
    for i in range(len(label_def)):
        replace_dict[label_def[i]] = i + 1
        reverse_dict[i + 1] = label_def[i]
    
    return replace_dict, reverse_dict

# main3:学習用データ前処理

replace_dict, reverse_dict = label_to_int_def(LIVEDOOR_DATA_LIST)
file_numlabel_Y = [replace_dict[word] if word in replace_dict else 0 for word in file_label_Y]

2つ目のスニペットでは、評価に使うmacro-F1の計算用関数と、学習推論の共通化できるコードを関数にして定義しています。macro-F1はsklernでも組み込み機能があるのですが、細かい仕様を調べるのが面倒だったので。

# macro-F1 (macro-Precision)

def macro_precision(predict_list, label_list):
    harmonic_mean = []
    
    print('')
    print('hermonic mean:')
    for label in np.unique(label_list):
        
        predict_num = len([ 1 for l in predict_list if l == label ])
        label_num = len([ 1 for l in label_list if l == label ])
    
        true_num = len([ 1 for i in range(len(predict_list)) if label_list[i] == label and predict_list[i] == label_list[i] ])
        
        mean_num = 2 * (true_num / predict_num) * (true_num / label_num) / ((true_num / predict_num) + (true_num / label_num))
        
        harmonic_mean.append(mean_num)
        print(label, ': ', mean_num)
    
    macro_f1 = sum(harmonic_mean)/len(np.unique(label_list))
    
    print('')
    print('macro_f1: ',macro_f1)
    

def main3to4(clf, embeddings_X, file_numlabel_Y, embeddings_test):
    # main3:学習
    start_time = time.time()
    
    clf.fit(embeddings_X, file_numlabel_Y) # 学習をする
    
    interval = time.time() - start_time
    print('lerning time: {}s'.format(interval))
    
    # main4:推論
    start_time = time.time()

    predict_list = clf.predict(embeddings_test) # 推論
    predict_list = predict_list.tolist()

    interval = time.time() - start_time
    print('predict time: {}s'.format(interval))
    
    return predict_list

3つ目のスニペットでいよいよ学習推論をさせます。

今回のデータセットではマルチクラス分類(1つのデータに1つのラベル、ラベルは複数)なのですが、元々マルチラベル分類(1つのデータに複数のラベルがつく)を最終的に実現したいので、その形に拡張できる方式を選んでいます。

OneVsRestClassifierはOne vs Rest戦略で複数のラベルの学習を自動でやってくれるsklearnの便利機能です。

One vs Restとはラベル分類をするときにA(One)とそれ以外(Rest)用の分類器をラベルごとに用意してしまう、と言う戦略です。ラベルが10個あったら分類器も10個作られます。

そこでOneVsRestClassifierを使うと、この10個の分類器を一発で作ってくれ、そのまま推論にも使えるようになります。使い方はコンストラクタに学習器(GaussianNB()LGBMClassifier())を渡すだけで、あとはfit()predict()をするだけです。


# main3to4:NaiveBayes

clf = OneVsRestClassifier(GaussianNB())
predict_list = main3to4(clf,  embeddings_X, file_numlabel_Y, embeddings_test)

# main5:評価
predict_str_list =  [reverse_dict[num] if num in reverse_dict else "none" for num in predict_list]
macro_precision(predict_str_list, file_label_test)

livedoorニュースコーパスのファイル名順に500件ずつ取り出し、学習器にナイーブベイズを使った場合は下記のような結果でした。ナイーブベイズらしく、学習推論は一瞬です。macro-F1は0.7でもうちょっと頑張りたい数字ですね。なお全データでやると0.64とさらに下がります。

lerning time: 0.08790898323059082s
predict time: 0.03849911689758301s

hermonic mean:
dokujo-tsushin :  0.7578947368421052
it-life-hack :  0.6105263157894737
kaden-channel :  0.7914438502673796
livedoor-homme :  0.5032258064516129
movie-enter :  0.8355555555555555
peachy :  0.6019417475728155
smax :  0.8725490196078431
sports-watch :  0.7865168539325842
topic-news :  0.7094339622641509

macro_f1:  0.718787538698169

と言うことで4つ目のスニペットでLightGBMを使った例を記載します。差分はOneVsRestClassifierに渡している学習器だけです。(学習器はsklearn互換のインターフェイスをもっていればsklearn以外のものも使えます。)

# main3to4:LightGBM

clf = OneVsRestClassifier(LGBMClassifier())
predict_list = main3to4(clf,  embeddings_X, file_numlabel_Y, embeddings_test)

# main5:評価
predict_str_list =  [reverse_dict[num] if num in reverse_dict else "none" for num in predict_list]
macro_precision(predict_str_list, file_label_test)

結果は下記の通りで、22秒で学習完了し、macro-F1は0.8とナイーブベイズより良くなってました。こちらも全データの場合は0.72と下がりました。

lerning time: 22.654714584350586s
predict time: 0.03147625923156738s

hermonic mean:
dokujo-tsushin :  0.7661691542288558
it-life-hack :  0.801980198019802
kaden-channel :  0.8629441624365483
livedoor-homme :  0.6103896103896103
movie-enter :  0.8711111111111111
peachy :  0.6701030927835052
smax :  0.9452736318407959
sports-watch :  0.9333333333333333
topic-news :  0.8055555555555555

macro_f1:  0.8074288721887908

どなたかの参考になれば幸いです。

参考文献

たくさんありますがピックアップして紹介します。

  1. scikit-learnによる多クラスSVM
  2. 【翻訳】scikit-learn 0.18 User Guide 1.12. 多クラスアルゴリズムと多ラベルアルゴリズム
  3. OneVsRestClassifier
  4. scikit-learn によるナイーブベイズ分類器
  5. 勾配ブースティングを使って文書分類モデルを作成してみた
  6. マルチラベル分類メモ

Profile picture

i氏 システムのデザインが好きな自称システムアーキテクト。データサイエンティスト見習い。Jamstackのアーキテクチャーに感動して、Gatsbyでブログを始めました。