ITANDI TECH BLOG

イタンジのスタッフブログです。イベントや技術情報などを発信しています。

WebpackをRailsに導入する方法を比較する: 後編

9a5b0608-318e-5e20-110c-d98fa941d784

こんにちは、エンジニアのケントです。

前編の解説により、webpackerが提供する方法論は

  • js環境構築がとても楽である
  • webpackerが提供する環境に開発がロックインされる

ということがわかりました。

後半ではWebpackとRailsを独立して使う方法を解説し、rubygemのWebpackerを使う方法との比較をしようかと思います。

WebpackとRailsを独立して使う

この方法を実装するにあたってはCookpadさんのブログを参考にさせていただきました。

まず、ルートディレクトリに frontend というディレクトリを作ります。

├── app
│   └── assets
│
├── lib
│   └── tasks
│
└── frontend

そして

├── app
│   └── assets
│
├── lib
│   └── tasks
│
├── frontend
│   ├── src
│   ├── .babelrc
│   ├── .eslintrc
│   ├── package.json
│   ├── webpack.config.js
│   ├── webpack.config.prod.js
│   └── yarn.lock

このように frontend ディレクトリにwebpackの開発に必要なコードを全て詰め込み、frontend/src ディレクトリに主なソースコードを書くスタイルです。

webpackerでは開発環境を定義するファイルが自動生成されましたが、今回の場合は開発環境を定義するファイルを .babelrc .eslintrc webpack.config.js webpack.config.prod.js などといったファイル群によしなに自作していく必要があります。

また、frontend ディレクトリにwebpackによるjsの環境を詰め込んだので、Railsのルートディレクトリからwebpackを起動するコマンドを用意したくなります。なので

├── app
│   └── assets
│
├── lib
│   └── tasks
│       └── webpack.rake
│
├── frontend

といったrakeコマンドを lib/tasks ディレクトリに追加してあげましょう コマンドの中身は

namespace :webpack do
  task build: :environment do
    begin
      result = exec('cd frontend && yarn run build')
    rescue
      print result
    end
  end
end

といった感じです。必要に応じてコマンドはよしなに生やしてください。

そして、最後にwebpackによる成果物の配信先を作る必要があります。

├── app
│   └── assets
│       └── javascripts
│           └── frontends
│
├── lib
│   └── tasks
│       └── webpack.rake
│
├── frontend

配信後は javascript_include_tag で任意のファイルを読み込んでください。

webpackでファイル圧縮をするのでアセットパイプラインは必要ないかもしれませんが、必要が生じた場合はマニフェストファイルも app/assets/javascripts の下に置きましょう。

あとは rails srake webpack:build コマンドをそれぞれ別コンソールから打てば開発をスタートすることができます。

必要に応じて webpack-dev-server をrakeコマンドから叩いてもいいですし、別々のコンソールを開くのが面倒な場合は foreman の導入も検討してください。

デプロイする場合は、capistrano

task :build do
  yarnでnode_modulesを作るコマンド
  webpackのコマンド
end

Rake::Task["assets:precompile"].enhance(%i(build))

と追記しておけば、precompile時にwebpackのコマンドをhookできます。 (この部分はこの方の記事を参考にしました)

環境によってはyarnが環境変数をうまく読んでくれないことがあるので、その場合は上記のコマンドに

'export NODENV_ROOT="nodenvの位置"',
'export NODENV_VERSION="nodeのバージョン"',
'export PATH="$NODENV_ROOT/bin:$PATH"',
'eval "$(nodenv init -)"',
'export PATH="$PATH:$(yarn global bin)"',

と行った環境変数を読むコマンドを泥臭く追記してください。

まとめ

Webpackerを使う方法とWebpackとRails別にする方法の pros & cons をまとめると

  • Webpacker
    • pros
      • 全自動でWebpack環境が作られる。今現在Webpackの知識が少なくても始められる
    • cons
      • Webpackerのレールから外れたくなると相当のWebpackの専門知識と覚悟が必要とされる
  • WebpackとRailsを独立して使う
    • pros
      • 自分のWebpackの知識の範囲内で環境を作れるので、後々のメンテナンスが楽になる
    • cons
      • 最初の段階でWebpackの知識がそれなりに要求される

といった印象でした。

WebpackをRailsのプロダクトに組み込みたい人の判断の助けになれば幸いです。

WebpackをRailsに導入する方法を比較する: 前編

Unknown

こんにちは、エンジニアのケントです。

2017年のフロントエンドの開発において、Webpackが大人気です。

