アシアルブログ

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

人事・採用プロセスを自動化/システム化させる - その1: 全体構成と進捗報告 -

お久しぶりです。江口です。

7/14(土),15(日),16(月)の三連休で開発合宿に参加してきました!!
2年ぶりにアシアルからは3名で参加させて頂きました。
本記事で「作ろうとしたもの」と「成果発表」を兼ねようと思います。
最初に書いておきますが、以下の構成のシステムを作ろうと思いましたが、
2泊3日では到底完成できませんでした...!!
その詳細を綴ります。。。

f:id:eguchi_asial:20180718215535p:plain

続きを読む

レコメンド - アイテムベース協調フィルタリング -

こんにちは江口です。

今回は、先日社内勉強会で以下の内容について発表しましたので、ブログでも共有させて頂きます。

【概要】
・アイテムベース協調フィルタリング(商品レコメンド)

- アイテムベース協調フィルタリングを実装してみた
- ユーザーが購入した商品の中からjaccard指数で類似商品を見つけ出す
- 対象商品をベースに同じ商品を購入している別のユーザーが購入した別の商品をレコメンド
- あとdjango使ってみた


▼技術要件
python 3.6.1
・django1.9
・heroku
・DB・・・heroku上はpostgress、localはsqlite3


▼レコメンドアルゴリズムについて

今回試してみたのは、「アイテムベース協調フィルタリング」と呼ばれる、商品が主語となる簡易的なレコメンドアルゴリズムになります。

よく見る「この商品を買った人はこんな商品も〜」みたいにオススメしてくるあれです。

アイテムベース協調フィルタリングの一環、「jaccard指数」による類似度算出を実際にコード組んで検証してみました。

▼jaccard指数とは。。。

実際に勉強会で使った資料を貼り付けちゃいます。。。。



こんな感じのアルゴリズムです。
例えば、Aさんが買った商品は「商品1」と「商品3」になります。
そしてAさんが買った「商品1」とその他全ての商品がどれだけ商品1と類似しているか?


『A&&B / A||B => Jaccard指数』でスコアリングしていきます。
資料の例だと、商品3がもっとも類似してますね。

ただ、Aさんはすでに商品3を購入してますので、除外して、商品4をレコメンドすることもできますね。

▼実際に触って見れます!
上で説明したアルゴリズムを実際に検証できるサンプルシステムを作ってみました。
・画面
https://lit-headland-28550.herokuapp.com/recommend/
(無料枠herokuなので、30分アクセスがないと立ち上がりに時間がかかることがあります。。ご了承ください。)

TOPページではこんな画面が表示されるかと思います。


ちょっとわかりにくいのですが、擬似的なECサイトを模してます。
「購入者」プルダウンで仮想敵にログインユーザーになったとして、「商品一覧」にある商品の「購入」ボタンを押すことで選択したユーザーで買い物ができます。

買い物が済むと、こんな感じで、購入した商品に類似したレコメンド商品一覧と、ユーザーの買い物履歴が確認できます!



自由にユーザーになりすまし、商品を買ったりキャンセルしたりしてみて、レコメンド具合を確かめて見てください。
私が試した時は、「PHP参考書」購入したら、「Java参考書」と「タンクトップ」がなぜかレコメンドされました。。。


ソースコード

ソースコードgithubで公開してます。
https://github.com/yueguchi/recommend-with-django

要点のみ、紹介させて頂きます。

・商品一覧の取得

商品一覧の取得は、requestをrecommend/view.pyにてrequestw受け取っています。
https://github.com/yueguchi/recommend-with-django/blob/master/recommend/views.py

この中の



def index(request):
    """
    商品一覧の閲覧・購入から、レコメンド(jaccardベース)を表示する
    """
    # 商品一覧
    items = Items.objects.all()
    # 購入者一覧(値だけのlistを取得する)
    users = Users.objects.all()#.values_list('name', flat=True)

    # レコメンド取得
    user_name = request.GET.get('user_name', '')
    recommend_items = Purchases.getRecommend(Purchases, user_name)
    # 購入履歴取得
    purchased_items = []
    if user_name:
        purchased_items = Purchases.objects.filter(user_id=Users.objects.get(name=user_name).id)

    return render(request, 'index.html',
        {
            'users': users,
            'items': items,
            'recommend_items': recommend_items,
            'purchased_items': purchased_items,
            'selected': user_name
        }
    )


