LoginSignup
38

More than 1 year has passed since last update.

国会議事録を可視化するツールをつくってみる

Posted at

やりたいこと

最近、コロナ禍のあれやこれやを背景に、国会ではいったいどんなことが話されているのだろう、と色々気になるようになりました。
調べてみると、国会議事録はAPIを用いて比較的容易にアクセスが可能とのこと。
せっかくなので、色々と勉強しながらスクレイピング加工ツール上で可視化をやってみました。

出来上がったもの

スクレイピングで国会議事録データを取得したのち、
こんな感じ↓で国会ごとの発言をワードクラウドで可視化しています。
対象:今年6月に行われた第204期国会党首討論会(国家基本政策委員会合同審査会)
ボタンを押すと次(前)の発言に移ります。
国会議事録を可視化してみる (1).gif

(ボタン押した後のラグがちょっと気になりますね)

ワードクラウドだけだと文脈が分かりにくいので、タブ切替で原文も見れるようにしてみました。
国会議事録を可視化してみる (2).gif

つくり方

環境

  • Google Colaboratory

(検証はしていないですが、特別なことはしていないのでライブラリさえインストールすればJupyter Notebookでも同じようなことはできるはず)

スクレイピング

国会議事録から発言を取得

国会の議事録はWeb上で公開されており、誰でも簡単に参照することが可能となっています。
APIも用意されており、検索キーワードを組み合わせたURLを叩くことで、任意の国会発言を取得可能です。
こちらの記事が参考になり、大部分を引用していますので、細かい説明は割愛します。
いるかのボックス_Pythonで国会会議録のテキストを取得する

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

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

def create_query(session, meeting_name, issue):
    """
    リクエストクエリの作成(必要情報を受け取り、URLに変換して返す)
    """
    # )1回分の発言を取得する
    params = {
        'nameOfMeeting': meeting_name,
        'maximumRecords': 1,
        "sessionFrom": session,  # 国会の第〇期を指す。一期分の国会を対象とするためFromとToは同じに
        "sessionTo": session,
        "issueFrom": issue,  # 各会議の第〇回を指す。一回分の会議とるためFromとToは同じに
        "issueTo": issue,
        }

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

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

    # 最終的に戻り値として返す発言情報をリスト型で宣言
    speech_list = []

    try:
        for record in root.findall('./records/record/recordData/meetingRecord'):
            # 会議録情報の取得
            session = record.find('session').text
            nameOfMeeting = record.find('nameOfMeeting').text
            issue = record.find('issue').text
            date = record.find('date').text
            print(nameOfMeeting, issue, date)

            i = 1
            chairman = None

            for speechRecord in record.findall('speechRecord'):
            # for i, speechRecord in enumerate(record.findall("speechRecord")):
                # 発言者と発言の取得
                speaker_group = speechRecord.find("speakerGroup").text
                speaker = speechRecord.find('speaker').text
                contents = speechRecord.find('speech').text

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

                    # 議長情報がなければ最初の発言者を議長と判断
                    if chairman is None:
                      chairman = speaker
                    # 議長の発言もスキップ
                    if speaker != chairman:

                        # 最初の区切り文字以降を発言として再格納(最初の区切り文字までは基本発言者名のため)
                        sep = " "  # 区切り文字
                        contents = sep.join(contents.split(sep)[1:])

                        # 発言情報の辞書作成
                        speech = {}
                        speech["発言No."] = i
                        speech["発言者"] = speaker
                        speech["発言者所属"] = speaker_group
                        speech["発言内容"] = contents
                        speech["国会会期"] = session
                        speech["会議名"] = nameOfMeeting
                        speech["会議号"] = issue
                        speech["会議日付"] = date

                        # 発言情報をリストに集積
                        speech_list.append(speech)

                        i += 1

    # エラー処理
    except ET.ParseError as e:
        print('ParseError: {}'.format(e.code))

    return speech_list

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def main():

    meeting_name = '国家基本政策委員会合同審査会'
    session = 204
    issue = 1

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

    # 取得した国会発言をmain外で色々処理をするためグローバル変数で宣言
    global minutes

    try:
        # リクエストクエリのURLからオブジェクト(今回はXML)を取得しutf8にデコード
        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))

    # try正常終了時の処理記述
    else:
        # 取得したxmlを国会発言として扱いやすい形(クラスオブジェクトのリスト)に変換
        minutes = parse_xml(res_xml,)

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -   
if __name__ == '__main__':
    main()