イタンジのプロダクトはRailsで開発することが多いのですが、WebpackをRailsに導入することを考える場合、大きく分けて二つの選択肢が考えられます。

  • rubygemのWebpackerを使う
  • WebpackとRailsを独立して使う

直近で二つのイタンジのRailsプロダクトにWebpackを組み込むお仕事をし、上記の両方を試してみたので、それぞれの導入方法と雑感、pros and consをまとめてみようと思います。

rubygem webpackerを使う

導入方法

webpackerは、Rails5.1から標準で導入されるwebpackのラッパーです。 導入方法はwebpackerのreadmeにだいたい載ってるので、詳細はそちらにお任せしますが、結論だけ説明すると

  1. gem 'webpacker', github: 'rails/webpacker' をgemfileに追記する
  2. bundle install する
  3. bin/rails webpacker:install コマンドを打つ

たったこれだけでwebpackによる開発をスタートすることができます。

webpacker導入後、Railsディレクトリ構成が変わりますが、ざっくりいうと以下のような構成が追加されます。

├── app
│      └── javascript --①
├── config
│      └── webpack   --②
├── public
│      └── packs        --③

webpackerでは、①で主なソースコードを書き、②で開発環境を定義し、③に開発の成果物を配信する形になります。

雑感

しかし、この②の部分がなかなかの曲者でして、なかなか複雑なディレクトリ構成となっています。

├── config
│      └── webpack --②
│               ├── configuration.js
│               ├── development.js
│               ├── development.server.js
│               ├── development.server.yml
│               ├── loaders
│               │         ├── assets.js
│               │         ├── babel.js
│               │       ├── coffee.js
│               │        ├── erb.js
│               │         ├── react.js
│               │         └── sass.js
│               ├── paths.yml
│               ├── production.js
│               ├── shared.js
│               └── test.js

このように開発環境を定義するファイルが乱立しているため、より込み入ったフロントエンドの開発をしたくなったタイミング等で開発環境を変更しようとした際に苦労する印象です。

(後編で解説しますが、単純にWebpackによるjs開発環境を用意するだけならこのような複雑な構成にはなりません。) とはいえモダンなjs開発環境をgemを入れてコマンドを打つだけで用意できるのは大変素晴らしいと思いました。

後編はWebpackとRailsを独立して使う方法を解説し、rubygemのWebpackerを使う方法との比較をしようかと思います。

「AIは不動産業界の敵か味方か?」不動産テック最新事例セミナーVol.1を開催しました!

現在、「フィンテック」(金融×IT)の次は「不動産テック」と言われるなど、ビッグデータ解析、AI(人工知能)などを活用し不動産業界にイノベーションを起こそうとする「不動産テック」に対して、注目が集まっています。当社は、今後ますます不動産業界のIT活用が進み、業界全体が発展することを目的として、不動産仲介会社、不動産管理会社を対象とした「不動産テック」に関するセミナーを開催することとなりました。参加者の皆さまに、身近でリアルな事例を知ってもらいたいという思いのもと、記念すべき第1回には、株式会社S-FIT代表取締役社長、紫原 友規様にお越しいただきました。

その一部をご紹介します。

はじめてAIと出会ったのは10年ぐらい前、23歳の時だった?

 まずはじめに、代表の伊藤が登壇し、自身がAI(人工知能)に関心を抱いたきっかけについて語りました。 「10年ぐらい前、当時23歳だった私は、三井不動産レジデンシャルリースに勤めていました。当時は社内でもエクセルが得意な方だったということもあり、自分でマクロを組んで賃料査定をしていました。現在のテクノロジーとは全然レベルが違いますが、その頃から『こういった作業を人ではなく機械が出来るようになれば、効率化出来ることがたくさんあるのでは?』と感じていました。今では実際にAI技術が具体的に活用され効率化が進んでおりますが、当時から必ずしも人が作業をする必要なく、機械で効率化出来ることを考えていましたね。その後、仲介会社を立ち上げる中で再度、『物件の入力などがもし機械によって自動的になったら、不動産取引がもっと円滑になるのではないか』と思いました。これらの経験が、イタンジを立ち上げるきっかけにもなりました。」

 その後は国内外の不動産テックの事例について、自社サービスのデモも含めて紹介をしました。  「今は不動産取引のデータをクラウド上に蓄積する時代。そのデータを活用することでさまざまなことが自動化される、つまりAIが本当に活躍する時代が来る」という話で締めくくりました。

不動産テック勉強会伊藤

集客専門チームを作ることで1人あたり月間300反響の対応が可能に!