ここでGETで受け取ってます。

肝心のレコメンドの処理は



recommend_items = Purchases.getRecommend(Purchases, user_name)


こちらでmodel経由で取得しています。

・レコメンド処理

modelは以下です。
https://github.com/yueguchi/recommend-with-django/blob/master/recommend/models.py

1. requestパラメータとしてユーザ名を受け取る
2. ユーザーが購入した全ての商品を取得する



user_purchases = Purchases.objects.filter(user_id=Users.objects.get(name=user_name).id)


3. 全ての商品を取得し、購入者と紐付ける



# 全購入商品を商品ID => 複数ユーザーIDで束ねる
        item_user_buyer = {}
        for item in Items.objects.all():
            user_id_list = []
            for purchase_by_item in Purchases.objects.filter(item_id=item.id):
                user_id_list.append(purchase_by_item.user_id)
            item_user_buyer[item.id] = user_id_list


4. ユーザーの購入した商品を主軸にループさせ、全ての商品とのjaccard指数を算出する



# jaccard
        recommend_items = []
        already_item_id_list = user_purchases.values_list('item_id', flat=True)
        # ユーザーが購入した商品をベースにループさせ
        for key1 in purchased_items_other_users:
            # 全ての商品を比較させる
            ret = []
            for key2, item in item_user_buyer.items():
                ret.append(str(key2) + "," + str(self.__jaccard(purchased_items_other_users[key1], item)))
            recommend_items.append(self.__getUniqueRecommendItems(ret, already_item_id_list))


5. 最後にjaccard指数にかけて、レコメンド商品を返却する



def __jaccard(e1, e2):
        """
        A & &B集合 / A||B集合で近似値を測るアルゴリズム
        """
        set_e1 = set(e1)
        set_e2 = set(e2)
        return float(len(set_e1  & set_e2)) / float(len(set_e1 | set_e2))


レコメンドはこんな感じになってます。
ちょっとTODO残ってましたが、お許しください。
全体の雰囲気が伝わればと思いますm(__)m

・購入時の二重SUBMIT防止

今回のレコメンドとはあまり関係ないですが、商品購入時にPRGパターンを使うことで、二重に商品を購入する事象を回避しています。

view.pyの


def purchase(request):
    """
    商品ID、購入者名を元に購入処理を行う
    PRGパターンによる二重submitを抑止
    """
    item_id = request.POST['item_id']
    user_name = request.POST['user_name']
    if request.method == 'GET' or not item_id or not user_name:
        raise Exception('this url is not support "GET"')
    # 登録
    p = Purchases(item=Items.objects.get(id=item_id), user=Users.objects.get(name=user_name))
    p.save()
    messages.success(request, '購入しました!')
    return redirect('item-list')


ここです。

POSTで購入し、RedirectでクライアントにGETで再アクセスさせることで、ブラウザリロードしても
二重で商品が買われることを防いでいます。


djangoを使って見て

使いやすかったです。入門しやすいと言いますか。

最近のFWにありがちなREPLでしたり、migrationも、



./manage.py shell
./manage.py dbshell
./manage.py migrate


これらで簡単に使えちゃいますし。

localサーバも



./manage.py runserver


python修正時に自動で再起動かけてくれますし、スタイリッシュな開発が気持ちよくできますね。

ただscaffoldのような仕組みはないのかもしれないのですが(あったらごめんなさい)、django adminがあれば余り不便には感じないかと思います。

▼まとめ
・アイテムベース協調フィルタリング「jaccard指数」は手軽なのに、そこそこ精度が良い
・jaccard指数は集合の類似度でレコメンドするよ
django楽しい

▼課題
djangoのORMにおけるN+1問題の検証が未検証

商品とユーザーが増えれば、その分だけ膨大な計算が毎リクエストで実行されるため、
今回みたいにrequest <-> responseのやり取りの中でリアルタイムに計算させるものじゃない。
バッチなりでDBにレコメンド状態を永続化させておいて、毎時更新するなりして、画面に表示するか等の工夫が必要。


