教師なし学習で事前学習用のWebテキストを分類する

はじめに

最近は大規模言語モデルの学習に用いるテキストの整形加工にハマっています。
前回は、mc4などのデータセットを教師あり学習でクリーニングするスクリプトを書きました。体感では、webサイトの半分以上は宣伝文や公序良俗に反するページなので、適切にフィルタリングしてあげないと、かなり偏った文章になります。

今回は、フィルタ後のwebサイト群を、教師なし学習でカテゴリ別に分類してみたいと思います。

データ分割のモチベーション

データセットを分割するモチベーションは、Branch-Train-Mergeと呼ばれる大規模言語モデルの学習テクニックに関係しています。

この手法では、カテゴリ別にデータセットを分割した上で、個別にモデルを訓練します。
LLMはなんだかんだで過去に学習したことを忘れてしまう&直近に学んだことをよく覚えている、という特徴があります。なので、各ジャンルに特化した専門家モデルを作って、それらを組み合わせて推論することで、予測精度を上げちゃおう、という感じのアイデアです。

Cluster-BTMというアイデアが提示された論文では、英文の事前学習データを教師なし学習(k-means)によって自動分類していました。

本記事では、日本語の記事を教師なし学習で分類してみようと思います。

テキストのベクトル化

単語のナイーブなベクトル化(tf-idf)

元の論文では、英文テキスト中の単語の出現頻度をベクトル化する手法(tf-idf)でクラスタリングしていました。

はじめはこの手法をそのまま日本語に適用してみたのですが、速度と精度がイマイチだったので、やめました。

参考: 3万件ほどの日本語ページを分かち書きして、tf-idf + k-meansでクラスタリング(n=6)した結果の例 (コードは割愛)

様々なwebサイトをかき集めると、単語がやたらと沢山出てくるので、tf-idfのベクトルがどんどん大きく・スパースになり、処理が重くなる一方で、分類精度もイマイチ、、というよくあるジレンマに直面しました。

FastTextによるベクトル化

というわけで、深層学習を使ってベクトル化することにしました。
学習済みのFastTextモデルで各単語をベクトル化 → 文全体で平均を取ることで、対象のテキストに対するベクトルを得ます。
FastTextを選択したのは、CPUで動くのと、早いと評判だからです。

Wikpediaの記事タイトルを元にしたクラスタリング学習

クラスタリングのモデル構築には、wikipediaタイトルを学習データに用いることにしました。各々のWebページそのものを用いるというアプローチも有りですが、wikipediaの記事タイトルは、コンパクト・キレイ・入手が容易・網羅的と判断したからです。

以下、実装コードになります。

ベクトル化クラスの定義

llru_cacheで「単語→ベクトル」の処理を軽くキャッシュしておきました。

import numpy as np
import MeCab
from functools import lru_cache
mecab = MeCab.Tagger("")


def extract_nouns(text):

    # 文章を形態素解析し、名詞を抽出
    nodes = mecab.parseToNode(text)
    nouns = []
    while nodes:
        features = nodes.feature.split(",")
        if features[0] == "名詞":
            nouns.append(nodes.surface)
        nodes = nodes.next

    return nouns


class Text2Vec:
    def __init__(self, model):
        self.model = model
        self.dim = 300

    @lru_cache(maxsize=10**4)  # 単語ベクトルの計算結果をキャッシュ
    def _word2vec_cached(self, word):
        try:
            return self.model.wv[word]
        except KeyError:
            return np.zeros(self.dim)

    def word2vec(self, word):
        return self._word2vec_cached(word)

    def text2vec(self, text):
        nouns = extract_nouns(text)
        vecs = [self.word2vec(n) for n in nouns]
        if len(vecs) == 0:
            return np.zeros(self.dim)
        else:
            return np.mean(vecs, axis=0)

wikipediaの記事名取得とベクトル化

from sklearn.cluster import KMeans, MiniBatchKMeans
from gensim.models.fasttext import load_facebook_model
import datasets
from tqdm import tqdm

#wikipediaのタイトルリストを取得: 1.5minほど
wiki_dataset=datasets.load_dataset("hpprc/wikipedia-20240101",split="train").shuffle()
title_list=[x['title'] for x in tqdm(wiki_dataset)]

#ベクトル化
model = load_facebook_model('cc.ja.300.bin')
t2v=Text2Vec(model)

#とりあえずはじめの10^5件で試す。
title_vecs=[t2v.text2vec(i) for i in tqdm(title_list[:10**5])]

クラスタリング

大規模データに対応した、MinibatchKMeansというクラスを使います。
とりあえず、100個にクラスタリングします。
(KmeansはデータをN当分はしてくれないので、多めにクラスタリングしておいて、後から人手で統合する作戦です)

import numpy as np
n_cluesters=100
title_vecs=np.array(title_vecs)
# k-meansクラスタリング
print("clustering...")
kmeans = MiniBatchKMeans(n_clusters=n_cluesters, random_state=1).fit(title_vecs)

# クラスタリング結果の出力
print(kmeans.labels_)

記事のクラスタリング結果の確認

n_test=10**5
classes=kmeans.predict(title_vecs[:n_test])
texts=title_list[:10**5]

# クラスタリング結果の出力
class_texts={}
for i in range(n_cluesters):
    class_texts[i]=[]
for i in range(len(texts)):
    class_texts[classes[i]].append(texts[i])    

for i in range(10):
    print("--------class",i)
    print(len(class_texts[i]))
    for j in range(min(3,len(class_texts[i]))):
        print(class_texts[i][j][:100].replace("\n",""))

わりといい感じに、分類できている気がします。

Webページの分類

学習したモデルでWebページを分類していきます。

テキスト読み込み

import glob
import json

#textsに、webページのテキストをlistとして読み込んでいきます。(ここはケース・バイ・ケースで修正)
texts=[]
for path in glob.glob("output/mc4/*"):
    with open(path, "r") as f:
        for line in f:
            d=json.loads(line)
            texts.append(d["text"])

len(texts)

分類

class_texts={}
for i in range(n_cluesters):
    class_texts[i]=[]

cnt=0
for data in tqdm(texts):
    cnt+=1
    #text=data['text']
    text=data
    genre=kmeans.predict([t2v.text2vec(text[:300])])[0]
    class_texts[genre].append(text)    

確認

for i in range(30):
    len_records=len(class_texts[i])
    if len_records==0:
        continue
    print("--------class",i)
    print(len(class_texts[i]))
    for j in range(min(3,len(class_texts[i]))):
        print(class_texts[i][j][:100].replace("\n",""))

結果

自然系、???系、地理系、テック系? といった分類でしょうか。

100%の精度ではありませんが、わりとキレイに、ジャンルごとに分けられたと思います。

まとめ

FastText + KMeansで、Webページをジャンル分けできそうなことがわかりました。
テキスト分類の精度は、個人的には及第点です(逆に完璧に分けすぎると、大規模言語モデルの知識が偏りすぎる恐れがあります)。
不要なWebページの除外や、テキストからのノイズ除去(「ホームはこちら」など)は、もう少し頑張りたいところです。

データ処理の手応えを掴めてきたので、近々、モデル学習にも取り組みたいところです。


この記事が気に入ったらサポートをしてみませんか?