雑記: 行列式にたどり着きたい話

キャラクターの原作とは関係ありません。何か問題点がありましたらご指摘いただけますと幸いです。
2018-11-08 この文字色の箇所を加筆しました。
f:id:cookie-box:20180305231302p:plain:w60

ジュン、行列式って何?

f:id:cookie-box:20180305231302p:plain:w60

話終わっちゃう!

f:id:cookie-box:20180305232608p:plain:w60

ハヤトは、行列とは何かは大丈夫なんですよね?

f:id:cookie-box:20180305231302p:plain:w60

行列っていうのは…数を並べたやつ?

f:id:cookie-box:20180305232608p:plain:w60

縦に m 個、横に  n 個ずつ長方形に並べたものですね。もっとも、行列式が定義できるのは縦にも横にも同じ個数ずつ並べた正方行列に対してですが。なので以下では正方行列のみ考えましょう。行列の各成分は、ここでは実数と考えておきましょうか(※ 次の吹き出しの例では行列の成分が複素数の場合もあります)。

f:id:cookie-box:20180305231302p:plain:w60

実数を縦に m 個、横に  m 個並べたものってことだな。それで?

f:id:cookie-box:20180305232608p:plain:w60

僕たちは行列で色々な情報を表現することができます。例えば、何年ものと何年ものの国債金利が同じ向きに変動しやすいかの情報や、ある SNS で誰と誰が密にやりとりしているかという情報を行列に表すことができます。また、量子力学のある形式では観測できる物理量を行列で表現します。このように何か情報を行列に表したら、次に僕たちはこう願うでしょう。

  • なるべく少ない変数で、色々な満期の国債金利の値を説明したい。
  • SNS のユーザを、特に密にやり取りしている人たちにグルーピングしたい。
  • ある状態における物理量を取り出したい。
しかし、ふつう用意したままの行列の各成分はこれらの願いを叶えてくれません。そもそも行列ってそのままだと扱いづらいんですよね。掛け算の順序は交換できないし、累乗するのも苦労するし、その他色々計算が大変だし、だからなるべく情報を損なわずに変数の数を削りたいと思っても削り方を示唆してくれないし…。

f:id:cookie-box:20180305231302p:plain:w60

勝手に行列にしておいてめちゃくちゃ文句いうなよ!

f:id:cookie-box:20180305232608p:plain:w60

ただ、行列であっても対角線上の成分を除いて他は全てゼロであるような行列=対角行列ならずっと取り扱いやすくなるんです。なので、できることなら目の前にある行列を対角行列にしたいんです。

f:id:cookie-box:20180305231302p:plain:w60

え? でも勝手に対角成分以外をゼロに書き換えたらダメだろ。情報が変わっちゃう。

f:id:cookie-box:20180305232608p:plain:w60

勝手に対角成分以外をゼロに書き換えるわけではありません。もし  m \times m 行列  A がある条件を満たせば、適当な  m \times m 行列  P を選んで  P^{-1}AP = B を対角行列にすることができるんです。

  • ここで  P^{-1} とは  P逆行列といって、 P P^{-1} = P^{-1}P = I I単位行列)になるような行列です。正方行列  P に対して逆行列  P^{-1} が必ずあるとは限りませんが、あるならただ一つに決まります。

f:id:cookie-box:20180305231302p:plain:w60

ふーん。でもさ、 P^{-1}AP = B はもう  A じゃないじゃん。対角行列になったのはよかったかもしれないけど、なんかもう  A とは別人じゃん。

f:id:cookie-box:20180305232608p:plain:w60

どんな目的でも  B A の代わりにできるとはいってませんよ。いわば  B異世界転生した  A の姿ですが、以下のような場合であれば異世界の方で作業しても差し支えないでしょう。

  • 出したい答えが異世界でも現実世界でも変わらないものである。
  • 出したい答えが異世界と現実世界で異なるものだが、異世界で出した答えを現実世界の姿に変換することができる。

f:id:cookie-box:20180305231302p:plain:w60

異世界転生て。じゃあどうやって転生するんだよその異世界に。

f:id:cookie-box:20180305232608p:plain:w60

どうやって  P を求めるのか、ということですね。 P^{-1}AP が対角行列になるような  P とはどんな行列か、いきなり全ての成分を考えようとするとわかりづらいので、 P の一番左側の列ベクトル  p_1 に着目してみましょう。 P^{-1}p_1 = \displaystyle \left( \begin{array}{c} 1 \\ 0 \\  \vdots \\ 0 \end{array} \right) でなければなりませんよね。 P^{-1} P単位行列ですから、その一番左の列はこうならなければならないです。他方、 P^{-1}Ap_1 = \displaystyle \left( \begin{array}{c} a_1 \\ 0 \\  \vdots \\ 0 \end{array} \right) でなければならないはずです。対角行列  P^{-1}AP の一番左の列ですから。するとこの2つのベクトルは平行なので  P^{-1}Ap_1 = a_1 P^{-1}p_1 = P^{-1} a_1 p_1 となり、この最左辺と最右辺に左から  P^{-1} をかけると結局  Ap_1 = a_1 p_1 となります。つまり  p_1 は、行列  A を左からかけたときに定数倍されるだけで向きが変わらないベクトルなんです。同じ議論が  P の全ての列のベクトルに対して成り立ちます。また、明記してきませんでしたが  P^{-1}AP という表記からわかるように  P逆行列をもつ行列(正則行列)なので、各列ベクトルは互いに線形独立でなければなりません(証明略)。まとめると、 m \times m 行列  A に対して  P^{-1}AP が対角行列となるような  P とは、「行列  A を左からかけたときに向きが変わらないような互いに線形独立なベクトルを  m 本並べたもの」であり、そのような  P が存在するための  A の条件はそのまま「行列  A を左からかけたときに向きが変わらないような互いに線形独立なベクトルが  m 本あること」です。

f:id:cookie-box:20180305231302p:plain:w60

へー、 P はそういう行列だったのか…いやでも、その「行列  A を左からかけたときに向きが変わらないようなベクトル」をどうやって求めるの?

f:id:cookie-box:20180305232608p:plain:w60

 Ap_1 = a_1 p_1 を式変形すると  (a_1 I - A)p_1 = \vec{0} となりますが、いま  p_1 は他の列のベクトルと線形独立であるようなベクトルであってほしいので、 p_1 \neq \vec{0} でなければなりません。いま  p_1 に着目していましたが、後ろの  a_1 I -A についてみてみましょう。いま、 a_1 I -A は、ゼロベクトルでないようなベクトル p_1 に左からかけるとゼロベクトルにしてしまうような行列でなければなりません。言い換えると、もし  a_1 I -A が「ゼロベクトルでないベクトルはゼロベクトルにしない」ような行列だったら、ほしい  p_1 を選ぶことができないんですよね。

f:id:cookie-box:20180305231302p:plain:w60

いってることはわかるけど…ゼロベクトルでないベクトルをゼロベクトルにするって、じゃあ結局  a_1 I -A はどんな行列なの?

f:id:cookie-box:20180305232608p:plain:w60

仮に  a_1 I -A逆行列  (a_1 I -A)^{-1} が存在するとしましょう。これを  (a_1 I - A)p_1 = \vec{0} の左からかけるとどうなります?

f:id:cookie-box:20180305231302p:plain:w60

 p_1 = \vec{0} だな。あれでも、p_1 \neq \vec{0} じゃないとダメなんじゃなかった?

f:id:cookie-box:20180305232608p:plain:w60

はい。つまり、 a_1 I -A には逆行列が存在してはならないんです。なので、行列 A に対して  a_1 I -A逆行列をもたないような a_1 が見つけられるか、という話になりますね。

f:id:cookie-box:20180305231302p:plain:w60

 a_1 I -A には逆行列があっちゃダメなのか…いや大して話簡単になってなくない? 逆行列があっちゃダメだったらそういう  a_1 I -A がわかるかっていうとわかんなくない?

f:id:cookie-box:20180305232608p:plain:w60

