読者です 読者をやめる 読者になる 読者になる

にほんごのれんしゅう

日本語として伝えるための訓練を兼ねたテクログ

Deep Furiganaを機械学習で自動でふる

Deep Furiganaを機械学習で自動でふる

注:今回、JSAI2017において、立命館大学の学生が発表した論文が、一部の小説家の方々の批判を浴びたそうですが、この内容はgithubにて炎上前から管理されていたプロジェクトであり、無関係です。

Deep Furiganaは、日本語の漢字に特殊な読み方を割り当てて、中二心をくすぐるものです
特殊な読み方(発音でなく文脈的な表現を表している)とすることが多く、外国人にとって日本語の学習の障害になっているということです。

図1. Fate Grand Orderのアルテラの場面、生命と文明を(おまえたち)と呼ばせる

つまりどういうことなのか

Deep Furiganaがある文脈の前後にて、生命と、文明と、おまえたちは、同等の意味を持っていると考えられます。 意味が等しいか、近しい中二っぽい単語をDeepFuriganaとして適応すればよいということになりそうです

ほかの例として、「男は故郷(ルビ:テキサス)のことを考えていた」これは、Deep Furiganaですが、これが書かれている小説の作中では、この”テキサス”と”故郷”には同じような単語の周辺分布を持つはずです。
1. 故郷とテキサスは、似た意味や用法として使われるのではないかという仮説が立ちます。
2. Deep Furiganaを多用するコンテンツは中二病に深く罹患したコンテンツ(アニメ・ゲーム・ラノベ等)などがアメリカの4chan掲示板で多いと報告されています

課題

Deep Furiganaは本来、発音する音でないですが、文脈的・意味的には、Deep Furiganaに言い換えられるということがわかりました。  

しかも、言い換えたコンテンツが中二病的な文章になっているという制約が入っていそうです。

Deep Furiganaをコンピュータに自動的に振らせることは可能なのでしょうか。いくつかの方法を使えば可能なように思わます。

1(文脈的に使用法が類似しており)かつ2(できるだけ中二っぽい単語の選択)が、最もDeep Furiganaらしいといえそうです

普通のニュースなどの文章にDeep Furiganを振ってみましょう。

機械学習でやっていきます。

説明と、システム全体図

やろうとしていることは、手間は多いですが、単純です。

  1. fastTextのセンテンスベクタライザで、小説家になろうの文章と、Yahoo Newsの文章をベクトル化します。
  2. 小説家になろうの文章をLabel1とし、Yahoo Newsの文章をLabel2とします
  3. このラベルを当てられるようにliblinearでlogistic-regressionで学習していきます
  4. ロジスティック回帰は確率として表現できるので、確率値で分類できるモデル*1を構築します
  5. 任意の文章の単語を(マルコフ)サンプリングで意味が近いという制約を課したまま、小説家になろうの単語に変換*2します
  6. このサンプリングした変換候補の文章の中からもっとも小説家になろうの文章であると*1を騙せた文章を採択します
  7. 再帰構造になっており、*2に戻ります

これをプログラムに落としたら、多くの前処理を含む、巨大なプロシージャになってしまいました。   理解せず、作ることが難しいシステムですが、全体の流れを正確に自分の中でイメージして、把握しておくことで、構築が容易になります。

図2. 各単語のベクトル化と、変換に使う候補である単語の一覧を作成

図3. Yahoo Newsとなろうの文章を文章全体でベクトル化して、判別問題に変換する

図4. 2,3で作成したモデルをもとに、変換候補の単語をサンプリング、なろうの確率が最大となる変換を見つける

実験環境

データセット

Yahoo News 100000記事 
小説家になろう、各ランクイン作品40位まで

学習ツール

liblinear
fastText

パラメータ等

liblinear( logistic, L2-loss )
fastText( nchargram=disable, dimentions=256, epoch=5 )

実行環境

- Ubuntu 17.04
- Core i5
- 16GByteMemory

実験結果

実際にこのプログラムを走らせると、このようになります

iterationごとの、なろう確率の変化

最初はあまりなろうっぽくないもととなる文章ですが、様々な単語選択をすることで、だんだん判別機を騙しに行けるようになってきます
これは、実はGANの敵対的学習に影響を受けており、SeqGANの知識を使いまわしています[1]。