以上です!今回もありがとうございました!!

機械学習で女性のタイプ判定処理を行う

こんにちは、江口です。

前回に引き続き、pythonネタです。

皆さんは、同僚との会話で「この女優、どう?」と聞かれて困ったことはありませんか?
私はあります。そして、良いとも悪いとも思えず、なんと言いますか、興味が湧かない時が一番返答に困ります。

今回はズバリ「機械学習で好みの女性なのかどうか仕分ける!」です。
ネタではありますが、真剣です。これによりデータに基づいた返答ができるようになります。

▼概要
1. 自分好みの女優の画像とそうでない画像を収集する。
2. 収集した画像から顔だけを抽出し、100x100にリサイズして保存する。
3. SVM分類器にかけて、機械学習を行い、学習結果をpickleに漬け込む
4. 女優画像が自分の好みかどうか判定する

こんな流れになってます。
詳しい流れ、実装は後述するソース解説で紹介します。

▼使用技術とその用途
python 3.6
・scikit-learn -> 機械学習ライブラリ
・GoogleCustomSearch API -> 画像取得に使用
・BeautifulSoup -> スクレイピングに使用
SVM分類器 -> 実際に機械学習するアルゴリズム
OpenCV -> 画像から顔・輪郭抽出するのに使用
・joblib->pickleファイル漬け込み

▼1. 自分好みの女優の画像とそうでない画像の収集


収集はsave_beautiful.pyで行います。


# -*- coding: utf-8 -*-
import requests
import re
import os
from PIL import Image as resizer

API_URL = "https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=%s &searchType=image &start=%s"


def getImageFromCustomAPI(start, end, word, kind):
    for i in range(start, end, 10):
        print(API_URL % (word, str(i)))
        response = requests.get(API_URL % (word + ",正面", str(i)))
        for j in range(len(response.json()["items"])):
            json = response.json()["items"][j]
            res = requests.get(json["link"], stream=True)
            if res.status_code == 200:
                with open("images/target_%s_%s.png" % (kind, (str(i) + "_" + str(j))), 'wb') as file:
                    for chunk in res.iter_content(chunk_size=1024):
                        file.write(chunk)


def resizeImages(dirname, regular):
    """
    images下の画像を2次元の特徴ベクトルに変換する為に100x100リサイズを行う
    """
    for image in os.listdir(dirname):
        if re.match(regular, image):
            print(image)
            img = resizer.open("images/" + image, 'r')
            img = img.resize((100, 100))
            img.save("images/" + image, 'png', quality=100, optimize=True)
    print("resized")


def main():
    # 「人名,正面」にすると、より多くの正面画像を拾える
    words = ['北川景子', '吉高由里子', '新垣結衣', '榮倉奈々', '安室奈美恵', '長澤まさみ', '西内まりや', '麻生久美子', '倉科カナ', '井上真央', '石原さとみ', 'ガッキー', '堀北真希']
    # words = ['フィーフィー', '安藤なつ', '澤穂希', '白鳥久美子', '光浦靖子', 'ブルゾンちえみ', 'おかずクラブオカリナ']
    for word in words:
        getImageFromCustomAPI(1, 92, word, "YES-" + word + "_")

    resizeImages("images", "target_")

if __name__ == '__main__':
    main()



解説・・・

画像の収集にはGoogleCustomSearchを利用しています。
qパラメータに自動で「,正面」という文字列を組み立て、searchインデックス(startパラメータ)に1~91までを10区切りで指定しています。
つまり、


https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=1
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=11
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=21
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=31
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=91


このような具合で順繰りにリクエストを発行していきます。
一回にリクエストで10枚、一人の女優につき1~91でつまり100枚近くの画像を収集しています。
今回は無料枠で利用しているため、以下のような制約があります。

・100req/day(17:00にリセットされる)
・一回に取得できるデータ量は10count

取得した画像は残さずimages/下に



target_YES-[女優名]__81_1.png

の形で保存しています。YESがタイプで、NOがタイプでないことを表すようにしています。
また、なるべく正面を向いている画像を取得しようと試みています。

最後に100x100にリサイズして改めて保存しています。

こんな感じで、私のタイプの女優画像が蓄積されていきます!



