終末 A.I.

データいじりや機械学習するエンジニアのブログ

word2vec の結果を利用して RNN で文章を生成してみる(1)

chainer のサンプルの中には RNN 利用して文章を学習し、コンテキストに沿った単語を選択できるようになる ptb のサンプルが付属しています。

今回はこいつをちょっと改造して、単語の識別IDではなく、word2vec で生成したベクトルを用いて ptb サンプルと同じことをやってみようと思いま......したが、残念がら chainer の仕様理解ができていなかったようで、一切パラメーター更新ができておらず、4000円ほどドブに捨てる結果となってしまいました。辛すぎる!

そういうわけで今日のところは、こういう風にやったらうまく学習できなかったという記録のみ記載しておきたいと思います。原因分かり次第、追記か別記事を書きます。

今回学習がうまくいかなかったモデルは以下のように実装しました。元の ptb サンプルから embedID のレイヤの関数を取り除き、Classification で loss を算出する代わりに、huber 損失関数という二乗誤差の親戚みたいな関数で loss を求めると言った構成になっています。入力は単語ベクトルで出力も単語ベクトルになります。LSTMかましていることからも分かるように、model をリセットするまでは以前の入力値も反映した出力をしてくれるモデルです。

class RNNLM(chainer.Chain):
    def __init__(self, n_vocab, n_units, train=True):
        super(RNNLM, self).__init__(
            l1=L.LSTM(n_vocab, n_units),
            l2=L.LSTM(n_units, n_units),
            l3=L.Linear(n_units, n_vocab),
        )
        self.train = train

    def reset_state(self):
        self.l1.reset_state()
        self.l2.reset_state()

    def __call__(self, x, t):
        h1 = self.l1(F.dropout(x, train=self.train))
        h2 = self.l2(F.dropout(h1, train=self.train))
        y = self.l3(F.dropout(h2, train=self.train))
        return F.huber_loss(y, t)

そして、このモデルの loss に対して特に何も考えずにもとの ptb サンプルと同様に、backword して、unchain して、optimizer を update したのですが、全くろくな結果を得ることができませんでした。調べてみると、重み値の更新が一切行われておらず、初期値のまま。 ......なんでや。Function でなくちゃんとレイヤをかまさなければならないのか、それとも loss の扱い方が悪かったのか。

<<追記>> 2016.4.17 夜

huber_loss で学習ができない件について、自己解決しました。huber_loss はミニバッチの学習には対応していないようで、そのために全く学習ができていなかったようです。実装上の都合なのか、関数の制限なのかは勉強不足ため理解できていませんが、ひとまず huber_loss の部分も二乗誤差に差し替えることにより無事学習が行えました。やったね!

しかし学習はできているようであるものの、全くいい感じに文章を覚えてくれません。やたらめったらΣを押してくるこになってしまいました。バッチサイズがでかすぎたのか、エポック数が足りないのか、単純に二乗誤差では学習がうまくいきにくいのか。ちょっと文献をあさってみる必要がありそうです。

Wikipedia を word2vec する

前回、青空文庫で word2vec を試してみましたが、結果を見ての通り、作家によって類似する単語が違ったり、そもそも語彙が少ないため、あまり汎用性のある結果を得ることはできませんでした。

ksksksks2.hatenadiary.jp

そこで今回は、日本語 Wikipedia のダンプデータを使用して、word2vec で学習させてみました。 Wikipedia ではこちらに記載されているように、Wikipedia 上で作成された様々なデータのダンプデータを配布しています。主なものだと、全ページの要約や全文、変わったものだと、ページ間のリンク関係のデータなどが含まれています。 今回は、日本語の最新情報から全文情報を取得して、使用しました。ちなみに、このデータは圧縮時でも2GB、展開すると10GB近くあるデータになります。このデータは XMLwiki 記法で記述されておりそのままでは使用できないため、wikiextractorを使用して平文になおした後、mecab分かち書きに変換して学習に用いました。ちなみに学習に用いたデータは3GBほどになります。

