2019年4月28日日曜日

PythonとLDAで国会会議録の発話を分類して可視化する

LDA(潜在的ディリクレ配分法)という手法があって、文書に複数の潜在的なトピックがあることを仮定したモデルのひとつ。ググると解説したサイトがたくさんある。詳しいことは自分の理解の範囲を超えるので、ここではPythonのgensimでLDAモデルを作成し、国会会議録の発話を分類してみたことをまとめておく。



環境


Windows10のWSL(Ubuntu 18.04)。



国会会議録発話の取得


Pythonで国会会議録のテキストを取得するのように、国会会議録の検索APIで国家基本政策委員会合同審査会(党首討論)2議会分のデータを取得した。ただし、進行役の鉢呂委員長と佐藤委員長の発言は除外した。また、話者などの不要なテキストを除去。コードは以下の通りで、devate.csvというファイルに保存した。

from urllib.request import Request, urlopen
from urllib.parse import quote
from urllib.error import URLError, HTTPError
import re
import pandas as pd
import xml.etree.ElementTree as ET

def create_query():
    """
    リクエストクエリの作成
    """
    # 国家基本政策委員会合同審査会(党首討論)2回分の発言を取得する
    params = {
        'nameOfMeeting': '国家基本政策委員会合同審査会',
        'maximumRecords': 2
        }

    return '&'.join(['{}={}'.format(key, value) for key, value in params.items()])

def parse_xml(res_xml):
    root = ET.fromstring(res_xml)

    try:
        # 取得したデータはPandasのデータフレームにする
        header = ['Date', 'Meeting', 'Speaker', 'Speech']
        df = pd.DataFrame(columns=header)

        for record in root.findall('./records/record/recordData/meetingRecord'):
            # 会議録情報の取得
            nameOfMeeting = record.find('nameOfMeeting').text
            issue = record.find('issue').text
            meeting = '{} {}'.format(nameOfMeeting, issue)
            date = record.find('date').text
            for speechRecord in record.findall('speechRecord'):
                # 発言者と発言の取得
                speaker = speechRecord.find('speaker').text
                speech = speechRecord.find('speech').text

                if speaker is not None:
                    # 先頭のspeechRecord(speechOrder=0)は出席者一覧などの会議録情報なのでスキップ

                    # 話者(speaker)を除去
                    # (拍手)など括弧でかこまれた箇所を除去
                    # 他人の発言中の発言〔~〕を除去
                    # 全角空白を除去(先頭に全角空白がある文がある)
                    speech = re.sub(r'\A○.*?君()|) |(.*?)|〔.*?〕| ', '', speech)

                    # データフレームに1行ずつ追加していく
                    row = [date, meeting, speaker, speech.strip()]
                    df.loc[len(df.index)] = row

        # 会議全体の発言をcsvに保存
        # 鉢呂委員長と佐藤委員長は進行役なので除外
        with open('debate.csv', 'w') as f:
            df[~(df['Speaker'].str.contains('鉢呂吉雄|佐藤勉'))].to_csv(f, index=True, header=True)

    except ET.ParseError as e:
        print('ParseError: {}'.format(e.code))

def main():
    # クエリはパーセントエンコードしておく
    request_url = 'http://kokkai.ndl.go.jp/api/1.0/meeting?' + quote(create_query())

    req = Request(request_url)

    try:
        with urlopen(req) as res:
            res_xml = res.read().decode('utf8')
    except HTTPError as e:
        print('HTTPError: {}'.format(e.reason))
    except URLError as e:
        print('URLError: {}'.format(e.reason))
    else:
        parse_xml(res_xml)

if __name__ == '__main__':
    main()

保存したcsvの中身は、以下のように会議名(Meeting)、発話者(Speaker)、発言内容(Speech)など。全部で63発話ある。


取得した発話データの確認


取得した発話テキストはMeCabで形態素解析した。対象品詞を「名詞」「形容詞」「副詞」(詳細分類のいくつかを除外)として、発話あたりの対象ワード数が3に満たない発話は対象外としたが、結局すべて3ワード以上だった。発話者ごとの発言数と発話ごとの分析対象ワード数をプロット。

import pandas as pd
import MeCab
import matplotlib.pyplot as plt

# matplotlibのターミナル対応
import matplotlib
matplotlib.use('Agg')