参考サイトから自分なりにカスタマイズしたポイントは、

  • 議長(会議内での一番最初の発言者)の発言を削除
  • 発言内容について、最初のスペースまでは「○菅義偉 私は…」みたいな感じて発言者の名前が表示されているので、これを削除して発言を取得

あたりです。会議の流れをつかむにあたり不要だと思ったので除外しました。

ここまでの結果確認

for i in minutes : print(i)
  ↓

{'発言No.': 1, '発言者': '枝野幸男', '発言者所属': '立憲民主党・無所属', '発言内容': '総理、お疲れさまでございます。\n\u3000今年は、一月二十日に東京で緊急事態宣言が発令され、三月二十一日まで約二か月半続きました。…', '国会会期': '204', '会議名': '国家基本政策委員会合同審査会', '会議号': '第1号', '会議日付': '2021-06-09'}
{'発言No.': 2, '発言者': '菅義偉', '発言者所属': '自由民主党・無所属の会', '発言内容': '政府として、この緊急事態宣言やまん延防止等、この措置を講ずるについて、専門家の先生方の委員会にかけて決定をするわけであります。…', '国会会期': '204', '会議名': '国家基本政策委員会合同審査会', '会議号': '第1号', '会議日付': '2021-06-09'}
  :
{'発言No.': 33, '発言者': '志位和夫', '発言者所属': '日本共産党', '発言内容': 'コロナ収束に集中させるべきだということを求めて、終わります。(拍手)', '国会会期': '204', '会議名': '国家基本政策委員会合同審査会', '会議号': '第1号', '会議日付': '2021-06-09'}

データ加工

取得した発言を形態素解析し、単語ごとに分離

次に、形態素解析を行なって文章→単語に切り分け+不要な品詞の除外を行ないました。
形態素解析については過去にこちらに記事にしたことがありますので、細かい説明は割愛します。
2chのスレッドをWordCloudで可視化してみる ~形態素解析・WordCloud編~

# 形態素分析ライブラリーMeCab と 辞書(mecab-ipadic-NEologd)のインストール 
!apt-get -q -y install sudo file mecab libmecab-dev mecab-ipadic-utf8 git curl python-mecab > /dev/null
!git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git > /dev/null 
!echo yes | mecab-ipadic-neologd/bin/install-mecab-ipadic-neologd -n > /dev/null 2>&1
!pip install mecab-python3 > /dev/null
# シンボリックリンクによるエラー回避
!ln -s /etc/mecabrc /usr/local/etc/mecabrc

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  
def get_partofspeech_bytext(text):
  """
  テキストから品詞付きの単語リストをMecabで作成し、特定の品詞のみをlistで返す
  """

  chasen_list = mecab.parse(text)
  word_list = []

  # chasen_listを1行まで分解
  # ex. 鉄巨人 名詞,固有名詞,一般,*,*,*,鉄巨人,テツキョジン,テツキョジン)← 表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音(https://taku910.github.io/mecab/#usage-tools)
  for line in chasen_list.splitlines():

    tmp_d = {}

    if len(line) <= 1: break
    pos = line.split()[-1]  # pos : Part Of Speech

    if len(pos.split(",")) == 9:
      tmp_d["表層形"] = line.split()[0]

      tmp_d["品詞"] = pos.split(",")[0]
      tmp_d["品詞細分類1"] = pos.split(",")[1]
      tmp_d["品詞細分類2"] = pos.split(",")[2]
      tmp_d["品詞細分類3"] = pos.split(",")[3]
      tmp_d["活用型"] = pos.split(",")[4]
      tmp_d["活用形"] = pos.split(",")[5]
      tmp_d["原形"] = pos.split(",")[6]
      tmp_d["読み"] = pos.split(",")[7]
      tmp_d["発音"] = pos.split(",")[8]

      if tmp_d["品詞"] == "名詞":
        if not any([tmp_d["品詞細分類1"] == ck_sp for ck_sp in ["非自立","代名詞","数","副詞可能","接尾"]]):
          word_list.append(tmp_d["表層形"])

      if any([tmp_d["品詞"] == ck_sp for ck_sp in ["動詞", "形容詞"]]):
        if tmp_d["品詞細分類1"] == "自立":
          word_list.append(tmp_d["原形"])

  return word_list

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  
# 発言すべてを形態素解析して単語リストを各発言オブジェクトに追加
for speech in minutes:
  word_list = get_partofspeech_bytext(speech["発言内容"])
  speech["単語群"] = word_list