結果の比較のため、イテレーション回数と学習アルゴリズムを変えて試してみました。イテレーション回数は、1回と10回。アルゴリズムは、cbow と skip−gram を使用しました。word2vec と cbow の詳細は、前回の記事ををご覧ください。skip−gram は、cbow とは逆で単語を入力し文脈語を出力するニューラルネットワークを学習する点が特徴です。サンプリングなどの学習に用いるテクニックは cbow と共通ですが、バックプロバケーションの必要がある出力が多いため、cbow より3〜4倍ほど学習に時間がかかります。 CPU4コア、メモリ16GHzの環境だと、skip−gramでイテレーション回数を10回にした場合、だいたい丸二日ほどかかりました。

できれば評価データを用いて定量的に見たかったのですが、日本語での評価データを探しきれなかったため、前回と同様、何個かピックアップして結果を見ていきたいと思います。結果を見るのは、単語同士の類似度比較と、単語の組み合わせでの類似度(足し算引き算の結果)比較を行いました。

  • 単語類似度

問題

ランク cbow 1 skip−gram 1 cbow 10 skip−gram 10
1 弊害 解決 課題 解決
2 課題 課題 矛盾 課題
3 不都合 コンフリクト 不都合 難問

明日

ランク cbow 1 skip−gram 1 cbow 10 skip−gram 10
1 きっと あした いつか あした
2 あした 明後日 きっと あす
3 君たち きっと あした きっと
  • 単語の組み合わせ類似度

日本 + 東京 - アメリカ

ランク cbow 1 skip−gram 1 cbow 10 skip−gram 10
1 ロサンゼルス ニューヨーク ニューヨーク ニューヨーク
2 ニューヨーク ヨンカーズ ロサンゼルス ロサンゼルス
3 サンフランシスコ ファロン ロンドン カリフォルニア

昼 + 太陽 - 夜

ランク cbow 1 skip−gram 1 cbow 10 skip−gram 10
1 満月 満月 満月 満月
2 夜空 夜空 夜空 トラウィスカルパンテクートリ
3 地平線 まばゆい 地平線 ぃだ

基本的にイテレーション回数が多い方が良いように見えますが、4番目の「昼 + 太陽 - 夜」を見ると、skip−gram 10の2,3番目の結果は全く意味が分かりません。「トラウィスカルパンテクートリ」はアステカ神話における明けの明星を擬人化した神様であるとのことです。「ぃだ」については全くわかりません。「てぃだ」であれば沖縄で太陽を意味する方言になりますが、mecab分かち書きする時点でうまく処理できていないものと思われます。

また、「明日」の類似単語として「きっと」が現れたり、「日本 + 東京 - アメリカ 」の結果に「ワシントン」が出てこないなど、必ずしも意図していない結果が出てきています。このあたりのアルゴリズムコーパスと欲しい結果とのギャップを埋めるようなワンクッションが、学習過程か学習結果の利用時にまだまだ必要そうです。

word2vec を青空文庫で試してみる

word2vec は単語のベクトル表現をえるための手法の一つで、ニューラルネットワークを利用して行われているものです。 登場した当時の他の単語ベクトル生成手法に比べ高速に、そして単語関係の表現能力が高い獲得できる点がポイントです。 CBOWとskip−gramの2つのアルゴリズムが提案されていて、今回はCBOWを使ってみました。

CBOWは、注目する単語の周辺 N 語を入力すると、注目する単語にカテゴライズするように学習します。 ニューラルネットワークの形としては、やや変則的ですが、入力層がNxV(Vは単語数)、出力層がVの2層のニューラルネットワークになります。 入力層から隠れ層への重み行列は単語ごとに共通で、単語表現を合計した後、V次元の入力層に入力するのと同じになります。 入力層や出力層での単語表現は、IDの割り当てになります。その単語を表現する次元は1、それ以外は0になっているベクトルです。 一方、学習の結果得ることができる単語ベクトルは、入力層から隠れ層への重み行列を用います。 このことにより、学習器が獲得した内部表現を利用することができるようになります。