続いて、S-FIT代表の紫原様から「IT戦略や働き方改革」についてお話いただきました。「お陰さまでS-FITの業績は好調です。売上は14年連続で増収しております。このような状況でも課題なのは、優秀な人材を確保すること。どうずれば人を増やさずに業績を伸ばせるのか?悩んだ末の答えが仲介で一番大変な集客を営業から切り離す分業化スタイルでした。この時期にイタンジと出会い、そこにAIを導入することで、多い時では一人で月間300反響の対応が可能になりました。」 「現状はまだ、どこをAIでやるのかはっきりしていない。でも、本当に正社員がやるべきなのか?、パートに任せるのか?、それともAIなのかを考えるようにしている」等、興味深いお話をしていただきました。

不動産テック勉強会紫原様

AIにさせたい業務とは?将来どのような業務がAIに置き換わるのか?

 パネルディスカッションでは、「AIにさせたい業務とは?将来どのような業務がAIに置き換わるのか?」等の質問がなされました。伊藤は「身体を使うこと、課題発見以外はAIにやらせたい。特に管理の領域はデータが豊富なので、家賃査定、入居者の審査、入居者とのコミュニケーション等、可能性が非常にある。」等と答えました。  対して紫原様からは、「今でも、クロージングに遠いものはAIでもできる。繁忙期の業務量、残業減らすのに役立っている。でも最終意思決定に関わるものはやっぱり人。」等のお話をしていただきました。  その他、「今後、業界で活躍するのはどのような人材か?」、「システムを自社で持つべきか」等の質問に対して、非常に活発な議論が交わされました。

不動産テック勉強会

最後は、参加企業者の方々と懇親会を開催しました。ビールを飲みながら、今後の不動産業界の未来について、熱く語り合いました。

これからも「不動産テック最新事例セミナー」を定期的に開催していきます!

次回は4月25日(火)です!不動産会社の皆さまのご参加を心からお待ちしています!申込は、下記のページからお願いいたします。

http://www.itandi.co.jp/seminers

機械学習エンジニア育成プログラムを外部にも公開しました

こんにちは、エンジニアの建三です。

前回のブログ機械学習エンジニア社内育成プログラム(MLTP)のお話をしました。開始から一ヶ月程経ち、大分コンテンツが溜まってきました。社内のエンジニアからも分かり易いと好評です。

そこで今回MLTPを外部にも公開することにしました

僕が機械学習を勉強していた時はとても苦労しました。何故苦労したかというと、世に出てる機械学習のコースや本は大抵理論的過ぎるか理論を飛ばしています。

大学で使うような教科書は大まかなコンセプトを理解するには良いんですが、大抵数式で説明されているので、僕のように数式だけでなくコードを読んでアルゴリズムを理解するような人には中々辛いです。

逆に実践書だと理論をすっ飛ばしてコードを書くので、理解出来ないまま終わることが多いです。

MLTPは理論を数式で説明するだけでなく、コードを書きます。しかもただコードをコピペするのではなく問題形式になっているので理解が深まります。答えがちゃんと載っているので、もしつまづいても問題ありません。

研究者を目指すほどではないがある程度の理論は知っておきたいという方にオススメです。

もしやってみて質問やフィードバック等ありましたらgitterまでお願いします。

全エンジニアに機械学習のスキルを

IMG_9481

こんにちは、エンジニアの建三です。

イタンジでは先週MLTP(Machine Learning engineer Training Program)なるものを始めました。これは何かというと、イタンジの全エンジニアに機械学習のスキルを身に付けてもらおうという試みです。

これには主に3つの理由ががあります。

1 - イタンジのエンジニアリング力の強化

Googleは全エンジニアが機械学習をある程度知ってる状態にしたいと宣言しています。どんなエンジニアでもRailsなどでWeb開発がある程度出来るのが必須なのと同じで、どんなエンジニアでも機械学習をそれなりに知っているのが普通という時代があと数年で来ると思います。

2 - 機械学習研究者/エンジニアは中々見つからない

最近こそエンジニアのための機械学習入門的なブログ/オンラインコース/本が出てき始めましたが、まだまだWeb Developerに比べて機械学習エンジニアは圧倒的に少なく採用が大変です。

更に、機械学習を効果的に使うにはその会社のビジネスと既存のシステムを理解している必要があり、機械学習エンジニアを雇ってから会社に貢献出来るようになるまである程度時間がかかります。

既存のエンジニアは既にそれらを理解しているため、後は機械学習を学べばすぐ会社に貢献出来ると考えています。

3 - 自分を置き換えられる存在にする

強い組織というのは誰が突然抜けても問題なくやっていける組織だと思います。その為には自分にしか知らないこと・出来ないことを徹底的になくす必要があります。

