にほんごのれんしゅう

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

自作レコメンドで最適な読書体験をしたい

最適な読書体験をしたい

 アマゾンなどでレコメンドされる本を上から見ていても読書体験がそんなに良くありません。
 本の売り上げランキングなどは、大衆に受ける本がほとんどであり、少々独特なセンスを持つ人たちにはそんなに受けが良くないです。
 結果として現状の解決策がSNSや人づてに聞き及ぶぐらいしかないのとジャケ買いなどがせいぜいです
 どうあるべきかを考えるとき、仮に他人の本棚を知ることができれば、集合知機械学習を用いて自分に向いているだろう本をレコメンドさせることができます

会社の技術共有会の小話で話した話

Matrix Factorization

 2000年台のNetflix Prizeからある伝統的な手法で、シンプルで動作が早く、ユーザが多くアイテムの数がとても多いときに有効な手法です。

DeepLearningでも実装できるし、sklearnなどでも関数が用意されています。

コード

自分のクエリとなる特徴量

 自分のAmazon Fionaという特定のURLにアクセスると自分の今までKindleで買ってきた本がAjaxレンダリングされます。
 Ajaxにより描画されていて、かつ、とても描画が遅いので普通の方法では自動取得できなく、google-chrome-headlessブラウザ等を利用してJSを実行しながら内容を取得できるようにします。
- 購入した本の一覧が見えるページ: https://amazon.co.jp/gp/digital/fiona/manage

実行コマンド

$ cd DataCollection
$ EMAIL=*****@gmail.com PASSWORD=***** python3 A001_from_kindle.py 
$ python3 B001_scan_local_html.py
fionaのURLをアクセスするとAjaxでこのように描画される

いろいろな人達の本棚の特徴量

 レコメンドを行うには大量のデータが必要になります。
 他人の本棚が必要になりますが、https://booklog.jp/ が本棚SNSになっているのでこれを利用します。
(すいません、スクレイピングしないと学習できないので、集めます)
実行コマンド

$ cd DataCollection
$ python3 A001_scrape.py

 現在120万ユーザが登録しているらしく、全体の一割程度でいいのでユーザの本棚をサンプルして、本棚に登録されている本を1として、登録されていない本を0とすると、巨大な疎行列を作ることができます。scipyのlil_matrixという疎行列ライブラリを利用して構築すると、400Mbyte程度に収めることができます。

実行コマンド

$ cd MakeBookReadMatrix
$ python3 A001.py
$ python3 B001.py
$ python3 C001.py

学習

一応、Matrix Factorizationにも過学習という概念があるので、2%をtestとして切り出して、ホールドアウトで、レコメンドしたときのMatrixとのMean Square Errorを小さくします。
実行コマンド

$ cd MakeBookReadMatrix
$ python3 D001.py --fit
fit non-negative matrix factorization
(1757, 1133108)
test mse = 0.000107 # <- 今回のデータ・セットではこのくらい

推論

Kindle Fionaから得られた本を、1*BOOK_NUMのMatrixに変形して、学習で作ったモデルに入力すると、各アイテム毎のレコメンドを行った際のウェイトを知ることができます。

実行コマンド

$ python3 D001.py

scores_00.csv というファイルができ、その中にタイトルとウェイトが記されている.

自分の結果

過去に漫画を大量に買っていたのでおおよそ納得の結果

別の絵本が多いユーザでもやってみましたが、絵本が多く上位に出るので想定通りできていることが確認できました。

依存(Ubuntuを想定)

$ wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
$ sudo apt install ./google-chrome-stable_current_amd64.deb
$ wget https://chromedriver.storage.googleapis.com/75.0.3770.140/chromedriver_linux64.zip # google-chromeのversionに応じたものを使ってください
$ unzip chromedriver_linux64.zip
$ sudo mv chromedriver /usr/local/bin/
  • requirements.txt
$ pip install -r requirements.txt