ちなみに今回は、chainer のword2vecサンプルではなく、C言語実装のword2vecを利用しました。 理由としては、単純にこっちの方が非常に高速だからです。こちらのブロク記事で一目瞭然ですが、C言語実装のword2vecはCPUだけで非常に高速に動作します。 C言語で実装されているということと、頻出語の学習を極力行わないようにするサブサンプリングという処理を行っているのが大きいのだろうと思います。

データには、青空文庫のテキストデータを使用しました。特定の作家の作品を全て落としてきて、青空文庫用の特殊記法やヘッダー、フッターを以下のようなコードで削って利用しました。 また、テキストを分かち書き形式にしておく必要がありますが、それにはmecabを利用しています。

import re
import argparse

# 文中の特殊記号の正規表現
replace_regex = [
    re.compile("《.+?》"),
    re.compile("|"),
    re.compile("[#.+?]"),
    re.compile("〔.+?〕")
]

def aozora2txt(input, output):
    f_in = open(input, 'r', encoding='shift_jis')
    f_out = open(output, 'a', encoding='utf-8')

    # 本文推定用
    start_line = "-------------------------------------------------------"
    end_word = "底本:"
    start_line_count = 0

    for line in f_in:
        # 終了と開始を判定する
        if line.find(end_word) > -1: break
        if line.find(start_line) > -1:
            start_line_count += 1
            continue
            
        if start_line_count < 2: continue
        
        for regex in replace_regex:
            line = regex.sub('', line) # 本文中の特殊記号を除去
        f_out.write(line)

    f_in.close()
    f_out.close()

作家は、夏目漱石太宰治。類似単語を検索する単語には、人生、愛。 パラメーターによる結果比較のため、出力するベクトルの次元数と、サブサンプリングに利用する値を変えてみています。ベクトルの次元数を変えることで獲得される内部表現の形が、サブサンプリングに利用する値を変えることで頻出語の扱われ方がそれぞれ影響を受けます。

  • 人生

夏目漱石

ランク ベクトル100
サンプリング1e-2
ベクトル200
サンプリング1e-2
ベクトル100
サンプリング1e-4
ベクトル200
サンプリング1e-4
1 人世 人世 触れろ 意義
2 自己 宇宙 意義 人世
3 宇宙 人格 美的 触れろ

太宰治

ランク ベクトル100
サンプリング1e-2
ベクトル200
サンプリング1e-2
ベクトル100
サンプリング1e-4
ベクトル200
サンプリング1e-4
1 人間 奇蹟 ドラマ ドラマ
2 青春
3 行為 行為 現実 青春

夏目漱石

ランク ベクトル100
サンプリング1e-2
ベクトル200
サンプリング1e-2
ベクトル100
サンプリング1e-4
ベクトル200
サンプリング1e-4
1 弄ぶ 対象
2 個々 純潔 深刻
3 宇宙 宇宙 信念

太宰治

ランク ベクトル100
サンプリング1e-2
ベクトル200
サンプリング1e-2
ベクトル100
サンプリング1e-4
ベクトル200
サンプリング1e-4
1 遂行 遂行
2 正義 愛情 異性 異性
3 愛情 信実 愛情 表現

結果としては、作家と頻出語の扱いの違いが、類似語の探索結果に大きく影響を与えているようです。思ったより出力次元数には影響がないようですね。一作家の文章だけなので全体として語彙が少なめ(一万五千語くらい)だからかもしれません。

Chainer の imagenet サンプルで遊んでみる