自分にしか出来ないことはある意味会社での自分の存在価値のようなものなので、それを守ろうとする人がいるかもしれません。しかし知識や情報は与えて減るものではないので、必死にそれを守るメリットはありません。

プログラムの内容

僕はnumpyで機械学習アルゴリズムを一から書くことのが一番機械学習の勉強になりました。なのでこのプログラムでも実際にアルゴリズムを書くことによって学ぶことを重点に置いています。 更に、イタンジで実際に使われてる機械学習のモデルを使い、実践で機械学習を使うためのスキルやノウハウを勉強します。

まだ始まったばかりなので今後変わるとは思いますが、以下の内容を網羅する予定です。

1 Traditional Machine Learning

numpyを使ってmini scikit-learn libraryを作ります。 色んなアルゴリズムを勉強してく中で同時に機械学習の基本的なコンセプトを身に付けます。

  1. Supervised Learning
  • K nearest Neighbors
  • Naive Bayes
  1. Unsupervised Learning
  • K means clustering

2 Deep Learningを学ぶ為の基礎

numpyを使ってmini kears libraryを作ります。

  1. Linear Regression
  2. Logistic Regression
  3. Feed Forward Neural Network(Back Propagation)

3 Deep Learning

PyTorchを使います。

  1. Recurrent Neural Network
  2. Convolutional Neural Network

第一回目の様子

先週に第一回が行われ、3種類の機械学習の説明(教師あり学習、教師なし学習、強化学習)とK-Nearest Neighbors(k-NN)の説明をしました。

レクチャーではk-NNのアルゴリズムを手で計算しました。

IMG_9490

IMG_9491

宿題では実際にk-NNをnumpyで書きます。Python機械学習の知識は人により差はありますが、全員コンプリートしたようです。

今後の展望

第一回目のレクチャーはライブで行いましたが、2回目以降はスクリーンキャストを作って各自のペースで進めてもらう予定です。エンジニアの中でも営業を兼任してる者が何人かいるので、勤務時間内と言えども全員揃うのは難しいです。更に今後入社するエンジニアにも使ってほしいので、使い回しが出来るものの方が良いと思っています。

上記のプログラムをこなすのに大体3ヶ月くらいかかりそうですが、もしこの取り組みが上手くいったら外部にも公開する予定です。

Deep LearningをやるならTensorflowよりもPyTorch!

Screenshot 2017-03-08 11.53.00

こんにちは、エンジニアの建三です。

Deep Learningのライブラリと言えばTensorflowが有名ですよね。1年半前にリリースされて以来、一瞬にして知名度を手にしました。僕はその頃Deep Learningを勉強していたので、Hacker NewsでTensorflowがバズってるのを見て何となく僕も興奮していたのを覚えています。しかし早速使ってみようと思いチュートリアルを進めたものの、LSTMやCNNの作り方が分からず断念しました。

僕はStanfordのコースDeep Learningを勉強したんですが(無料でクオリティ超高いです)インストラクターのAndrej KarpathyはTensorflowよりもTorchを勧めていました。ホームワークはnumpyを使うんですが、アーキテクチャが正にTorchのPython版という感じで、すごくしっくりくるんですよね。それまで色んなリソースを使ってDeep Learningを勉強して中々理解出来なかったのがやっと理解出来たんです。

しかしTorchの言語はLuaで、Python大好きの僕にはちょっと辛かったのでKerasを使うことにしました。KerasはTorchに影響されてるので、レイヤーを重ねていくというところがすごく似ています。でもTorchよりももっと簡単に書けるのでscikit-learnのDeep Learning版といった感じです。イタンジでもAIチャットやクローラーにKerasを使っています。

しかし最近やっと強化学習を勉強し始め、Kerasよりもっとフレキシブルなライブラリを使った方がいいなと思い他のライブラリを探し始めました。すると何とTorchのPython版が出てるじゃありませんか!

その名もPyTorch(そのまんま)。

PyTorchを賞賛する声

TensorflowよりもPyTorchを好むのは僕だけではありません。

Redditでは、Kaggleの優勝者のJeremy HowardがPyTorchの方が使い易いと言っています。

Hacker NewsではSalesforceのエンジニアがChainerからPyTorchに移行する予定と言っています。

そして僕にTorchの素晴らしさを教えてくれたAndrej KarpathyはTwitterでもはやTensorflowは古いという爆弾発言...

DeepMindはTorchからTensorflowに移行しましたが、もうPyTorchを使ってるだろうと予想しています。facebookもTorchを使ってる代表的な会社の一つで、PyTorchの開発にがっつり携わっています。