▼2.収集した画像から顔だけを抽出して保存



好みの女優の系統を判定したいので、背景や服のような副次的情報は不要ですし、むしろ判断を難しくさせますので、純粋に顔だけを抽出しにいきます!

・pickup-faces.py



# -*- coding: utf-8 -*-

import os
import cv2


def main():
    """
    images下の美人画像一覧を読み出し、faces下に顔画像検出した結果を保存する
    """
    for image_path, _, files in os.walk('images'):
        if len(_):
            continue
        face_path = image_path.replace('images', 'faces')
        if not os.path.exists(face_path): os.makedirs(face_path)
        for filename in files:
            if not filename.startswith('.'):
                save_faces(image_path, face_path, filename)


def save_faces(image_path, face_path, filename):
    """
    真正面顔判定用のOpenCVファイルを使って、顔画像を切り出す
    """
    print(image_path, face_path, filename)
    # カスケード分類器を読み込む(正面顔の検出分類器)
    cascade = cv2.CascadeClassifier('haarcascades/haarcascade_frontalface_alt2.xml')
    image = cv2.imread('{}/{}'.format(image_path, filename))
    gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    faces = cascade.detectMultiScale(gray_image)
    # Extract when just one face is detected
    if (len(faces) == 1):
        (x, y, w, h) = faces[0]
        image = image[y:y+h, x:x+w]
        image = cv2.resize(image, (100, 100))
        cv2.imwrite('{}/{}'.format(face_path, filename), image)
    else:
        cv2.imwrite('{}/{}'.format(face_path + "/misses", filename), image)
        print("skipped.")


if __name__ == '__main__':
    main()



解説・・・

顔・輪郭の画像抽出にはOpenCVを利用しています。
imagesフォルダにあるYES or NO画像を全てOpenCVの顔抽出分類器にかけて、うまいこと輪郭画像が抽出できたら、faces下に顔画像を保存しています。

参考になるように、失敗した場合は、faces/misses下に保存しています。
missesを見ると大体が横顔画像だったり、複数人写っていたり、遠すぎたり、影りが多かったりとする画像が殆どでした。

faces下の画像はこんな感じです。




▼3. SVM分類器にかけて、機械学習を行い、学習結果をpickleに漬け込む



save_vector3.py



# -*- coding: utf-8 -*-
"""
Face画像特徴ベクトルに変換し、保存する
"""
from PIL import Image
import os
import re
import numpy as np
from sklearn.externals import joblib
from sklearn import svm


def convertImageVector3(img):
    """
    3次元(100x100x3(RGB))から1次元に変換する
    """
    s = img.shape[0] * img.shape[1] * img.shape[2]
    img_vector3 = img.reshape(1, s)
    return img_vector3[0]


def getDatas():
    """
    3次元ベクトル学習画像データと正解ラベルを対にして、pickleファイルにして保存する
    """
    files = ["faces/" + f for f in os.listdir("faces") if re.match("target_", f)]
    labels = []
    datas = []
    for image in files:
        as_arrayed_img = np.asarray(Image.open(image))
        # 3次元かどうか
        if (len(as_arrayed_img.shape) == 3):
            # RGBが3がどうか
            if (as_arrayed_img.shape[2] == 3):
                datas.append(convertImageVector3(np.asarray(Image.open(image))))
                # 「YES」と「NO」を抽出
                labels.append(image.split("faces/target_")[1].split("-")[0].replace("reversed_", ""))
            else:
                print("skip not rgb3")
    print("converted.")
    return (datas, labels)


def learn(datas, labels):
    """
    データを学習
    """
    clf = svm.LinearSVC()
    clf.fit(datas, labels)
    joblib.dump(clf, "pkls/beauty.pkl")
    print("learned.")

if __name__ == '__main__':
    datas, labels = getDatas()
    print(len(datas), len(labels))
    learn(datas, labels)



解説・・・

かいつまんで言いますと、画像を3次元のベクトルから1次元に変換をかけた形で、正解ラベル(YES or NO)と一緒にパターンを学習させるということを行います。
俗にいう「教師あり学習」を行なっています。