ええ、 a_1 I -A の姿はわかりませんね。ならいっそ小さい行列で直接逆行列を求めてみるのはどうでしょうか。逆行列がないとはどういうことか確かめてみましょう。表記が煩わしいので  a_1 I -A = C とおき、各成分を  c_{ij} とでも書くことにして、 C^{-1} の各成分  x_{ij} c_{ij} で表すことを目指しましょう。逆行列のすべての成分を求めるのは大変そうなので、一番左の列ベクトルだけ考えてみましょう。 2 \times 2 行列、 3 \times 3 行列の場合であれば以下を解くということです。右辺は単位行列の一番左の列ですね。

 \displaystyle \begin{pmatrix} c_{11} & c_{12} \\ c_{21} & c_{22} \end{pmatrix} \begin{pmatrix} x_{11} \\ x_{21} \end{pmatrix} = \begin{pmatrix} 1 \\ 0 \end{pmatrix}  \displaystyle \begin{pmatrix} c_{11} & c_{12} & c_{13} \\ c_{21} & c_{22}  & c_{23} \\ c_{31} & c_{32}  & c_{33}\end{pmatrix} \begin{pmatrix} x_{11} \\ x_{21} \\ x_{31} \end{pmatrix} = \begin{pmatrix} 1 \\ 0 \\ 0 \end{pmatrix}
どうでしょうか。未知変数の個数と同じ数連立されている連立方程式になっているので、解くことができそうですよね。どうなりますか?

f:id:cookie-box:20180305231302p:plain:w60

俺が解くの!? …まあ、 2 \times 2 行列の場合は  x_{21}x_{11} の式で表して消せばいいのか。こうなるな。

 \displaystyle x_{11} = \frac{c_{22}}{c_{11}c_{22}-c_{12}c_{21}}\, , \; \; x_{21} = \frac{- c_{21}}{c_{11}c_{22}-c_{12}c_{21}}
 3 \times 3 行列の場合も同じ要領だよな。 x_{31}x_{21}x_{11} の式で表して、さらに  x_{21}x_{11} の式で表せば消していけるはずだ。…何これめんどくさ! ノートめっちゃつかう!
 \displaystyle x_{11} = \frac{c_{22}c_{33} - c_{23}c_{32}}{c_{11}c_{22}c_{33}+c_{12}c_{23}c_{31}+c_{13}c_{32}c_{21}-c_{11}c_{23}c_{32}-c_{12}c_{21}c_{33}-c_{13}c_{31}c_{22}}
 x_{11} はこう!  x_{21} x_{31} はかくのめんどいから略! どうだ!? 計算ミスはプロデューサーにいってくれ!

f:id:cookie-box:20180305232608p:plain:w60

分母がゼロの場合が場合分けされていないので不正解です。

f:id:cookie-box:20180305231302p:plain:w60

えーそんなとこ!?

f:id:cookie-box:20180305232608p:plain:w60

いま逆行列がないとはどういうことか知りたかったんですよ? 逆行列の全ての成分を求めてみればわかりますが、ハヤトがかいたそれらの式の分母がゼロのとき、そのときのみ  C には逆行列が存在しないんです。そうでなければ逆行列は存在します。なぜならそうやって各成分を求めることができるでしょう?

f:id:cookie-box:20180305231302p:plain:w60

あーそういうことか。あ、わかった気がする。 a_1 I -A逆行列をもたないような a_1 を見つけるには、 a_1 I -A の成分を上の分母に代入してそれがゼロになるような  a_1 を見つければいいのか。…って考えるとなんかそうやって計算すれば  a_1 って絶対見つかりそうじゃない? 見つからないときとかあんの?

f:id:cookie-box:20180305232608p:plain:w60

…説明は割愛しますが  a_1 は必ず見つかりますよ(※)。そして、a_1 I -A によってゼロベクトルにされてしまう  p_1 も必ず求まります。どちらかというと問題なのは、「線形独立な  p_1 m 本求まるか」という点の方ですね。…その話は一旦そこで止めておいて、ハヤトはいま  2 \times 2 行列、 3 \times 3 行列と逆行列を求めてみてどうでした? 行列のサイズがより大きくなった場合にも、逆行列の存在の有無を確かめられそうな知見が得られたでしょうか?

a_1、つまり、行列  A固有値複素数の範囲で必ず存在しますが、代数的に解けるかはまた別の話です。a_1 は行列  A の固有方程式といわれる m 次方程式の解になりますが、5次以上の方程式になると代数的に解けない場合がありますので(雑記: 5次以上の方程式に解の公式が存在しない話 - クッキーの日記)。しかし、代数的に解けなくとも何らかの表現で特定することはできるでしょう。そもそも実用場面ではもっぱら数値的に求めることになるとは思います。
f:id:cookie-box:20180305231302p:plain:w60

得られてねーよ!

f:id:cookie-box:20180305232608p:plain:w60

そうですね、確かに地道に逆行列の成分を求めるのは見通しのよい方法ではないかもしれません。別の手順で「目の前の行列が逆行列をもつかどうか確かめる方法」、僕たちの最初の願いに即していうならば「目の前の行列が対角化できるかどうか確かめる方法」を考えた方がよさそうです。計算が簡単になるかどうかはさておき、先ほどの分母にあたる式に統一的な表式があった方が便利ですよね。そこが a_1 を求めるカギですから。

f:id:cookie-box:20180305231302p:plain:w60

俺の苦労なんだったの!?

f:id:cookie-box:20180305232608p:plain:w60

そもそも  2 \times 2 から  3 \times 3 になるだけでも逆行列を求めるのは面倒になりますよね。それも、 3 \times 3逆行列を求めるのに  2 \times 2逆行列を求めたときの知見が活かされていません。そこを順々に活かすような方法を編み出せば、もっと大きなサイズの行列にも対応していける形になると思いませんか?

f:id:cookie-box:20180305231302p:plain:w60

いや、俺はこれ以上サイズの大きな行列には向き合っていきたくないんだけど。

f:id:cookie-box:20180305232608p:plain:w60

よく調べてみると、 3 \times 3 行列の逆行列は、 2 \times 2逆行列を求めたときの成分の分母にあたる部分と関連付けることができるんですよね。実は、 m \times m 行列の逆行列の成分の分母にあたる部分と、その行列の左斜め上に1を置いて上側にはゼロを敷き詰めたような  (m+1) \times (m+1) 行列の逆行列の成分の分母にあたる部分が同じになることが証明できるんです。この、ある行列の「逆行列の成分に分母としてあらわれる部分」には特別な名前が付けられていて、それが「行列式」です。

f:id:cookie-box:20180305231302p:plain:w60

タイトル回収すんのおせーよ!

f:id:cookie-box:20180305232608p:plain:w60

行列式の定義からはじめたくなかったんですよね。自然な流れでどうしてこんな  \sum_{\sigma \in S_m} {\rm sgn}(\sigma) \prod_{i=1}^m c_{i \sigma (i)} 形の行列式に僕たちは向き合わなければならないのかたどり着きたいじゃないですか。でもここから余因子展開をはじめるとなると記事の尺がちょっと。あ、上の行列式の表式の意味は参考文献 2. を参照してください。行列式がどのような意味をもつかも参考文献 2. に記述があります。参考文献 1. にはアニメーションによる直感的なすばらしい説明があります。参考文献 1. の中ほどの「3次の座標変換」というアニメーションに照らし合わせていうならば、 A を対角化したいとき、 a_1 I -A という行列の線形変換はこの赤い平行六面体がぺちゃんこにつぶれている=行列式がゼロ=逆行列をもたない状態でなければならないですね。

つづかない

直感 Deep Learning: 5章メモ(単語分散表現)(番外編)

以下の本を読みます。

直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ
Antonio Gulli Sujit Pal 大串 正矢

オライリージャパン 2018-08-11
売り上げランキング : 1992

Amazonで詳しく見る
by G-Tools
キャラクターの原作とは関係ありません。本読みメモですが脱線します。何か問題点がありましたらご指摘いただけますと幸いです。
前回:その3 / 次回:まだ
f:id:cookie-box:20180305231302p:plain:w60

前回 keras で Skip-gram 学習器をつくってみたけど、学習した結果ってどんな感じになるんだ?

f:id:cookie-box:20180305232608p:plain:w60

それなんですが…当初「機関車トーマス」を題材にするつもりだったんですが、解析するために持ち出そうとすると小さい子が泣いちゃうんですよね…。

f:id:cookie-box:20180305231302p:plain:w60

小さい子から絵本取り上げるなよ!

f:id:cookie-box:20180305232608p:plain:w60

それに、元々テキストファイルになっていませんし…なので、題材を変えて、春名さんのセリフを解析することにします。春名さんにとってドーナツがどのように重要なのか手がかりが得られるかもしれませんし。

f:id:cookie-box:20180305231302p:plain:w60

趣旨変わってる!?

