💨

【週末研究】04. 単語とグラフ構造の関係 - トピックモデルの自動ラベル付与

2022/06/03に公開

トピックモデルの自動ラベル付与

  • UPDATE: コード誤りがあったため、よくない結果であったが、コードを修正したら改善した

外観

今回は、クラスタリングなどの課題である各トピックに対するラベル付けを、自動的にしてみます。

アイデアとしては、トピックモデルの各単語(語彙)の発生確率を使って、
word2vec などの別途学習した埋込ベクトル(上位10個など)を使って、トピックベクトルの期待値(加重平均)を算出。
算出したトピックの期待値ベクトルに最も類似する、単語ベクトルに対応する単語文字列をラベルとします。

i.e.

\begin{align} E[topic_k] &= \sum_{i \in V_{top10}^{(k)}} p_i v_i \\ & where~ v_i := wv[tkn_i] \in \mathbb{R}^{n}; wv: \text{word vector} \\ & ~~~~~~ V_{top10}^{(k)} : \text{the set of vocab index for top10 vectors in the topic } k \end{align}

以上を、図示すると以下のように表現できます。
auto-topic の構造

トピックモデルの学習(左側)とは別に、単語の埋込ベクトルを学習するデータセット(上記↑の図では、Wikipedia データ)を用意し、モデルを学習しておきます(右側)。

トピックモデル(LDA)の学習により得られる、トピック k の 各単語・語彙の発生確率(の期待値)と、
対応する埋込ベクトルを、word2vec モデルから取得して、トピック k の期待値ベクトル E[topic_k] を計算します。
この期待値ベクトル E[topic_k] に最も近い名詞(っぽい単語)を、word2vec モデルから取得して トピック k のラベルとします。


実機検証

では早速、実機検証した結果を見て、考察していきます。

以下では、コードを抜粋して説明します。

実験用コードの全貌を参照されたい方は、notebookを参照ください。

リポジトリは、こちらにありますので、独自定義のクラスの中身など、参照ください。

トピック抽出したい文書

import requests
from bs4 import BeautifulSoup

url = "https://ledge.ai/authorinterview-book-bert-int/"
# url = "https://www.yomiuri.co.jp/world/20230104-OYT1T50056/"
# url = "https://www3.nhk.or.jp/kansai-news/20230104/2000069636.html"
# url = "https://news.yahoo.co.jp/articles/fad0c4f41d46b686e0566bf10e4c016a641a9dab"
# url = "https://news.yahoo.co.jp/articles/4d2d14fd1ca1dc9b134c5f493896a7e30fc1e781"
res = requests.get(url)

soup = BeautifulSoup(res.content, "lxml")
for tg in ["script", "noscript", "meta"]:
    try:
        soup.find(tg).replace_with(" ")
    except:
        pass


簡易クレンジングして保持

  • UPDATE: 結果には影響はなかったが、"。" だけの行をスキップ処理とした
import re

def clean_text(text: str):
    contents = []
    for txt in re.split(r"(。|\n)", text):
        txt = txt.strip().replace("\u200b", "").replace("\u3000", " ")
        txt = re.sub(r"\n+", "\n", txt)
        txt = re.sub(r"([\W])\1+", " ", txt)
        if not txt:
            continue
        if txt == "。":
            continue
        # contents.append(txt)
        # contents.append(txt.split("\n")[-1])
        contents.extend(txt.split("\n"))
    return contents

text = soup.get_text()
contents = clean_text(text)

後に、↓のように使います

X: TextSequences = [contents]
pipe_topic.fit(X)

白ヤギコーポレーションの word2vec モデル

まずは、すぐに試せる点を考慮し、白ヤギコーポレーション社が公開してくれている、軽い学習済 word2vec モデルを使って試してみます。

# use shiroyagi's word2vec pretrained model
from gensim.models.word2vec import Word2Vec
w2v = Word2Vec.load("data/word2vec.gensim.model")
w2v

関数コードサンプルの紹介

以下、関数化したコードサンプルを紹介していきます

各トピックの上位単語の取得

  • UPDATE: コード誤りがあったため修正
    • 引数に、model_bow を指定すべきところを global な変数を参照していたため、不整合なvocab を使用していた