学習にはSVMというアルゴリズムを使用し、YESつまり「私のタイプである女性の画像はこのような3次元ベクトルを有しています。」また、「私のタイプでない女性の画像はこのような3次元ベクトルを有しています。」と識別基準を教え込ませています。

SVMにより、YESとNOの境界値最大マージンを取り、識別を行います。

scikit-learnでSVM使った学習は



clf = svm.LinearSVC()
clf.fit(datas, labels)


このようにdataと正解のlabelをセットすることで学習させることができますが、永続化されません。

そのため、Objectを直列化し、学習データをpickleと呼ばれる形式で漬物保存します。



joblib.dump(clf, "pkls/beauty.pkl")


こうすることで、予測実行時に毎回学習し直すことなく、pklファイルをロードすることで機械学習過程を引き継いで予測・判定を行うことが可能になります。

例えば、学習したその場で予測判定を行うケースは



    clf = svm.LinearSVC()
    clf.fit(datas, labels)
 res = clf.predict(t_image_vector3)


このような記載になるかと思われます。
しかし、学習は結構な計算負担があるため、毎回学習計算を走らせず、pklで学習記録を読み出すことができます。

その場合は以下のような記載になります。



    clf = joblib.load(learnedFile)
    # 予測開始
    res = clf.predict(t_image_vector3)



▼4. 女優画像が自分の好みかどうか判定する



いよいよ判定です。
今のところ、imagesに1980の画像と、facesにその半分の1100画像が格納されています。
(顔輪郭抽出時に多くが失敗し、データが半減しました。。)

これらを学習した結果、「ガッキー」は私のタイプなのかを判定します!

judge.py



# -*- coding: utf-8 -*-

import os
import io
import urllib.request
from PIL import Image
from sklearn.externals import joblib
import numpy as np
import cv2
from datetime import datetime as dt
import requests
from bs4 import BeautifulSoup as bs
import uuid


def convertImageVector3(img):
    """
    3次元(100x100x3(RGB))から1次元に変換する
    """
    s = img.shape[0] * img.shape[1] * img.shape[2]
    img_vector3 = img.reshape(1, s)
    return img_vector3[0]


def saveFaceImg(img):
    image = np.asarray(img)
    cascade = cv2.CascadeClassifier('haarcascades/haarcascade_frontalface_alt2.xml')
    faces = cascade.detectMultiScale(image)

    if (len(faces) == 1):
        (x, y, w, h) = faces[0]
        image = image[y:y+h, x:x+w]
        image = cv2.cvtColor(cv2.resize(image, (100, 100)), cv2.COLOR_BGR2RGB)
        cv2.imwrite('{}/{}'.format("targets", dt.now().strftime("%Y%m%d%H%M%S") + "_" + str(uuid.uuid1()) + ".png"), image)
        return image
    return ""


def main(imgs):
    # 試験データをhttp経由で取得し、100x100にリサイズ
    t_image_vector3 = []
    for img in imgs:
        target = saveFaceImg(Image.open(io.BytesIO(urllib.request.urlopen(img).read())).resize((100, 100)))
        if (len(target)):
            print(img)
            t_image_vector3.append(convertImageVector3(target))
    # 学習済みデータの取得
    learnedFile = os.path.dirname(__file__) + "/pkls/beauty.pkl"
    clf = joblib.load(learnedFile)
    # 予測開始
    res = clf.predict(t_image_vector3)
    for ret in res: print(ret)

if __name__ == '__main__':
    SEARCH_URL = r"https://www.google.co.jp/search?q=%s &source=lnms &tbm=isch &sa=X &biw=1439 &bih=780"
    TARGET_IMG_SRC_PATTERN = r"https://encrypted-"

    targets = []
    html = requests.get(SEARCH_URL % "ガッキー").text
    # BeautifulSoupでHTMLを解析
    soup = bs(html, 'html.parser')
    target_src = []
    images = soup.find_all('img')
    for img in images:
        targets.append(img['src'])
    main(targets)
    # main(['http://blog-imgs-42.fc2.com/m/e/m/memoriup/03267.jpg'])


解説・・・

Google画像検索をスクレイピングしています。
画像検索で「ガッキー」で検索させ、imgタグのsrc属性を抽出して、その画像URLの取得画像結果を分類器にかけています!
URLから画像を取得し、取得した画像をそのままresizeにかける記載は以下の通りです。