再現できないときいは

 よく指摘されるので、Linuxの用意や再現が難しい場合は、私のデータで学習したときのデータからモデルまでの動作が確認できたときのスナップショットがあるので、参考にしてみてください。

まとめ

 自分の知識や体験の幅を広げるには、レコメンドでウェイトが付いているが、リコールを高めに見たときに低いウェイトの方に来ている本を読むと世界や価値観の広がりを高めることができているように思います。
商業的にはおそらく高いウェイトの作品をレコメンドするとよいのでしょうが、自分に近すぎるコンテンツということもあり食傷気味であり、Amazonででる本などは興味を惹かれなかったのですが、自分でこのレコメンドエンジンを使う分にはこの制約がなくて良さそうです。

Pythonで100万ドキュメントオーダーのサーチエンジンを作る(PageRank, tfidf, 転置インデックスとか)

検索エンジンを何故作ってみたかったか

  • もともとこのブログのコンセプトのNLP的なことで、情報を整理してなにか便利にしたかった(Googleと同じモチベーションの世界の情報を整理する)
  • 4年前にほぼ同じシステムを作ろうとしたとき、500万を超える大量のインデックスを検索するシステムは、数学的な理解度が十分でない+エンジニアリング力が伴わないなどでギブアップした背景があり、今回再チャレンジしたくなった
  • ほぼすべての機能をpure python(+いくつかの例外はある)で実装して、世の中の ソフトウェアを使うだけ検索エンジンをやってみたなどではなく、実際に理解して組んでみることを目的としたかった

依存パッケージと依存ソフトウェア

GitHubのコードを参照してください

様々なサイトを巡回する必要があり、requestsが文字コードの推論を高確率で失敗するので、nkflinux環境で入れている必要があります。

$ sudo apt install nkf
$ which nkf
/usr/bin/nkf

Mecabも入れます

$ sudo apt install mecab libmecab-dev mecab-ipadic
$ sudo apt install mecab-ipadic-utf8
$ sudo apt install python-mecab
$ pip3 install mecab-python3
$ git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git
$ ./bin/install-mecab-ipadic-neologd -n

残りの依存をインストールします

$ pip3 install -r requirements.txt

再現

基本的にGitHubのコードをUbuntu等のLinuxでAから順に実行してもらえば、再現できます。

クローラ(スクレイパー)はやろうと思えば無限に取得してしまうので、適当にSEEDを決めて、適当な時間で終了することを前提としていています。

全体の処理の流れ

  1. クローリング
  2. クローリングしたHTMLを, title, description, body, hrefsをパースしデータを整形する
  3. IDF辞書の作成
  4. TFIDFのデータを作成
  5. 転置したurl, hrefの対応を作成(単純な被参照量の特徴量)
  6. 非参照数のカウントと、PageRankのための学習データの作成
  7. URLとtfidfのウェイトの転置インデックスを作成
  8. hash化されたURLと実際のURLの対応表の作成
  9. PageRankの学習
  10. 検索のインターフェース

プログラムの詳細

A. クローリング

特定のドメインによらず、網羅的にクローリングしていきます。 ブログサイトをシードとしてドメインを限定せずどこまでも深く潜っていきます。
多様なサイトをクロールするがとても重いので、自作した分散可能なKVSをバックエンドのDBと利用しています。SQLLiteなどだとファイルが壊れやすく、LevelDB等だとシングルアクセスしかできません。

B. HTMLのパースと整形

Aで取得したデータは大きすぎるので、Bのプロセスで、tfidfでの検索の主な特徴量となる、"title", "description", "body"を取り出します。
また、そのページが参照している外部のURLをすべてパースします。

soup = BeautifulSoup(html, features='lxml')
for script in soup(['script', 'style']):
    script.decompose()
title = soup.title.text
description = soup.find('head').find(
                'meta', {'name': 'description'})
if description is None:
    description = ''
else:
    description = description.get('content')
body = soup.find('body').get_text()
body = re.sub('\n', ' ', body)
body = re.sub(r'\s{1,}', ' ', body)

BeautifulSoupでシンプルに処理することができる.