def pickup_topic_words(model_topic: TopicModel, model_bow: VectorizerBoW, topn: int = -1):
    # topn: 各トピックの上位の単語の数
    topics = []
    for topic_probs in model_topic.get_topic_probabilities():
        indices = topic_probs.argsort()[::-1][:topn]
        topic = [(model_bow.vocab[idx], topic_probs[idx]) for idx in indices]
        topics.append(topic)
    return topics

名詞(っぽい)単語リストと数値リストへの分解

def parse_topic(topics: list):
    words = []
    numbs = []
    for w, p in topics:
        do_skip: bool = False
        do_skip |= bool(re.search(r"^[あ-ん]", w))    # です、ます などは、スキップ
        do_skip |= bool(re.search(r"[あ-ん]$", w))    # 動名詞 などは、スキップ
        do_skip |= w[0].isnumeric()                  # 数字から始まるラベルはスキップ
        if do_skip:
            continue
        words.append(w)
        numbs.append(p)
    return words, numbs

トピックラベルの推定

import numpy
import re


def estimate_topic_label(w2v, topics: list):
    proper_topics = {}
    topic_labels = []

    for idx_tpc, tpc in enumerate(topics):
        # トピック情報を、単語リストとその確率リストに分解する
        _words, _probs = parse_topic(tpc)

        # word2vec モデルに含まれる単語のみに絞る
        words = [w for w in _words if w in w2v.wv]
        probs = [p for w, p in zip(_words, _probs) if w in w2v.wv]

        # 単語リストからベクトルに変換し、期待値ベクトルを算出
        vectors = w2v.wv[words]
        probs = numpy.array(probs).reshape(-1, 1)
        topic_vector = (vectors * probs).sum(axis=0)    # 期待値ベクトル

        # トピックに対する期待値ベクトルに類似するベクトルを十分な数(topn=100) を取得しておく
        estimated_topic_labels = w2v.wv.similar_by_vector(topic_vector, topn=100)

        # ラベルと類似度を取得し、最も類似度が高い最初のインデックスの要素を保持
        labels, similarities = parse_topic(estimated_topic_labels)
        topic_label = labels[0]
        similarity = similarities[0]

        # 重複しないトピックラベル集合(proper_topics)として記録しておく
        if topic_label not in proper_topics:
            proper_topics[topic_label] = (idx_tpc, similarity, words, probs)

        # 重複を許すトピックラベル(topic_labels)として記録しておく
        topic_labels.append((topic_label, similarity))

    return topic_labels, proper_topics

トピック数の自動推定

# トピック数を自動で特定するサンプル
# # トピック数が proper_topics と一致するまで、減らしていくことで、トピック数を特定する

# w2v : is already loaded
topic_label_counter = {}

n_topic_words = 7       # to calculate the average over ... あまり多くするとノイズに近いベクトル値が加算される
n_topics = 15           # default topic numbers


rs = numpy.random.RandomState(12345)

while True:
    # トピックモデルのパイプラインを構築
    pipe_topic = Pipeline(
        steps=[
            (TokenizerWord(use_stoppoes=True, use_orgform=True), None),
            (VectorizerBoW(), None),
            (TopicModel(n_topics=n_topics, n_epoch=2000, random_state=rs), None),
        ],
        name="pipe_topic",
        do_print=False,
    )

    # トピックモデルを学習
    X: TextSequences = [contents]
    pipe_topic.fit(X)

    model_bow: VectorizerBoW = pipe_topic.get_model(1)
    model_topic: TopicModel = pipe_topic.get_model(-1)
    topics = pickup_topic_words(model_topic, model_bow, topn=n_topic_words)
    topic_labels, proper_topics = estimate_topic_label(w2v, topics)

    # トピックラベルをカウント
    for tpc in proper_topics:
        cnt = topic_label_counter.get(tpc, 0) + 1
        topic_label_counter[tpc] = cnt
    
    # ループの終了条件
    if len(proper_topics) >= n_topics:
        break

    # 状態/処理文脈としてトピック数を更新・保持
    n_topics = len(proper_topics)

出力結果例

