朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

Word2vec や fastText を Java のコードに適用して “add + map - list = put” のような結果を得たい

はじめに

開発部の tasaki です。 2013 年の Word2vec や 2016 年の fastText など、自然言語処理の分野には単語をベクトル(分散表現)に変換する手法がいくつかあります。 一旦分散表現に変換してしまえば加減算などの線形代数的な操作、 例えば “king - man + woman = queen” (王から男性を引き算し、女性を足し算すると女王になる)というような単語同士の演算が可能となります。 これを自然言語ではなくプログラムのコード(を元にした文書)に適用すればどうなるかということが気になったので gensim という Python のライブラリを使って実装をしてみました。

動作確認環境

  • Debian 9.5 (Linux 4.9.0)
  • Java 8
    • Apache BCEL 6.2
  • Python 3.5
    • gensim 3.6.0

全ソースコード

今回用いているソースコードの全文はこちらに置いています。

Java のコードを文書に変換する

今回、Word2vec / fastText の学習の対象として Java のコードを選んでいます。ここでは様々な理由 3 からソースコードではなくコンパイル後の class ファイル(バイトコード)の方を使います。また、今回は呼び出しているメソッドの名前だけを扱い、パッケージ名・ローカル変数名・フィールド名・メソッドの仮引数名、その他変数の型やメソッドの引数・戻り値の型については考慮しないものとします 4

class ファイルの解析はアドホックにやるのであれば Apache Commons BCEL を使うのが手っ取り早いです。 例えば以下のような Java のソースコード 5 があったとします。

...
List<File> jarFiles = cl.getArgList().stream()
        .map(s -> Paths.get(s).toFile())
        .collect(Collectors.toList());
jmethdeps.run(jarFiles);
...

このソースコードをコンパイルした後の class ファイルを BCEL に読み込ませると、Code オブジェクトから以下のような文字列(厳密には byte[])を取得することができます。

...
107:  aload_3
108:  invokevirtual     org.apache.commons.cli.CommandLine.getArgList ()Ljava/util/List; (34)
111:  invokeinterface   java.util.List.stream ()Ljava/util/stream/Stream; (35)  1       0
116:  invokedynamic     0:apply ()Ljava/util/function/Function; (36)    00
121:  invokeinterface   java.util.stream.Stream.map (Ljava/util/function/Function;)Ljava/util/stream/Stream; (37)       2       0
126:  invokestatic      java.util.stream.Collectors.toList ()Ljava/util/stream/Collector; (38)
129:  invokeinterface   java.util.stream.Stream.collect (Ljava/util/stream/Collector;)Ljava/lang/Object; (39)   2       0
134:  checkcast         <java.util.List> (40)
137:  astore            %5
139:  aload             %4
141:  aload             %5
143:  invokevirtual     com.github.ikasat.jmethdeps.Jmethdeps.run (Ljava/util/List;)V (41)
...

ここで invokevirtual, invokeinterface, invokestatic, invokespecial にのみ着目してメソッド呼び出しだけを抽出します。 パッケージ名については無視し、内部クラスである場合は外側のクラス名を考慮しないものとしています。

CommandLine.getArgList
List.stream
Stream.map
Collectors.toList
Stream.collect
Jmethdeps.run

更に、PascalCase / camelCase になっているクラス名/メソッド名を単語の境界で分割し、以下のような単語列とします。

command line get arg list
list stream
stream map
collectors to list
stream collect
jmethdeps run

今回は 1 つのメソッドを上記の単語列に変換したものを自然言語における 1 文 (sentence) とみなして学習を行います 6

gensim を使う

前節で用意した単語列を用いて単語を分散表現に変換します。Word2vec, fastText ともに公式実装がありますが、今回のように手軽に使いたいだけであれば gensim という Python 製のフリーソフトウェア(ライセンスは LGPL v2.1)を使うとよいでしょう。

radimrehurek.com

