うつブログ分類器をつくってみた(結果編)

の続きです

やりたいこと

自然言語処理をつかってブログをうつ病と正常の2クラスに自動で分類したい

おおまかな手順

  1. ブログ村メンタルヘルスランキング に掲載されているブログからスクレイピング
  2. 取得したhtmlからブログ毎に名詞のみ抽出 (BoW)
  3. TfIdfなどで前処理してモデルにつっこむ
  4. 結果の解釈

2,3,4からです

名詞の抽出~前処理

文書分類というタスクを解く際に用いられる特徴量にもいろいろあると思いますが、今回はもっとも素直な方法であるBoWを行いました。

BoWとはBag of Words の略で、文章の構造を無視して単語だけに注目して文書の特徴量をつくる方法です。単語をバッグにポイポイ入れてくイメージですね(適当)
具体的には、以下の3ステップで特徴ベクトルをつくります.

  1. 文書に登場する単語を拾っていき、単語の辞書を作成する
  2. 作成した辞書と文書の単語を照らしあわして、文書毎のBoWをつくる
  3. BoWをもとに行が文書,列が単語,要素が文書内の単語出現頻度、となる行列をつくる

まずは名詞抽出部分のコードです.

import MeCab

def picking_noun(text):
    tagger = MeCab.Tagger('-Ochasen')
    tagger.parse(' ')
    node = tagger.parseToNode(text)
    noun_list=[]
    while node:
        meta = node.feature.split(',')
        if meta[0] == '名詞' and meta[6] !='*':
            noun_list.append(meta[6])
        node = node.next
    return noun_list

MeCabに文を入力すると、返り値として品詞や単語の原形を返します。
たとえば、「私は毎日6時に起きています」という文章をかませると、

BOS/EOS,*,*,*,*,*,*,*,*
名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
助詞,係助詞,*,*,*,*,は,ハ,ワ
名詞,副詞可能,*,*,*,*,毎日,マイニチ,マイニチ
名詞,数,*,*,*,*,6,ロク,ロク
名詞,接尾,助数詞,*,*,*,時,ジ,ジ
助詞,格助詞,一般,*,*,*,に,ニ,ニ
動詞,自立,*,*,一段,連用形,起きる,オキ,オキ
助詞,接続助詞,*,*,*,*,て,テ,テ
動詞,非自立,*,*,一段,連用形,いる,イ,イ
助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
BOS/EOS,*,*,*,*,*,*,*,*

このように品詞情報がかえってくるので、これをもとに名詞のみを抽出します。
今考えてみると名詞だけじゃなくて動詞や形容詞を入れたほうがよかった気もするんですが、まあ名詞だけでも十分特徴量になります。

次に、辞書作成から頻度行列をつくる部分です. お決まりの作業ですね。

from gensim import corpora, matutils,models

def create_dense_vector(all_noun_list,no_below,no_above,num_topics):
    dic = corpora.Dictionary(all_noun_list)
    dic.filter_extremes(no_below = no_below, no_above = no_above)
    bow_corpus = [dic.doc2bow(d) for d in all_noun_list ]
    #tfidfで重み付け
    tfidf_model = models.TfidfModel(bow_corpus)
    corpus = tfidf_model[bow_corpus]
    # lsi で次元削減
    #lsi_model = models.LsiModel(corpus, id2word = dic, num_topics = num_topics)
    #corpus = lsi_model[corpus]
    dense = matutils.corpus2dense(corpus,num_terms=len(dic)).T
    return dense

ここでは、gensimで辞書作成、BoW作成、Tf-Idfによる重み付けをしています。

まず、

dic.filter_extremes(no_below = no_below, no_above = no_above)

の部分では、あまりに出現回数が少ない単語と多くの文書に共通する単語を除去しています。 たとえば、no_below = 30, no_above =0.3 とすると、
全文書で30回以下しか登場してない単語と、3割以上の文書で登場した単語を除去することができます。

Tf-Idfとは、簡単にいうと単語の頻出度に重みをつける処理です。
多くの文書で登場する単語は重要度を下げ、逆に特定の文書でしか登場しないような単語の重要度を上げるといったかんじです。詳しくはぐぐってみてください。