Image.open(io.BytesIO(urllib.request.urlopen("http://画像のURL").read())).resize((100, 100))


ソースでは、このresizeした画像をcv2モジュールで顔画像抽出した画像をtargets下に保存し、いよいよ判定コードにかけています。

それがこちらです。



    # 学習済みデータの取得
    learnedFile = os.path.dirname(__file__) + "/pkls/beauty.pkl"
    clf = joblib.load(learnedFile)
    # 予測開始
    res = clf.predict(t_image_vector3)
    for ret in res: print(ret)


上記で表示された結果を共有しますと。。。



NO
YES
YES
YES
NO
YES
NO
NO
YES
YES
YES
NO


となりました。
判定を行なった画像URLもログに出しているのですが、確かに微妙にガッキーではない画像も混じっていたため、
7,80%くらいの確度といった具合のように感じられます。

とはいえ、データが全然数が足りていないので、これから地道に学習データを増やしていこうと思います!!

学習データ量が圧倒的に足りないので、足していきつつも、次回はいよいよTensorflowで多重パーセプトロンディープラーニングによる分類・判定を行おうと思います!!

今回も拙い文章、ソースでしたが、お付き合いいただきありがとうございました。
ソースコードGithubで管理(?)してますので、こちらをちょこちょこ修正していく形になるかと思いますが、ご了承ください。

https://github.com/yueguchi/asial-blog/tree/master/beauty-judge

<<追記>>
学習素材がやはり少ないのが悩みだったので、少し工夫しました。

faces下の画像を全てy軸に反転させることで素材を二倍に増やしました。

copy_faces.py



# -*- coding: utf-8 -*-

"""
女優の画像素材を増やすために、すでにあるfaces下の画像を反転させ、
画像素材として扱わせることで、二倍の素材が手に入る
"""
import os
import cv2


def main(targetDir):
    for path, _, files in os.walk(targetDir):
        for file in files: 
            if not file.startswith('.'):
                print(path + '/' + file)
                img = cv2.imread((path + '/' + file), cv2.IMREAD_COLOR)
                reversed_y_img = cv2.flip(img, 1)
                cv2.imwrite(path + '/' + file.replace('target_', 'target_reversed_'), reversed_y_img)

if __name__ == '__main__':
    main("faces")



実行後、pklを作り直してください。(save_vector3.pyを実行)
これで素材が二倍になります。

実際に反転させた画像はこんな感じです。



麻生久美子がy軸に反転した形で表示されています。

学習素材を増やしたあとに、今度は「ハーマイオニー」でGoogle画像検索の結果を判定器にかけたところ、100%でした。



    html = requests.get(SEARCH_URL % "ハーマイオニー").text




YES
YES
YES
YES
YES
YES
YES
YES


以上です。

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あたりでネタを考えて展開してみようと思います!

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

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

問. 配列を+(プラス)演算子で加算すると?

さて問題です。次のようなPHPスクリプトがあります。



<?php
$fruit1 = array('apple', 'banana', 'cherry');
$fruit2 = array('kiwi', 'lemon', 'melon');

$fruit = $fruit1 + $fruit2;
?>


$fruitの値は次のA~Cのどれになるでしょうか?



A. array('apple', 'banana', 'cherry')
B. array('apple', 'banana', 'cherry', 'kiwi', 'lemon', 'melon')
C. array('kiwi', 'lemon', 'melon')


では、早速試してみましょう!



<?php
$fruit1 = array('apple', 'banana', 'cherry');
$fruit2 = array('kiwi', 'lemon', 'melon');

print_r($fruit1 + $fruit2);
?>




Array
(
    [0] => apple
    [1] => banana
    [2] => cherry
)


というわけで、正解は「A」です!

如何だったでしょうか。皆さんの思っていた通りの結果になりましたでしょうか?

ちなみに、私はてっきり「B」になるものと思ってました……。なお、



$fruit = array_merge($fruit1, $fruit2);


であれば「B」になります。

ググってみたら、以前のPHPプロ!のTIPSでも「配列の + (プラス) 演算子」として取り上げられてますね...orz