Angular(Google)からReact(facebook)の流れを思い出します。一時期AngularはJS frameworkの頂点に立ち誰もAngularを超えられないと思いきや、Reactがあっさりと超えてしまいましたよね。

JSにしろDeep Learningにしろ僕はfacebookの作るものの方が好きみたいです...

Tensorflowを使う一番のメリットは、研究と実装で同じライブラリを使えるというところにあります。Deep Learningの学び易さを重視するならPyTorchの方が良いでしょう。numpyからtensorへのコンバートが出来るのですごく楽です。

PyTorchでGRUを作ろう!

単純なGRUのClassifierを作りました。 まだチュートリアルが全然ないので、こんな簡単なモデルですらかなり苦労しました... 何度も諦め「Kerasでいいや...」と思ったんですが、踏ん張った甲斐がありました。

完成版のGistはこちらにあります。

Pytorchではモデルをクラスで定義します。initで必要なレイヤーをinitializeします。この場合だとnn.GRUnn.Linearの2つです。 そしてforwardでどうそれらのレイヤーを繋げるかを書きます。Input -> GRU -> Linearというシンプルな構造です。

import torch
import torch.nn as nn
from torch.autograd import Variable

class GRU(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(GRU, self).__init__()

        self.hidden_size = hidden_size

        self.gru = nn.GRU(input_size, hidden_size)
        self.linear = nn.Linear(hidden_size, output_size)

    def forward(self, input, hidden):
        _, hn = self.gru(input, hidden)
        ## from (1, N, hidden) to (N, hidden)
        rearranged = hn.view(hn.size()[1], hn.size(2))
        out1 = self.linear(rearranged)
        return out1

    def initHidden(self, N):
        return Variable(torch.randn(1, N, self.hidden_size))

PyTorchはKerasやscikit-learnのようにmodel.fit(X,y)で勝手に学習してくれるわけではないので、ボイラープレートを書く必要があります。

import numpy as np
np.random.seed(1337)

def batch(tensor, batch_size):
    tensor_list = []
    length = tensor.shape[0]
    i = 0
    while True:
        if (i+1) * batch_size >= length:
            tensor_list.append(tensor[i * batch_size: length])
            return tensor_list
        tensor_list.append(tensor[i * batch_size: (i+1) * batch_size])
        i += 1

class Estimator(object):

    def __init__(self, model):
        self.model = model

    def compile(self, optimizer, loss):
        self.optimizer = optimizer
        self.loss_f = loss

    def _fit(self, X_list, y_list):
        """
        train one epoch
        """
        loss_list = []
        acc_list = []
        for X, y in zip(X_list, y_list):
            X_v = Variable(torch.from_numpy(np.swapaxes(X,0,1)).float())
            y_v = Variable(torch.from_numpy(y).long(), requires_grad=False)

            self.optimizer.zero_grad()
            y_pred = self.model(X_v, self.model.initHidden(X_v.size()[1]))
            loss = self.loss_f(y_pred, y_v)
            loss.backward()
            self.optimizer.step()

            ## for log
            loss_list.append(loss.data[0])
            classes = torch.topk(y_pred, 1)[1].data.numpy().flatten()
            acc = self._accuracy(classes, y)
            acc_list.append(acc)

        return sum(loss_list) / len(loss_list), sum(acc_list) / len(acc_list)

    def fit(self, X, y, batch_size=32, nb_epoch=10, validation_data=()):
        X_list = batch(X, batch_size)
        y_list = batch(y, batch_size)

        for t in range(1, nb_epoch + 1):
            loss, acc = self._fit(X_list, y_list)
            val_log = ''
            if validation_data:
                val_loss, val_acc = self.evaluate(validation_data[0], validation_data[1], batch_size)
                val_log = "- val_loss: %06.4f - val_acc: %06.4f" % (val_loss, val_acc)
            print("Epoch %s/%s loss: %06.4f - acc: %06.4f %s" % (t, nb_epoch, loss, acc, val_log))

    def evaluate(self, X, y, batch_size=32):
        y_pred = self.predict(X)

        y_v = Variable(torch.from_numpy(y).long(), requires_grad=False)
        loss = self.loss_f(y_pred, y_v)

        classes = torch.topk(y_pred, 1)[1].data.numpy().flatten()
        acc = self._accuracy(classes, y)
        return loss.data[0], acc

    def _accuracy(self, y_pred, y):
        return sum(y_pred == y) / y.shape[0]

    def predict(self, X):
        X = Variable(torch.from_numpy(np.swapaxes(X,0,1)).float())      
        y_pred = self.model(X, self.model.initHidden(X.size()[1]))
        return y_pred       

    def predict_classes(self, X):
        return torch.topk(self.predict(X), 1)[1].data.numpy().flatten()


これでKerasと同じように使えます。イタンジでは住所、物件名、最寄り駅など7種類のテキストを判別するClassifierにこのモデルを使っています。データだけダミーに置き換えました。

from sklearn.model_selection import train_test_split

MAX_LEN = 30
EMBEDDING_SIZE = 64
BATCH_SIZE = 32
EPOCH = 40
DATA_SIZE = 1000
INPUT_SIZE = 300

def main():
    class_size = 7

    ## Fake data
    X = np.random.randn(DATA_SIZE * class_size, MAX_LEN, INPUT_SIZE)
    y = np.array([i for i in range(class_size) for _ in range(DATA_SIZE)])

    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.2)

    model = GRU(INPUT_SIZE, EMBEDDING_SIZE, class_size)
    clf = Estimator(model)
    clf.compile(optimizer=torch.optim.Adam(model.parameters(), lr=1e-4),
                loss=nn.CrossEntropyLoss())
    clf.fit(X_train, y_train, batch_size=BATCH_SIZE, nb_epoch=EPOCH,
            validation_data=(X_test, y_test))
    score, acc = clf.evaluate(X_test, y_test)
    print('Test score:', score)
    print('Test accuracy:', acc)

    torch.save(model, 'model.pt')