図5. イテレーション(単語の探索をする)ごとに、判別機をだんだん騙せるようになっている

sample.1

基の文章が、このような感じ

大きな要因の一つにツイッターやフェイスブック、ブログの普及で、他者の私生活の情報が手に入りやすくな>ったことが挙げられるのではないでしょうか。以前よりも、他者と比べる材料がずっと増えたわけです。

なろうに可能な限り意味を保持し続けて、単語を置き換えた場合

大きな俗名の一つにツイッターやヴァイオレンス、絵日記の普及で、一片のコウウンキの情報が両手に入りやすくなったことが挙げられるのではないでしょうか。前回よりも、一片と比べる硝石がずっと増えたわけです。

これを連結すると

大きな要因<<俗名>>の一つにツイッターやフェイスブック<<ヴァイオレンス>>、ブログ<<絵日記>>の普及で、他者<<一片>> の私生活<<コウウンキ>>の情報が手<<両手>>に入りやすくなったことが挙げられるのではないでしょうか。以前<<前回>>よりも、他者<<一片>>と比べる材料<<硝石>>がずっと増えたわけです。

このようになります。 ちょっと中二チックですね。 Facebookがヴァイオレンス(暴力?)と近しいとか、まぁ、メンタルに関する攻撃といって差し支えないので、いいでしょう。 ブログを絵日記という文脈で言い換えたり、以前を前回と言い換えたりすると中二属性が上がります。

sample.2

もとの文章はこのようになっています

「ランサムウエア(身代金要求型ウイルス)」という名のマルウエア(悪意を持ったソフトウエア)がインターネット上で大きな話題になっている。報道によると、5月12日以来、ランサムウエアの新種「WannaCry」の被害がすでに150カ国23万件以上に及んでおり、その被害は日に日に拡大中だ。

変換後がこのようになる

「ランサムウエア(捕虜ヴェンデン型カイコ)」という片羽のマルブラトップ(敵意を持ったソフトウエア)が奈良公園で大きな話題になっている。命令違反によると、5月12日以来、ランサムウエアの冥界「WannaCry」の二次被害がすでに150カ国23万件半数に及んでおり、その二次被害は日に日に産地偽装オオトカゲだ。

これを連結すると

「ランサムウエア(身代金<<捕虜>>要求<<ヴェンデン>>型ウイルス<<カイコ>>)」という名<<片羽>>のマルウエア(悪意<<敵意>>を持ったソフトウエア)がインターネット上<<奈良公園>>で大きな話題になっている。報道<<命令違反>>によると、5月12日以来、ランサムウエアの新種<<冥界>>「WannaCry」の被害<<二次被害>>がすでに150カ国23万件以上<<半数>>に及んでおり、その被害<<二次被害>>は日に日に拡大<<産地偽装>>中<<オオトカゲ>>だ。

悪意が敵意になったり、新種が冥界になったり、以上が半数になったり、被害が二次被害になったりします。なろうでは、こういう単語が好まれるのかもしれません   拡大中が産地偽装オオトカゲとなったのは、作品に対する依存性があるのだと思います  

まとめ

おお、これが、DeepFuriganaかって感動はちょっとずれた視点になりましたが、意味を維持しつつ、学習対象のラノベ等に近づけるという技も可能でした

例えば、判別機をCharLevelCNNにしてみると、より人間書く小説に近い文体になるということがあるかもしれません

学習データの作品に強く影響を受ける、かつ、特定の作品の特定のシーンに寄ることがあって、エッチな単語に言い換え続けてしまう方にだましに行ってしまうというのもありました

問題設定によってはDeepFurigana以外にも使えそうです

コード

githubにて管理しております。

前処理用のコード

$ python3 narou_deal.py {引数}