少しだけポイントを説明します。

辞書に「mecab-ipadic-NEologd」を指定して解析精度の向上を図る

Mecabのデフォルトの辞書は精度がイマイチなので、上記記事同様「mecab-ipadic-NEologd」を使用しています。
「mecab-ipadic-NEologd」は高頻度で更新されている辞書で、比較的新しい単語もそれなりの精度で分離してくれます。
そのため、今回のような時事的なトピックスを扱う場合は特に有用です。

形態素解析結果から特定の品詞の単語を抽出

Mecabによって形態素解析した単語群は、品詞情報が付加されます。
今回は助詞などの意味合いを持たない単語を除外したいため、その後必要な品詞(名詞、動詞、形容詞)のみ抽出しています。
また形態素解析により動詞などの原形も取得できます。今回は動詞・形容詞は原形に置き換えるようにしてみました。

ここまでの結果確認

for i in minutes : print(i["単語群"])
 ↓

['総理', '疲れる', 'ござる', '一月二十日', '東京', '緊急事態宣言', '発令', 'する', '三月二十一日', '続く', …]
['政府', '緊急事態宣言', 'まん延', '防止', '措置', '講ずる', '専門家', '先生', '委員会', '決定', 'する', …]
 :
['コロナ', '収束', '集中', 'する', '求める', '終わる', '拍手']

ワードクラウドのパラメータにTR-IDFを用いてみる

TF-IDFについて

TF-IDFは文章における単語の重要度を表す評価指標です。
TF(Term Frequency):単語の出現頻度。一つの文章における<当該単語の出現数 / 全単語の出現数>
IDF(Inverse Document Frequency):逆文章頻度。log<全文章の数 / 当該単語が出現する文章の数>
で、TF-IDFはTFとIDFの積となります。
つまり評価対象の単語が文章中でたくさん出ればTF-IDFは大きくなり、他の文章でもよく登場するような単語はTF-IDFは小さくなります

TF-IDFの算出

TF-IDFの算出には、gensimという自然言語処理でよく用いられるライブラリを使用しています。
詳細はまだ理解途中ですが、下記の記事を参考にしました。
gensimのtfidfあれこれ【追記あり】
gensim の tfidf で正規化(normalize)に苦しんだ話

import gensim
from gensim import corpora,models

# 内包表記で各発言から単語リストのみ抽出
word_arr = [speech["単語群"] for speech in minutes]

# 単語:id対応表となる辞書作成
dictionary = corpora.Dictionary(word_arr)
# word_arrをcorpus化
corpus = list(map(dictionary.doc2bow, word_arr))
# tfidf modelの生成
test_model = models.TfidfModel(corpus)
# corpusに適用
corpus_tfidf = test_model[corpus]

# id->単語へ変換
texts_tfidf = [] # id -> 単語表示に変えた文書ごとのTF-IDF
for doc in corpus_tfidf:
    text_tfidf = []
    for word in doc:
        text_tfidf.append([dictionary[word[0]], word[1]])
    texts_tfidf.append(text_tfidf)

# 辞書形式(単語: tf-idf)へ変換
tfidf_dict_list = []
for text_tfidf in texts_tfidf:
  tfidf_dict = dict(text_tfidf)
  tfidf_dict_list.append(tfidf_dict)

WordCloudには{"単語1":数値, "単語2":数値, …}といったdict形式でデータを渡す必要があるので、
list形式からdict形式に変換しています。

ここまでの結果確認

for i in tfidf_dict_list : print(i)
 ↓

{'いかが': 0.0554326015967912, 'ござる': 0.13677592146632747, 'する': 0.03856311049532222, 'なる': 0.07072277774580338, 'まん延': 0.10966148150981538, 'もし': 0.13677592146632747, 'セット': 0.21932296301963075, 'リバウンド': 0.28140165272160134, ・・・ '防止': 0.09380055090720045}
{'する': 0.050666059051216, 'なる': 0.01991120744768438, 'まん延': 0.06174792837494015, '中心': 0.06174792837494015, '厳しい': 0.05281699297879102, '思う': 0.08930935396149137, '措置': 0.10563398595758204, '皆さん': 0.27323259217630924, '緊急事態宣言': 0.03754945807674369, ・・・'高さ': 0.07701546327698747, '高齢者': 0.1234958567498803}
 :
{'する': 0.026970697262414765, '収束': 0.3936196373795167, '求める': 0.34639600992438235, 'コロナ': 0.34639600992438235, '拍手': 0.3936196373795167, '終わる': 0.34639600992438235, '集中': 0.5739591941532223}