if __name__ == '__main__':
    main()

こんな感じでKerasっぽいログが出ます。

Epoch 1/40 loss: 1.9836 - acc: 0.1379 - val_loss: 1.9776 - val_acc: 0.1557
Epoch 2/40 loss: 1.9634 - acc: 0.1550 - val_loss: 1.9748 - val_acc: 0.1521
Epoch 3/40 loss: 1.9454 - acc: 0.1711 - val_loss: 1.9727 - val_acc: 0.1579
Epoch 4/40 loss: 1.9285 - acc: 0.1904 - val_loss: 1.9712 - val_acc: 0.1579
Epoch 5/40 loss: 1.9121 - acc: 0.2061 - val_loss: 1.9701 - val_acc: 0.1614

PyTorchの不満

一つPyTorchの不満があるとすれば、Tnesorのタイプが間違っているが為にエラーになることが多々あり苦戦しました。 上記の_fitのところでX.float()でfloatにしy.long()にしています。何故かそうしないと「Typeが違うよ!」というエラーが出ます。何故そのタイプじゃなきゃいけないのか分からないし、それくらい自動でやってくれと思いました。 しかもエラーメッセージがすごく分かり辛く、どこが間違ってるか理解するまでに時間がかかりました。

Documentationでもあまり触れられてないので、ここは改善してほしいと思っています。

PyTorchの勉強の仕方

正直オフィシャルのチュートリアルは微妙です。PyTorchのリソースをまとめたRepoにもっとクオリティの高いチュートリアルがあるので、こっちがオススメです。

まとめ

Tensorflowや他のライブラリを使ってる人は是非PyTorchを一度試してみることをオススメします。僕もまだまだ学びたてですが、もっと多くの人にPyTorchの素晴らしさを知ってほしいと思っています!

汎用的クローラーの構築

こんにちは、エンジニアの建三です。

クローラーを作る際に必要なものは大きく分けて3つあります。

1. URLを取得するスパイダー - 不動産の例で言うと、サイトに掲載されている全物件の詳細ページのURLを取得するスパイダーが必要です。

2. XPath(あるいはCSS Selector)の特定 - 物件名や家賃などの物件情報を取得する為のXPathが必要です。

3. テキストの整形 - XPathで得られるのはあるDOMのテキストなので、更にそのテキストから求めてる情報を取得する必要があります。

これ以外の機能(定期的にクローリングする、エラーの対処など)は、サイトに関わらず共通するものですので、サイト毎に必要なのは上記の3つになります。

アプローチ

汎用的なクローラーには主に2つのアプローチがあります。

GUIXpathを特定する方法

1つ目はPortiaのように、GUIで取得したい情報のDOMを指定する方法です。これは上記の2を簡単にしたものと言えます。この方法だと確実に必要な情報が取得出来ますが、幾つか欠点があります。

それでも1000サイトやるのは大変 早ければ1サイト30分くらいで出来るかもしれませんが、慣れるまで時間がかかります。

DOM構造が変わったらやり直さなければならない 将来DOM構造が変わった時にまたXPathの特定をしなければいけないのでメンテナンスが大変です。