個人的にはあまり直感的な動きではないように思えるので、他の言語ではどうなんだろうと、ちょっと試してみました。

Python


fruit1 = ['apple', 'banana', 'cherry']
fruit2 = ['kiwi', 'lemon', 'melon']

print fruit1 + fruit2




['apple', 'banana', 'cherry', 'kiwi', 'lemon', 'melon']


Ruby


fruit1 = ["apple", "banana", "cherry"]
fruit2 = ["kiwi", "lemon", "melon"]

p fruit1 + fruit2




["apple", "banana", "cherry", "kiwi", "lemon", "melon"]


んー、PHPだけ違いますね。

なぜPHPではこういった実装になっているのかはわからないのですが、array_merge()よりも+演算子の動作のほうが便利な場合もありますので、動作の違いをきちんと把握して使いこなしたいところですね。

このように言語によって実装が異なりますので、配列の結合を「+」演算子で行う際はみなさんもご注意くださいませ。

文字列のあいまい比較

森川です。

PHPで関数のマニュアルを探すさいに、chmを使うことも多いですが、僕はよくブラウザのアドレスに直接 www.php.net/[関数名] として検索を行っています。

そうすると、ちゃんとした関数名を忘れていても、似通った関数名の一覧が表示されるので結構便利です。該当する関数がない場合に似通った単語を探すという処理をマニュアルページでは行っています。

PHPマニュアルのページはソースを見ることができます。間違った関数を指定した場合、「http://jp2.php.net/manual-lookup.php?pattern=str_repsdflaceeee&lang=ja」にリダイレクトされ、その中の「show source」リンクをクリックすると「http://jp2.php.net/source.php?url=/quickref.php」にジャンプします。

実際には、以下のようにsimilar_text関数を使用して2つの単語の類似性を計算しています。



// Compute similarity of the name to the requested one
if (function_exists('similar_text')  & & !empty($notfound)) {
  similar_text($funcname, $notfound, $p); 
 $temp[$entry] = $p;
}


それ以外にも同じように類似性を計算するための関数として、levenshtein関数があります。計算量としてはlevenshtein関数の方が小さいようです。(参考:レーベンシュタイン距離

しかし、大量の単語リストがデータベースに保存されている場合、リストを一度取得してからPHPで計算を行わなければならないので、どうせならデータベースに関数を追加してみました。勉強がてらPL/Pythonを使ってlevenshtein関数を実装してみました。以下がプロシージャの内容になります。



DROP FUNCTION IF EXISTS levenshtein(text, text);
CREATE FUNCTION levenshtein (source text, target text)
  RETURNS integer
AS $$
  if (target is None) or (source is None):
    return None

  len1 = len(source)
  len2 = len(target)

  if len1 == 0:
    return len2
  if len2 == 0 :
    return len1

  if (len1 > 255) or (len2 > 255):
    return None

  list1 = []
  list2 = []
  for i in range(len2+1):
    list1.append(i)
    list2.append(0)

  tmp_cost = 0
  for i1 in range(0, len1) :
    list2[0] = list1[0] + 1
    for i2 in range(0, len2) :
      tmp_cost = 1
      if source[i1] == target[i2] :
        tmp_cost = 0
      c0 = list1[i2] + tmp_cost
      c1 = list1[i2 + 1] + 1
      if (c1 < c0) :
        c0 = c1
      c2 = list2[i2] + 1
      if (c2 < c0) :
        c0 = c2
      list2[i2+1] = c0
    tmp = list1
    list1 = list2
    list2 = tmp
  return list1[len2]
$$ LANGUAGE plpythonu;


おそらく普通にPostgreSQLを使用している場合はPL/Pythonは使えなくて、configureオプションに--with-pythonが必要になります。パッケージインストールの場合は、postgresql-pythonのようなパッケージを追加でインストールします。

インストールor再コンパイルができたら、PL/Pythonをデータベースに登録します。



createlang -U postgres plpythonu [db名]


あとは上記のプロシージャの内容をデータベースで実行するだけです。あ、ちなみにほとんどテストしていないので、使用する際は自己責任でお願いします。

これ、結構使えるのでは?と思ったのですが、全くスピードがでなくてすごくショック…

うーむ。どうしたらよいのだろう。。。