Deep Learning といえば、やはり画像認識での利用です。今さら感もないではないですが、chainer の imagenet サンプルは、日々進歩している画像認識処理を様々な形のネットワークで試してみることができ、Deep Learning の仕組みがどのようになっているか、chainer でオリジナルのネットワークを組み立てるにはどのように記述したらいいか、概要を把握するにはぴったりのサンプルだと思います。 今回は、chainer 1.7.1 に付属している imagenet サンプルのうち NIN ネットワークサンプルを利用して画像認識を行ってみました。 画像データセットとして、こちらの256 Object Categoriesを使用しました。

Deep Learning で画像認識を行う場合、一般的に畳み込みニューラルネットワーク(CNN)と呼ばれるものを用います。ネットワークの名前にもなっている畳み込み処理ですが、Deep Learning 特有の処理ではなく、画像処理として一般的に用いられているフィルタ処理のことを指します。このフィルタ処理は、数学的には行列演算として表現することができ、前層の出力に重みをかけ合計することにより次層の入力とする、ニューラルネットワークの演算処理でそのまま表現することができます。 CNN では、学習によりこの重みが更新される、つまり画像を処理するためによりよいフィルタを獲得していくことが成果となります。また、フィルタは何層にも重ねることができ、フィルタの出力結果に対するフィルタといったより複雑な特徴抽出を行うことができます。

このフィルタ処理の結果は、似たような画像であっても微妙な違いにより出力にムラが出ることがあります。そのムラを除去するために用いられるのがプーリング層で、出力結果を安定させる効果があります。この層は、特定区間の平均や最大を出力とする固定の操作が行われるため、学習による重みの更新を行う必要はありません。プーリング層は、一般的に畳み込み層の直後に置かれます。

今回使用したNINは、畳み込み層として、MLPConv と呼ばれる非線形な畳み込み層を使用しています。具体的には、3つの畳み込み層の後に1つのプーリング層があるという構造になります。NINは、このMLPConv層を4つつなげることにより、複雑な特徴表現を学習するようなネットワークになっています。

imagenet サンプルでは、学習データの用意は自分で実装する必要があります。画像は適当な画像データベースからおとしてくれば良いですが、学習に用いるデータセットのリストの作成と、学習データを256x256サイズにリサイズしておく必要もあります。今回は、ラベル毎にランダムで、80%を学習用データ、残りを検証用データとして扱うようにしました。また、縦横それぞれが256ピクセルになるように拡大・縮小することにより、画像をCNNで扱えるようにリサイズしています。

import os
import argparse
import random
from PIL import Image


parser = argparse.ArgumentParser(description='Image net dataset create')
parser.add_argument('--root', '-r', default='.', help='image files root path')
parser.add_argument('--output', '-o', default='Images', help='output root path')


args = parser.parse_args()
train_list_file = open('train_list.txt', mode='w', encoding='utf-8')
validate_list_file = open('validate_list.txt', mode='w', encoding='utf-8')
train_rate = 0.8 # 80% の確率で学習用データとする

if not os.path.lexists(args.output): os.mkdir(args.output)
directories = os.listdir(args.root)

for i, directory in enumerate(directories):
    full_dir_path = os.path.join(args.root, directory)
    if not os.path.isdir(full_dir_path): continue

    for file_name in os.listdir(full_dir_path):
        try:
            # Resize 256x256
            input_path = os.path.join(full_dir_path, file_name)
            if not os.path.isfile(input_path): continue

            path = os.path.join(args.output, file_name)
            Image.open(input_path).resize((256, 256), Image.LANCZOS).convert("RGB").save(path)

            label = directory.split(".")[0]
            line = path + " " + label + "\n"

            if train_rate > random.random():
                train_list_file.write(line)
            else:
                validate_list_file.write(line)
        except:
            print(file_name, "is not image")

train_list_file.close()
validate_list_file.close()

ちょっと前置きが長くなりましたが、早速学習結果を見てみましょう。まず、30エポック学習させた結果どのような画像を認識できるようになったか見てみます。ラベルが256ありますが、そのうち野球用バッドの画像セットについての結果です。バッドといえば言わずもがなこんな感じの画像が含まれています。