それぞれ以下のように sentences (文のリスト)を与えれば学習でき、most_similar メソッドの positive / negative 引数に単語を与えることで分散表現の加算/減算ができます 7

# Word2vec の場合
import gensim
sentences = [["foo", ...], ...]  # 文(文字列のリスト)のリスト (List[List[str]])
model = gensim.models.word2vec.Word2Vec(sentences, iter=30)
result = model.wv.most_similar(positive=["bar", ...], negative=["baz", ...])
for word, score in result:
    print("{:6.4f} {}".format(score, word))
# fastText の場合
import gensim
sentences = [["foo", ...], ...]  # 文(文字列のリスト)のリスト (List[List[str]])
model = gensim.models.FastText(sentences, iter=30)
result = model.wv.most_similar(positive=["bar", ...], negative=["baz", ...])
for word, score in result:
    print("{:6.4f} {}".format(score, word))

API の詳しい使い方については公式リファレンスをご参照ください。

実行例

対象

今回学習の対象として以下のライブラリ、および依存ライブラリの JAR ファイルを使います(Maven の GroupId:ArtifactId:Version 表記)。

com.fasterxml.jackson.core:jackson-databind:2.9.7
com.google.guava:guava:26.0-jre
commons-cli:commons-cli:1.4
org.apache.commons:commons-lang3:3.8.1
org.eclipse.jdt:org.eclipse.jdt.core:3.15.0
org.eclipse.jetty:jetty-server:9.4.12.v20180830
org.slf4j:slf4j-simple:1.7.25

学習の繰り返し回数 (iter パラメータに与える値) は 30 としています。他のパラメータはデフォルト値のままです。

Word2vec

それでは Word2vec でこの記事のタイトルの add - list + map を試してみます(スコア上位の5つを表示)。

> add - list + map
0.4842 put
0.4554 hash
0.4404 contains
0.4403 rehash
0.4192 remove

期待通り、put が最上位に出てきました。

なお、結果はデータセットやパラメータが同じでも学習の度に変わり、スコア (similality) もそれなりに変動します。 このようなランキングを作ると上位陣の顔ぶれはあまり変わらずとも順位の入れ替わりは結構起こります。 今回 put は 1 位でしたがそうでない場合もたまにあります(上位にはだいたい入ってきます)。 その点はあらかじめご了承ください。

逆算の関係にある put - map + list の方もやってみます。

> put - map + list
0.3990 size
0.3592 add
0.3416 reap
0.3295 array
0.3128 get

こちらは add は 2 位ではありますが上位には出てきました。

list, map, add, put について個別に関連語をランキング化してみると以下のようになります。

> list
0.5378 collection
0.4960 capabilities
0.4790 iterators
0.4688 indexed
0.4653 all
> map
0.4781 hash
0.4568 alignment
0.4527 dictionary
0.4409 properties
0.4372 inverse
> add
0.5872 size
0.4667 reap
0.4596 remove
0.4365 contains
0.4188 all
> put
0.4929 hash
0.4367 dictionary
0.3820 get
0.3815 map
0.3533 remove

その他の単語でもいくつか試してみます。

> reader - read + write
0.4246 writer
0.4071 slow
0.3139 json
0.3085 pool
0.3066 contatenation
> float - int
0.4178 infinite
0.3528 n
0.3522 inf
0.3327 na
0.2787 decimal
# (camelCase の分割の際に NaN が na と n に分割されてしまっているようです)
> get + lock
0.4271 acquire
0.4187 inc
0.3482 fetch
0.3446 transition
0.3373 semaphore
> capacity - size
0.3944 ensure
0.3842 unencoded
0.3721 funnel
0.3661 spare
0.3601 padding

fastText

Word2vec の代わりに fastText モデルを用いた場合の結果です。 fastText には「共通の部分文字列を持つ単語同士を関連が深い語と推定する」という特性があり、結果にもそれが表れています。

