アシアルブログ

アシアルの中の人が技術と想いのたけをつづるブログです

Python x 自然言語処理でアシアルブログの単語ランキングを算出する

はじめまして、江口です。

機械学習やらAIやらtensorflowやらで最近耳にすることが増えたと思うPythonについての記事です。
先日書店でも並んでいる本が殆どRやPythonばかりで、機械学習の盛り上がりが伝わってきました!
今回はネタですが、以下の技術を使って、アシアルブログの頻出単語ランキングを算出して円グラフ化してみました。

▼使用言語


Python 3.6

▼使用library


unicodedata -> 全角文字判定に使用

requests -> アシアルブログのHTML文字列取得に使用

BeautifulSoup -> スクレイピングに使用

MeCab -> 形態素解析に使用

CountVectorizer -> 単語頻出数算出に使用

matplotlib -> データ可視化(円グラフ)に使用

▼その他ツール


conda 4.3.16 ->パッケージ管理に使用
spyder 3.1.2 -> IDE
jupyter notebook 4.3.1 -> 対話式にコードを実行し、グラフ等も即時に可視確認できる

最初に結果です!

▼アシアルブログ頻出単語ランキング




・処理概要


下記ソースコードにも記載してあるのですが、アシアルブログのTOPページ(http://blog.asial.co.jp/)から「/[0-9]{4}」形式のリンクのみをスクレイピングで抽出し、ブログの詳細記事をクローリングして全てのブログ記事からBODYタグを抽出して、さらにそこから全角の一般名詞のみを抽出してます。

・考察


最終的にTF解析で上位10件を円グラフで頻出単語ランキングとして表示させています。
さすがOnsenUIの効果でしょうか。「温泉」が1位の143個でした!
2位の「アプリケーション」は汎用的すぎて弾いても良かったですね^^;
コンポーネントは最近は宇都宮さんのブログでvuejsの「コンポーネント」が結構目立ったのだと思います。
それ以降の単語は、汎用的と言いますか、あまり参考にならなそうなので、こちらも弾いていいかもですね!

ソースコード


・asialblog_ranking.pyで下記を保存
・TTF_PATHは人それぞれ場所が異なります。



#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Pythonでアシアルブログの単語ランキングを算出する
[前提]アシアルブログの記事詳細はhttp://blog.asial.co.jp/[0-9]{4}の形式になっている
1. TOPサイトから上記形式のhrefを全て抽出する
2. 1で取得したURLを全てクローリングし、BODYタグの全ての全角文字を抽出する
3. 2で抽出した全角文字を形態素解析に掛けて、一般名詞のみを抽出する
4. 3で取得した一般名詞をTF解析で単語頻出度を算出する
5. 最終的にmatplotlibで円グラフに上位10件を表示する

Created on Wed Apr 12 11:38:42 2017

@author: eguchi
"""
import unicodedata
import requests as req
from bs4 import BeautifulSoup as bs
import MeCab as mc
from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager
import pylab
import re

# 定数(と言って良いのかは微妙ですが)
BASE_URL = 'http://blog.asial.co.jp/'
EXCLUDE_STR_LIST = ['年月日', '年月', 'アシ', 'アシアル', 'アル', '情報', '株式会社', '全国']
TTF_PATH = '/Users/hogehoge/anaconda/lib/python3.6/site-packages/matplotlib/mpl-data/fonts/ttf/ipam.ttf'

# 1. TOPサイトからhttp://blog.asial.co.jp/[0-9]{4}の形式のhrefを全て抽出する
html = req.get(BASE_URL).text
# BeautifulSoup4でHTMLを解析
soup = bs(html, 'html.parser')
for script in soup(["script", "style"]):
    script.extract()

# 記事詳細のlinkのみを取得
blogs = []
for link in soup.select('a[href]'):
    if (re.match('^/[0-9]{4}$', link.attrs['href'])):
        blogs.append(BASE_URL + link.attrs['href'])

# 2. 1で取得したURLを全てクローリングし、BODYタグの全ての全角文字を抽出する
# 全てのblogを走査
contents_lo = ''
for blog in blogs:
    html = req.get(blog).text
    # BeautifulSoupでHTMLを解析
    soup = bs(html, 'html.parser')
    for script in soup(["script", "style"]):
        script.extract()
        # bodyコンテンツ内のinner textのみを抽出
    contents = soup.find('body').get_text()
    # extractで救いきれなかったので、unicodedata.east_asian_widthで全角かどうか判定
    for uniStr in contents:
        str_width = unicodedata.east_asian_width(uniStr)
        if str_width == 'W':
            contents_lo += uniStr

# 3. 2で抽出した全角文字を形態素解析に掛けて、一般名詞のみを抽出する
# 形態素解析にかけて、一般名詞だけを抽出
m = mc.Tagger('mecabrc')
mecab_result = m.parse(contents_lo)
info_of_words = mecab_result.split('\n')
words = []
for info in info_of_words:
    if '\t' in info:
        kind = info.split('\t')[1].split(',')[0]
        category = info.split('\t')[1].split(',')[1]
        if kind == '名詞' and category == '一般' and (info.split('\t')[0] not in EXCLUDE_STR_LIST):
            words.append(info.split('\t')[0])

# 4. 3で取得した一般名詞をTF解析で単語頻出度を算出する
# TF解析で単語数を取得する
vectorizer = CountVectorizer(min_df=1)
tf_idf_result = vectorizer.fit_transform([' '.join(words)])
# スコアの配列を取得
tf_idf_result_array = tf_idf_result.toarray()

# ラベルを取得
feature_names = vectorizer.get_feature_names()

# スコアとラベルが一対になるように辞書型に変換(ついでに小数点の第3位切り捨て)
dic = {}
for k, v in enumerate(feature_names):
    dic[v] = round(tf_idf_result_array[0][k], 3)

# 辞書型をvalueで降順sortしてlistにして返却
sorted_list = sorted(dic.items(), key=lambda x: x[1], reverse=True)
plot_value = np.zeros([10])
plot_label = [0 for i in range(10)]
i = 0
for rank in sorted_list:
    if i >= 10:
        break
    plot_value[i] = rank[1]
    plot_label[i] = rank[0] + '(' + str(rank[1]) + ')'
    i += 1

# 5. 最終的にmatplotlibで円グラフに上位10件を表示する
# マッププロット
prop = matplotlib.font_manager.FontProperties(fname=TTF_PATH, size=14)
pylab.clf()
patches, texts = plt.pie(plot_value, labels=plot_label, counterclock=False, startangle=90)
plt.axis('equal')
pylab.setp(texts, fontproperties=prop)


▼苦労したとこ


1. script.extract()で救いきれないscript


 今回は一般名詞だけ集計したかったので、スクレイピング結果からscriptタグに関する記載は除外したかったのですが、完全には救えず。。
 そのため、苦しい感じではありましたが、「全角の単語に絞ろう」という逃げに走りました。
 


unicodedata.east_asian_width(uniStr)


pythonの標準モジュール「unicodedata」にある「east_asian_width」関数を使って、対象の文字が全角かどうかを判定することで回避することにしました!
https://docs.python.jp/3/library/unicodedata.html

標準でも随分リッチなことが手軽にできて便利だな〜。。。

2. 除外対象単語



ソースコードに思いっきり「EXCLUDE_STR_LIST」って羅列しているのですが、
mecabで使う辞書をカスタマイズすればもっとスマートに管理できるのだろうなと思いました!
今回はネタということで。

3. matplotlibの日本語ラベル豆腐問題



これが地味に一番ハマりました。
matplotlibを標準デフォルトのまま使うと、日本語ttfファイルがlib下に存在しないため、
いわゆる豆腐問題「□」に陥りました。

日本語ttfを「matplotlib/mpl-data/fonts/ttf/」下にコピーして配置し、パスを



prop = matplotlib.font_manager.FontProperties(fname=TTF_PATH, size=14)
pylab.setp(texts, fontproperties=prop)


とすることで、plot時に日本語フォントを見るようにして解決しました。

4. pythonのversion


今回はlocalのmacでサクッとやってたのですが、当初考えなしにpythonいじってたら
macのデフォルトのpythonのversionが2系だとわかり、急遽環境を整えることにしました^^;

そこで今回チョイスしたのはanacondaです。

anacondaにはpythonの開発に必要なモジュールが一通り揃ってますので、楽チンでした。

anacondaをインストールすると、環境変数に自動で以下のように追記されるので、



# added by Anaconda3 4.3.1 installer
export PATH="/Users/hogehoge/anaconda/bin:$PATH"


source ~/.bash_profileでmacで扱うpythonをインストールしたanaconda下を見るようにしました。

anacondaを入れると、「Anaconda-Navigator」というアプリケーションも同胞されるため、
そちらを起動すると、以下のような管理画面が立ち上がるので、



「spyder」というアプリケーションを立ち上げますと、pythonIDE(統合開発環境)が起動します。
今回の円グラフもspyder上のconsole(IPython)で実行、表示させたものを貼り付けてます。

spyderでの作業風景はこんな感じです。



▼最後に・・・



そもそもの話ではありますが、ランキングって円グラフで見せるものなんですかね?^^;
バ〜っと作りきってから、ふと思ってしまいました。。。

Pythonには数値を簡単に扱うライブラリが豊富なので、「あ、これやってみたい」が驚くほど手軽にできてしまうのが魅力だなと感じました。

今回のネタも急に思いついたのですが、次回は機械学習、特にscikit-learn(サイキットラーン)やtensorflowあたりでネタを考えて展開してみようと思います!

拙い文章ではありましたが、読んで頂き本当にありがとうございました!

※クローリングする際は、対象のサイトでクローリングしても問題ないか確認した上で実行するようにしましょう!!

「似ている」を探そう

今日から8月ですね!我が家の猫も夏毛に生え替わるようで、私も毛だらけです。志田です。

さて、今回は「似ている」を探したいと思います。なにかとなにかがどのくらい似ているのか、という情報があれば、それが役に立つシーンはたくさんありますよね。
ブログの似ている記事を探したり、趣味の似ているユーザを探したり、用途は様々です。
「何が似ているのか」という尺度にも様々あるように、類似度というのはいろいろな観点から調べることができます。

今回は、アシアルブログから似ている記事を探してみたいと思います。
手順としては、Mecabというライブラリを使って記事を形態素解析し、単語に分けます。
そして、元となる記事とその他の記事全体を見て、コサイン類似度という手法を用いて記事同士の類似度を測定し、似ている記事を3件見つけます。

(1) ブログ記事を取得する


ブログ記事を取得し、ディレクトリに保存しておきます。
アシアルブログですと、URLの後ろについた番号が記事のIDとなっているので、entry/ID.htmlというような形式で保存しました。総数757件!
結構な量だったので、50件ずつダウンロード保存しました。(門脇さん、ブログに負担がかかってたらゴメンナサイ…)

(2) ブログ記事から本文をスクレイピングする


先ほど取得したブログ記事には、HTMLヘッダや再度メニューなど、類似度の分析に必要のない部分まで含まれています。
これを、本文だけ取得(スクレイピング)していきたいと思います。
また、ソースコードが含まれていると「if」「$i」といった構文が多く出現して本文の類似度に結びつかなくなると考え、ソースコード部分も省くことにしました。



include('parser/simple_html_dom.php');
for($i=1; $i<839; $i++){
  $file = "entry/$i.html";
  if(is_file($file)){
    $html = file_get_html($file);

    //本文抜き出し
    $div = $html->find('div[class=item-paragraph]');
    $string = $div[0]->outertext;
    //本文からソースコードを消す
    $div = $html->find('ol[class=boxcode-main]');
    foreach($div as $d){
      $string = str_replace($d->outertext, '', $string);
    }
    //ファイルに保存
    file_put_contents($file, $string);
  }
}


HTMLのパースには、PHP Simple Html Dom Perserを利用しました。
PHP Simple Html Dom Perser
これを利用することで、本文からクラスがitem-paragraphであるdivを取得(アシアルブログの本文です)し、本文からboxcode-mainというolタグ(ソースコード)を削除します。
(後で気づいたけれど、番号なしソースコードは省けていないかもしれません…)

(3) 本文を形態素解析する


形態素解析をすると、文章を意味のある単語で区切ることができます。
例えば、「すもももももももものうち」という文章ならば、「すもも:名詞」「も:助詞」「もも:名詞」「も:助詞」…というように、単語と品詞、その他の情報というように分割してくれます。
今回は、形態素解析エンジンにMeCabを使いました。

PHPMeCabを利用するには、MeCab本体とphp-mecabをインストールする必要があります。
インストールは下記のブログが非常にわかりやすかったです。
PHPでMecab利用 - リハビリ日記
また、PHP5.3系を使っている方は、下記のブログを参考にphp_mecab0.4.1をインストールしてください。
2010-07-06 - なんというていたらく



$mecab = new Mecab_Tagger();

$search = array(' &nbsp;', '<', '>', '"');
for($i = 1; $i < 100; $i++){
  $file = 'entry/'.$i.'.html';
  if(is_file($file)){
    $body = str_replace($search, '', strip_tags(file_get_contents($file)));
    $node = $mecab->parseToNode($body);
    while($node){
      //品詞ID38、41、47のものだけ取得
      if($node->posid == 38 || ($node->posid >= 41  & & $node->posid <= 47) ){
        $result[] = $node->surface;
      }
      $node = $node->getNext();
    }
    //出現回数が10回以上の単語のみにする
    $omomi = omomi($result, 10);
   
    $omomi_str = '';
    if(is_array($omomi)){
      foreach($omomi as $key => $value){
        $omomi_str .= $key.",";
      }
      $omomi_str = mb_convert_encoding($omomi_str, 'UTF-8', "auto");
      $sql = "INSERT INTO blog_test VALUES($i, '$omomi_str')";
      mysql_query($sql);
      $result = array();
    }
  }
}


形態素解析した結果、品詞ID38(一般名詞)、41(固有名詞:一般)、47(固有名詞:地名)のものだけ取得するようにしました。
この品詞IDを指定することで、助詞がむやみにキーワード化するのを避けます。
品詞IDリストは、MeCab 品詞IDを参考にしてください。
途中、omomi(名前がダサい)という関数にかけ、出現頻度が10回以上の単語を抽出しています。
出現頻度が高い単語で類似度をとった方がより似ているというのと、出現頻度が低いものまで含めてしまうと、長い記事のときたくさんキーワードがとれてしまい、
コサイン類似度を求めるときに処理が重くなるからです。



function omomi($ary, $limit = 1){
  $return = array();
  foreach($ary as $a){
    if(isset($return[$a])){
      $return[$a]++;
    } else {
      $return[$a] = 1;
    }
  }
  arsort($return);

  reset($return);
  if(current($return) >= $limit){
    foreach($return as $key => $r){
      if($r < $limit){
        unset($return[$key]);
      }
    }
  }
  return $return;
}


関数omomiでは、受け取った配列の出現回数をカウントし、第二引数で渡された出現頻度以上の単語のみの配列を返します。
こうして、各記事の形態素解析と、出現頻度による単語の抽出、DBに保存までを行いました。

(4) コサイン類似度を求める


それでは、ある記事に対する似ている記事を、記事全件から探していきます。
ここで用いるのが、文書の類似度を求めるときなどに利用されるコサイン類似度というものです。
これは、2つの記事のベクトルがどれだけ似ているかというものを調べます。

コサイン類似度では特定の計算式を用い、1に近づくほど似ているという結果になります。(1だと全く同一の内容、0.8~0.9だとかなり似ている、といったように)
今回ですと、ブログから形態素解析を行って意味のある名詞を多く取り出したので、文書の成分が似ているものがヒットするはずです。

コサイン類似度の計算関数は、下記のようになっています。



function cosSim($w1 = null, $w2 = null){
    $w = array_values(array_unique(array_merge($w1, $w2)));
    for($i=0; $i<count($w); $i++){
        $m1[$i] = false;
        $m2[$i] = false;
    }

    //$wが$m1にあるか調べる
    foreach($w1 as $wi){
        $key = array_search($wi, $w);
        if($key !== FALSE)
            $m1[$key] = true;
    }

    //$wが$m2にあるか調べる
    foreach($w2 as $wi){
        $key = array_search($wi, $w);
        if($key !== FALSE)
            $m2[$key] = true;
    }

    //Cos類似度用計算
    //分子
    $and = 0;
    for($i=0; $i<count($m1); $i++){
        if($m1[$i]  & & $m2[$i])
            $and++;
    }
     
    //分母
    $m1and = 0;
    foreach($m1 as $m){
        if($m)
            $m1and++;
    }
    $m2and = 0;
    foreach($m2 as $m){
        if($m)
            $m2and++;
    }
   
    $m1and = sqrt($m1and);
    $m2and = sqrt($m2and);
   
    if($and == 0 || $m1and == 0 || $m2and == 0){
        return 0;
    } else {
        return $and/($m1and * $m2and);
    }
}


コサイン類似度の関数では、元となる記事の単語配列$w1と、比べたい記事の単語配列$w2を渡し、配列をマージ・ユニーク化します。
マージされた配列を元に、元記事と比べたい記事のどちらにも存在する単語を検索し、そのヒット数を調べます。
最後に、コサイン類似度の計算式に当てはめます。

似ている記事は、下記のようにして求めました。
($_POST['number']で元記事の番号を指定した場合のプログラムです)



//似ている記事を探す
if(isset($_POST['number'])){
  $result = mysql_query('select * from blog_test', $con);
  while($row = mysql_fetch_row($result)){
    $blog[$row[0]] = $row[1];
  }

  //カンマで区切って配列にする
  foreach($blog as $key => $value){
    $row = explode(",", $value);
    $blog[$key] = $row;
  }

  echo "<table border=1><tr><th>記事番号</th><th>記事のキーワード</th>";
  echo "<th>似ている記事番号</th><th>似ている記事のキーワード</th></tr>";

  $moto_key = intval($_POST['number']);
  $result = array();
  foreach($blog as $key2 => $value2){
    if($moto_key != $key2  & & count($blog[$moto_key]) != 0  & & count($value2) != 0){
      $result[$key2] = cosSim($blog[$moto_key], $value2);
    }
  }
  arsort($result);
  echo "<tr><th>".$moto_key.".html</th><td>".aryToStr($blog[$moto_key]).'</td><td>';

  //似ている記事番号と類似度を3つ表示
  $i=0;
  foreach($result as $k => $v){
    echo "<b>".$k.".html</b> : ".$v."<BR>";
    $i++;
    if($i == 3){
      break;
    }
  }
  echo '</td><td>';

  //似ている記事のキーワードを3つ表示
  $i=0;
  foreach($result as $k => $v){
    echo aryToStr($blog[$k])."<HR>";
    $i++;
    if($i == 3){
      break;
    }
  }
  echo '</td></tr>';
  echo '</table>';
}

//配列を渡すとカンマ区切りで表示する
function aryToStr($ary){
  $str = '';
  foreach($ary as $r){
    $str .= $r.", ";
  }
  return $str;
}


上記のプログラム、ちょっとテーブルタグが入っていて汚いですが;結果は$resultに3件入っています。

これを実行し、824番の記事(【デザイナー必見】iPhone/AndroidアプリをHTML+Javascriptで作成(PhoneGapのススメ))と似ている記事を検索します。
824番の記事からは、「PhoneGap」「アプリ」というキーワードがとれました。
検索した結果、下記のような類似記事を3件見つけることができました!



421番(iPhone 開発合宿@伊香保)キーワード:アプリ 類似度:0.82
828番(【PhoneGap】Xcode4でPhoneGapプロジェクトを作成する方法)キーワード:プロジェクト、PhoneGap、Xcode 類似度:0.58
738番(iPhoneアプリ開発開始時に気をつけるべきファイルの取り扱い (2))キーワード:ID、App、アプリ 類似度:0.58

このように、記事の本文からも似たものを探すことができます。場合によっては、ブログ記事に自分でつけたカテゴリやキーワードといったタグ情報よりも、より似ている記事が見つけられるかと思います。

ブログの内容によっては、MeCabの辞書ファイルに独自の単語を入れた方がいい場合や、キーワードの出現頻度(重み)を増減させたほうがいいものなど、あると思います。
動かしながらベストな数値を見つけてみて下さい。

似ているといっても、何がどのくらい似ているのかという尺度はいろいろあります。
音楽ならアーティスト、ジャンルといったタグから、曲調、テンポといった曲の成分まで、どれを比べるかによって類似度を調べる分類器が違ってきます。
こうして見つかった類似性をどのように活かしていくかによっても、楽しみが広がりますね。

参考
MeCab: Yet Another Part-of-Speech and Morphological Analyzer
PHP Simple Html Dom Perser
PHPでMecab利用 - リハビリ日記
2010-07-06 - なんというていたらく
PHPエクステンションのリポジトリ始めました - 讃容日記