Javaで機械学習（DeepLeaning4j） 文書を学習して特定の単語と関連性の高い単語を抽出してみる

## やること
[Wikipediaの記事](https://ja.wikipedia.org/wiki/%E3%83%87%E3%82%A3%E3%83%BC%E3%83%97%E3%82%A4%E3%83%B3%E3%83%91%E3%82%AF%E3%83%88_(%E7%AB%B6%E8%B5%B0%E9%A6%AC))を学習データとして、
「ディープインパクト」と関連性の高い単語（名詞と動詞）を抽出させる。

## ポイント
- とにかく専門知識不要で簡単に作って試せるところに重点をおく
- 言語はJavaで[DeepLearning4j](https://deeplearning4j.org)を使う
- DeepLeaningでありながら学習データ（コーサス：文書集）がWikiの１記事というのは実は本当にナンセンス
- でも、やっぱり簡単に試したいから、あえて１記事のみの学習で行う

## 事前に準備するテキスト

[ここ](https://ja.wikipedia.org/wiki/%E3%83%87%E3%82%A3%E3%83%BC%E3%83%97%E3%82%A4%E3%83%B3%E3%83%91%E3%82%AF%E3%83%88_(%E7%AB%B6%E8%B5%B0%E9%A6%AC))からテキストを適当に貼り付けて、UTF-8で保存しておく。

## 実装

#### 簡単なのでMavenで

```xml:pom.xml
（省略）

    <repositories>    
        <repository>
            <id>ATILIKA dependencies</id>
            <url>http://www.atilika.org/nexus/content/repositories/atilika</url>
        </repository>    
    </repositories>

(中略)
        <dependency>
            <artifactId>lucene-core</artifactId>
            <groupId>org.apache.lucene</groupId>
            <version>5.1.0</version>
        </dependency>        
        <dependency>
            <artifactId>lucene-analyzers-kuromoji</artifactId>
            <groupId>org.apache.lucene</groupId>
            <version>5.1.0</version>
        </dependency>

        <dependency>
            <groupId>org.deeplearning4j</groupId>
            <artifactId>deeplearning4j-ui</artifactId>
            <version>0.5.0</version>
        </dependency>

        <dependency>
            <groupId>org.deeplearning4j</groupId>
            <artifactId>deeplearning4j-nlp</artifactId>
            <version>0.5.0</version>
        </dependency>

        <dependency>
            <groupId>org.nd4j</groupId>
            <artifactId>nd4j-native</artifactId>
            <version>0.5.0</version>
        </dependency>    

        <dependency>
            <groupId>org.atilika.kuromoji</groupId>
            <artifactId>kuromoji</artifactId>
            <version>0.7.7</version>
            <type>jar</type>
        </dependency>

```

#### Kuromoji の Tokenizer を ラップする
そのままでは、英語しか形態素解析できないため、kuromojiを使えるようする。
[こちら](http://qiita.com/keigohtr/items/f2c5022823370d78dde5)が非常に参考になった。
というか、ほぼそのまま。ただし、一部手を加えている。

```java:KuromojiIpadicTokenizer.java

/**
 * 日本語の形態素解析が必要なため
 * KuromojiのTokenizerを、dl4jのTokneizerインターフェースで包む
 * @author 
 */
public class KuromojiIpadicTokenizer implements Tokenizer{
    private List<Token> tokens;
    private int index;
    private TokenPreProcess preProcess;

    /**
     * 辞書がどうしても有効にならず・・・
     * とりあえず、比較的良い感じになりやすいようにModeをSearchに設定
     */
    public KuromojiIpadicTokenizer (String toTokenize) {
      
        try{
            org.atilika.kuromoji.Tokenizer tokenizer 
                = org.atilika.kuromoji.Tokenizer.builder()
                    .userDictionary("D:\\deepleaning\\mydic.txt")
                    .mode(org.atilika.kuromoji.Tokenizer.Mode.SEARCH)
                    .build();
            tokens = tokenizer.tokenize(toTokenize);
            index = (tokens.isEmpty()) ? -1:0;
        } catch (IOException ex) {
            Logger.getLogger(KuromojiIpadicTokenizer.class.getName()).log(Level.SEVERE, null, ex);
        }
    }


    @Override
    public int countTokens() {
        return tokens.size();
    }

    @Override
    public List<String> getTokens() {
        List<String> ret = new ArrayList<String>();
        while (hasMoreTokens()) {
            ret.add(nextToken());
        }
        return ret;
    }

    @Override
    public boolean hasMoreTokens() {
        if (index < 0)
            return false;
        else
            return index < tokens.size();
    }

    /**
     * 関連をたどる単語は、名詞と動詞（基本形）に絞る
     * カスタム名詞はkuromojiのユーザ辞書が効けば必要かも
     * それ以外の品詞は半角スペースに落として、解析を避ける
     * @return 
     */
    @Override
    public String nextToken() {
        if (index < 0)
        return null;

        Token tok = tokens.get(index);
        index++;
        if(!tok.getPartOfSpeech().startsWith("名詞") 
            && !tok.getPartOfSpeech().startsWith("動詞")
            && !tok.getPartOfSpeech().startsWith("カスタム名詞")){
            return " ";
        } else if (preProcess != null) return preProcess.preProcess(tok.getPartOfSpeech().startsWith("動詞") ? tok.getBaseForm() : tok.getSurfaceForm());
        else return tok.getSurfaceForm();
    }

    @Override
    public void setTokenPreProcessor(TokenPreProcess preProcess) {
        this.preProcess = preProcess;
    }
    
}
```

```java:KuromojiIpadicTokenizerFactory.java
/**
 * KuromojiのFactoryをラップ
 * @author 
 */
public class KuromojiIpadicTokenizerFactory implements TokenizerFactory {

    private TokenPreProcess preProcess;

    private static String preValue = "";
    
    @Override
    public Tokenizer create(String toTokenize) {
//        System.out.println(toTokenize);
        if (toTokenize == null || toTokenize.isEmpty()) {
            // 例外ではなく、半角スペースで解析をさける
            // そうしないと改行が連続した文書を学習できない
            toTokenize = " ";
        }
        KuromojiIpadicTokenizer ret = new KuromojiIpadicTokenizer(toTokenize);
        ret.setTokenPreProcessor(preProcess);
        return ret;
    }

    @Override
    public Tokenizer create(InputStream paramInputStream) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void setTokenPreProcessor(TokenPreProcess preProcess) {
        this.preProcess = preProcess;
    }

    @Override
    public TokenPreProcess getTokenPreProcessor() {
        return this.preProcess;
    }
    
}
```

#### 学習させて結果を出力させる

[こいつ](https://deeplearning4j.org/ja/ja-word2vec)を使う。

```java:WordVecSample.java
/**
 * 文章から単語を抽出し、
 * その関連性（近い位置に頻出する単語は関連性が高い理論、雑な言い方だけど）を
 * 学習させて、関連性の高い単語を算出するサンプル
 * @author 
 */
public class WordVecSample {
    public static void main(String[] args) throws IOException{

        /**
         * 元が英語用なので
         * ストップワード（評価しない単語）はJapaneseAnalyzerを流用
         * 評価対象を動詞（基本形）と名詞に絞ってはいるが「ある」「いる」等を対象にされてもつらいので
         */
        List<String> stopWords = new ArrayList<>();
        stopWords.addAll(Arrays.asList(JapaneseAnalyzer.getDefaultStopSet().toString().split(", ")));
        // 今回は試しに漢数字も追加してみた
        stopWords.addAll(Arrays.asList("一","ニ","三","四","五","六","七","八","九","十"));
        
        /**
         * コーパス(文章集)データのロード
         * ■ 今回は学習データが圧倒的に少ないので、この程度では実用は不可能
         *    あくまでサンプル・・・
         */
        System.out.println( "学習データを読込中..." );
        File f = new File( "D:\\deepleaning\\deepImpact.txt" );
        SentenceIterator ite = new LineSentenceIterator( f );
        // プレプロセッサを継承して語句をならす
        // この時点で半角カナ変換とかも入れておいた方がいいかも知れない
        ite.setPreProcessor((String sentence) -> sentence.toLowerCase().replaceAll("\n", " "));
        
        /**
         * 文章を単語に分解
         * preProcessでトークン分割の前に表記をならすのがポイント
         * ■ 形態素解析は、日本語対応のため、kuromojiを利用するように差し替えを行う
         */
        final EndingPreProcessor preProcessor = new EndingPreProcessor();
        KuromojiIpadicTokenizerFactory tokenizer = new KuromojiIpadicTokenizerFactory();
        tokenizer.setTokenPreProcessor((String token) -> {
            if(token == null) {
                return " ";
            } else {
                token = token.toLowerCase();
                String base = preProcessor.preProcess( token );
                return base;
            }
        });

        /**
         * モデル（トークナイザーと各種設定）の作成
         * 今回は少ない文章でそれっぽい結果が出るようにパラメータを調整した
         */
        System.out.println( "モデルを構築中..." );
        Word2Vec vec = new Word2Vec.Builder()
            .minWordFrequency( 1 )          // 指定以下の出現数の単語は学習しない ⇒ 今回はコーサスが少ないので設定も低め
            .iterations( 3 )                // 学習時の反復回数
            .batchSize( 1000 )              // １反復で学習する単語の最大数
            .layerSize( 120 )               // 単語のベクトル次元数
            .learningRate( 0.09 )           // 学習率
            .minLearningRate( 1e-3 )        // 学習率の最低値
            .useAdaGrad( false )            // http://www.jmlr.org/papers/volume12/duchi11a/duchi11a.pdf 使わない
            .negativeSample( 30 )           // スキップグラムにて利用する逆向き回答数 コーサスが豊富なら恐らく減らした方が良い
            .stopWords(stopWords)           // 対象外ワード：ある とか れる とか どこにでも出てくる単語は評価から外す
            .iterate( ite )                 // コーサスのモデル
            .tokenizerFactory(tokenizer)    // トークナイザー
            .build();

        /**
         * 学習
         */
        System.out.println( "学習中..." );
        vec.fit();

        /**
         * 解析用に学習結果のモデルを出力
         * 実際は、これを適切な手段で永続化すると良い
         */
        WordVectorSerializer.writeWordVectors( vec , "D:\\words2.txt" );

        /**
         * 学習結果の出力
         */
        /*
            // ２つの単語の類似度をコサイン距離で出せるがサンプルが少ないのでコメント記載のみ
            String word1 = "単語１";
            String word2 = "単語２";
            double similarity = vec.similarity( word1 , word2 );
            System.out.println( String.format( "The similarity between 「%s」 and 「%s」 is %f" , word1 , word2 , similarity ) );
        */

        // 任意の単語と類似性が認められるものを５つ選出してみる
        String word = "ディープインパクト";
        int ranking = 5;
        Collection<String> similarWords = vec.wordsNearest( word , ranking );
        System.out.println( String.format( "「%s」と関連性が強いと推定される単語 ⇒ %s" , word , similarWords ) );
    }
    
}
```

### 出力結果

２回目以降は前回の学習結果を引き継がない。
毎回学習リセットでの結果

```:１回目
「ディープインパクト」と関連性が強いと推定される単語 
⇒ [直線, 騎手, 馬場, 走る, シンボリルドルフ]
```

```:２回目
「ディープインパクト」と関連性が強いと推定される単語 
⇒ [ブル, アンドレ, ライター, 勝つ, 指摘]
```

```:３回目
「ディープインパクト」と関連性が強いと推定される単語 
⇒ [受ける, 馬券, ため, 倍率, 馬]
```

```:４回目
「ディープインパクト」と関連性が強いと推定される単語
⇒ [対戦, 洋文, 評価, 香港, 自ら]
```

```:５回目
「ディープインパクト」と関連性が強いと推定される単語 
⇒ [成績, 鉄, ラン, シロッコ, くる]
```

１回目はそれなり。だが、２回目以降がひどい。。。

１記事だけでの学習なので、この結果はどうしようもない。
学習データを増やし、パラメータを調整すれば良い感じにはなると思う。

ただ、競走馬名の辞書を追加し、馬名の語句分割を防ぐ
ストップワードをもう少し追加する、くらいはどちらにしても必要そう。
あと動詞はいらなかったかな、と。