また、LSIでの次元削減に関してですが、
処理速度、精度の両面でそこまで変化がなかった & 1単語を1特徴量としたほうが結果の解釈がしやすい、
という理由から今回はしませんでした。

データセットについて

(ほんとはこれはじめに書いとかないといけないやつ)

さっきの作業でインプットのデータは作れたわけですが、肝心の教師ラベルをどうしようという問題があります。
正常か鬱かの二クラス分類をしたいので二種類のデータとそれに対応したラベルが必要ですが、もちろんブログの筆者が正常か鬱かなんて正確なところはわかりません。

そこで今回は便宜的に、

うつ病もしくは躁うつ病カテゴリに登録されているブログ → うつ
ブログ村総合ランキングに登録されているブログ → 正常

としてラベルを付けました。 流石にどうなのかなー、自称うつとかあやしすぎるわー
といろいろ頭をよぎりますが、 ま、気楽にいきましょう、雰囲気でいいんですよ(開き直り)

こうした結果、データ数は、

うつ 正常 合計
274 216 490

となりました。
さてさて、果たしてこれでうまく学習してくれるんでしょうか...?

結果と解釈

SVM、ナイーブベイズ、ランダムフォレストにぶちこみました(コードは省略します) 訓練データで、CV=10のクロスバリデーションでそこそこチューニングをした後、
テストデータを予測させました

その結果がこちらになります

モデル accuracy precision recall f1 AUC
SVM 0.939 0.94 0.94 0.94 0.973
NB 0.939 0.94 0.94 0.94 0.981
RF 0.929 0.94 0.93 0.93 0.954

予想以上に高い精度が出ました。
なんか怪しい感じがしますね。

そこで気になるのは、いったいどんな特徴量が重要か、ということです。
今回は次元削減などしていないので、1単語=1特徴量ということになります。
ランダムフォレストで特徴量の重要度を計算し、横軸に特徴量(単語名)、縦軸に重要度をプロットした結果が以下です。

f:id:alotofthings88:20180605160519j:plain

うつ病、うつ、症状、診断、主治医 など病気関連の単語が並んでいますね。
これを見ると、今回のデータセット内でつくったテストデータに対して高い精度が出たのは納得です。

しかしこれは当初の意図とちょっと違います。
本来は「ある人がうつ病っぽいかどうかをブログの文書で判別しよう」という趣旨なのですが、
実際は、「文書内にうつ病関連のワードが多く含まれているか」という判別を一部でしてしまっていることが分かります。

これだと、うつ病っぽい人が書いたブログにうつ病関連のことが書かれてなければ、正常クラスに誤分類してしまう可能性が高いはずです。
そのためこの92%という数字にはあまり意味がありません...
うーむ...


さて、これで終わるのもかなり残念な感じです。
せっかくなので、病気関連のワードを除去したデータに対して学習をさせてみました。

[医師,病,医者,患者,診断,主治医,症状,通院,うつ病,うつ,精神病,科]
(「科」は精神科の科だと判断 )
これらの語を除去したデータセットを作り直し、再度学習させた結果が以下です

モデル accuracy precision recall f1 AUC
SVM 0.878 0.88 0.88 0.88 0.957
NB 0.878 0.88 0.88 0.88 0.960
RF 0.867 0.88 0.87 0.87 0.952

全体的に5%近く落ちてしまいました。 とはいえある程度精度がでているので、他の特徴量もそれなりに働いているということですね。
どのようなデータが誤分類されたかというと
FP : 精神疾患以外の重度な病気(癌など)の人のブログ
FN : 昔うつだったけどいまは安定している様子のブログ
というかんじでした。

また、さきほどと同じくナイーブベイズの結果が一番良いのも少し意外ですね。
ナイーブベイズはわりかし古典的な手法なので最近はあまり使われていないイメージだったのですが、シンプルな文書分類だったら十分使えるのかもしれません。

次に特徴量の重要度グラフです(クリックで拡大できます)

f:id:alotofthings88:20180605180054p:plain

