見出し画像

人工知能学会にインスパイアされたので文章の重複排除を試してみた

note AI Creativeでエンジニアをしております、武藤です。

noteが協賛している2024年度人工知能学会全国大会(第38回)に初参加しました。人工知能学会の参加レポートについてはこちらをご参照ください。

参加しただけで終わってしまうのはもったいないので、私が特にインスパイアされたセッションを元に自分で実装してみようと思います。


インスパイアされたセッション

岡崎 直観先生の「大規模言語モデルの開発」のセッションが非常に面白かったので、このセッションの中からトピックをピックアップして実装してみようと思います。

しかしながら、非常に網羅的で完成度の高いセッションだったので、深掘りしてみたいトピックがいっぱいあって悩みます。

GPUを使って事前学習となるとコストがえげつないですし、どうしようか考えました。結論としては、下記スライドの第1部の「事前学習データの重複排除」のところが個人的に興味深いので、今回は重複排除を試そうと思います。


事前学習データの重複排除とは?

大規模言語モデルの学習には大規模なデータセットが必要になります。下記のスライドのようにどういうデータを使って事前学習させるか、というのは大規模言語モデルの個性を決定するのに重要な要素になります。

ウェブサイトからデータを取得してくる場合、重複が多くて情報量があまりないような文章は読み込ませたくないですね。

何も知らない赤ちゃんにどんな本を読ませてあげたいか、というお母さんの気持ちになって考えると重複を排除したキレイなデータセットを使って学習させることが重要になります。

ということで、最初にテストデータを作った上で下記の二つのアプローチをもとに文章の重複排除を実装してみようと思います。

1. NearDup:
データの文字列や構造の類似性に基づいて重複を検出します。これは、データの外観が非常に似ているか、ほぼ同一である場合に効果的です。

2. SemDeDup:
データの意味的な類似性に基づいて重複を検出します。これは、異なる表現でも同じ意味を持つデータを特定するのに適しています。

※  AIと一緒に実装していますが論文通りの正確な実装ではないと思うので、なんとなく雰囲気を感じ取っていただければと思います。

0.テストデータを作る

今の時代はGPT-4oやClaude Opusなどがあって便利です。テストデータや教師データも簡単に作れてしまいます。

GPT-4oに対して「このオリジナルテキストと10%類似している文章と30%類似している文章を書いてください」みたいなプロンプトを投げて、重複度合いのグラデーションのあるデータを作っていきます。
これでテストデータは完成です。

類似度のグラデーション

1. NearDupで重複を検知する

2つの文章で重複排除をしたいときに「同じような単語が多ければ重複してるのでは?」と思いつきそうです。このようにデータの文字列や構造の類似性に基づいて重複を検出するアプローチがNearDupです。

NearDupを実装するにあたって、ちょっと難しい話ですがMinHashによるジャッカード係数というのを使います(下記スライド参照)

例えば2つの文章を単語やn-gramごとに区切ったあとに、ハッシュ関数(h1)を使ってハッシュ値にします。2つの文章のハッシュ値を比べて、一番小さいハッシュ値(MinHash)が同じであれば2つの文章は同じだと判定します。スライドのように2つの文章のハッシュ関数(h1)のMinHashは一致しないので2つの文章は違うということになります。

このようにして、ハッシュ関数をいくつか用意(h2, h3, h4…)して、例えば2つの文章のh1〜h4のうちh3のみMinHashが一致するならば ジャッカード係数の近似値は1 / 4 (類似度=0.25)になります。

非常に説明がむずいので詳しい説明が知りたくなったらChatGPT先生に聞いてみてくださいという感じですが、とりあえず実装は下記のようにしてみました。

from datasketch import MinHash, MinHashLSH
from janome.tokenizer import Tokenizer


# MinHash 間の Jaccard 類似度を計算
def calculate_similarity(docs, threshold=0.0):
    tokenizer = Tokenizer()
    lsh = MinHashLSH(threshold=threshold)
    minhashes = {}

    # MinHashを計算してLSHに挿入
    for key, doc in docs.items():
        m = MinHash()
        for token in tokenizer.tokenize(doc):
            m.update(token.surface.encode("utf8"))
        lsh.insert(key, m)
        minhashes[key] = m

    # originalとの類似度を計算
    similarities = {}
    original_key = "original"
    original_minhash = minhashes[original_key]
    for key in lsh.query(original_minhash):
        if key != original_key:
            similarities[key] = original_minhash.jaccard(minhashes[key])

    return similarities