結局整形はしなければならない 3は自動化出来てないので、1サイト毎に作らなければなりません。

自動でXpathを特定&整形する方法

もう1つの方法は、自動的にXpathを特定する方法です。当然これが出来たらこっちの方が楽なので、精度次第ということになります。

この方法は前例があまり見つからず、どこまで難しいのかあまり予測出来ませんでした。社内でどちらのアプローチを取るか意見が分かれましたが、一ヶ月後者を試してみて、ダメだったら諦めて前者のアプローチを取るということになりました。

あれから2ヶ月後...

結論から言うと、1ヶ月の開発でそこそこのものが出来、自動でいけるなというのを確信しました。更に1ヶ月開発を続け、10サイトで9割の情報が自動で取れるまでになりました。

元々完全自動は無理だと分かっていて9割取れれば良い方だと思っていたので、期待以上のものが出来ました。勿論開発に使ってる10サイトで9割というのは機械学習で言う教師用データの精度と同じなので、実際はもう少し低くなります。

大まかな流れ

以下がアルゴリズムの大まかな流れです。

1 - ラベルと値を特定する 項目によって機械学習正規表現、または両方を使いラベルと値を特定します。ラベルが必要な項目とそうでない項目があります。

2 - レイヤーを重ねて候補を絞っていく 機械学習正規表現だけでは完全に値を特定出来ないので、様々な手法を使い正しい値を探します。

3 - 特定したXpathを保存する Xpathが特定出来たら、それをサイト毎に保存します。特定出来なかった項目は手動で書きます。

4 - 整形する Xpathを使って取ってきたものを整形します。

HTML elementのXpathを取得するプログラム

Pythonのlxmlを使ってHTMLのマニュピレーションをしているんですが、elementからXPathを自動的に取る関数がないので自分で書きました。(gistはこちら) XPathを使ったことがある人はご存知だと思いますが、XPathは何通りもの書き方があり、質の良いXPathと質の悪いXPathがあります。indexだと壊れる可能性が高いので、なるべくidやclassを使うようにします。

from urllib import request
from lxml import etree
import re

def get_index(e):
    tag = e.tag
    prev_list = [i for i in e.itersiblings(preceding=True) if i.tag == tag]
    next_list = [i for i in e.itersiblings() if i.tag == tag]
    if len(prev_list + next_list) == 0:
        return None
    return len(prev_list) + 1

def is_valid_class(c, siblings):    
    if re.search(r'[0-9]', c):
        return False
    c = c.strip()
    for sibling in siblings:
        if c in sibling:
            return False
    return True

def get_one_path(e):
    index = get_index(e)
    index = "[%s]" % (index) if index else ""
    this_attrib = e.attrib
    if 'id' in this_attrib:
        val = this_attrib['id']
        if not re.search(r'[0-9]', val):
            return e.tag + "[@id='%s']" % (val)
    if 'class' in this_attrib:
        ## 同じタグで同じクラスのものがsiblingにない場合のみclassを使用
        tag = e.tag
        prev_list = [i for i in e.itersiblings(preceding=True) if i.tag == tag and 'class' in i.attrib]
        next_list = [i for i in e.itersiblings() if i.tag == tag and 'class' in i.attrib]
        siblings = [e.attrib['class'].split(' ') for e in prev_list + next_list]
        class_list = this_attrib['class'].split(' ')
        for c in class_list:
            if is_valid_class(c, siblings):
                return e.tag + "[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]" % (c)
    return e.tag + index

def get_xpath(e):
    my_xpath = ''
    while True:
        path = get_one_path(e)
        my_xpath = "/%s%s" % (path, my_xpath)
        e = e.getparent()
        # root tagまでたどり着いた
        if e is None:
            return my_xpath

url = 'https://rent.tokyu-housing-lease.co.jp/rent/8016671/6337'
def main():
    with request.urlopen(url) as f:
        data = f.read().decode('utf-8')     
        tree = etree.HTML(data)
        given_path = "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/th[1]"
        p = tree.xpath(given_path)[0]
        print(get_xpath(p) == given_path)

if __name__ == '__main__':
    main()

1. ラベルと値を特定する。

第1ステップは、機械学習正規表現を使ってラベルと値を特定します。その際に1サイトにつき200物件ほどのデータを使います。

特定したら、そのelementのXPathを先ほどのget_xpathで取得し、テキストと同時に下記のようなフォーマットで保存します。

以下は賃料の値の例です。2物件だけ表示しています。1つ目のリストに4つリストが入っています。これは1つ目の物件で賃料と思われるテキストを含むHTML elementが4つあることを示しています。