> add - list + map
0.4469 put
0.4329 rehash
0.4050 remove
0.3992 contains
0.3975 hash
> put - map + list
0.3582 size
0.3471 add
0.3400 item
0.3160 listen
0.3149 as
> list
0.6575 lists
0.6040 listen
0.5344 collection
0.5275 listeners
0.4778 listener
> map
0.5863 maps
0.5035 gap
0.4716 mapped
0.4695 multimap
0.4512 dictionary
> add
0.6029 addr
0.5416 size
0.5133 odd
0.5039 reap
0.4765 remove
> put
0.4818 hash
0.4685 rehash
0.4102 ahead
0.4046 head
0.3831 values
> reader - read + write
0.5952 writer
0.5709 writeln
0.4654 bootstrapper
0.4593 header
0.4418 readers
> float - int
0.4506 floats
0.4436 floating
0.4138 floor
0.4100 n
0.3758 flow
> get + lock
0.5540 mock
0.5434 clock
0.5201 locker
0.5163 getenv
0.5041 locks
> capacity - size
0.3776 str
0.3772 calc
0.3424 sequence
0.3376 naming
0.3342 entity

fastText の方は「字面が近い単語を近縁語と見なす」おかげで map/maps/mapped/multimap を近い語とみなしてくれる一方、list/listen, map/gap, add/addr/odd, lock/mock/clock など韻を踏んでいるだけで意味的には遠い語のスコアが高くなってしまっています(これはこれで面白いのですが……)。

おわりに

総じてちょっと惜しい感じのするところもありますが、意外とそれらしい結果にはなっているようにも見えます。 メソッドの命名に悩んだ時に類語を調べたり動詞・名詞のコロケーションを調べたりするツールとして多少は役立つかもしれません8 。 サンプル数を増やしたり偏らせたりするとまた違った結果になると思われるので、気になった方は好きなプログラミング言語を対象に試してみるとよいと思います。

その他、gensim を使えば doc2vec で文書の類似度を算出したり LDA で文書の分類をしたりできるのですが、今回のデータではピンと来るような結果にはなりませんでした……。 比較的少ないコード量で有名なアルゴリズムを手軽に使えるライブラリとして gensim はとても有用なので是非利用してみてください。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。


  1. グラフデータベースNeo4jを使ってコードの依存関係を解析するの時に使ったプログラムと似た形式で出力できます(過去記事の宣伝)。元は rexdep のダンプする JSON 形式です

  2. Pythonのパッケージングのベストプラクティスについて考える2018の実践例にもなっています(過去記事の宣伝)

  3. class ファイルからであれば呼び出しているメソッドの属するクラス名を簡単に取得できるというのが最大の理由です。ソースコードをパースする場合は Eclipse JDT で抽象構文木を辿っていくのが正攻法と思われますが、Java の構文についての深い知識が求められそう、ASTVisitor の大量のメソッド の中から適切にオーバーライドをする必要がある、プロジェクト毎にソースコードのパスの指定を行うなどの下準備が必要、といった理由により見送っています

  4. パッケージ名・ローカル変数名・仮引数名はプロジェクトや書き手により命名がまちまちな気がするので今回は除外(ローカル変数名・仮引数名は javac に -g:vars, -parameters オプションを付けなければ class ファイルからは取得できない)。他は単に面倒だったのでとりあえず無視……

  5. jmethdeps のソースコードから抜粋

  6. 引数のオーバーロードがある場合も全て 1 文にまとめてしまっています

  7. モデルのクラスの __init__ に文のリストを与えられた段階で学習が行われます。学習の回数指定は iter パラメータ引数でできます。最初に sentences を与えない場合は build_vocab, train メソッドを個別に呼ぶこともできます

  8. 今回の場合 private / protected なメソッド・internal なパッケージのメソッドの呼び出しも対象としているので、それを除外すれば API 名としてより適切な単語がサジェストされる可能性はあります