ワードクラウドの生成

ワードクラウドも先ほどと同じ記事で紹介しているので、細かい説明は割愛します。
2chのスレッドをWordCloudで可視化してみる ~形態素解析・WordCloud編~

from wordcloud import WordCloud

#フォントをColabローカルにインストール
from google.colab import drive
drive.mount("/content/gdrive")
# 事前に自分のGoogleDriveのマイドライブのトップにfontというフォルダを作っておき、その中に所望のフォントファイルを入れておく
# フォルダごとColabローカルにコピー
!cp -a "gdrive/My Drive/font/" "/usr/share/fonts/"

f_path = "BIZ-UDGothicB.ttc"  # Colabローカルのfontsフォルダにコピーしておく必要あり
stop_words = []

# インスタンスの生成(パラメータ設定)
wordcloud = WordCloud(
    font_path=f_path, # フォントの指定
    width=960, height=600,   # 生成画像のサイズの指定
    background_color="white",   # 背景色の指定
    stopwords=set(stop_words),   # 意図的に表示しない単語
    max_words=350,   # 最大単語数
    max_font_size=100, min_font_size=5,   # フォントサイズの範囲
    collocations = False    # 複合語の表示
    )

# すべての発言に対してWordCloudを作成し画像に変換(作成したtfidf辞書から)
for speech, tfidf_dict in zip(minutes, tfidf_dict_list):
    wc = wordcloud.generate_from_frequencies(tfidf_dict)  # ←ワードクラウドの生成
    speech["WordCloud"] = wc.to_image()

以前の記事の内容から大きく異なる点といえば、下から3行。前項で格納したTF-IDFを使用して、wordcloud.generate_from_frequencies()メソッドでワードクラウドを生成したところです。
さらにto_image()メソッドでこの時点で画像(PIL形式)に変換して辞書に再格納しています。

ここまでの結果確認

from IPython.display import display
display(minutes[0]["WordCloud"])

 ↓

ツール上での可視化

Panelでインターフェース実装

さて、ここまでくれば一応当初の目的であった「国会議事録を可視化する」というのは達成できているのですが、
どうせであればインターフェースも強化してツールとして仕立ててみます。
今回はPanelというライブラリを使用してインターフェースを実装しました。
PanelはJupyterやGoogleCoraboratory上にシンプルな記述でインタラクティブなインターフェースを実装できるライブラリです。
正直、私もつい最近存在を知ったのですが、本当に色々なウィジェットがあり様々なことが実現できそうです。
興味がある方は是非一度公式リファレンスを覗いてみてください。
Panel公式リファレンス(英語)

事前準備:画像のファイル化

Panelで画像データを扱おうとする場合、PIL形式そのままでは使えないみたいで、一度ファイルに変換する必要があります。
ただ、ファイルとしていちいち保存をするのは何となく嫌。。。
色々と調べたところ、標準ライブラリioの中にある、BytesIOを用いることで、メモリ(バイナリストリーム)上に画像をファイルとして生成できるみたいです。
今回はこれを使用してファイルの保存を回避しました。

# PIL画像をメモリにファイルとして格納する
from io import BytesIO

for s in minutes:
  buffer = BytesIO()  # メモリの確保
  s["WordCloud"].save(buffer, format="png")  # PNG画像として保存
  s["WordCloud_BytesIO"] = buffer  # 画像アドレスの格納

これで、各発言の["WordCloud_BytesIO"]を画像リンクとして扱えるようになりました。

インターフェース作成

今回は下記のようなコードで実装しました。

import panel as pn
pn.extension()

# 各ウィジェットの定義
next_button = pn.widgets.Button(name='Next', button_type='primary', width= 75)
prev_button = pn.widgets.Button(name="Prev", button_type="primary", width= 75)
info_box = pn.pane.Markdown("", background="whitesmoke", min_width= 300, margin=10,style={"padding": "2em 2em 3em",})
img = pn.pane.image.PNG(minutes[0]["WordCloud_BytesIO"])
speech_box = pn.pane.Markdown(f"{minutes[0]['発言内容']}", 
                              background="whitesmoke", 
                              height = 480, 
                              margin=10,
                              style={"padding": "1em 2em ","overflow-y": "scroll", })