# トピックの出力
# # 自動付与したラベルと、各トピックの上位単語の表示
# # 理想的には、この出力結果に違和感がないこと

for idx_tpc, (lbl, sim) in enumerate(topic_labels):
    print("-" * 100)
    print(f"topic[{idx_tpc}]: {lbl} : {topic_label_counter[lbl]} ({sim:0.3f})")
    print(" " * 4 + f" ... {[_t for _t, _s in topics[idx_tpc][:20]]}")

トピックの出力

----------------------------------------------------------------------------------------------------
topic[0]: 人工知能 : 5 (0.887)
     ... ['する', 'BERT', '自然言語処理', 'いる', 'AI', 'の', 'できる']

ざっくりと「人工知能」というラベルが付与されました

# 実際に期待値ベクトルを算出するときに使った単語を表示
# # 上記の自動付与ラベルと上位単語の関係性に違和感があるときに確認すると良いだろう
print("estimated n_topic:", len(proper_topics))

for lbl, v in proper_topics.items():
    tpc_idx, sim, words, probs = v
    print(f"[{tpc_idx:02d}]: {lbl}: {words}")

実際に、期待値ベクトルの算出に使った単語(ベクトル)を確認すると

estimated n_topic: 1
[00]: 人工知能: ['自然言語処理', 'AI']

「自然言語処理」、「AI」の2つの単語から推定されるトピックのラベルとして、「人工知能」が付与されたようです。


独自に学習させた Wikipedia のモデルを使った結果

Tensorflow Datasets の "train" データセットの約180万のパラグラフを、10,000パラグラフずつ追加学習した独自学習モデルを使った結果です。

----------------------------------------------------------------------------------------------------
topic[0]: AI : 3 (0.916)
     ... ['する', 'BERT', '自然言語処理', 'AI', 'いる', 'ある', 'の']
----------------------------------------------------------------------------------------------------
topic[1]: 自然言語処理 : 3 (0.923)
     ... ['する', 'BERT', '自然言語処理', 'いる', 'AI', 'の', 'できる']

実際に、トピックラベル推定に使った単語は、「AI」と「自然言語処理」でそのまま、という結果に

estimated n_topic: 2
[00]: AI: ['自然言語処理', 'AI']
[01]: 自然言語処理: ['自然言語処理', 'AI']


ニュース記事で word2vec モデルを追加学習した結果

上記の Tensorflow Datasets の "train" データセットの独自学習モデルに、さらに
ニュース記事をクロールして追加学習したモデルを使った結果です。

----------------------------------------------------------------------------------------------------
topic[0]: BERT : 3 (0.915)
     ... ['BERT', 'する', '自然言語処理', 'AI', 'の', 'いる', 'できる']
----------------------------------------------------------------------------------------------------
topic[1]: ユニモーダルモデル : 1 (0.850)
     ... ['する', '自然言語処理', 'BERT', 'AI', 'いる', 'できる', 'データセット']
----------------------------------------------------------------------------------------------------
topic[2]: 自然言語処理 : 3 (0.882)
     ... ['する', '自然言語処理', 'いる', 'BERT', 'AI', 'できる', 'の']

実際に、トピックラベル推定に使った単語は、以下の通り

estimated n_topic: 3
[00]: BERT: ['BERT', '自然言語処理', 'AI']
[01]: ユニモーダルモデル: ['自然言語処理', 'BERT', 'AI', 'データセット']
[02]: 自然言語処理: ['自然言語処理', 'BERT', 'AI']

0002 は、トピックを構成する単語群が同じで、もっとも確率が高いトピックを構成する単語の違いがそのまま反映されているようです。
一方で、01 は、「データセット」が追加され、暗黙の前提である「ユニモーダルモデル」というトピックを抽出している点は非常に興味深いところです。

特に、最近のニュース記事をクロールして学習させたため、先の2つの Wikipedia だけのトピックラベルの推定よりも、より具体的なトピックラベルの推定結果になったように感じます。

参考記事 / 参考URL


次回は、今回の記事では省略した、Word2Vec の追加学習の仕方や Spider を使ったニュース記事のクロールについて補足しようと思います。
ちなみに、前回はこちら

GitHubで編集を提案

Discussion