f:id:cookie-box:20180305232608p:plain:w60

春名さんのセリフはまとめられているページ(若里 春名 - アイドルマスターsideM Wiki*)からパースしてきたんですが、セリフ中のカタカナが半角になっているので以下のページのスクリプトを拝借しました。ただ、このスクリプトで半角の伸ばし棒が全角の伸ばし棒に変換できなかったので、それだけはテキストエディタ上で手でやりました。

今回 Beautiful Soup で td タグ中の文字列を収集したんですが、改行が含まれているセリフがパースできなかったんですよね。面倒なのでとばしました。セリフを csv に書き出して、この記事の最下部にあるスクリプト(※)を実行した結果が以下です。

0                                 楽しいことがないなら、自分で作る!
1 ジュンはコーヒーのコトはまだ許してないけど、勉強教えてくれるのはホント助かってる。ナツキは無...
2  なぁ、プロデューサー。オレ、バカだけど…やる気だけは本物だからさ。たまには思い出してくれよなっ。
Name: str, dtype: object
正例の数: 5021
負例の数: 4947
(9968, 3)

===== 単語登場回数 (ユニーク単語数:1280, 語数:5922) =====
[('て', 232), ('の', 220), ('な', 195), ('に', 181), ('だ', 155), 
 ('が', 112), ('は', 111), ('も', 111), ('オレ', 91), ('よ', 91), 
 ('た', 90), ('し', 87), ('と', 85), ('で', 82), ('ん', 81), 
 ('プロデューサー', 75), ('って', 75), ('ぜ', 74), ('か', 69), ('ない', 66), 
 ('を', 66), ('ドーナツ', 57), ('さ', 47), ('てる', 43), ('から', 43)]

===== 文書ベース単語登場回数 (文書数:286) =====
[('な', 161), ('て', 154), ('の', 153), ('に', 131), ('だ', 128), 
 ('が', 96), ('も', 96), ('オレ', 88), (' よ', 88), ('は', 86), 
 ('で', 77), ('し', 75), ('ぜ', 74), ('と', 73), ('ん', 73), 
 ('た', 73), ('プロデューサー', 71), ('って', 66), ('か', 64), ('ない', 62), 
 ('を', 62), ('ドーナツ', 48), ('さ', 45), ('から', 41), ('てる', 40)]

===== Skip-gram対の例 =====
(こと (30), 今日 (45)) -> 0
(ない (20), け (977)) -> 0
(自分 (128), で (14)) -> 1
(が (6), なら (49)) -> 1
(ない (20), 自分 (128)) -> 1
(楽しい (75), が (6)) -> 1
(の (2), は (7)) -> 1
(けど (28), て (1)) -> 1
(てる (24), ナツキ (244)) -> 1
(ホント (129), は (7)) -> 1
(けど (28), 補給 (1064)) -> 0

登場頻度が上位の単語は助詞や助動詞ばかりに見えますね…上位25件で意味のある単語は「オレ」「プロデューサー」「ドーナツ」くらいでしょうか。…それはさておき、なんで僕春名さんに許されてない感じになってるんでしょうか?

f:id:cookie-box:20180305231302p:plain:w60

いや俺が知りたいよ! …でも、助詞や助動詞ばかりっていいのか? 多くの単語が助詞や助動詞と隣り合っていることが多い、ってだけになっちゃわない?

f:id:cookie-box:20180305232608p:plain:w60

本来はストップワードを除去しなければならないでしょうね。でも今回はよくわからなかったので単語の正規化やストップワード除去はしていません。なので雰囲気だけです。雰囲気だけで「春名さんのセリフ」コーパスの各形態素を4次元空間に埋め込み、さらに2次元に次元削減してプロットした図が以下です。なお、次元数やエポック数は全くチューニングしていません。最低限損失が減少したなというくらいです。

f:id:cookie-box:20181030090639p:plain:w520

f:id:cookie-box:20180305231302p:plain:w60

おお、こうなるのか…だから何!?

f:id:cookie-box:20180305232608p:plain:w60

そうですね…「オレ」から見て「ドーナツ」「ドラム」が同じ方角にあるので、どっちも好きなんじゃないですか?

f:id:cookie-box:20180305231302p:plain:w60

分析が雑!

f:id:cookie-box:20180305232608p:plain:w60

春名さんの結果だけだとよくわからないので、参考のために他の事務所のアイドルの方ですが、椎名法子さんのセリフも解析してみました。何でも彼女もドーナツに目がないということで。

f:id:cookie-box:20180305231302p:plain:w60

なんで他の事務所にまでドーナツに目がないアイドルがいるんだ!?

f:id:cookie-box:20180305232608p:plain:w60

春名さんの方が後発ですけどね。椎名さんについてもセリフがまとめられているページ(椎名法子 -アイマス デレステ攻略まとめwiki【アイドルマスター シンデレラガールズ スターライトステージ】 - Gamerch)から適当にパースしました。スクリプトの実行結果が以下です。

0              すっごーい!ごちそうさまでしたー!
1    ぷはーっ!もう最っ高!会場中が幸せの輪になっちゃった☆
2        いい仕上がりだね!私たち、とってもクリスピー!
Name: str, dtype: object
正例の数: 3631
負例の数: 3629
(7260, 3)

===== 単語登場回数 (ユニーク単語数:994, 語数:4198) =====
[('!', 191), ('○', 147), ('♪', 146), ('の', 132), ('て', 110), 
 ('に', 108), ('?', 94), ('は', 90), ('プロデューサー', 89), ('ドーナツ', 72), 
 ('た', 69), ('だ', 65), ('~', 60), ('で', 58), ('ー', 57), 
 ('も', 56), ('よ', 53), ('っ', 52), ('ね', 49), ('と', 45), 
 ('あたし', 42), ('し', 41), ('を', 41), ('か', 39), ('が', 37)]

===== 文書ベース単語登場回数 (文書数:309) =====
[('!', 154), ('♪', 134), ('の', 115), ('に', 95), ('プロデューサー', 89), 
 ('て', 89), ('?', 82), ('は', 81), ('○', 74), ('ドーナツ', 70), 
 ('た', 61), ('だ', 57), ('~', 54), ('で', 54), ('よ', 50), 
 ('も', 50), ('ね', 49), ('ー', 47), ('っ', 45), ('あたし', 40), 
 ('し', 40), ('と', 38), ('か', 37), ('を', 37), ('が', 35)]

===== Skip-gram対の例 =====
(でし (400), ー (15)) -> 1
(! (1), でし (400)) -> 1
(ごちそうさま (399), ! (1)) -> 1
(い (48), カワイク (371)) -> 0
(ー (15), ちゃお (839)) -> 0
(ごちそうさま (399), また (310)) -> 0
(た (11), ! (1)) -> 1
(もう (104), 最 (401)) -> 1
(輪 (49), 幸せ (130)) -> 1
(幸せ (130), 話 (380)) -> 0
(高 (402), こう (560)) -> 0

春名さんのセリフの解析に利用したスクリプトを使いまわしたので、椎名さんのセリフにのみ登場する記号が除去できていないですね。セリフのページでは「〇〇プロデューサー」となっているので、〇という単語が上位に出てきてしまっています。それで、プロットは以下ですね。
f:id:cookie-box:20181030092936p:plain:w520
彼女も「あたし」から見て「アイドル」と「ドーナツ」が同じ方向にありますね。…つまり、春名さんや椎名さんにとってドーナツとは、仕事なのでは?

f:id:cookie-box:20180305231302p:plain:w60

なんでだよ!?

f:id:cookie-box:20180305232608p:plain:w60

まあ今回は処理に行き届いていない面も多いですし、実際には膨大なコーパスで学習済みのネットワークを調整するべきなんでしょう。確かテキストの5章の最後の方はそのようなやり方の話だったので、きちんと分析したい場合はそちらを参考にしてみましょう。


スクリプト
# -*- coding: utf-8 -*-
import numpy as np
import pandas as pd
from keras.layers import Dot, Input, Dense, Reshape, Embedding
from keras.models import Model
from keras.preprocessing.text import *
from keras.preprocessing.sequence import skipgrams
import MeCab
import codecs
from sklearn.manifold import TSNE
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties

# Skip-gram ペア収集クラス
class SkipGramCollector():
    def __init__(self, list_text_raw, window_size):
        self.filters = '、….!?、。「」『』\n\r'
        self.tagger = MeCab.Tagger('-Owakati')
        self.window_size = window_size # 周辺の何単語を考慮するか
        
        # 形態素解析し全単語を収集して、単語-->ID辞書と、ID-->単語辞書を作成
        self.list_text = []
        for text_raw in list_text_raw:
            text = self.tagger.parse(text_raw)
            self.list_text.append(text)
        self.tokenizer = Tokenizer(filters=self.filters)
        self.tokenizer.fit_on_texts(self.list_text)
        self.word2id = self.tokenizer.word_index
        self.id2word = {v:k for k, v in self.word2id.items()}
        
        # 文章ごとに Skip-gram 対を得る
        df = pd.DataFrame()
        for text in self.list_text:
            wids = [self.word2id[w] for w in text_to_word_sequence(text, filters=self.filters)]
            pairs, labels = skipgrams(wids, len(self.word2id), window_size=self.window_size)
            df_ = pd.DataFrame()
            df_['x0'] = np.array([pair[0] for pair in pairs]).astype(np.int64) # 中心語
            df_['x1'] = np.array([pair[1] for pair in pairs]).astype(np.int64) # 文脈語
            df_['y'] = np.array(labels).astype(np.int64) # 正解ラベル
            df = pd.concat([df, df_])
        
        # 負例にサンプリングされた Skip-gram 対のうち正例に含まれているものを除去
        df_dup_check = df[df.duplicated(keep=False) & (df.y == 0)] # 複数ある対であって負例
        df_dup_check = df_dup_check[df_dup_check.duplicated() == False].copy()
        for index, row in df_dup_check.iterrows():
            df_temp = df[(df.x0 == row['x0']) & (df.x1 == row['x1'])]
            if np.sum(df_temp.y == 1) > 0: # 正例があるのでこの対の負例からは削除する
                df_temp = df_temp[df_temp.y == 0]
                df = df.drop(index=df_temp.index)
        df.reset_index(drop=True, inplace=True)
        print("正例の数:", np.sum(df.y == 1))
        print("負例の数:", np.sum(df.y == 0))
        self.df = df

# Skip-gram 識別器クラス
class SkipGramDiscriminator():
    def __init__(self, vocab_size, embed_size):
        self.vocab_size = vocab_size # 語彙数
        self.embed_size = embed_size # 埋め込み次元数
    
    def create_model(self):
        # 中心語ID --> 中心語数値ベクトル表現
        x0 = Input(shape=(1,))
        y0 = Embedding(self.vocab_size, self.embed_size,
                       embeddings_initializer='glorot_uniform')(x0)
        y0 = Reshape((self.embed_size,))(y0)
        self.word_embedder = Model(x0, y0)
        
        # 文脈語ID --> 文脈語数値ベクトル表現
        x1 = Input(shape=(1,))
        y1 = Embedding(self.vocab_size, self.embed_size,
                       embeddings_initializer='glorot_uniform')(x1)
        y1 = Reshape((self.embed_size,))(y1)
        self.context_embedder = Model(x1, y1)
        
        # 内積 --> ロジスティック回帰
        y = Dot(axes=-1)([y0, y1])
        y = Dense(1, kernel_initializer='glorot_uniform', activation='sigmoid')(y)
        self.discriminator = Model(inputs=[x0, x1], outputs=y)
        self.discriminator.compile(loss='mean_squared_error', optimizer='adam')
        print(self.discriminator.summary())

if __name__ == '__main__':
    _train = False # 単語分散表現を学習する
    _plot = True # 結果をプロットする
    who = 'noriko' # 'haruna'
    
    if _train:
        # ===== 学習用 Skip-gram 対の生成 =====
        df = pd.read_csv(who + '.csv') # 'str' というカラムにセリフが入っているデータフレーム
        print(df['str'].head(3))
        sgc = SkipGramCollector(df['str'].values, 2)
        print(sgc.df.shape)
        np.savetxt('word_' + who + '.csv', np.array([k for k, v in sgc.word2id.items()]),
                   delimiter=',', fmt='%s')
        
        print('\r\n----- 単語登場回数 (ユニーク単語数:' + str(len(sgc.word2id)) +
             ', 語数:' + str(sum([v[1] for v in sgc.tokenizer.word_counts.items()])) + ') -----')
        print(sorted(sgc.tokenizer.word_counts.items(), key=lambda x:x[1], reverse=True)[0:25])
        
        print('\r\n----- 文書ベース単語登場回数 (文書数:' + str(len(sgc.list_text)) + ') -----')
        print(sorted(sgc.tokenizer.word_docs.items(), key=lambda x:x[1], reverse=True)[0:25])
        
        print('\r\n----- Skip-gram対の例 -----')
        for index, row in sgc.df.iterrows():
            print("({:s} ({:d}), {:s} ({:d})) -> {:d}".format(
              sgc.id2word[row['x0']], row['x0'], sgc.id2word[row['x1']], row['x1'], row['y']))
            if index == 10:
                break
        
        # ===== ネットワークの学習 =====
        sg = SkipGramDiscriminator(len(sgc.word2id), 4) # 4次元に埋め込む場合
        sg.create_model()
        sg.discriminator.fit([sgc.df.x0.values, sgc.df.x1.values], sgc.df.y.values,
                             batch_size=32, epochs=100)
        weight = sg.word_embedder.get_weights()[0]
        print(weight.shape)
        np.savetxt('weight_' + who + '.csv', weight, delimiter=',')
    
    if _plot:
        # ===== 2次元に次元削減してプロット =====
        fp = FontProperties(fname=r'C:\WINDOWS\Fonts\YuGothB.ttc', size=13)
        weight = np.loadtxt('weight_' + who + '.csv', delimiter=',')
        words = np.loadtxt('word_' + who + '.csv', delimiter=',', dtype='unicode')
        print(weight.shape)
        weight2 = TSNE(n_components=2, random_state=0).fit_transform(weight)
        print(weight2.shape)
        plt.scatter(weight2[:,0], weight2[:,1], c='darkgray')
        for word in (['オレ', 'プロデューサー', 'ドーナツ', 'ドラム'] if who is 'haruna' else \
                     ['あたし', 'プロデューサー', 'ドーナツ', 'アイドル']):
            i = np.where(words == word)
            plt.scatter(weight2[i,0], weight2[i,1], c='black')
            plt.text(weight2[i,0], weight2[i,1], word, fontproperties=fp)
        plt.savefig('figure_' + who + '.png')

(次回があれば)つづく

異常検知と変化検知: 11章メモ(密度比推定による変化検知)

以下の赤い本の11章を読みます。キャラクターは適当です。誤りがありましたらご指摘いただけますと幸いです。

異常検知と変化検知 (機械学習プロフェッショナルシリーズ)異常検知と変化検知 (機械学習プロフェッショナルシリーズ)
井手 剛 杉山 将

講談社 2015-08-08
売り上げランキング : 56236

Amazonで詳しく見る
by G-Tools
f:id:cookie-box:20180513082851p:plain:w60

10章の「疎構造学習による異常検知」では、9章のように「ある変数のようすが少し前と今とで変化したか」を測ろうとするだけでは検知できない、「複数の変数間の依存関係の変化」を見出そうという話でしたね。そのために、まず「直接相関」と「間接相関」を区別しました。そして、この本では変数間の依存関係に専らガウス型グラフィカルモデルを仮定することにして、データから精度行列を推定すればどの変数どうしが直接相関をもつのかわかるという寸法でしたね。

f:id:cookie-box:20180513082908p:plain:w60

でもその精度行列は単にデータに対して事後分布が最大になるような精度行列をとればいい(MAP推定すればいい)ってわけじゃなかったね。精度行列は解釈のしやすさの観点でもロバスト性の観点でもなるべくスカスカであってほしい。だったら、最大化すべき目的関数が「精度行列の各要素の絶対値の和の定数倍」を差し引くようなペナルティ項を含んでいるといい。これはMAP推定において事前分布にラプラス分布を仮定すると達成される。この最適化にはブロック座標降下法を用いるんだったね。

f:id:cookie-box:20180513082851p:plain:w60

疎構造学習はある意味、明示的にわかりやすさを求めたモデルだったんですね。しかし、143ページの最後の方に気になる書き方がしてあります。つまり、明示的にわかりやすさを求める必要はないはずだよね、と。もちろん、わかりやすさを目指さずともわかりやすいモデルがぽんと出てくれば苦労しませんが…。

f:id:cookie-box:20180513082908p:plain:w60