病気ワードがまだ一部見られますが、それ以外の部分で解釈をしてみると...

  • 上位に「応援」「ランキング」「クリック」「ポチ」などブログの応援を促すような名詞
  • 「睡眠」「死」「社会」「安定」などうつ病のイメージに近いワードも
  • 「リビング」「テーブル」は、うつ傾向の人はあまり外出しないので、家の中の話題が多いことを示唆している...??
  • 「抱っこ」「フード」「犬」などの謎ワード

最後のは犬を飼っていると出てきそうな単語ですよね。
うつ病であることと犬を飼っていることに相関がある...??? まあ流石に無理な解釈ですね。

まあこのように解釈が難しい部分も多いですが、とりあえず言えるのは
うつ病ブログはブログランキングの応援を促しがち」
ということですかね。
ブログを書くことを励みにしている人が多い傾向が分かったのは個人的にはまあまあ面白い発見でした

今後の課題

病気関連ワードが分類に影響をあたえてしまう問題について考えてみようと思います。
これは、主にうつ病自体を話題にしているブログをデータに採用したことが原因だと思われます。
うつ病について言及している」ということとうつ病である」ということは本来明らかに違います。 であるにもかかわらず、今回の特徴量の選び方(名詞抽出)だとそこを区別することが出来ていないんですね。

このような誤分類を避けるにはどうすればいいんでしょうか?
思いつくものとしては、

  1. 単語じゃなくて文章の構造などを特徴量にする
  2. ブログの各記事に対してトピック解析をして、トピックが病気関連の記事はデータから除外する

1はアイデアとしては良いと思うのですが、そんなに文構造に違いがあるとも思えないのでうまくいかなそう
2はわりと有りだと思います。うつ病の人の、話題が病気以外についての文書だけ集めることができるので、さきほどのような影響を避けることができそうです。

次やってみたいこと

  • ↑の2とか doc2vecを使って、うつ以外の精神疾患を含めたテキスト多クラス分類
  • Twitter API使う系のやつ
  • うつうつ言いすぎて疲れたのでもっと明るいテーマで

技術ブログはじめてで読みづらかった部分もあったと思いますが、
最後まで読んでいただきありがとうございました
これからも気分で書いていければいいなとおもいます

うつブログ分類器をつくってみた(準備編)

なにかしらアウトプットしようかなとおもってブログはじめました。
続く気がしません。

やりたいこと

自然言語処理をつかってブログをうつ病と正常の2クラスに自動で分類したい

おおまかな手順

  1. ブログ村メンタルヘルスランキング に掲載されているブログからスクレイピング <-- 今回はここだけ
  2. 取得したhtmlからブログ毎に名詞のみ抽出 (BoW)
  3. TfIdfなどで前処理してモデルにつっこむ
  4. 結果の解釈

準備編と称してブログスクレイピング(クローリング)の部分を書いてみます.
まあ大層なことしてないんで書かなくてもいいかなと思ったんですが、自分用メモ&ブログ書く準備体操を兼ねて、ということで。

2,3,4は次回書きます

クローリング

ランキングサイトから各ブログのURLを一気に取得

from selenium import webdriver
import re

options = webdriver.chrome.options.Options()
options.add_argument("--headless")
driver = webdriver.Chrome(chrome_options=options)

def collecting_blog_url(ranking_url):
    driver.get(ranking_url)
    rankings = driver.find_elements_by_xpath("//li[@class='entry-rankingtitle']")
    links = [ranking.find_elements_by_xpath("./a")[0] for ranking in rankings]
    urls =[]
    for link in links:
        url = link.get_attribute('href')
        # ブログURLのみを抽出, url デコード
        url = urllib.parse.unquote(re.findall(r"url=(.*)$",url)[0])
        urls.append(url)
    return urls

最初BeautifulSoupでスクレイピングしてみたらすかすかのhtmlがかえってきました.
これはこのサイトのhtmlがjavascriptレンダリングされているのが理由ですね.

そういうときはseleniumを使うといいらしいです. seleniumxpathなどを用いて要素抽出できるので、ここではそうしています.

Python+Selenium+Chrome(+BeautifulSoup)でスクレイピングする を参考にさせていただきました)


取得したurlをブログサービスごとに整理