f:id:KSKSKSKS2:20160327151445j:plain

一方で、下のようなバッド以外のものも混じった意地悪なデータも学習セットにはだいぶ含まれています。

f:id:KSKSKSKS2:20160327151435j:plain

このような画像を認識した結果、基本的に学習セットにあるデータに関しては、バッドと認識するようになりました。混合画像までバッドと認識してしまうのはちょっと考えものですが、そのように教え込んでいるので仕方ありません。一方、未学習データに関しては下の一番目や三番目の画像はバッドと認識してくれますが、二番目の画像は残念ながらバッドとしては認識してくれませんでした。なんとなくですが、向きが関係してるのではないかと予想しています。

f:id:KSKSKSKS2:20160327151645j:plain f:id:KSKSKSKS2:20160327152126j:plain f:id:KSKSKSKS2:20160327152408j:plain

次に、識別精度を見てみます。比較として同じデータで別に学習し、10エポックで学習を終了したモデルの結果も掲載します。見ての通り、学習用データにおいても、検証用データにおいても、30エポックまで学習したモデルの方が識別精度が良くなっています。 学習用データセットの識別精度は大幅に上昇しており、ほとんどの画像を正しくラベリングできるようになっています。しかし、先ほどのバッド例のように、他の物体も混じっている画像なども多く含まれているため、学び方としてはあまり良くないように思われます。 検証用データの精度があまり上昇していなことからもそのあたりは読み取っていただけるかなと思います。もうちょっとスクリーニングされているデータセットでないと、適当にやるには厳しかったかもしれません。

30 epoch result:
train accuracy: 0.926514966153
validate accuracy: 0.422843056697

10 epoch result:
train accuracy: 0.390098686893
validate accuracy: 0.315529991783

最後に、上で比較したそれぞれのモデルの第一層の重みを可視化したものを比較してみました。左がエポック10まで学習したもの、右がエポック30まで学習したものとなります。なんとなくですが、右の方が濃淡がはっきりしているように見えます。しっかり特徴抽出を行えるようになっているということですね。ここまで自動で識別できることを考えると、多少複雑な識別問題でもそれなりのデータセットを用意すれば的確に認識してくれそうです。

f:id:KSKSKSKS2:20160327154322p:plain f:id:KSKSKSKS2:20160327154325p:plain

Chainer の ptb サンプルで遊んでみる

AWSGPU環境をなんとか整えたので、RNN で遊んでみようと思い、Chainer の ptb サンプルを試しに動かしてみました。 ptb サンプルでは、入力された単語系列(文章)を元に、次の単語を推論する構造で、RNNのよくあるモデリングになっています。 ちなみに前回までに入力された単語(ようするに文脈)を覚えておく構造にはLSTMという特殊な層を利用しています。 また、入力層と出力層にはそれぞれの単語に相当するユニットが存在します。語彙が一万個あればそれぞれ一万ユニット必要で、学習時にユニットが割り当てられていない単語については入力も出力も行うことができません。

このRNNの学習はなかなかに遅く、今回は epoch5 くらいまで学習させてみましたが、GPU機で軽く2時間はかかりました。 g2.xlarge インスタンスは1時間100円するので、学習が終わったら自動で停止してくれるギミックを作っておかないとオチオチ夜も寝られません。

今回は、学習はリモートのGPU環境、テストデータの結果出力はローカルのCPU環境で行いました。 サンプルコードは Chainer 1.7.1 に付属のものを使用しています。 ちなみに、結果出力用のコードは下のような感じです。

import numpy as np
import six

import chainer
from chainer import cuda
import chainer.links as L
from chainer import optimizers
from chainer import serializers

import net


def load_data(filename):
    global vocab
    global inv_vocab
    words = open(filename).read().replace('\n', '<eos>').strip().split()
    dataset = np.ndarray((len(words),), dtype=np.int32)
    for i, word in enumerate(words):
        if word not in vocab:
            vocab[word] = len(vocab)
            inv_vocab[len(vocab)-1]=word # 単語逆引き用辞書
        dataset[i] = vocab[word]
    return dataset