引数説明

  • –step1: 小説家になろうスクレイピング
  • –step2: 小説家になろう形態素解析
  • –step3: Yahoo Newsを必要件数取得(ローカルに記事をダウンロードしている必要あり)
  • –step4: なとうとYahooの学習の初期値依存性をなくすために、データを混ぜる
  • –step5: fastTextで単語の分散表現を獲得
  • –step6: fastTextの出力をgensimに変換する
  • –step7: なろうとYahooの判別機を作るために、データにラベルを付ける
  • –step8: テキスト情報をベクトル化するためのモデルを作ります
  • –step9: なろうに存在する名詞を取り出して変換候補をつくります
  • –step10: なろうと、Yahooのテキストデータをベクトル化します
  • –step11: liblinearで学習します
  • –step12: データセットが正しく動くことを確認します

DeepFuriganaを探索的に探していくプログラム

$ python3 executor.py

データセット

5.5GByteもあるのでDropboxは使えないし、どうしようと悩んでいたのですが、TwitterBitTorrentプロトコルを利用するといいみたいなお話をいただき、自宅のサーバを立ち上げっぱなしにすることで、簡単に構築できそうなので、TorrentFileで配布したいと思います。

日本ではまだアカデミアや企業の研究者が使えるトラッカーが無いように見えるので、いずれ、どなたかが立ち上げる必要がありますね。
(つけっぱなしで放っておけるWindowが今無いのでしばらくお待ちください)

$ {open what your using torrent-client} deep_furigana_vars.torrent

参考文献

[1] SeqGAN論文
[2] The Bittorrent P2P File-Sharing System: Measurements and Analysis

前処理にディープラーニングを使う

前処理にディープラーニングを使う

目的

  • スクレイパーなどで集めた画像には、ターゲットとする画像以外必要ないケースが度々ある
  • データセットづくりと呼ばれる画像からノイズ画像を取り除くスクリーニングの作業の簡略化の必要性
  • 画像のスクリーニングを機械学習でやってしまおうという試みです

前処理そのものにディープラーニングを投入する

  • 画像処理において、学習したい画像かどうかをスクリーニングすることは膨大なコストがかかるので、この作業自体を自動化したい
  • 今回はスクレイパーでいい加減にあつめたグラビア女優の画像7万枚超えを、手動でスクリーニングするのは極めて困難なので、VGG16を転移学習させてフィルタを作っていきます
  • 一枚10円で500枚のペア(positiveとnegative)のデータセットを知り合いのニートに作ってもらう
  • ニートの作成したデータセットをもとに、転移学習させてフィルタを構築

システム構成図

図1. システム構成図

人間との比較

  • 実は人間よりどれくらい早くできるかとうことも検証したくて、自分の目で見て判断して分類していくのと、機械ではどの程度の差があるか試した
  • 人間は6時間で5000枚ぐらいのチェックが限界であった(精神的に大いに疲弊する)
  • 対して75000枚をGTX 1080 2基で 50分位である。圧倒的に機械学習の方がよい

ネットワークの出力の特性を知っておく

  • 活性化関数や最小化する目的関数の設計は実にバラエティに富んでおり、組み合わせは考え始めると無数にあるように見える
  • 内部がリニアであり、そのロジットを取ったロジスティック回帰が確率表現として優秀なのでよく使う
  • softmax, categorical crossentropyとかは出力値を寄せきってしまうので、あまり確率表現に向いていないように見える
  • 今回はロジットを使う

過学習の防止

  • どの程度、データセットにフィッティングさせていくかかがかなり重要なので、訓練データとバリデーションデータに分けて未知のデータセットに対しても汎化性能を確認する
  • 今回はepochごとにmodelを保存してベストなモデルを探索することで選んでいった -> 最適は85epochぐらいがよかった

しきい値の決定

  • 0~1に変化する値である
  • 当然0.5がしきい値であるが0.5から始めていき、しきい値を調整する

図2. しきい値 0.5を上回った画像

図3. しきい値 0.5を下回った画像

-> いろいろ調整たが、多めにスクリーニングするとして、しきい値を0.65とした。

感想

  • 作ろう作ろうと思って後回しにしてた案件です
  • できてよかったです
  • ニートは社会的評価も本人評価も色々めんどくさく、ご機嫌をとるのが大変だったので、やるならクラウドワークスのほうが良さげです

全体の流れ

コードはgithubにおいておきます。非商用・研究目的では好きに使ってください
bitbucket.org bitbucketよくわかってないので、何か不具合があればtwitterで教えていただけると幸いです。