[
  [
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[1]",
      "7.5万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[2]",
      "0.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/dl[contains(concat(' ', normalize-space(@class), ' '), ' rent_view_side_info ')]/dd[7]",
      "7.5万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[contains(concat(' ', normalize-space(@class), ' '), '  ')]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "7.5万円"
    ]
  ],
  [
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[1]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[2]",
      "0.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/dl[contains(concat(' ', normalize-space(@class), ' '), ' rent_view_side_info ')]/dd[7]",
      "8.2万円〜8.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[1]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[2]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[3]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.3万円"
    ]
  ]
]

ご覧の通り1つに絞れていないので、他の手法を使う必要があります。

2. レイヤーを重ねて候補を絞っていく

第2ステップがメインのアルゴリズムになります。第1ステップで特定したものを更に絞っていくのですが、手法が沢山あり、それらをレイヤーと呼んでいます。

項目によって色んなレイヤーを使い分けます。全て説明しようとすると一つのレイヤーで記事が一つ書けてしまうほどなので、よく使うレイヤーの概要だけ説明します。

全物件に共通してるものを除外する

ヘッダー、フッターなど、サイトで共通してるものは物件の情報でないので除外します。

項目毎にスレッショルドを決めています。例えばスレッショルドが.9で200物件使っていたら、180物件に存在していれば共通のものと判断します。何故こんなことをするかというと、共通情報がたまに一部のページにだけなかったりするので、少し余裕を持たせています。

リピートしてるものを除外する

物件詳細ページには、その物件以外にも、似ている物件や同じ建物の空室一覧が載っています。それらの物件は大抵複数載っているので、リピートしてるかどうかを見ます。

以下は住所の例です。また2物件だけ表示しています。ご覧の通り、2〜4つ目のXPathdivのindex以外同じなのが分かります。これらはメイン物件の情報ではないので除外します。

[
  [
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' bottom ')]/tr[1]/th[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "所在地"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[1]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[2]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[3]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ]
  ],
  [
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' bottom ')]/tr[1]/th[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "所在地"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[1]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[2]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[3]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ]
  ]
]

ラベルに近いものを採用する

特定したラベルを使い、そのラベルに一番近い値を採用します。この「近さ」を計るには、ラベルのXPathと値のXPathがどれだけ違うというのを計算します。

敷金や礼金など、ラベルがないと区別がつかないものには必須です。

ポピュラーなものを採用する

最後の方の工程で使われます。それぞれのXpathがどれくらいのページに使われてるかを計算し、一番使われているものを採用します。ノイズを削除するのに使います。

最初に出てきたものを採用する

これも最後の方の工程で使います。 それぞれのページで一番最初に出てきたXpathを採用するというシンプルな手法ですが、中々使えます。他のレイヤーである程度絞っていれば、後は先に出てきたものがメイン物件の情報である可能性が高いです。

3 - 特定したXpathを保存する

第2ステップでレイヤーを特定したら、サイト毎にjsonファイルにまとめます。その際に必要なメタデータを自動で取得します。

以下はWinsproというサイトの例です。設備・条件は読点で区切られてるので、それを保存しておきます。敷金、礼金、保証金の場合は同じelementに複数の項目が入ってるので、ポジションを保存しておきます。

{
  "option": {
    "label": "設備・条件",
    "delimiter": "、",
    "path": [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div[contains(concat(' ', normalize-space(@class), ' '), '  ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[text()='設備・条件']/following-sibling::td"
    ]
  },
  "shikikin": {
    "label": "敷金/礼金",
    "position": 0,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_info']/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/礼金']/following-sibling::td[1]"
  },
  "reikin": {
    "label": "敷金/礼金",
    "position": 1,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_info']/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/礼金']/following-sibling::td[1]"
  },
  "hosyokin": {
    "label": "敷金/保証金",
    "position": 1,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div[contains(concat(' ', normalize-space(@class), ' '), '  ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/保証金']/following-sibling::td[1]"
  }
}

4. 整形する

第3ステップで作ったデータを使ってスクレイピングと整形をします。Scrapyを使っていますが、デフォルトのParserではなくlxmlを使っています。

まとめ

今回ご紹介した方法は、不動産サイトのように項目が主にテーブルの中に入ってるようなものに向いています。ブログ記事のような長いテキストの中から情報を抽出するのであれば自然言語処理を使った方が良いでしょう。

もし汎用的クローラーを作ってる方がいらっしゃったら是非情報交換しましょう!

エンジニアの採用も強化しておりますので気軽にオフィスに遊びに来て下さい!