vocab = {}
inv_vocab = {}
train_data = load_data('ptb.train.txt')
train_data = load_data('ptb.train.txt')
test_data = load_data('ptb.test.txt')
print('#vocab =', len(vocab))

# 学習済みモデル取り込み
model = L.Classifier(net.RNNLM(len(vocab), 650))
serializers.load_npz('rnnlm.model', model)
model.predictor.reset_state() # LSTM層の初期化

for i in six.moves.range(10):
    data = test_data[i:i+1]
    print(inv_vocab[data[0]])

    x = chainer.Variable(data, volatile=False)
    h0 = model.predictor.embed(x)
    h1 = model.predictor.l1(h0)
    h2 = model.predictor.l2(h1)
    y = model.predictor.l3(h2)

    # 出力結果のうち上位3個を出力
    prediction = list(zip(y.data[0].tolist(), inv_vocab.values()))
    prediction.sort()
    prediction.reverse()

    for i, (score, word) in enumerate(prediction):
        print('{},{}'.format(word, score))
        if i >= 2: break

上で書いたように、RNNの学習はそれまでにどんな値を入力されたかどうかで変化します。 今回は、サンプルのテスト用データ内の「no it was n't black monday」という文を使用して、毎回LSTM層のパラメーターをリセットした文脈を無視した場合と、最初の単語の時のみLSTM層のパラメータをリセットした文脈を考慮した場合でそれぞれ結果を比べてみました。

  • 文脈無視
no
longer,10.33497428894043
doubt,8.990683555603027
way,8.030651092529297
it
is,10.528656005859375
will,9.515053749084473
has,9.51209831237793
was
a,8.827898979187012
<unk>,8.493036270141602
the,7.862812042236328
n't
<unk>,7.8151350021362305
yet,6.953648090362549
<eos>,6.921860694885254
black
<unk>,9.096553802490234
and,8.121747016906738
<eos>,7.601911544799805
monday
<eos>,10.35195541381836
's,9.545741081237793
and,8.740907669067383
<eos>
the,9.107492446899414
<unk>,7.814574241638184
but,7.787456512451172

<eos> は End Of State の略で文の終わりを意味します。<unk> は Unknown の略で未知語を意味し、今回学習に用いたサンプルデータでは、特定の人名に相当します。 最初の単語の no を見てみると no longer, no doubt, no way など慣用句的に使用されるような単語が出力されており、うまいこと学習できていることがわかります。他の単語を見ても、文章のどこかには出てきそうな組み合わせばかりで、次の単語を推測するというタスクの結果としては悪くないもののように思えます。

  • 文脈考慮
no
longer,10.33497428894043
doubt,8.990683555603027
way,8.030651092529297
it
is,9.707919120788574
<eos>,9.36506462097168
will,9.056198120117188
was
a,8.486732482910156
<unk>,8.168980598449707
the,8.025396347045898
n't
a,8.514782905578613
the,8.452549934387207
clear,8.159638404846191
black
<eos>,12.180181503295898
in,10.142268180847168
to,9.996685028076172
monday
<eos>,11.57198715209961
and,9.793222427368164
in,9.25011920928955
<eos>
the,9.041053771972656
but,8.242995262145996
mr.,8.005669593811035

さて今度は、以前までの単語系列を考慮した場合の学習結果です。特筆すべきは n't の次の単語を推測した結果で、文脈を考慮しなかった場合は、<unk>, yet, <eos> が推測結果でしたが、考慮した場合は、a, the, clear と was n't に続きそうな単語が推測されていることが分かります。(yet や <eos> は wasn't の次にはあまりこないように思われます。) なんとなくですが、文脈を考慮して結果が出てきてるような感じですね。モデルやデータセット、学習量によってどれくらい結果が変わるかも調べてみると面白そうです。