$ git clone https://${YOUR_ID?}@bitbucket.org/nardtree/maeshori-toolkit-for-deeplearning.git

step1. 入力サイズに合わせて変形する

ニートから帰ってきたデータは500のpositive,negativeのフォルダに別れたデータセットであった
フォルダ名を答えとして、224×224のサイズに変形する。この時単純な変形にしてしまうと縦横比が崩壊してしまうので維持する細工を入れる。
実行

$ python3 image-resizer.py --gravia_noisy

コード

def gravia_noisy():
  target_size = (224,224)
  dir_path = "./gravia-noisy-dataset/gravia/*/*"
  max_size = len(glob.glob(dir_path))
  for i, name in enumerate(glob.glob(dir_path)):
    if i%10 == 0:
      print(i, max_size, name)
    save_name = name.split("/")[-1]
    type_name = name.split("/")[-2]
    if Path("gravia-noisy-dataset/{type_name}/{save_name}.minify" \
        .format(type_name=type_name, save_name=save_name)).is_file():
      continue
    try:
      img = Image.open(name)
    except OSError as e:
      continue
    w, h = img.size
    if w > h :
      blank = Image.new('RGB', (w, w))
    if w <= h :
      blank = Image.new('RGB', (h, h))
    try:
      blank.paste(img, (0, 0) )
    except OSError as e:
      continue
    blank = blank.resize( target_size )
    os.system("mkdir -p gravia-noisy-dataset/{type_name}".format(type_name=type_name))
    blank.save("gravia-noisy-dataset/{type_name}/{save_name}.mini.jpeg" \
      .format(type_name=type_name, save_name=save_name), "jpeg" )

step2. 学習する

最終的にはResNetを使うが、速度がほしい前処理のタスクのためVGG16で学習を行う
softmaxでなくて、sigmoid + binary_crossentropyです
実行

$ python3 deep_gravia_maeshori.py --train

コード

from keras.applications.vgg16 import VGG16
def build_model():
  input_tensor = Input(shape=(224, 224, 3))
  model = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

  dense  = Flatten()( \
             Dense(2048, activation='relu')( \
               BN()( \
                model.layers[-1].output ) ) )
  result = Activation('sigmoid')( \
              Dense(1, activation="linear")(\
                 dense) )

  model = Model(input=model.input, output=result)
  for layer in model.layers[:11]:
    if 'BatchNormalization' in str(layer):
      ...
    else:
      layer.trainable = False
  model.compile(loss='binary_crossentropy', optimizer='adam')
  return model

step3. 全体のデータセットに適応する

適切にフォルダに画像を配置して行ってください
実行

$ python3 deep_gravia_maeshori.py --classify

コード

def classify():
  os.system("mkdir ok")
  os.system("mkdir ng")
  model = build_model()
  model = load_model(sorted(glob.glob('models/*.model'))[-1])
  files = glob.glob("bwh_resize/*")
  random.shuffle(files)
  for gi, name in enumerate(files):
    try:
      img = Image.open('{name}'.format(name=name))
    except FileNotFoundError as e:
      continue
    img = [np.array(img.convert('RGB'))]
    if not os.path.exists(name):
      continue
    result = model.predict(np.array(img) )
    result = result.tolist()[0]
    result = { i:w for i,w in enumerate(result)}
    for i,w in sorted(result.items(), key=lambda x:x[1]*-1):
      if w > 0.65:
        os.system("mv {name} ok/".format(name=name))
      else:
        os.system("mv {name} ng/".format(name=name))
      print(gi, name, w, file=sys.stderr)

評価極性辞書の構築

評価極性辞書の構築

極性辞書は別に感情を取り扱うだけのものじゃない(と思う)

  極性のPolarityという意味で軸を対象にネガティブとポジティブが存在するものをさすそうです

 よく感情をガロア理論のように何かしらの対象構造を取れるとする主張も多いのですが、わたしはこの主張に対して少し懐疑的で、果たして楽しいの反対は悲しいなのか、無数の軸がある中でどうしてそれを対称だと思ったのかなどなど色々疑問点はあります。

 すでに東北大学の乾研究室さまが、感情に関する極性に関してはプロフェッショナルであり、たまに彼らの研究成果を後追いしているレベルです。

 さて、多くの研究では最初に極性の辞書を主観評価で決定します。これは、主に何を持って悪感情か、嬉しい感情なのか不明なため人間が定義してやる必要があるのですが、ここに主観が混じるので、評価者の人間の判断に委ねられるという側面があります。

 機械学習らしく、データ量で押し切ってしまうことで、もっと簡単に文章の極性が取れるものがあるので今回ご紹介します。
 そして、感情ではなく(商品やサービスの)評価を扱います 。