C. IDF辞書の作成

頻出する単語の重要度を下げるために、各単語がどの程度のドキュメントで参照されているかをカウントします。

D. TDIDFの計算

B, Cのデータを利用して、TFIDFとして完成させます
title description bodyはそれぞれ重要度が異なっており、 title : description : body = 1 : 1 : 0.001
として処理しました。

# title desc weight = 1
text = arow.title + arow.description 
text = sanitize(text)
for term in m.parse(text).strip().split():
    if term_freq.get(term) is None:
        term_freq[term] = 0
    term_freq[term] += 1

# title body = 0.001 
text = arow.body
text = sanitize(text)
for term in m.parse(text).strip().split():
    if term_freq.get(term) is None:
        term_freq[term] = 0
    term_freq[term] += 0.001 # ここのweightを 0.001 のように小さい値を設定する

F. あるURLと、あるURLのHTMLがリンクしているURLの転置インデックスを作成

昔良くあった、URLのリンクを色んな所から与えるとSEOができるということを知っていたので、どの程度外部から被参照されているか知るため、このような処理を行います

G. 被参照数のカウントと、PageRankのための学習データの作成

Fで作成したデータをもとに、networkxというライブラリでPageRankのノードのウェイトを学習可能なので、学習用データを作成します

このようなデータセットが入力として望まれます(右のハッシュがリンク元、左のハッシュがリンク先)

d2a88da0ca550a8b 37a3d49657247e61
d2a88da0ca550a8b 6552c5a8ff9b2470
d2a88da0ca550a8b 3bf8e875fc951502
d2a88da0ca550a8b 935b17a90f5fb652
7996001a6e079a31 aabef32c9c8c4c13
d2a88da0ca550a8b e710f0bdab0ac500
d2a88da0ca550a8b a4bcfc4597f138c7
4cd5e7e2c81108be 7de6859b50d1eed2

H. 単語から簡単にURLを逆引きできるように、転置インデックスの作成

最もシンプルな単語のみでの検索に対応できるように、単語からURLをすぐ検索できるindexを作ります
出力が、単語(のハッシュ値)ごとにテキストファイルが作成されて、 URLのハッシュ , weight(tfidf) , refnum(被参照数) のファイルが具体的な転置インデックスのファイルになります

0010c40c7ed2c240        0.000029752     4
000ca0244339eb34        0.000029773     0
0017a9b7d83f5d24        0.000029763     0
00163826057db7c3        0.000029773     0

I. URLとhash値の対応表の作成

URLはそのままメモリ上に持つとオーバーフローしてしまうので、sha256をつかって先頭の16文字だけを使った小さいhash値とみなすことで100万オーダーのドキュメントであってもある程度実用に耐えうる検索が可能になります。

J. PageRankの学習

Gで作成したデータを学習してURLにPageRankの値を学習します。

networkxを用いれば凄くシンプルなコードで学習する事ができます

import networkx as nx
import json
G = nx.read_edgelist('tmp/to_pagerank.txt', nodetype=str)
# ノード数とエッジ数を出力
print(nx.number_of_nodes(G))
print(nx.number_of_edges(G))
print('start calc pagerank')
pagerank = nx.pagerank(G)
print('finish calc pagerank')
json.dump(pagerank, fp=open('tmp/pagerank.json', 'w'), indent=2)

K. 検索のインターフェース

検索IFを提供

$ python3 K001_search_query.py
(ここで検索クエリを入力)

$ python3 K001_search_query.py
ふわふわ
                   hurl    weight  refnum  weight_norm                                                            url  pagerank  weight*refnum_score+pagerank
9276   36b736bccbbb95f2  0.000049       1     1.000000  https://bookwalker.jp/dea270c399-d1c5-470e-98bd-af9ba8d8464a/  0.000146                      1.009695
2783   108a6facdef1cf64  0.000037       0     0.758035     http://blog.livedoor.jp/usausa_life/archives/79482577.html  1.000000                      0.995498
32712  c3ed3d4afd05fc43  0.000045       1     0.931093          https://item.fril.jp/bc7ae485a59de01d6ad428ee19671dfa  0.000038                      0.940083
...