# 対象とする品詞
MAIN_CAT_LIST = ['名詞', '形容詞', '副詞']
# 除外する詳細分類1
SUB_CAT_LIST = ['数', '代名詞', '接尾', '非自立', '特殊', '接続詞的', '動詞非自立的']
# ストップワード
# 辞書に定義されていない語はアスタリスクに置き換えらるため
STOP_LIST = ['*']

m = MeCab.Tagger('-Ochasen')
m.parse('')

def to_word_dic(text):
    # テキストを形態素解析して、対象品詞の原型と品詞をdictionary形式で返す

    node = m.parseToNode(text)

    base_list = []
    cat_list = []

    while node:
        feature_split = node.feature.split(',')

        base_form = feature_split[6]
        main_cat = feature_split[0]
        sub_cat = feature_split[1]

        if main_cat in MAIN_CAT_LIST and sub_cat not in SUB_CAT_LIST and base_form not in STOP_LIST:
            base_list.append(base_form)
            cat_list.append(main_cat)

        node = node.next

    return {'base': base_list, 'category': cat_list}

def plot_speech(df):
    # 発話データのプロット
    
    print('発話数:', len(df.index))

    # 発話者ごとの発言数
    plt.figure()
    df.groupby('Speaker')['Speech'].count().plot.bar()
    plt.tight_layout()
    plt.savefig('bar_by_speaker.png')
    plt.clf()

    # 発話ごとの分析対象ワード数
    df['base'].map(len).plot.bar()
    plt.tight_layout()
    plt.savefig('strlen_by_speech.png')
    plt.clf()

def to_df(wd_min):

    try:
        with open('debate.csv', 'r') as f:
            df = pd.read_csv(f, index_col = False)

        print(df.dtypes)

        # 指定した品詞のみのワード(原型)リスト
        df['base'] = df['Speech'].apply(lambda x: to_word_dic(x)['base'])

        # ワード数が3未満は省く
        print()
        print('Before: ', len(df.index))
        df = df[df['base'].map(len)>=wd_min]
        print('After: ', len(df.index))

        plot_speech(df)

        return df

    except Exception as e:
        if hasattr(e, 'message'):
            print(e.message)
        else:
            print(e)
        return False

if __name__ == '__main__':
    to_df(wd_min=3)

発話者ごとの発言数。党首討論なので安倍総理の発言が多い。

発話ごとの分析対象ワード数。


LDAモデルの作成


gensimのmodels.ldamodelでLDAモデルの作成ができるが、ここではマルチコア対応のmodels.ldamulticoreを使う。トピック数は、データが少ないので4に設定。一応、ワードの出現頻度も確認しておく。

import pandas as pd
import numpy as np
from collections import defaultdict
import MeCab

from gensim.corpora import Dictionary
from gensim.models import LdaModel
from gensim.models.ldamulticore import LdaMulticore
import multiprocessing

import matplotlib.pyplot as plt

# matplotlibのターミナル対応
import matplotlib
matplotlib.use('Agg')

# CPUコア数
NUM_CORES = multiprocessing.cpu_count()
print('num_cores=', NUM_CORES)

def count_word_freq(dct, corpus):
    # ワードの出現頻度(全体/文章ごとカウント)

    # ワードごとの頻度
    word_freq = defaultdict(int)

    # ワードごとの頻度(文書でカウント)
    doc_freq = defaultdict(int)

    for id2freq in corpus:
        for id, freq in dict(id2freq).items():
            word_freq[dct.id2token[id]] += freq
            doc_freq[dct.id2token[id]] += 1

    # 頻度が多い方から100ワード
    print('全ワード数=', len(word_freq))
    print('全文書中のワード出現頻度=')
    print(sorted(word_freq.items(), key=lambda kv: -kv[1])[:100])
    print('特定ワード出現文書頻度=')
    print(sorted(doc_freq.items(), key=lambda kv: -kv[1])[:100])