Amazon, 楽天などの星は一次元情報であり、極性を構築するのに最適

 商品やサービスを気に入った場合には星が多く付き、気に入らなかったら少なくなるという単純な関係が成立しています。

 星が多い≒気に入った≒喜び、感謝
 星が少ない≒気に入らなかった≒落胆、嫌い、嫌

という仮定が入っていることにご注意してください。

ということもあって、星情報は一次元で仮定を起きやすくやりやすいデータセットであります。

星の分布について

 以前はAmazonで簡単な評価を行ったことがありますが、今回は楽天のデータセットについて行いました。
 楽天のデータセットは商品をクローリングしたものを20GByte超えのHTMLファイル(ただし、レビューは200MByte程度)を利用しました。
 単純に星5個と、星1個を比較するのが理想なのですが、残念ながらひどい非対称データとなってしまいます。そのため、星5個 vs 星3,2,1個とします。

データの公開

 最近クローリングに関しての倫理やコンプライアンスなどを読んでいると、クロールしたでデータの公開は問題ないように思えます[1,2]。ここからダウンロードできるようにしておきます。
 クローラは先日公開したKotlinの実装のものを利用しました。リミットを外して利用すると、非常に高負荷になるので、秒間1アクセス程度がやはり限度のようです。
 なお、このようにして集めたデータに関しては商用利用は原則ダメなようです。

Polarity(極性)

 ここには一部のみ記します。GISTには全て記すので、必要に応じて参照してください。
 ここから全体を参照できます。
気に入らないに入る TOP10

ダイエー -4.211883314885654
がっかり -3.724952240087231
最悪 -3.629671589687795
二度と -3.615183142062377
期待はずれ -3.364096361814979
在庫管理 -3.251811276002615
シーブリーズ -3.243134607447971
返金 -3.223751242139063
江尾 -3.142244830633572
お蔵入り -3.044963500843487
...

気にいるに入る TOP 10

本当にありがとうございました 2.330683071541743
幸せ 2.40608193615266
閉店セール 2.415456005367995
強いて 2.425465450266797
増し 2.622845298273817
5つ 2.628383278795989
モヤモヤ 2.637474892812968
ドキドキ 2.759164930673644
しいて 3.162614441418143
迫る 3.249573225146807

極性の計算の仕方

 極性の計算は割と簡単にできて、全ての単語のlog(出現頻度+1)*weightの合計値 0を下回ると否定的、0を上回ると肯定的です。
式にするとこのようなものになります。

 さらに、確率表現とするとこのようなものになります。  

データセットのダウンロードと、機械学習を一発で行うスクリプト

 Ubuntu 16.04でコンパイルした各種バイナリや、学習用データセットをダウンロード可能です    学習用データセットをダウンロードして、libsvmフォーマットに変更して、学習まで一気に行います

$ sh generate-polarity.sh

step by step. 機械学習

コードはこちらのgithubから参照できます。 github.com

step. 0 レビューデータと星数を特定のフォーマットで出力

 ダウンロードしたレビューのデータに対して、レビューデータとその時の星の数を抜き出して、あらかじめ決めたフォーマットで出力していきます。このフォーマットの形式をいかにちゃんと設計できるかも、技量だと思うのですが、ぼくはへぼいです。

{星の数} セバレータ { レビューコンテンツ }
{星の数} セバレータ { レビューコンテンツ }
….

kotlinで書くとこんな感じです。