# 文書データの定義
docs = {
    "original": "一番気になっていたセッション。note AI Creativeでも将来的に大規模言語モデルを作るかも...みたいな可能性はあるので、実際の知見を得たいなぁと思って参加しました。結論から言うと、発表の章立て、知見の深さ、もう全て最高と思える内容でした。大規模言語モデルを作る際に指針にできそうな話ばかりだったので、このセッションを聞いて岡崎先生は私の推しになりました。",
    "doc_sim_10": "セッションについて少し興味がありました。note AI Creativeが大規模言語モデルを作る可能性があるので、参加しました。内容はとても良く、役立つ情報が多かったです。このセッションの後、岡崎先生を尊敬するようになりました。",
    "doc_sim_30": "気になっていたセッションに参加しました。note AI Creativeが将来的に大規模言語モデルを作るかもしれないので、知見を得たいと思いました。結論として、発表の内容や知見の深さは素晴らしかったです。このセッションを通じて、岡崎先生を推しと感じました。",
    "doc_sim_60": "最も気になっていたセッションでした。note AI Creativeも将来的に大規模言語モデルを作る可能性があり、実際の知見を得るために参加しました。結論として、発表の章立てや知見の深さは全て素晴らしい内容でした。大規模言語モデルを作る際の指針にできそうな話が多く、このセッションを聞いて岡崎先生は私のお気に入りになりました。",
    "doc_sim_90": "一番気になっていたセッションです。note AI Creativeでも将来的に大規模言語モデルを作る可能性があるので、実際の知見を得たいと思って参加しました。結論から言うと、発表の章立てや知見の深さ、もう全て最高と思える内容でした。大規模言語モデルを作る際に指針にできそうな話ばかりで、このセッションを聞いて岡崎先生は私の推しになりました。",
}

# 全docsの類似度計算をするために閾値は0にする
similarities = calculate_similarity(docs, threshold=0.0)

print("originalとの類似度:")
for key, similarity in similarities.items():
    print(f"{key}: {similarity}")

MinHashLSHというものも使用してますが、これは類似したハッシュ値を持つ文書を効率的に検索するために使われます。これにより、全てのペアを比較することなく、類似文書の候補を絞り込むことができます。

ということで、このスクリプトを実行してみると下記の結果が得られます。

originalとの類似度:
doc_sim_10: 0.2890625
doc_sim_30: 0.4765625
doc_sim_60: 0.546875
doc_sim_90: 0.8671875

スクリプトの実行結果

doc_sim_10というのは「originalと10%類似しているテストデータ」のことです。(「0.テストデータを作る」の章を参照)

実行結果としては、doc_sim_10はoriginalと28%類似しているという結果などがわかります。このテストデータを作った時とまぁまぁ近しい類似度になっているのである程度重複を検知できていると思われます。

2. SemDeDupで重複を検知する

次にSemDeDupを見ていきます。

SemDeDupでは、文章のEmbeddingを取得して意味的に重複している文章を検知します。なので違う単語を使っていても文章全体の意味が同じような文章ならば重複と判断します。

上記スライドのようにSemDeDupではまず文書をベクトルに変換して、クラスター分けします。
文章の重複を検出するときには、全文書で類似度計算すると計算量がエグいので、クラスター内の文書間のみで類似度計算することで計算量を抑えている感じそうです。

とりあえず実装は下記のようにしてみました。
クラスター分けをする関係でdoc_animal_1 / doc_animal_2 / doc_animal_3という関係ないテストデータも混ぜてます。

from sentence_transformers import SentenceTransformer, util
from sklearn.cluster import KMeans