(10.30) 式は正常データとテストデータの確率密度の比を含んでいる。最初からこれを推定すればもっとシンプルになるのでは、って10章は結ばれているね。細かい構造まで立ち入らないに越したことはないからね。

f:id:cookie-box:20180513082851p:plain:w60

いや…(10.30) 式っていうほど「密度比直接推定すればいいじゃん」って示唆してますかね…。まあ前置きが長くなったので本題の11章に入りましょう。ここでの問題設定は、正常だとわかっているデータ  \mathcal{D} が与えられたときに検査対象データ  \mathcal{D'} から異常標本を見つけ出す…特に検査対象データをひとまとめに考える…特異スペクトル変換法やグラフィカルラッソに基づく異常解析も検査対象データをひとまとめに考えていたような感じでしたが…まあいいです。正常なデータがしたがう分布  p(x) と検査対象データがしたがう分布 p'(x) をつかって、ある検査対象データの「異常さの度合い」を  \ln \bigl(p'(x) / p(x)\bigr)=- \ln \bigl(r(x)\bigr) という負の対数密度比で測ることはできます。これは4ページの「異常度」とは違って、 p'(x) は異常データのしたがう分布というわけではないので、密度比は「異常っぽさよりも正常っぽさが何倍強い」というのを表すわけではありません。異常標本に対して密度比  r(x) が小さな値を取ることは図11.1からも直感的に理解できますが、特に異常標本の割合を  \alpha、異常標本がしたがう分布を  \tilde{p}(x) として  p'(x) = (1 - \alpha) p(x) + \alpha \tilde{p}(x) とかける場合は密度比の取りうる値の範囲が議論できて、つまり 0 と  1/(1-\alpha) の間になるということですね。それで、正常標本の「異常さの度合い」は  - \alpha 程度になるので、「異常さの度合い」がここから大きくずれたらその標本は異常が疑われると。

f:id:cookie-box:20180513082908p:plain:w60

146~147ページが主張しているのは、「正常データと検査対象データの密度比 r(x)」さえわかれば検査対象データに異常標本が混入しているかどうかがわかるってことだね。

f:id:cookie-box:20180513082851p:plain:w60

なるほど、だから密度比を直接推定すればよいとなるのですね。ただ「検査対象データに異常標本が含まれているか」という問題設定自体がここで初めてで、かついきなり密度比から出発したので、147ページにあるように「発想の転換」というよりは、とりあえず考えてみたらこうなってた、って感じがしますが…。まあそれはそれで、じゃあどうやって密度比 r(x) を推定するのかということで、基本的なモデルは必要なわけで、それが (11.2) 式の線形モデル r_{\theta}(x) ですね。基底関数  \psi(x) というのは各データを b 次元空間に埋め込む写像に見えますね。この基底関数の具体的な形は後回しで、線形結合のパラメータを如何に推定するかですが、目的関数が必要ですよね。しかし、密度比の学習の目的関数などどう設定すればよいのでしょう…。

f:id:cookie-box:20180513082908p:plain:w60

r_{\theta}(x) の定義より、「r_{\theta}(x)p(x)/p'(x) に近くなければならない」→「r_{\theta}(x)p'(x)p(x) に近くなければならない」→「r_{\theta}(x)p'(x)p(x) の一般化KLダイバージェンスは小さくなければならない」という流れだね。

f:id:cookie-box:20180513082851p:plain:w60

だから r_{\theta}(x)p'(x)p(x) の一般化KLダイバージェンス(の  \theta に依存しない項を除いたもの)を目的関数とするということですか…確かにそれで線形モデル r_{\theta}(x) のパラメータを最適化できそうです。あれでも、一般化KLダイバージェンスではなくてKLダイバージェンスでは駄目なのですか? r_{\theta}(x)p'(x)p(x) もいま確率分布でしょう?

f:id:cookie-box:20180513082908p:plain:w60

r_{\theta}(x)p'(x) は確率分布の近似であって確率分布ではないかな。この積分を常に1にする制約を入れておけば KL ダイバージェンスの最小化でもいいんじゃないかな。

f:id:cookie-box:20180513082851p:plain:w60

r_{\theta}(x) は密度比なので非負ではあるとして、(11.5) 式の J(\theta) の第1項は r_{\theta}(x) が小さければ小さいほど(非負なのでゼロに近いほど)小さくなり、 第2項は逆に r_{\theta}(x) が大きければ大きいほど小さくなるので、これらが上手くバランスするところに最適な r_{\theta}(x) があるんですよねきっと。ただ、この式を最適化するときに第1項を1に等しくするという制約は自ずとは入らないですよね? 150ページの「すなわち、問題 (11.5) は、制約  \langle r_{\theta} \rangle _{\mathcal{D}'} =1 のもとで訓練データに対する異常度を最小にする最適化問題と解釈できます」というの、ここで新たにそのような制約を課したんでしょうか?? 厳密に1に等しくならなければならないというのではなく単に第1項が r_{\theta}(x) に制約を課していると言いたかったんでしょうか?? よくわかりません…。

つづきは後で

直感 Deep Learning: 5章メモ(単語分散表現)(その3)

以下の本を読みます。

直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ
Antonio Gulli Sujit Pal 大串 正矢

オライリージャパン 2018-08-11
売り上げランキング : 1992

Amazonで詳しく見る
by G-Tools
キャラクターの原作とは関係ありません。本読みメモですがよく脱線します。誤りがありましたらご指摘いただけますと幸いです。
前回:その2 / 次回:まだ
f:id:cookie-box:20180305232608p:plain:w60

5章の5.3節まで読みましたが、5.4節は実際に単語分散表現を学習する場合はこれらのようなやり方があるといった紹介なので、手を動かしてみた方がよさそうですね。そもそも、ここまで登場した単語分散表現モデルも、手元で実装してみませんか?

f:id:cookie-box:20180305231302p:plain:w60

じゃあまず Skip-gram からだな。142~144ページにコードが載ってるからこれを実行するだけ…あれっ、ど初っ端の一行目から、keras.layers から Merge という名前のオブジェクトはインポートできませんって言われたんだけど! どういうことなの!?

f:id:cookie-box:20180305232608p:plain:w60

何? keras のバージョンの違いでしょうか…この本の要求バージョンは xii ページによると 2.1.6 ですね。プロデューサーさんのマシンに入っているのは…2.2.2 ですね。

f:id:cookie-box:20180305231302p:plain:w60

えーどうする? 勝手にプロデューサーの環境の keras のバージョン下げる?

f:id:cookie-box:20180305232608p:plain:w60

それはやめた方がいいんじゃ…。そもそも、keras.layers.Merge でやりたかったのは中心語の数値ベクトルと文脈語の数値ベクトルの内積を取ることですよね…keras Merge で検索すると、keras.layers に Dot というそれそのもののレイヤーがありますよ。試してみると…ちゃんと内積が取れますね。

# -*- coding: utf-8 -*-
import numpy as np
from keras.layers import Dot, Input
from keras.models import Model

x0 = Input(shape=(3,))
x1 = Input(shape=(3,))
y = Dot(axes=-1)([x0, x1])
model = Model(inputs=[x0, x1], outputs=y)
model.compile(loss='mean_squared_error', optimizer='adam') # dummy
print(model.summary())

x0_samples = np.array([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]])
x1_samples = np.array([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [3.0, 3.0, 3.0]])
y_samples = model.predict([x0_samples, x1_samples])
print(y_samples) # --> [[6.0], [12.0], [18.0]]

これを応用すれば Skip-gram モデルは容易に実装できるはずです。せっかくなのでクラスにしてみましょう。Skip-gram が正例か負例か判定するモデルなので、SkipGramDiscriminator という名前にしてみました。

# -*- coding: utf-8 -*-
import numpy as np
from keras.layers import Dot, Input, Dense, Reshape, Embedding
from keras.models import Model

class SkipGramDiscriminator():
    def __init__(self, vocab_size, embed_size):
        self.vocab_size = vocab_size # 語彙数
        self.embed_size = embed_size # 埋め込み次元数
    def create_model(self):
        # 中心語ID --> 中心語数値ベクトル表現
        x0 = Input(shape=(1,))
        y0 = Embedding(self.vocab_size, self.embed_size,
                       embeddings_initializer='glorot_uniform')(x0)
        y0 = Reshape((self.embed_size,))(y0)
        self.word_embedder = Model(x0, y0)
        # 文脈語ID --> 文脈語数値ベクトル表現
        x1 = Input(shape=(1,))
        y1 = Embedding(self.vocab_size, self.embed_size,
                       embeddings_initializer='glorot_uniform')(x1)
        y1 = Reshape((self.embed_size,))(y1)
        self.context_embedder = Model(x1, y1)
        # 内積 --> ロジスティック回帰
        y = Dot(axes=-1)([y0, y1])
        y = Dense(1, kernel_initializer='glorot_uniform', activation='sigmoid')(y)
        self.discriminator = Model(inputs=[x0, x1], outputs=y)
        self.discriminator.compile(loss='mean_squared_error', optimizer='adam')
        print(self.discriminator.summary())
    
if __name__ == '__main__':
    sg = SkipGramDiscriminator(6, 3) # i,love,green,eggs,and,ham 6語を3次元空間へ埋込
    sg.create_model()
    x0_samples = np.array([[1], [4], [1], [4], [2]]) # 中心語: love,and,love,and,green
    x1_samples = np.array([[0], [5], [2], [2], [2]]) # 文脈語: i,ham,green,green,green
    y_samples = sg.discriminator.predict([x0_samples, x1_samples])
    print(y_samples) # 中心語と文脈語のペアであるかどうかの判定結果(学習まだ)

    # 中心語の数値ベクトル表現は中心語の Embedding 層の重みそのもの
    print(sg.word_embedder.get_weights())

    # IDから数値ベクトル表現を取り出せることの確認
    print(sg.word_embedder.predict([[0]])) # i の数値ベクトル表現
    print(sg.word_embedder.predict([[1]])) # love の数値ベクトル表現
# 中心語と文脈語のペアであるかどうかの判定結果(学習まだ)
[ [0.45034274]
  [0.4851311 ]
  [0.5297962 ]
  [0.5046127 ]
  [0.41040125] ]
# 中心語の数値ベクトル表現は中心語の Embedding 層の重みそのもの
[array([ [-0.3271621 , -0.14325774, -0.4473939 ],
        [-0.63613635,  0.71521103,  0.3045839 ],
        [-0.4950593 , -0.3844918 , -0.79637474],
        [ 0.52346075, -0.1862371 , -0.66979444],
        [-0.79966545, -0.35221177,  0.17136681],
        [ 0.13642508, -0.48409775, -0.34481126] ], dtype=float32)]
# IDから数値ベクトル表現を取り出せることの確認
[ [-0.3271621  -0.14325774 -0.4473939 ] ]
[ [-0.63613635  0.71521103  0.3045839 ] ]

まだ学習データを準備していないので正しく学習できるか試行できていませんが、モデル構造は意図通りなのではないでしょうか。

f:id:cookie-box:20180305231302p:plain:w60

そっか、Skip-gram の学習データって、コーパス内の単語に ID を割り振って、各IDのペアに正例か負例かのラベルを貼らないといけないんだよな。いちいち手でやってらんないし、学習データを生成する関数も書いた方がよさそうだな。

f:id:cookie-box:20180305232608p:plain:w60

それについては、143ページで紹介されている keras の skipgrams 関数がそれをやってくれるのでしょうね。ただ、英語コーパスを使用する場合なら本の通りでよさそうですが、もし日本語コーパスを使用したいならもっと特別な前処理が必要でしょうから、その方法を自分で調べる必要がありそうですね。今回は学習データに何をつかいましょうか?


2018-10-14 追記
f:id:cookie-box:20180305231302p:plain:w60

もうめんどくさいから機関車トーマスでいいよ。

f:id:cookie-box:20180305232608p:plain:w60

トーマスの原著(英語)があればいいのですが、手元には日本語版しかないので、ではやはり日本語用のプレ処理をしましょう。以下のリンクを参考にしました。

MeCab形態素解析結果を用いて、Skip-grams を愚直に書くなら以下のようになると思うんですよね。書いたクラスは、文章のリストを渡すと1文ずつ形態素解析して、未登録の単語を登録しながら文章をID列に変換して、Skip-grams の正例をひたすら集めています。ただ、負例をどのように準備すべきかがわからないんですよね。もちろん、正例にないIDのペアは負例なのですが…。keras の skipgrams 関数ではどのようにサンプリングされているのか確認したいですね。skipgrams 関数の入力はID列のようなので、単語にIDを割り振るまではプレ処理して後は任せるのがいいのかもしれませんが。

# -*- coding: utf-8 -*-
import MeCab
import codecs

class SkipGramCollector():
    def __init__(self, window_size):
        self.window_size = window_size # 周辺の何単語を考慮するか
        self.tagger = MeCab.Tagger('-Owakati')
        self.raw_words = []
        self.center_words = []
        self.context_words = []
    def _process_one_sentence(self, sentence):
        words = self.tagger.parse(sentence).split(' ') # 文 --> 単語列
        id_seq = [] # 単語列 --> ID列
        for word in words:
            if word in ['、', '「', '」', '\n']:
                continue # とりあえず無視
            if word not in self.raw_words:
                self.raw_words.append(word)
            index = self.raw_words.index(word)
            id_seq.append(index)
        n_words = len(id_seq)
        for id in id_seq: # 中心語と文脈語のペアの正例を登録
            for context_id in range(id - self.window_size, id):
                if -1 < context_id:
                    self.center_words.append(id)
                    self.context_words.append(context_id)
            for context_id in range(id + 1, id + self.window_size + 1):
                if context_id < n_words:
                    self.center_words.append(id)
                    self.context_words.append(context_id)
    def process_sentences(self, sentences):
        for sentence in sentences:
            if sentence == u"\r\n":
                continue
            self._process_one_sentence(sentence)

if __name__ == '__main__':
    split_sentence = None
    f = codecs.open('thomas_the_tank_engine.txt', 'r', 'utf-8') # 改行なしのテキスト
    for line in f:
        split_sentence = line.split(u'。')

    sgc = SkipGramCollector(2)
    sgc.process_sentences(split_sentence)

    # 最初の10単語をプリント
    print(sgc.raw_words[0:10])
    # 最初の10個の正例をプリント
    for i in range(10):
        print(sgc.raw_words[sgc.center_words[i]], ',', 
              sgc.raw_words[sgc.context_words[i]])
['機関', '車', 'トーマス', 'は', '大きな', '駅', 'で', 'はたらい', 'て', 'い']
機関 , 車
機関 , トーマス
車 , 機関
車 , トーマス
車 , は
トーマス , 機関
トーマス , 車
トーマス , は
トーマス , 大きな
は , 車

f:id:cookie-box:20180305231302p:plain:w60

…なあジュン、「機関車」が「機関」と「車」に分けられるのってどうなの? 機関車は機関車だろ。

f:id:cookie-box:20180305232608p:plain:w60

それは僕も結構そう思ったんですよね…。


2018-10-22 追記
f:id:cookie-box:20180305232608p:plain:w60

まあ形態素解析が多少意図通りでない点は置いておいて、Mecab形態素解析だけして、後は keras に任せた例が以下です。日本語テキストについて Skip-grams の正例も負例も得られていますね。ここでは「機関車トーマス」の最初の見開き3ページ分の文章を使いました。周辺の何単語を考慮するかの window_size は 1 にしています。skipgrams 関数は全ての正例をサンプリングし、デフォルトでは正例と同数の負例をランダムサンプリングしてくれるようですね(正例の何倍の数の負例をサンプリングするかは引数 negative_samples で調整できます)。

# -*- coding: utf-8 -*-
from keras.preprocessing.text import *
from keras.preprocessing.sequence import skipgrams
import MeCab
import codecs
import numpy as np
import pandas as pd

if __name__ == '__main__':
    tagger = MeCab.Tagger('-Owakati')
    
    # list_text に文章たちを格納(ただし、各文章は単語ごとに半角空白区切りになった状態)
    list_text = []
    f = codecs.open('thomas_the_tank_engine.txt', 'r', 'utf-8') # 改行なしのテキスト
    for line in f:
        list_text_raw = line.split('。')
        for text_raw in list_text_raw:
            text = tagger.parse(text_raw)
            list_text.append(text)
    
    # 半角空白区切りの文章たちを Tokenizer に渡して含まれる全単語を収集
    tokenizer = Tokenizer(filters='、。「」\n\r')
    tokenizer.fit_on_texts(list_text)
    
    # 単語-->ID辞書と、ID-->単語辞書を作成
    word2id = tokenizer.word_index
    id2word = {v:k for k, v in word2id.items()}
    
    # 文章ごとに Skip-gram 対を得る
    df = pd.DataFrame()
    for text in list_text:
        # 文章をID列化
        wids = [word2id[w] for w in text_to_word_sequence(text, filters='、。「」\n\r')]
        # ID列から Skip-gram 対を得る
        pairs, labels = skipgrams(wids, len(word2id), window_size=1)
        df_ = pd.DataFrame()
        df_['x0'] = np.array([pair[0] for pair in pairs]).astype(np.int64) # 中心語
        df_['x1'] = np.array([pair[1] for pair in pairs]).astype(np.int64) # 文脈語
        df_['y'] = np.array(labels).astype(np.int64) # 正解ラベル
        df = pd.concat([df, df_])
    
    df.reset_index(drop=True, inplace=True)
    print(df.shape)
    
    # 負例にサンプリングされた Skip-gram 対のうち正例に含まれているものを除去 (?)
    df_dup_check = df[df.duplicated(keep=False) & (df.y == 0)] # 複数ある対であって負例
    df_dup_check = df_dup_check[df_dup_check.duplicated() == False].copy()
    for index, row in df_dup_check.iterrows():
        df_temp = df[(df.x0 == row['x0']) & (df.x1 == row['x1'])]
        if np.sum(df_temp.y == 1) > 0: # 正例があるのでこの対の負例からは削除する
            df_temp = df_temp[df_temp.y == 0]
            df = df.drop(index=df_temp.index)
    df.reset_index(drop=True, inplace=True)
    print(df.shape)
    
    # 結果確認
    print("正例の数:", np.sum(df.y == 1))
    print("負例の数:", np.sum(df.y == 0))
    for index, row in df.iterrows():
        print("({:s} ({:d}), {:s} ({:d})) -> {:d}".format(
          id2word[row['x0']], row['x0'], id2word[row['x1']], row['x1'], row['y']))
        if index == 10:
            break
(444, 3)
(442, 3)
正例の数: 222
負例の数: 220
(は (3), ぼく (65)) -> 0
(大きな (13), 駅 (21)) -> 1
(まし (10), た (4)) -> 1
(駅 (21), 大きな (13)) -> 1
(まし (10), さ (69)) -> 0
(駅 (21), ピッピー (20)) -> 0
(はたらい (22), とおく (33)) -> 0
(トーマス (8), つい (30)) -> 0
(車 (7), からっぽ (39)) -> 0
(で (14), 駅 (21)) -> 1
(い (9), まし (10)) -> 1

ここで1点取り扱いがわからなかったのですが、本の143ページでは1つの文章のみから Skip-gram 対を抽出していますが、実際のコーパスには文章が複数含まれているはずです。その場合、1文1文ずつ skipgrams 関数を適用すると、負例としてサンプリングされる Skip-gram 対が実は他の文章では正例であったということがあるかもしれません。上のスクリプトでは、まずは1文1文ずつ全ての文章に対して Skip-gram 対を得て、最後にもし正例にもなっている負例が含まれていれば除去するということをしています(ので、最終的な正例の数と負例の数が微妙にずれています)。 しかし、どう処理するのが一般的なのかわかりません。

f:id:cookie-box:20180305231302p:plain:w60

ふーん? まあでも、学習データができたんなら早速学習してみようぜ!

f:id:cookie-box:20180305232608p:plain:w60

そうですね。例えば上のスクリプトに対して、上で実装したニューラルネットワークを読み込んで、抽出した学習データをそのまま渡せば学習できますよ。

# -*- coding: utf-8 -*-
from keras.preprocessing.text import *
from keras.preprocessing.sequence import skipgrams
import MeCab
import codecs
import numpy as np
import pandas as pd
from skip_gram_discriminator import SkipGramDiscriminator # 上で実装したクラスを読み込み

if __name__ == '__main__':
    # ここの部分のスクリプトは省略

    sg = SkipGramDiscriminator(len(word2id), 3) # 3次元に埋め込む場合
    sg.create_model()
    sg.discriminator.fit([df.x0.values, df.x1.values], df.y.values,
                         batch_size=32, epochs=100)

(次回があれば)つづく

直感 Deep Learning: 5章メモ(単語分散表現)(その2)

以下の本を読みます。

直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ
Antonio Gulli Sujit Pal 大串 正矢

オライリージャパン 2018-08-11
売り上げランキング : 1992

Amazonで詳しく見る
by G-Tools
キャラクターの原作とは関係ありません。本読みメモですがよく脱線します。誤りがありましたらご指摘いただけますと幸いです。
前回:その1 / 次回:まだ
f:id:cookie-box:20180305232608p:plain:w60

単語分散表現とは、単語間の関係が反映された単語の数値ベクトル表現を得ることでしたね。

f:id:cookie-box:20180305231302p:plain:w60

今回は5.2節の word2vec からか。word2vec には CBOW と Skip-gram というアーキテクチャ?があって、前者では文脈語から中心語を予測して、後者では中心語から文脈語を予測する…あのさ、

  • word2vec 自体は何を指すの? 「CBOW と Skip-gram があります」じゃ説明になってなくない?
  • 文脈語と中心語って何?
  • なんか単語から単語を「予測する」って言ってるけどさ、いまやりたいのは単語を数値ベクトルにすることだよね? なんで予測始めちゃったの??

f:id:cookie-box:20180305232608p:plain:w60

CBOW と Skip-gram の具体的な手順を追ってみれば自ずとわかるでしょう。本でも英語の童話の文を例に挙げられていますが、ここでは以下の文章で考えてみます。機関車トーマス (汽車のえほん 2) | ウィルバート・オードリー, レジナルド・ドールビー, 桑原 三郎, 清水 周裕 |本 | 通販 | Amazon の最初の文章ですね。

「タンク式機関車のトーマスは大きな駅ではたらいていました」

f:id:cookie-box:20180305231302p:plain:w60

なんでトーマス!?

f:id:cookie-box:20180305232608p:plain:w60

プロデューサーさんがはまっているので。上の日本語文を形態素解析すると以下のようになると思います。実際にやっていないので脳内 tokenizer ですが。 11個の単語がありますので、1~11のIDを付けておきます。

タンク式機関車12トーマス34大きな567はたらい89いまし1011

f:id:cookie-box:20180305231302p:plain:w60

なんか1個目の単語から聞き慣れないんだけど。タンク式機関車って何?

f:id:cookie-box:20180305232608p:plain:w60

タンク式というんだから水を貯めるタンクが専用の炭水車ではなく本体に積載されている機関車に決まってるでしょう。物語中では最重要キャラクターのトーマスとパーシーがこのタンク式なんです。

f:id:cookie-box:20180305231302p:plain:w60

そっか…。

f:id:cookie-box:20180305232608p:plain:w60

ここである単語とその周辺の数単語を中心語と文脈語とよぶことにします。例えば、「トーマス」に着目してこれを中心語として、ここから2単語以内にある単語を文脈語とするとします。

タンク式機関車12トーマス34大きな567はたらい89いまし1011
先に Skip-gram の方の手順から追いかけます。Skip-gram では中心語から文脈語を予測します。正確には、以下のようなモデルを学習します。
  • 「中心語:トーマス、文脈語:タンク式機関車」という入力 → OK(1)
  • 「中心語:トーマス、文脈語:駅」という入力 → NG(0)
「タンク式機関車」は「トーマス」から2単語以内にあるので OK だが、「駅」は「トーマス」から2単語以内にはないので NG ということです。予測器というより判定器といった方がしっくりきますね。結果的に予測はしているんですが。そして、この判定器は以下のようにつくることにします。
  1. 中心語を中心語用の Embedding 層で数値ベクトルにする。
  2. 文脈語を文脈語用の Embedding 層で数値ベクトルにする。
  3. 1. と 2. の内積をとる。
  4. 3. に出力の次元数が1の Dense 層をかぶせ、sigmoid で活性化して最終的な判定とする。
    言い換えるとこの層では、中心語と文脈語の内積という1次元の説明変数をつかって「本当に中心語と文脈語のペアでありうるか?」の分離境界をロジスティック回帰するんですね。

f:id:cookie-box:20180305231302p:plain:w60

はいはい質問! Embedding 層って何? 説明がないよね?

f:id:cookie-box:20180305232608p:plain:w60

調べると、例えば全11単語を5次元に埋め込むとしたら Embedding 層は11×5次元の行列であるようですね。何のことはない、各単語がどのような数値ベクトルになるかをそのまま重みとして保持する(ように学習する)層なんですね。まあそれで、中心語側の Embedding 層による数値ベクトル化が、今回得たい単語分散表現に他なりません。

f:id:cookie-box:20180305231302p:plain:w60

あっ、そこを最終的につかうんだ! 予測したいわけじゃないのに予測してたのは、後から Embedding 層を取り出すためだったのか。…でもさ、そもそもなんでそんな予測しようって思ったんだ? なんか天から降ってきた感じしない?

f:id:cookie-box:20180305232608p:plain:w60

そうですね…頭を真っ白にして、「目の前にあるコーパス内の全単語を、単語間の何らかの距離を反映するようにN次元空間に埋め込んでください」と言われたらどうすればいいか考えてみましょう。但し、そのコーパス以外に何の辞書も知識もないとします。その制約下である単語とある単語が近くにあるかどうかの基準は、「登場頻度が似ているか」「同じ文章内に登場しやすいか」「文章内で隣り合って登場しやすいか」「文章内で登場する位置が序盤か中盤か終盤かが似ているか」など色々考えられます。文章内での登場位置に踏み込んだルールであって最も基本的なのはやはり「文章内で隣り合って登場しやすいか」なのではないでしょうか。そしておそらく「隣同士」としてしまうと単語間が近いとみなす基準があまりに狭いので「M単語以内」の方がいいと思います。であれば、「ある単語とある単語がM単語以内に登場しやすいか」を考えることになります。Skip-gram で「中心語」と「文脈語」を別々に Embedding するのはおそらく単語間の距離が非対称だからでしょう。どの単語からみても近い単語というのはありそうですが、それで対称性をもつ距離を入れたらあらゆる単語が近くに集まってしまいそうですよね。…のように考えれば、Skip-gram 分類器を学習しようとするのは素朴な発想なのではないでしょうか。

f:id:cookie-box:20180305231302p:plain:w60

長っ! じゃあ Skip-gram は無理がない発想なんだとして、もう1つの CBOW ってのはどうなの?

f:id:cookie-box:20180305232608p:plain:w60

CBOW は Skip-gram とは逆に文脈語から中心語を予測するということですが、入出力はこうですね。出力されるのは、入力された文脈語群の中心語はどれかという確率分布です。以下の入力だと、「トーマス」のところで大きい確率であってほしいですね。

  • 「文脈語群:タンク式機関車、の、は、大きな」という入力 → 全ての単語上の確率分布
このモデルは以下のようにつくることにします。
  • 各文脈語を共通の Embedding 層で数値ベクトルにする。
  • 全文脈語の平均ベクトルを出す。
  • それを出力の次元が語彙数の Dense(softmax で活性化)で中心語の予測分布にする。
こうやって学習したときの Embedding 層が求める単語分散表現です。ネットワーク構造は Skip-gram のときと多少異なりますが、要は文脈語への Embedding の方を利用しようということですね。

f:id:cookie-box:20180305231302p:plain:w60

…Skip-gram のときの「内積をとる」もそうだったんだけど、こっちの CBOW の「平均をとる」もかなり決め打ちの操作っぽいような? せっかくニューラルネットつかってるのにさ。

f:id:cookie-box:20180305232608p:plain:w60

そうすることで単語空間がユークリッド空間っぽくなるんじゃないですか(適当)。

f:id:cookie-box:20180305231302p:plain:w60

じゃ Skip-gram と CBOW はどっちをつかえばいいの?

f:id:cookie-box:20180305232608p:plain:w60

140ページに、Skip-gram の方は低頻度語の予測に優れ、CBOW の方は高速とあります。確かに CBOW は Embedding 層が1つしかないので学習が速そうですね。ただ予測器の入力が「文脈語群」なので、低頻度語が混ざった入力の場合、低頻度語以外の寄与で正解を当ててしまい、低頻度語の表現が正しく得られないのかもしれません。Skip-gram は、各単語を「ある単語が周囲に登場しそうか」で特徴づけますから、低頻度語であっても表現の学習が疎かにはならなさそうです。

f:id:cookie-box:20180305231302p:plain:w60

なるほど。これで Skip-gram と CBOW は終わって…147ページの真ん中らへんの「類似した単語がひとかたまりになります」ってどういうこと?

f:id:cookie-box:20180305232608p:plain:w60

例えば…「[ X ] はバンドに欠かせない弦楽器である」という文章の [ X ] には「ギター」も「ベース」も入りますよね。なのでありうる文章をじゅうぶん含んだコーパスで学習すれば Skip-gram や CBOW で「ギター」や「ベース」は近くなるはずです。次に「High×Joker の[ X ] 担当は[ Y ] である」という文章を考えると、[ X ] に応じて[ Y ] には「秋山隼人」「榊夏来」が入るはずですが、「ギター」と「ベース」がほとんど近いならば先ほどと同様の原理で「秋山隼人」と「榊夏来」も近くなるはずです。ということは、「秋山隼人→ギター」「榊夏来→ベース」という2つのベクトルはほとんど似ているはずなんです。このベクトルはさしずめメンバーと担当楽器を結ぶベクトルですね。

f:id:cookie-box:20180305231302p:plain:w60

おお! なんか熱い!

f:id:cookie-box:20180305232608p:plain:w60

となると、「榊夏来 - ベース + ギター = 秋山隼人」のような計算式も成り立ちそうです。

f:id:cookie-box:20180305231302p:plain:w60

…それは違くない? その計算結果は俺じゃなくて、ギター持ったナツキじゃん。

f:id:cookie-box:20180305232608p:plain:w60

左辺はナツキからベース取り上げてギター持たせるって意味じゃないんで…。

f:id:cookie-box:20180305231302p:plain:w60

次の 5.2.4 節はどんな話?

f:id:cookie-box:20180305232608p:plain:w60

現実に単語分散表現を学習するときには Keras より gensim などが便利でしょうという話ですね。

f:id:cookie-box:20180305231302p:plain:w60

Keras の本なのに!?

f:id:cookie-box:20180305232608p:plain:w60

gensim にはある単語に類似度が高い上位の単語を出せる関数や、単語間の類似度を出せる関数が備わっているようですね。

f:id:cookie-box:20180305231302p:plain:w60

この類似度って具体的に何を計算しているんだろう?

f:id:cookie-box:20180305232608p:plain:w60

実際に使ってみて数値表現から検算してみるとよさそうですね。


2018-10-07 追記
f:id:cookie-box:20180305231302p:plain:w60

5.3節は GloVe か。なんか word2vec とは総じて何なのかわからないまま GloVe に入っちゃったんだけど…。GloVe では「文脈」というものを考えるのか。LSA の特異値やトピックモデルのトピックのようなものなのかな…いや違うな。むしろ LSA やトピックモデルでいう文書に相当するのがここでの文脈(通常は単語列)なのか。単語文脈共起行列をさらに単語特徴行列と特徴文脈行列の積に分解するみたいだし。だから、特異値やトピックに相当するのはむしろ「特徴」か。じゃあ GloVe は、文脈は特徴の(重み付き)組合せでてきていて、特徴は生成する単語分布をもつと考えているのかな。でどうやって行列を分解するのかというと…ランダムな値から出発して誤差(再構築誤差)を減少させるように SGD で学習するのか。この誤差の定義がわかんないから全然わかんないな。

f:id:cookie-box:20180305232608p:plain:w60

論文を参照するように書いてありますからね。

f:id:cookie-box:20180305231302p:plain:w60

まあいいや。153ページの図で単語特徴行列がグレーに塗られているから、これが求める単語分散表現そのものなんだな。152ページと153ページで繰り返し出てくる「word2vec は予測ベースで GloVe はカウントベース」ってどういうことなんだろ?

f:id:cookie-box:20180305232608p:plain:w60

153ページの最下部からは、前者は個別データから学習が始まり後者は全体データの単語共起統計量から学習が始まるというように書いてありますね。word2vec の Skip-gram はある別の単語がある単語の文脈語であるかどうかを個別に学習させますが、GloVe はある単語の文脈のヒストグラムを再現するように一気に特徴を学んでいるようには見えますね。Skip-gram でもバッチサイズが大きければ一気に学習する感じにはなると思うのですが。最初から単語共起統計量をターゲットにする方が精度よく特徴を学べるのかもしれません。154ページに GloVe の方が一般に精度がよいとありますから。ただ並列化しないと計算が遅いのが欠点ということなのでしょうか。この本ではあまり詳細に書いていないのでわかりませんね。

(次回があれば)つづく