fun rakuten_reviews(args: Array<String>) { 
  Files.newDirectoryStream(Paths.get("./out"), "*").map { name ->
    if( name.toString().contains("review") ) { 
      val doc = Jsoup.parse(File(name.toString()), "UTF-8")
      doc.select(".revRvwUserMain").map { x ->
        val star    = x.select(".revUserRvwerNum").text()
        val comment = x.select(".revRvwUserEntryCmt").text().replace("\n", " ")
        println("${star} __SEP__ ${comment}")
      }
    }
  }
}

step. 1 単語のindex付け

 機械学習で扱えるように、単語にIDを振っていきます。深層学習のチュートリアルでよくあるものですが、私がよく使う方法を記します。
 なお、今回は、Javaを使えるけどPythonは無理という方も多く、言語としての再利用性もJVM系の方が高いということで、Kotlinによる実装です。

fun termIndexer() { 
  val br = BufferedReader(FileReader("./dataset/rakuten_reviews_wakati.txt"))
  val term_index = mutableMapOf<String, Int>()
  while(true) { 
    val line = br.readLine()
    if( line == null ) break
    val ents = line.split(" __ SEP __ ")
    if( ents.size < 2 ) continue
    val terms = ents[1]
    terms.split(" ").map { x -> 
      if( term_index.get(x) == null ){ 
        term_index[x] = term_index.size + 1
        println("${x} ${term_index[x]}")
      }
    }
  }
}

step. 2 libsvmフォーマット化

 高次元データの場合、スパースで大規模なものになりやすく、この場合、Pythonなどのラッパー経由だと正しく処理できないことがあります。そのため、libsvm形式と呼ばれる形式に変換して扱います。
 直接、バイナリに投入した方が早いので、以下の形式に変換します。

1 1:0.12 2:0.4 3:0.1 ….
0 2:0.59 4:0.1 5:0.01 ...

 Kotlinで書くとこんな感じ

fun svmIndexer() {
  val term_index  = File("./dataset/term_index.txt").readText()
  val term_id     = term_index.split("\n").filter { x ->
    x != ""
  }.map { x -> 
    val (term, id) = x.split(" ")
    Pair(term, id)
  }.toMap()
  val br = BufferedReader(FileReader("./dataset/rakuten_reviews_wakati.txt"))
  while(true) { 
    val line = br.readLine()
    if( line == null ) break
    val ents = line.split(" __ SEP __ ")
    if( ents.size < 2 ) continue
    var stars = 0.0
    try { 
      stars = ents[0].replace(" ", "").toDouble()
    } catch ( e : java.lang.NumberFormatException ) { 
      continue 
    }
    val terms = ents[1]
    val term_freq = mutableMapOf<String, Double>()
    terms.split(" ").map { x -> 
      if ( term_freq[x] == null ) term_freq[x] = 0.0
      term_freq[x]  =  term_freq[x]!! + 1.0
    }
    val id_weight = term_freq.keys.map { k -> 
      Pair(term_id[k]!!, Math.log(term_freq[k]!! + 1.0) )
    }.sortedBy { x ->
      val (id, value) = x
      id.toInt()
    }.map { x ->
      val (id, value) = x
      "${id.toInt()}:${value}"
    }.joinToString(" ")
    val ans = if( stars.toDouble() > 4.0 ) 1 else if( stars.toDouble() <= 3.0 ) 0 else -1
    if( ans >= 0 ) {
      println("${ans} ${id_weight}")
    }
  }
}

step. 3 機械学習アルゴリズムにかけて、学習する

 学習アルゴリズムは割となんでもいいと思っているのですが、この前にQiitaで公開したデータに対して、素性の重要度の見方を書いてなかったので、重要度の確認の方法も兼ねて、liblinearで学習してみます。

$ ./bin/train -s 0 ./dataset/svm.fmt

 さて、これでsvm.fmt.modelというファイルができます。このファイルの中のデータが、素性の重要度と対応しておりこのようなフォーマットになっています。

solver_type L2R_LR
nr_class 2
label 1 0
nr_feature 133357
bias -1
w
-0.1026207840831818 
0.01714376979176739
....

-0.10\~\~という表記が、1ばんめの素性の重要度で、マイナスについていることがわかります。

step. 4 学習結果と、単語idを衝突させる

 単純に重みのみ書いてあるとよくわからないので、idと重みを対応づけて、わかりやすく変形します。