def arranging_url_by_blog_type(urls):
    arranged_urls={'fc2':[],'ameblo':[],'goo':[],'hatena':[]}
    for url in urls:
        #末尾を / に統一
        if url[-1] !='/':
            url += '/'
        
        if re.match(r'.*?fc2',url):
            arranged_urls['fc2'].append(url)
        elif re.match(r'.*?ameblo',url):
            # スマホ用urlをPC用urlに統一
            url = re.sub('s\.ameblo','ameblo',url) 
            arranged_urls['ameblo'].append(url)
        elif re.match(r'.*?goo',url):
            arranged_urls['goo'].append(url)
        elif re.match(r'.*?hatenablog',url):
            arranged_urls['hatena'].append(url)
    
    return arranged_urls

これはこの次の記事スクレイピングのための作業です.

https://alotofthings88.hatenablog.com/ の中に'hantenablog'が含まれているように、ブログurl中に各blogサービスの名前が絶対入っているので、それを正規表現で一々チェックしています.

今回はfc2, ameblo, hatena, goo,しかチェックしていませんが、、

f:id:alotofthings88:20180602142931p:plain

これだけでも半分くらいのブログをカバーできたので、とりあえずよしとしました.
泥臭い...もっと綺麗に書けないかな...


blogサービス毎に各記事のurlを一気に取得

import time
import requests
import chardet

def getting_html(url):
    response = requests.get(url,headers=headers)
    time.sleep(1) # サーバー負荷回避
    encoding = chardet.detect(response.content)['encoding']
    html = response.content.decode('utf-8')
    return html


def collecting_articles_url(blog_url, blog_type):
    article_urls =[]

    if blog_type=='fc2':
        #fc2は全記事一覧ページが存在
        index_url = blog_url + 'archives.html'
        html = getting_html(index_url)
        article_urls.extend(re.findall(blog_url+r'blog-entry-.*?\.html',html))

    elif blog_type=='ameblo':
        #amebloは1page20件の一覧ページが存在
        page = 1
        while True:
            index_url = blog_url + 'entrylist-' + str(page) +'.html'
            html = getting_html(index_url)
            article_urls.extend(re.findall(blog_url+r'entry-.*?\.html',html))
            nums = re.findall(blog_url+r'entrylist-([0-9]*)\.html',html)
            page_index = [int(num) for num in nums]
            if page + 1 in page_index :
                page +=1  
            else:
                break
      
    return article_urls

# 長いのでhatena と goo は省略

記事の本文を抽出する前に、各記事のurlを取得する必要があります.
ここはいろいろ迷ったんですが、この4つのブログサービスは記事一覧ページが存在しているのでそれを利用しました.

fc2の場合はブログURL + archives.html で全記事一覧が見れます. 超便利.
fc2以外は 1ページに20件ずつ表示とかなのでページを進ませるロジックを書く必要があります.
ちなみにhatenaは https://alotofthings88.hatenablog.com/archive?page=1 というかんじです

やっぱり泥臭い...


各記事URLから本文とタイトルのテキストを抽出する

from extractcontent3 import ExtractContent

def extract_article_text(article_url):
    extractor = ExtractContent()
    opt = {"threshold":50}  # オプション値を指定する
    extractor.set_option(opt)

    extractor.analyse(getting_html(article_url))
    text = [text.strip() for text in extractor.as_text()]
    content = text[0]
    title = text[1]
    return content, title

ようやく記事URLも集め終わってよしテキストを手に入れるぞ!って思ったんですが、
「あれ、URLから本文のみを抽出するってどうやるんだ?」 との疑念が浮かびけっこう焦りました(今更)

いやもちろん、さっきと同じようにxpathやらidを使ってスクレイピングすれば出来ないことはないんですが、これが厄介なことにブログテンプレート毎にhtml構造も違えばid名class名もまちまちなんですよね。
まじかー...

そう思って調べていると、本文抽出ライブラリなるものが存在することが分かりました。
PythonでブログのHTMLから本文抽出 2015 - orangain flavor

このサイトに本文抽出ライブラリの比較表があるのですが、それを見る限り一番無難そうだったextractcontent3を使いました.

試しに抽出してみると、

f:id:alotofthings88:20180602143408p:plain (内容がわからないように縮小してます)

いい感じ. ほんと便利な世の中ですね!
というわけでスクレイピング編おわり、

結果編につづく....