実際の使用例

"雷ちゃん"等で検索すると、ほしい情報がおおよそちゃんと上に来るようにチューニングすることができました。
Pixivについては明示的にクローリング先に設定していないが、Aのクローラがどんどんとリンクをたどりインデックスを作成した結果で、自動で獲得したものです。

"洒落怖"など、他のクエリでも望んだ結果が帰ってきています。

検索のスコアリングはどうあるべきか

手でいろいろ試してみて、良さそうなスコアはこのような感じなりました。(私が正解データです)

本来は、謎のヒューリスティックに依存した掛け算やlogを取るアプローチより、正解の並び順を与えて、機械学習で正しい並び順になるように学習するのが筋が良いです。

SEOという文化が登場してもう何十年にもなると思うのですが、高いweightを被参照リンクなどに与えるとひどいことになります。アフィリエイトブログやアドネットワークのようなものが大量に上に来てしまいます。

結局できた

様々なクエリを売ってみてそれなりの検索をすることができました。
少なくとも500万 ~ 1000万ドキュメントぐらいならば、すぐ検索できるので、適切なユースケース(図書館とか病院のカルテとか)を設定すればいろいろな応用ができそうです。

アメブロでの流行語大賞のその後、各デモグラで使用する単語の違いなど

 国内最大級の自然言語のデータ・セットが一般ユーザがアクセスできる範囲であるものは2chアメブロなどがあります。
アメブロは広大で数千万の投稿があると思われますが、全てをパースし切ることはできませんでした。(またしなくてもサンプルサイズ的に十分だという視点ではあります)

ユーザには女性が多く、後述するFaceBookやLineなどの浸透スピードを鑑みると、レートマジョリティに相当するメディアだと思われます。

せっかくいろいろ遊べそうなコーパスであるのに、どうにも使い勝手が良くなく何かを導くのにアイディアをひねる必要があります。

アメブロ自体が自分と違う人間たちの巣窟であるので、なかなかおもしろい分析が思いつかず、だいぶ放置していたのですが、重い腰を上げてデータの基本的なところを見ていきます。

データ

  • 2019/05/10からの過去のアメブロのスナップショット
  • 1272万記事
  • 142万人のプロフィール

ダウンロード

コード

基本的にはデータをダウンロードして A, B, C, D, ... の順序で実行していくことで再現できます(メモリが32GBは必要です)

GitHub

分析(集計)すること

こういった大規模コーパスで分類するのに向いている疎行列 + Stochastic Gradient Descent

Andrew NGさんが説明しているStochastic Gradient Descentの動画がありわかりやすいです。

すべてのデータ・セットを一度にオンメモリに展開して最適化するというものではないので、 1000万を超えるコーパスでもうまくすれば分類することができます。

ScikitLearnには線形モデル各種をSGDで最適化できる関数が用意されていて、SVM, Logistic Regressonなどなどが損失関数の設定で行えるようになっています。
またペナルティも、L1, L2のほかelasticが最新のScikitLearnでは用意されていて便利です(2019年5月現在のAnacondaのデフォルトのScikitLearnは実装されていないようですが、pipから入る方はインターフェースがきちんと実装されています)

実際にやった分類

ScikitLearnのSGDClassifierを利用して、ペナルティをelasticとしました。

kfoldしながら、optunaでAUCの最大となる点を探索しながら各種ハイパラを探索させています。

コード

代表的な角度でのクラシフィケーション(分類)とその重要度

埼玉県民とそれ以外の違い

埼玉県民とそれ以外を分類を行おうとすると、 AUC 0.503 で実際はほとんど分類できませんでした。
分類性能は悪いが、その判断基準となった特徴量がユニークで面白いです。

埼玉県民のコーパスの特徴として「池袋」が入っているとそれらしいという結果になっており、実質的に池袋は埼玉県民の土地であるということがわかります。