# インフォメーション領域(マークダウン形式)のスタイルカスタマイズ用
info_style = """
<style>
h3{text-decoration: underline;font-weight:normal}
h2{text-indent: 0.5em;}
</style>
"""

# 画像・インフォメーション更新用の関数
def update():
    speech = minutes[speech_no]
    img.object = speech["WordCloud_BytesIO"]  # 画像(WordCloud)更新

    # インフォメーション領域の記述(マークダウン形式のためタブ等で整形すると表示がおかしくなる)
    # マークダウン形式では半角スペース2つで改行扱いので、見えないけど半角スペース*2埋め込んである
    mk = info_style + f"""
第{speech['国会会期']}期国会  
{speech['会議名']} {speech['会議号']}  
{speech["会議日付"]}  
<br>
### 発言No.**{str(speech["発言No."]).zfill(2)}**
<br>
### 発言者
## {speech['発言者']}
### 所属
## {speech['発言者所属']}
    """
    info_box.object = mk  # インフォメーション領域更新
    speech_box.object = speech["発言内容"]  # 発言原文領域の更新


# Nextボタン用のイベント関数
def speech_to_next(event):
    global speech_no
    if speech_no != len(minutes):
        speech_no += 1
        update()

# Prevボタン用のイベント関数
def speech_to_prev(event):
    global speech_no
    if speech_no != 0:
        speech_no += -1
        update()


# 初期状態へ更新
speech_no = 0
update()

# 各ボタン押下時の動作設定
next_button.on_click(speech_to_next)
prev_button.on_click(speech_to_prev)

# ウィジェット配置
pn.Row(pn.Column(info_box,
                 pn.Row(prev_button, next_button, margin= 20, align="center"),
                 ),
      pn.Tabs(("Image", img),("原文", speech_box))).servable()

見た目に関する内容がそれなりにあるにも関わらず、結構シンプルに記述ができているのではないかと思います。
個人的にはTkinterに似ているけど、Tkinterよりも書きやすい印象です。

大雑把な流れは下記の通り。

ウィジェットの定義

各ウィジェットをパラメータを指定してインスタンスを生成します。パラメータはwidthheightmarginなど比較的にHTMLのdivタグで設定するものが多いです。
styleパラメータはstyle={"padding": "1em 2em ","overflow-y": "scroll", }みたいな感じでdict形式で値を渡します。ただ、ウィジェットの種類によっては一部スタイル項目が無効になるものもあるみたいで注意が必要ですね。
(例えばpane.Str()ではfont-familyが反映されない、pane.Markdown()ではborder-radius(角丸め)が効かない、等)
今回、パラメータ設定で上手くいかないところは<style>タグを使って強引に設定しました。

ウィジェットの更新

インスタンス.obj(value) = 更新後の要素・値だけでOKです。
とてもシンプルですね。特に再描画の指示も必要ありません。

ウィジェットの動作割り振り

今回は参照する発言を前後させるボタンに動作を割り振っています。
まず、各動作を規定する関数を作成します。↑のdef speech_to_prev(event):のところです。
その後、動作を指定するボタンにインスタンス.on_click(イベント関数)とメソッドを呼び出すだけ。
こちらも非常にシンプルですね。

ウィジェットの配置

pn.Row()pn.Column()などを使って、作成したウィジェットを配置していきます。
()の中にウィジェット名を入れるだけで、Rowなら横並び、Columnなら縦並びに配置します。
さらにpn.Column(info_box, pn.Row(prev_button, next_button)のように入れ子にすることで、複雑なレイアウトも簡単に表現することができます。
またpn.Tab()を使用すれば、タブ切り替えも簡単に実装することが可能です。

ここまでの結果確認

 ↓

以上で完成です!

所感・今後

  • ワードクラウドだけだと文脈が分かりにくいな…と思っていたのですが、原文を添えることによっていい感じに可視化できたと思います。別タブで頻出単語などの統計データ等を出しても面白いかも。
  • Panelライブラリの存在を後から知ったので、可視化部分のみツール化となりました。できればスクレイピング~データ加工もツール内に埋め込みたいです。Panelは本当に色々と便利そうで、もっと勉強すれば、業務でも使えそう。
  • 議論の流れを可視化するにはもっと色々工夫があってもよさそう。例えば発言者の政党ごとにワードクラウドの色を変えてみたり。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38