# 事前学習済みモデルのロード
model = SentenceTransformer("stsb-xlm-r-multilingual")
# 文書データの埋め込み
docs = {
    "original": "一番気になっていたセッション。note AI Creativeでも将来的に大規模言語モデルを作るかも...みたいな可能性はあるので、実際の知見を得たいなぁと思って参加しました。結論から言うと、発表の章立て、知見の深さ、もう全て最高と思える内容でした。大規模言語モデルを作る際に指針にできそうな話ばかりだったので、このセッションを聞いて岡崎先生は私の推しになりました。",
    "doc_sim_10": "セッションについて少し興味がありました。note AI Creativeが大規模言語モデルを作る可能性があるので、参加しました。内容はとても良く、役立つ情報が多かったです。このセッションの後、岡崎先生を尊敬するようになりました。",
    "doc_sim_30": "気になっていたセッションに参加しました。note AI Creativeが将来的に大規模言語モデルを作るかもしれないので、知見を得たいと思いました。結論として、発表の内容や知見の深さは素晴らしかったです。このセッションを通じて、岡崎先生を推しと感じました。",
    "doc_sim_60": "最も気になっていたセッションでした。note AI Creativeも将来的に大規模言語モデルを作る可能性があり、実際の知見を得るために参加しました。結論として、発表の章立てや知見の深さは全て素晴らしい内容でした。大規模言語モデルを作る際の指針にできそうな話が多く、このセッションを聞いて岡崎先生は私のお気に入りになりました。",
    "doc_sim_90": "一番気になっていたセッションです。note AI Creativeでも将来的に大規模言語モデルを作る可能性があるので、実際の知見を得たいと思って参加しました。結論から言うと、発表の章立てや知見の深さ、もう全て最高と思える内容でした。大規模言語モデルを作る際に指針にできそうな話ばかりで、このセッションを聞いて岡崎先生は私の推しになりました。",
    "doc_animal_1": "犬の習性を勉強しつつも結構楽しんでいた。私はやはり動物好きである。だが、そんな私が特に苦心したことがある。それはトイレである。",
    "doc_animal_2": "猫の行動パターンを観察しながら、飼育の喜びを感じていた。私は動物を愛する者だと再認識した。しかし、私が最も頭を悩ませたのは、猫のトイレ問題だった。",
    "doc_animal_3": "鳥の鳴き声に耳を傾け、その美しさに心を奪われた。私は生き物に魅了されている自分に気づいた。だが、私が特に苦労したのは、鳥小屋の清掃だった。",
}

# 埋め込みの取得
texts = list(docs.values())
embeddings = model.encode(texts)

# k-meansクラスタリング
num_clusters = 2  # クラスタ数を2に設定
kmeans = KMeans(n_clusters=num_clusters, random_state=0).fit(embeddings)
labels = kmeans.labels_

# クラスタごとの埋め込みを取得
cluster_embeddings = {i: [] for i in range(num_clusters)}
for i, label in enumerate(labels):
    cluster_embeddings[label].append((i, embeddings[i]))

# originalの埋め込みとクラスタ
original_idx = list(docs.keys()).index("original")
original_embedding = embeddings[original_idx]
original_cluster = labels[original_idx]

# 同じクラスタ内でのoriginalとの類似度を計算
similarities = util.cos_sim(original_embedding, embeddings).numpy()

print(f"originalが属するクラスタ: {original_cluster}\n")
print("originalと同じクラスタ内の文書との類似度:")

for idx, embedding in cluster_embeddings[original_cluster]:
    if idx != original_idx:
        key = list(docs.keys())[idx]
        similarity = similarities[original_idx][idx]
        print(f"{key}: {similarity}")

# 各文書のクラスター情報を表示
print("\n各文書のクラスター情報:")
for i, key in enumerate(docs.keys()):
    print(f"{key}: Cluster {labels[i]}")

このスクリプトを実行してみると下記の結果が得られます。
doc_sim_10というのは「originalと10%類似しているテストデータ」のことです。(「0.テストデータを作る」の章を参照)

originalが属するクラスタ: 0

originalと同じクラスタ内の文書との類似度:
doc_sim_10: 0.9090326428413391
doc_sim_30: 0.9692308902740479
doc_sim_60: 0.9535486102104187
doc_sim_90: 0.9936169981956482

各文書のクラスター情報:
original: Cluster 0
doc_sim_10: Cluster 0
doc_sim_30: Cluster 0
doc_sim_60: Cluster 0
doc_sim_90: Cluster 0
doc_animal_1: Cluster 1
doc_animal_2: Cluster 1
doc_animal_3: Cluster 1

スクリプトの実行結果

originalとdoc_sim_*は大体90%以上同じ意味の文章ですよという結果になりました。doc_sim_*は文章の意味的にはoriginalと同じなので、SemDeDupでは意味的な類似度を見事に検出できています。

またdoc_animal_*も違うクラスターに分類されているので類似度計算をしないようにしています。

まとめ

今回はNearDupとSemDeDupで重複を検出するような実装を試みました。

上記のような重複検出の仕組みができたら、あとはどのくらいの類似度ならば「重複」と判定するかという閾値を決めてあげれば文章の重複排除ができるようになります

意味的な重複まで排除するのか、重複排除する場合の閾値はどうするのか、といったチューニングポイントはあると思うので本番に導入するときが来たら慎重に決めていきたいなと思いました。

今回は事前学習の中の「重複排除」というトピックだけを考えてみましたが、そのほかにも日本語のトークン化をどうするか、どういった大規模言語モデルのアーキテクチャで学習するべきかといったポイントはあるので、事前学習は非常に沼だなぁというお気持ちになりました。

ここまで読んでいただいてありがとうございました。

▼noteエンジニアの記事が読みたい方はこちら






この記事が参加している募集

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