表1. 埼玉県民とその他

男女の分類

男女の分類で見ると、 AUC 0.741 になり、そこそこの性能で分類できます。

男性言葉、女性言葉が分類用の特徴量として目立っており、どんな言葉を使えば男性らしく、女性らしく統計的になるのかわかりました。

表2. 男女の分類

1988年生以前生まれと以後生まれ

勝手に若いというを1988以前, 1988以降の生まれの人の記事の分類をすると、 AUC 0.681 で分類できる

おそらく携帯電話の頭である 090 が最もわかりやすい特徴量になっていることや、息子、娘がいること、文章中の も年寄りくさいな、、、と思っていたのですが想像通りなる部分もあるし、アルゴリズムにより初めてこの特徴量が効くのかと認識できるものもあります。

Twitterは若者のSNSツール、らしい。

表3. 年代別

サービスのユーザの人口の高齢化

ニコニコ動画も高齢化しているというデータがあるように、実はアメブロでも同様の老化が起きていることがわかっています。
ユーザプロフィールと登録している生年月日を突合すると、ブログを書いたときの平均年齢が逆算できる。
その時の平均の年齢を age とすると、このようなグラフになります

図1. 年齢の経年変化

ほぼサービス人口の年齢が新陳代謝していません

結局流行語(大賞)は長生きするのか

見ていきましょう

アメブロはサービスとしても現在進行系で記事数が増加しておりそのバイアスを排除するため、その集計粒度の月の総数で割ることで一定の正規化をしています

壁ドン(2014年)

図2. 壁ドン

妖怪ウォッチ(2014年)

爆買い(2015年)

図4. 爆買い

PPAP(2016年)

図5. PPAP

ライフステージなどに応じたキーワード

ガン

図6. ガン

出産

図7. 出産

結婚

図8. 結婚

周期性のあるキーワード

花粉症

図9. 花粉症

PM2.5

図10. PM2.5

暑い

図11. 暑い

2018年が過去ないほどの比率で”暑い”と言われており、実際に人々がそう感じていたということが定量的にわかります

TV,新聞に対して、SNS各種は継続して人気が上がり続ける

ブロードキャスト型のコンテンツのテレビに対して、twitter, facebook, line, youtube, instagramなどは人気が上がり続けています

Twitter

図12. Twitter

Facebook

図13. Facebook

line

図14. line

インスタグラム

図15. インスタグラム

YouTube

図16. YouTube

テレビ

図17. テレビ

新聞

図18. 新聞

まとめ

何らかの判別のモデルを作成して、その特徴量を見ると何で分離されているのかわかりやすいのですが、判別性能が十分に出ないのであれば、強く一般化できない特徴量だと思われます。

アメブロという媒体の特性か、Twitterで流行りの激おこぷんぷん丸などのバズワードや言い回しは殆ど使われていないようでした。

サービス自体が若い世代の参入が少ないという状態にあるので、ガンの頻出や結婚出産もブログサービスの経過年月に比例して上がっていることがわかりました。

高齢化の影響を受けつつもテレビと新聞は人気が現状維持か少し落ち気味です。年齢的なバイアスを外せばSNSに順調にシェアを奪われているように見えます。

不動産価格分析とモデルの作成とクローム拡張

  • 機械学習で不動産を予想する意味
    特徴量から重要度を知ることができる

  • EndUserにとって嬉しいことは?
    線形モデルならばChrome ExtentionなどJavaScriptなどにモデルを埋め込むこともでき、意思決定の補助材料などとして、不動産の情報の正当性を推し量る事ができる

  • 管理会社にとって嬉しいことは?
    特徴量の重要度が明確にわかるため、設備投資戦略をどうするか、異常に値段がずれてしまっている案件の修正などに用いることができる

データを集める

  • ダウンロード済みのデータはこちらLink

モデルを検討する

  • ElasticNetを利用
    ElasticNetはL1, L2の正則化をあわせた線形モデルで、オーバーフィットを強力に避けて、予想するアルゴリズム