def main():
    # 最少ワード数(1発言あたりの対象となる語の最少数)
    wd_min = 3

    # 最少出現ワード数
    no_below = 5

    # 全文書に対する特定ワードが出現する文章の最大割合
    no_above = 0.6

    df = to_df(wd_min=wd_min)
    text_l = df.base.tolist()

    dct =  Dictionary(text_l)

    # 作成した辞書から対象外のワードを除去
    dct.filter_extremes(no_below=no_below, no_above=no_above)

    # 文章(ワードリスト)ごとの指定した語のidとカウント(BOW)を返す
    corpus = [dct.doc2bow(text) for text in text_l]

    # id2token作成のため(dctに1度アクセスしないとid2tokenの辞書が作成されない)
    print('dct[0]=', dct[0])

    # ワードごとの頻度
    count_word_freq(dct, corpus)

    #lda_model = LdaModel(corpus=corpus, num_topics=4, id2word=dct, minimum_probability=0.0, random_state=1)
    lda_model = LdaMulticore(corpus=corpus, workers=NUM_CORES, num_topics=4, minimum_probability=0.0, id2word=dct, random_state=1)

    # トピックごとの重要度の高いワードのトップ10と重要度
    print('*** topics ***')
    for top in lda_model.print_topics(num_topics=-1, num_words=10):
       print(top)

if __name__ == '__main__':
    main()

以下はトピックごとの重要度の高いワードのトップ10とその重要度。重要度は異なるものの、割と同じワードがどのトピックにも含まれている。すべて同じ党首討論のデータだからだろうか?



pyLDAvisで可視化


pyLDAvisというPythonライブラリがあって、トピックモデルのトピックをJupyter Notebookやブラウザで可視化できる。

まずはpyLDAvisのインストール。


Jupyter Nootebookで表示できるらしいが、自分の環境では表示できなかったので、htmlとして出力した。
import pyLDAvis
import pyLDAvis.gensim

vis = pyLDAvis.gensim.prepare(lda_model, corpus, dct, sort_topics = False)
pyLDAvis.save_html(vis, 'lda.html')


作成したhtmlをブラウザで表示すると以下のような画面が表示され、マウスで操作できる。


t-SNEで次元圧縮して可視化


今回はトピック数は4だったが、4次元を可視化することは難しいので、次元圧縮して可視化する。最近t-SNEという次元圧縮の方法があることを知ったので試してみた。sklearn.manifold.TSNEでt-SNEを行えるが、このページにあるt-SNEの説明は自分には理解が難しい・・

とりあえず、作成したLDAモデルを使って次元圧縮して、次元圧縮した結果をトピックごとに色分けした散布図と、発話者ごとに色分けした散布図を作成した。

import pandas as pd
import numpy as np
from sklearn.manifold import TSNE

import matplotlib.pyplot as plt

# matplotlibのターミナル対応
import matplotlib
matplotlib.use('Agg')

def plot_tsne(df, by):
    colx = df.columns[0]
    coly = df.columns[1]

    fig, ax = plt.subplots(figsize=(10,10))
    grouped = df.groupby(by)
    for (label, group) in grouped:
        ax.plot(group[colx], group[coly], marker='o', linestyle='', ms=8, label=label)
        ax.legend()

    plt.savefig('tsne_{}.png'.format(by))
    plt.clf()

def main()
 # 他のコードは省略

    # トピック重要度のベクトル作成
    vec_l2 = []
    for doc in corpus:
        topic_l = lda_model.get_document_topics(doc, minimum_probability=0.0)
        vec_l = []
        for vec in topic_l:
            vec_l.append(vec[1])

        vec_l2.append(vec_l)

    vec_na = np.array(vec_l2)

    # t-SNEによる次元圧縮
    #tsne_model = TSNE(n_components=2, verbose=1, random_state=0, init='pca')
    tsne_model = TSNE(n_components=2, perplexity=30, verbose=1, random_state=0, init='pca')
    tsne_lda = tsne_model.fit_transform(vec_na)

    tsne_df = pd.DataFrame(tsne_lda)
    # ドミナントトピック番号
    tsne_df['dt'] = vec_na.argmax(axis=1).tolist()
    tsne_df['Speaker'] = df['Speaker']

    # t-SNEの結果をプロット
    plot_tsne(tsne_df, 'dt')
    plot_tsne(tsne_df, 'Speaker')

if __name__ == '__main__':
    main()

トピックごとに色分けした散布図。トピックごとにきれいに分かれている。


発話者ごとに色分けした散布図。


データ数が少ないのでどれほど正確かわからないが、結果の図を見る限り、党首討論なので安倍総理の発話はどのトピックにもまんべんなくあるし、共産党の志位委員長と国民民主党の玉木代表の発話は同じトピックに属している。


0 件のコメント:

コメントを投稿