fun weightChecker() {
  val term_index = File("./dataset/term_index.txt").readText().split("\n").filter { x ->
    x != ""
  }.map { x -> 
    val (term, index) = x.split(" ")
    Pair(index, term) 
  }.toMap()
  File("./dataset/svm.fmt.model").readText().split("\n").filter { x -> 
   x != "" 
  }.mapIndexed { i,x -> 
    Pair(i - 6, x)
  }.filter { xs -> 
    val (i, x ) = xs
    i >= 0
  }.map { xs ->
    val (i, x ) = xs
    //println( "${term_index[i.toString()]}, ${x}" )
    Pair(term_index[(i+1).toString()], x.toDouble() )
  }.sortedBy{ xs ->
    val (term, weight) = xs
    weight
  }.map { xs ->
    val (term, weight) = xs
    println("${term} ${weight}")
  }
}

参考文献

[1] http://qiita.com/nezuq/items/c5e827e1827e7cb29011
[2] http://q.hatena.ne.jp/1282975282

Kotlinによるスクレイピング

🔱Kotlinによるスクレイピング🔱

図1. 艦これの画像をKotlinでスクレイピングした画像で作った阿武隈のモザイクアート

PythonからKotlinへ部分的な移行@機械学習エンジニアの視点

 Pythonは便利な言語です。しかし、スクリプト言語で型を厳密に評価しないということと、いくつかの高負荷な操作において、うまく行かないことがあります。
 個人的な経験によるものですが、分析対象が巨大になり、より並列性が求められるプログラムにおいては、Pythonの再現性のないエラーについて悩まされることが多かったです。
 何気なく触ってみたKotlinは結構使いやすく、Python3で実装していたScraperを移植してみました。
 (なお、私はJavaをろくに触ったことがないです)

Pythonのthreadとmultiprocessをつかったスクレイパー

図2. ずっとPythonで使ってたScraperのアクセス方式

PythonではマルチプロセスとThreadは区別されており、Threadはマルチコアにならないです(1CPUしか使えないのでこの細工がいる)。

Kotlinニュービーなのですが、PythonのthreadとKotlinのthreadは動作が違うように見えます。
KotlinのThreadではプロセスが分割しないのに、CPUの使用率が100%を超えるため、複数CPUを使った効率的なThreadingが行われているようです。(つまり、Multiprocessで分割したあと、Threadを配下で実行する必要がなさそう)

Kotlinのインストール

Ubuntuでのインストールの方法です。

$ curl -s https://get.sdkman.io | bash
$ sdk install kotlin

メモリを増やす

環境変数JAVA_OPTにJVMのメモリが記されているらしく、普通に使うと、メモリ不足で落ちるので、今風になおしておいたほうがいいでしょう。 わたしはこのような設定にしています。

JAVA_OPTS="-Xmx3000M -Xms3000M"

CUIでのコンパイル&実行のしかた

 わたしはJavaが苦手で、Javaを極力避けるようなキャリアプランを歩もうと思っていたのですが、それは主にエクリプスやIDEやらのツール自体の学習コストが多いように感じていて、つらいと思うことが多かったからです。
 KotlinもIDEを使えば便利なのですが、CUIコンパイル&実行で問題が生じない限りはCUIでいいやと思っています。
 コンパイルには、いろいろあるのですが、runtimeを含めて、jarファイルに固めてしまうのがユーザビリティ高めでした。

$ kotlinc foo.kt -include-runtime -d foo.jar

 これでコンパイルができます。
 複数のファイルをまとめてjarにすることができます。(foo.ktでbar.ktの関数やClassを参照できます)

$ kotlinc foo.kt bar.kt -include-runtime -d foo.jar

 JavaMavenなどでコンパイルして得ることができるjarファイルをclasspathに追加することで利用可能です。 (alice.jar, bob.jarというファイルを利用するとします)
 多くのJavaの資産を再利用することができるので、たいへん助かります。

$ kotlinc foo.kt bar.kt -cp alice.jar:bob.jar -include-runtime foo.jar

 例えば、外部のjarファイルを利用したkotlinのjarを実行する際にはこのようなコマンドになります。

$ kotlin -cp alice.jar:bob.jar:foo.jar FooKt

 このFooKtという名称はmain関数が含まれるfoo.ktファイルを指定するのに用いるようです。    

