にほんごのれんしゅう

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

ドメインにより意味が変化する単語の抽出

ドメインにより意味が変化する単語の抽出

立命館の学生さんが発表して、炎上した論文を、わたしもJSAI2017に参加していた関係で、公開が停止する前に入手することができました
論文中では、幾つかのPixivに公開されているBL小説に対して定性的な分類をして、終わりという、機械学習が入っていないような論文でしたので、わたしなりに機械学習を使ってできることを示したいという思いがあります。(そんなに大変な問題でないように見えて、かつ、問題設定も優れていたのに、なぜ…)

炎上に対して思うところ(主観です)

PixivのBLのコンテンツを参照し、論文にハンドル名を含めて記述してしまっており、作家の方に精神的な不可をかけてしまうという事件がありました。 非常にRTされている代表的なツイートは、以下のようになっています。 (該当ツイートは盗用との指摘を受けたので消しました、検索すれば出るものなで、大乗だと思います) 多くの人がいろいろなことを言っており、情報は混沌としています。
わたしが解決すべき問題と捉えているのは、二点です。

- 引用することで、不利益を被る人の対応と配慮
- 転載と引用の混同を正す

解決できるように鋭意、TPOをわきまえて話しましょう。良い未来が開けることを期待しています。

(アダルトドメインなどにおける)意味が変化する単語とは

元論文では、バナナなどの意味が変化するとされていますが、もう少し詳細に考えていましょう
文脈によって意味が変化するということが成り立つとき、たとは、「冷たい」とあっただけであっても、以下のような文脈依存で意味が変化しているようにみえることがあります。  

例1.

アイスコーヒーを頼んだ。冷たい。こんなに夏の熱い日に、カフェで飲むコーヒーは最高だ。

例2.

事務的な口調で支持される。冷たい。彼はこんなに人間性に乏しかっただろうか。

温度が冷たいのか、人の人格が冷たいのか、文脈で人間なら簡単に理解できます。

人間だからこの文脈によりなにかしら、意味というか、感じ方が違うものをうまくピックアップすることはできないでしょうか
実はskip thoughtなどに代表される文章のベクトル化などの技術を使うとできるということを示します。

ドメインや文脈により意味が変化する語を抽出する

ドメインという粒度だと、多分ニュースサイトと、小説サイトでは、同じ単語を持っていても微妙にニュアンスが異なるかと思います。   skip gram的な考え方だと、周辺にある単語の分布を見ることで意味が決定すると言う仮説があります。その応用例がword2vecで馴染み深い物かもしれません。  

図1. skip gram

Word2Vecではエンピリカルな視点から、意味がベクトル化されているということがわかっています
今回の提案では、これをより拡張して、単語の意味が周辺の文脈から何らかの影響を受けていると仮定して、モデルを作ります。  

図2. 文脈により相対位置を決定

文脈を定義する

単語より大きな粒度である、文脈というものを明確に定義する必要があります
文脈の粒度を、文としました。文をベクトル化するのに、skip thought vector[1]や、doc2vec[2], fastText[3]などが利用できます   このベクトルに意味らしきものが含まれるかという議論ですが、ICML2016のtext2imageによると、文章ベクトルから画像の生成に成功していることから鑑みて、なんらかの特徴が内包されているかとわかります[4]

図3. Sentence Vectorizer

文章をベクトル化して、そのベクトルからの相対位置を決めるのは計算量的に面倒なので、量子化する

前後の文脈から、単語がどのようになるかは、求めることができそうのはわかりましたが、ベクトルの位置から計算していくというのは少々厄介なので、ベクトルを教師なしクラスタリング(kmean)で量子化します。   クラスタリングすると、文章がどのクラスに属するのか唯一に決まり、計算が楽です。

図4. 量子化

幅を決めて前後の文脈から単語を表現していく

単語をベクトル化するにはWindwos幅を決めて、前後を単語を見ていきますが、これにならない、前後の文章をみて単語に対してどのような文脈が偏在しているか、求めます。   今回は、前後の文章を三つ見ていきます。

図5. プログラム内部でのデータ構造のイメージ

図6. 文脈によりエンベッティングされた単語

実験

長くなりましたが、仮説とそれに伴う理論の構築がすみましたので、実験です。 仮説に寄ると、ドメインが異なると、単語の文脈が変化し通常と異なる分布になることが期待できます。   二つのコーパスを利用しました

  1. 2GByteのYahoo News + 小説家になろう
  2. 1.8GByteのノクターンノベルズのHTML[5]

この二つのコーパスから、文脈を考慮した単語ベクトルを作成し、同じ単語のcosine similarityが近い、遠いを計算し、直接的でないアダルトドメインのみ固有で意味が変化するものが定量的に検知できます。

結果

結果は、概ね良好かなという印象です
ただ、もっとデータがあるべきだなぁと思いました
やはり計算には膨大な時間が必要で、全データセットを計算して結果を得るのに24時間ぐらいの計算時間を要したので、それなりにパワーのあるマシンで計算することをおすすめします  

CPU: Ryzen1700x
Memoery: 48GByte

意味が変化する(consine similarityが遠い)単語トップ10です。

単語     consine similarity
0.00152
増し 0.00130
0.00130
発射 0.00122
0.00116
繋がっ 0.00103
熱い 0.00101
当たる 0.00095
たっぷり 0.00093
回し 0.00093
握っ 0.00050
表1. 文脈が変化している単語 top 10

また、意味が逆にそんなに遠くないものです。

単語 cosine similarity
思考 0.03889
原因 0.03745
かつて 0.03451
0.03083
足首 0.03005
怪我 0.02739
無事 0.02721
その間 0.02608
部下 0.02531
維持 0.02514
表2. 文脈が変化していない単語 top 10

考察

仮説を一個立てる必要がありましたが、既存のword2vec等が動作している仮説を担保としているため、間違いが無いように思います
このように、ドメインによって意味やニュアンスが異なる単語は、定量的なアプローチで分析することができるので、小説を定性的な視点から分析する価値をあまり感じなかったのですが、人によりアプローチは色々あると思います

今書いているコンテンツに応じて簡単にフィルタリングするべき単語などの抽出ができることが、分かりました
具体的な方法としては、基準となるドキュメントで生成した単語の文脈の分散表現と、ドメイン依存した暗喩や比喩などの分散表現は分布が異なるので、cosine similarityが遠くなることが期待できます

ソースコード

https://github.com/GINK03/DomainDependencyMemeJsai2017

github.com

deal.pyに一連の処理が記してありますが、コメントを要所にいれましたが、とにかく工程が多いので、どうしても解説が必要な方はツイッター等で聞いていただければ幸いです(アカデミアの方に限ります)  

参考文献

[1] Sent2Vec
[2] Doc2Vec
[3] fastText
[4] ICML2016, text2image
[5] ノクターンノベルズ(R18です、注意してください)

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