1 / (2 * n_samples) * ||y - w*x||^2
+ alpha * l1_ratio * ||w||
+ 0.5 * alpha * (1 - l1_ratio) * ||w||^2

目的関数はこのように定義され、alpha, l1_ratioのパラメータを自由に設定することでCross-Validationの性能を確認することができる

  • 独立な情報でone-hotベクトルを仮定
    線形モデルは事象が可能な限り独立であると嬉しく、特に特徴量の重要度を知りたい場合、categoricalな変数がone-hotベクトルで表現されている方が、continiusな値の係数を出すより解釈性能として優れる(これは性能とトレードオフの場合がある)

  • optunaで最適化 alpha, l1_ratioは自由に決められる 0 ~ 1の値なので、探索する値の対象となる。
    optunaというライブラリが超楽に探索できる

def objective(trial):
    l1_ratio = trial.suggest_uniform('l1_ratio', 0, 1)
    alpha = trial.suggest_uniform('alpha', 0, 1)
    loss = trainer(l1_ratio, alpha) # <- 具体的なfoldをとって計算するコード
    return loss

このように目的関数を定義して、最適化の対象となる変数を必要なだけ作成する

東京と全国でモデルを分け、n-foldの結果

東京

$ E001_encoding_tokyo_csv.py
$ python3 F001_train.py
...
[I 2019-03-23 03:25:29,056] Finished trial#19 resulted in value: 3.4197571524870867. Current best value is 2.2600063056382806 with parameters: {'l1_ratio': 0.003751256696740757, 'alpha': 0.8929680752855311}.
2.2600063056382806 # <- 平均、2.26万ぐらいはズレがあって、このパラメータが最良であったという意味

全国

$ E001_encoding_all_country_csv.py
$ python3 F001_train.py
...
[I 2019-03-23 03:31:46,979] Finished trial#19 resulted in value: 1.8120767773887. Current best value is 1.37366033285635 with parameters: {'l1_ratio': 0.006727252595147615, 'alpha': 0.1862555099699844}.
1.37366033285635 # <- 平均、1.37万ぐらいはズレがあって、このパラメータが最良であったという意味

Chrome Extentionを作る

モデルを具体的にどう使うかいくつかのユースケースがあるが、今回はWebの面に対して各要素から、家賃価格を予想するようなものを作成する。

モデルの作りが線形モデルになっているため、割と簡単にJavaScriptにモデルを組み込むことができる。(面側の言葉のゆらぎなどに脆弱ですが)

githubreal-estate-value-prediction/chrome-ex-template/srcChromechrome://extensions/より、インストールすることができます。

分析

  • 東京23区の一般的な特徴量(部屋の種類、何区、設備など)からt-sneを行うと、クラスタが分かれるより連続している事がわかる

  • 東京23区の一万円あたりで借りられる面積のバイオリン図
  • 都道府県の一万円あたりで借りられる面積のバイオリン図

特徴量重要度

ElasticNetの特徴量の重要度で、重要度がプラス側に倒れるほど価格にプラスに影響する特徴量で、マイナスに倒れるほど価格にマイスに影響する特徴量になる。

  • 東京の結果
細かいので拡大して閲覧してください
  • 全国の結果
    細かいので拡大して閲覧してください

Chrome Extentionのユースケース

なれないJavaScriptを書いて作ったものになります。

このGitHubのコードをもとに作成しました。

"ネットで賃貸"さまのデータから、"Homes"さまの東京の詳細情報を評価できるChrome Extentionを作ってみました。

JSわからないなりに頑張った…

  • 価格差が大きいとはなにかあるのでは無いかを考えられる
     当然ながら、事故物件であるようなフラグは存在しないので、今回のモデルは特徴量として獲得していない。 例えば、異常に安い物件があるのならば、なにかそれを説明する変数(事故など)が背景にあると考える一助になる。
      今回のモデルがあれば相場がわからない人がぼったくられるようなことを防ぐようなことも、インターフェースを整理すれば可能になるだろう。

コード説明