JavaScriptをつかったサイトはphantomjs, selenium, jsoupの組みあせが楽

 JavaScriptによる非同期のデータ読み込みがある場合、単純に取得してjsoupなどで解析するだけだと、コンテンツが取れません
 JavaScriptを動作させて人間が見ていると同じような状態を作らないといけないので、selenium経由でphamtomjsを動作させてJavaScriptを動作させます。
 たとえば、MicrosoftBingの画像検索は、Ajaxで描画されており、JavaScritpが動作しない環境では動作できません。(実験的な目的ですので、実際に画像をスクレイピングする際は、API経由で行ってください)

    val driver = PhantomJSDriver()
    driver.manage().window().setSize(Dimension(4096,2160))
    driver.get("https://www.bing.com/images/search?q=${encoded}")
    //すべての画像が描画されるのを待つ
    Thread.sleep(3001)
    val html = driver.getPageSource() 

 htmlという変数に、JavaScriptが動作したあと描画された状態のhtmlが入ります。これをjsoupに入れることで、各種画像のsrcのURLがわかります。
 発見した画像のURLを元に、wgetコマンドで任意のディレクトリ以下のフォルダに格納します。

    val doc  = Jsoup.parse(html.toString(), "UTF-8")
    println(doc.title())
    doc.select("img").filter { x ->
       x.attr("class") == "mimg"
    }.map { x ->
       val data_bm = x.attr("data-bm")
       val src = x.attr("src")
       Runtime.getRuntime().exec("wget ${src} -O imgs/${name}/${data_bm}.png")             
    }

 PhantomJSはこのサイトからコンパイル済みのバイナリをダウンロードして、PATHが通った場所においておく必要があります。

Thread

 いくつか書き方があるようですが、もっとも簡単にできた実装です。
スクレイピングするロジック全体を{}で囲った部分がthreadのインスタンスになって、そのthreadをstartさせたり,joinしたりして並列に動かすことができます。  

    val threads = url_details.keys.map { url ->
      val th = Thread {
        if(url_details[url]!! == "まだ") { 
          _parser(url).map { next ->
            urls.add(next)
          } 
          println("終わりに更新 : $url")
          url_details[url] = "終わり"
          // save urls
          _save_conf( mapper.writeValueAsString(url_details) )
        }
      } 
      th
    }

オブジェクトのシリアライズ、デシリアライズ

 jacksonというシリアライズモジュールが限定的に使えるようです。
 Javaのライブラリだけではうまくいかないようで、Kotlin用のモジュールを別途読み込む必要があります。
 限定的というのは、MutableMap<String, DataClass>をシリアライズ、デシリアライズしてみたところうまく行きませんでした。
MutableMap<String, String>はうまく行くので、ネスト構造がだめか、Data Classに対応してないかよくわかっていないです。
 シリアライズの例

val mapper = ObjectMapper().registerKotlinModule()
val serialzied = mapper.writeValueAsString(url_details) 

 デシリアライズの例

val mapper = ObjectMapper().registerKotlinModule()
val url_details = mapper.readValue<MutableMap<String, String>>(json)

実際にスクレイピングしてみる

まずgit cloneする

$ git clone https://github.com/GINK03/kotlin-phantomjs-selenium-jsoup-parser.git

幅優先探索

 今のところ、二種類のスクレイピングまで実装が完了していて、単純にJavaScriptを評価せずに、幅優先探索で、深さ100までスクレイピングをする。
(自分のサイトのスクレイピングに使用していたため、特に制限は設けていませんが、50並列以上の並列アクセスで標準でアクセスするので、適宜調整してください。)

$ sh run.scraper.sh widthSearch ${yourOwnSite}

画像探索

 Microsoft Bingを利用して、画像検索画面で検索します。
 (実験的にAPIを使わないでAjaxで描画されるコンテンツを取れるかどうか、チャレンジしたコードですので、大量にアクセスして迷惑をかけてはいけないものだと思います)
検索リストは、github上のkancolle.txtのファイルを参考にしてください。

sh run.scraper.sh image ${検索クエリリスト} ${出力ディレクトリ}

ソースコード

github.com