ElasticsearchとPytorchとBertで全文検索(root権限のないところでの設定)

#はじめに
いろんな方が実装されているものの寄せ集めです。
（敬意を込めて参照先を記さないと。追々やっていきます。）
やろうとしていることは、
・ドキュメントをBertでベクトル化
・Elasticsearchでコサイン類似検索
という感じで、辞書を作ることなく、だいたい同じような意味合いの文書をヒットさせるという環境の構築をします。

#環境を構築してみる
root権限とかない一般ユーザ環境のUbuntu 16.04.3でお試しです。
Python3.Xは入っているものとしてください。
素人なのでいかんせん違うところがあるかもです。
~~文体に力がないのは外が暗くて眠いからです。~~
改めて見るといかんせん文体暗すぎですね。(Mar.2.20)

###Pytorchをインストール

Bertを使うのに今回はPytorchを使うことにしました。
そう決めたのは確かchainerが開発停止を発表したときだったと思います。
個人的にはあまり良くわかっていなかったのですが、めっちゃ詳しい人が、これからはPytorchがアツい的なことを言っていたので深く考えずにそうしました。

```bash
pip install torch==1.1.0 torchvision==0.3.0 --user
```

Pytorchはcudaによるので、環境を調べて最適なインストールをしたほうがいいです。詳しくはここをみてください。
~~権限があるなら別に--userはいらないです。~~
（root権限がないこと前提なのでつけないとですね）

https://pytorch.org/

###sumyのインストール
文書要約ライブラリ。特に何もなくそのまんまのインストールです。

```bash
pip install sumy --user
```

###mojimojiのインストール
全角半角コンバータ。同じくそのまんまのインストールです。

```bash
pip install mojimoji --user
```
###pyyamlのインストール。
yamlライブラリ

```bash
pip install pyyaml --user
```


###Juman++のインストール

京都大学の黒橋・河原研究室で開発されたもの。
こういう研究のおかげで僕らは楽ができるんですね。本当にありがとうございます。
http://nlp.ist.i.kyoto-u.ac.jp/index.php?JUMAN++

```bash
#ユーザ域に格納先を作成しておく
mkdir ~/usr/local/bin
```

で、ここからがJuman++インストール本番

```bash
#ソースコード取得
wget https://github.com/ku-nlp/jumanpp/releases/download/v2.0.0-rc3/jumanpp-2.0.0-rc3.tar.xz

# 解凍してディレクトリに入る
tar xvf jumanpp-2.0.0-rc3.tar.xz 
cd jumanpp-2.0.0-rc3

# ビルド用ディレクトリを作成・移動
mkdir bld
cd bld

# ビルド
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=$HOME/usr/local
make
make install

#作業域削除
rm -rd jumanpp-2.0.0-rc3
```

###Bertの準備
ちなみにここに出てくる学習済みセットも黒橋・河原研究室の成果物です。
頭が上がりません・・・。
http://nlp.ist.i.kyoto-u.ac.jp/index.php?BERT%E6%97%A5%E6%9C%AC%E8%AA%9EPretrained%E3%83%A2%E3%83%87%E3%83%AB

```bash
#基本パッケージインストール
pip install pytorch-pretrained-bert --user
pip install pyknp --user

#学習済みファイルのダウンロード
wget http://nlp.ist.i.kyoto-u.ac.jp/nl-resource/JapaneseBertPretrainedModel/Japanese_L-12_H-768_A-12_E-30_BPE.zip
unzip Japanese_L-12_H-768_A-12_E-30_BPE.zip
rm Japanese_L-12_H-768_A-12_E-30_BPE.zip
```

###Elasticsearchのインストール

```bash
#取得
wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.5.0-linux-x86_64.tar.gz
#解凍
tar xvf elasticsearch-7.5.0-linux-x86_64.tar.gz
#パスを変え、日本語プラグインをインストール
cd elasticsearch-7.5.0
bin/elasticsearch-plugin install analysis-kuromoji

#kibanaもインストールしておこう
cd
wget https://artifacts.elastic.co/downloads/kibana/kibana-7.5.0-linux-x86_64.tar.gz
#解凍
tar xvf kibana-7.5.0-linux-x86_64.tar.gz
#格納場所変更
mv ~/elasticsearch-7.5.0 ~/usr/local/elasticsearch
mv ~/kibana-7.5.0-linux-x86_64 ~/usr/local/kibana

#Pythonで制御できるようにする
pip install elasticsearch --user
```

###環境変数とか調整

```bash
#環境変数の設定
vi .profile
```
.profileに以下を書き加え保存

```vim
export ELASTIC_HOME="$HOME/usr/local/elasticsearch/bin"
export KIBANA_HOME="$HOME/usr/local/kibana/bin"
export PATH="$ELASTIC_HOME:$KIBANA_HOME:$HOME/usr/local/bin:$PATH"
```
再ログイン又は以下のコマンドで設定反映

```bash
#即時反映
source .profile
#コマンドで確認
echo $PATH
```

###動作確認

#####Juman++の動き確認

```bash
#バージョン表示
jumanpp -v
#動作確認
echo "適当な文字列を判定させてみる" | jumanpp
```
適当な文字列を判定させてみる、が形態素分析されたら成功

#####ElasticsearchとKibanaの起動

```bash
elasticsearch -d 
kibana
```
一般ユーザとしての起動なのでサービス化はしてないです。

これで普通に通れば環境変数がちゃんと設定されてると思います。
kibanaのポートは5601がデフォルトなので、Webページが表示されればOKかと。
ちなみに、elasticsearchがインストールされてないと、kibanaが起動しないので、
kibanaの起動で両方いっぺんに確認できる、とおもう。

#Pythonで動かしてみる

文章を20センテンスくらいに圧縮して、半角全角コンバートして、Bertベクトル化する、というもの。クラス化してもいいのかもしれない。
JavaとかC#だと積極的にそうするんだけど、Pythonだとどうも気乗りしない。
selfてどうよ。


```python
BERT_BASE_DIR = "/[学習済みセットのあるところ]/Japanese_L-12_H-768_A-12_E-30_BPE/"
VOCAB_FILE = "vocab.txt"

import mojimoji
import re

from pyknp import Juman

import numpy as np
import torch
from pytorch_pretrained_bert import BertTokenizer, BertModel

from sumy.parsers.plaintext import PlaintextParser
from sumy.nlp.tokenizers import Tokenizer
from sumy.summarizers.lex_rank import LexRankSummarizer

juman = Juman()
bert_tokenizer = BertTokenizer(BERT_BASE_DIR + VOCAB_FILE, do_lower_case=False, do_basic_tokenize=False)
use_cuda = False
model = BertModel.from_pretrained(BERT_BASE_DIR)

#------------------------------------------------------------------
# 一文から、名詞、形容詞、副詞、動詞のみで、分かち書きの一文を返す
#------------------------------------------------------------------
def __get_wakachi(sentence):
    
    target = mojimoji.han_to_zen(sentence)
    temp = juman.analysis(target)
    result =  [mrph.genkei for mrph in temp.mrph_list() if mrph.hinsi in ('名詞', '形容詞', '副詞', '動詞')] 

    return ' '.join(result) + ' 。'

#------------------------------------------------------------------
# 分かち書きの文章リストから、BERTトークンを作成する
#------------------------------------------------------------------
def __get_bert_tokens(wakachi_list):
    
    bert_tokens = [bert_tokenizer.tokenize(wakachi)[:126] + ['[SEP]']  for wakachi in wakachi_list]
    return ['[CLS]'] + sum(bert_tokens, [])

#------------------------------------------------------------------
# BERT_TOKENSから文書全体のベクトルを生成する
#------------------------------------------------------------------
def __get_sentence_embedding(bert_tokens, pooling_layer=-2, pooling_strategy='REDUCE_MEAN'):
    
    ids = bert_tokenizer.convert_tokens_to_ids(bert_tokens)
    tokens_tensor = torch.tensor(ids).reshape(1, -1)

    if use_cuda:
        tokens_tensor = tokens_tensor.to('cuda')
        model.to('cuda')
        torch.cuda.set_device(4)

    model.eval()
    with torch.no_grad():
        all_encoder_layers, _ = model(tokens_tensor)

    embedding = all_encoder_layers[pooling_layer].cpu().numpy()[0]
    if pooling_strategy == "REDUCE_MEAN":
        return np.mean(embedding, axis=0)
    elif pooling_strategy == "REDUCE_MAX":
        return np.max(embedding, axis=0)
    elif pooling_strategy == "REDUCE_MEAN_MAX":
        return np.r_[np.max(embedding, axis=0), np.mean(embedding, axis=0)]
    elif pooling_strategy == "CLS_TOKEN":
        return embedding[0]
    else:
        raise ValueError("specify valid pooling_strategy: {REDUCE_MEAN, REDUCE_MAX, REDUCE_MEAN_MAX, CLS_TOKEN}")

#------------------------------------------------------------------
# 要約をする
#------------------------------------------------------------------
def __get_summary(wakachi_list, SENTENCES_COUNT):
    
    #summarizer設定
    parser = PlaintextParser.from_string(''.join(wakachi_list), Tokenizer('japanese'))
    summarizer = LexRankSummarizer()
    summarizer.stop_words = [' ']  
    
    summary = summarizer(document=parser.document, sentences_count=SENTENCES_COUNT)
    
    #要約選出された文章の分かち書きリスト作成
    result = [wakachi_list[wakachi_list.index(sentence.__str__())].strip() for sentence in summary]

    return result

#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
#ドキュメントからベクトルを取得する
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
def get_vector(doc):
    
    works = re.split('\n+',doc.replace('。', '。\n'))
    wakachi_list = [__get_wakachi(i) for i in works]
    if len(wakachi_list) > 20 :
        wakachi_list = __get_summary(wakachi_list, 20)
    print(len(wakachi_list))
    bert_tokens = __get_bert_tokens(wakachi_list)
    result = __get_sentence_embedding(bert_tokens)
    
    return result

```

#CSVからElasticsearchにデータを流し込むとき

あらかじめ、yamlにElasticsearchへの定義を書いておきます。
例えば、data.yamlとして以下のように記します。

```yaml
settings:
  index:
    analysis:
      analyzer:
        my_analyzer:
          type: custom
          tokenizer: kuromoji_tokenizer
          filter:
            - kuromoji_baseform
mappings:
  properties: 
    article_date:
      type: keyword
    title:
      type: text
      index: true
      analyzer: my_analyzer
    article:
      type: text
      index: true
      analyzer: my_analyzer
    source:
      type: keyword
    article_vector:
      type: dense_vector
      dims: 768
      
```
article_vectorというやつがBertのベクトルを入れるところですね。
用意するCSVは、新聞記事を想定し、

掲載日時,タイトル,記事,提供元

としました。ファイル名はarticle.txtです。
掲載日時をタイムスタンプ型にしていないのは、日付変換がめんどくさそうだったからです。

そしたら、以下のコードでCSVを読み込み、Elasticsearchに登録します。

```python
import csv
import os
import sys
from elasticsearch import Elasticsearch, helpers
import yaml

def create_index(index='articles'):
    es = Elasticsearch()
    
    #全部消して再定義する場合
    #print(es.indices.delete(index=index, ignore=[404]))

    setting = yaml.load(open('./data.yaml'), Loader=yaml.SafeLoader)
    properties = setting['mappings']['properties']
    print(setting)
    #新規に作成する場合は以下をコメントアウト。データ追加の場合はコメント
    #print(es.indices.create(index=index, body=setting))
    #print(es.indices.flush())

    def generate_data():
        with open('./article.txt', 'r') as f:
            reader = csv.reader(f)
            attrs = next(reader)
            for lid, row in enumerate(reader):
                data = {
                    '_op_type': 'index',
                    '_index': index,
                }
                for j, value in enumerate(row):
                    if attrs[j] in properties:
                        data[attrs[j]] = value
                    if attrs[j] == 'article':
                        data['article_vector'] = get_vector(value).tolist()
                        
                yield data
    
    print(helpers.bulk(es, generate_data()))
    
create_index()
```

あんまり大量のデータを流し込もうとするとエラーが出る。
bulkの使い方がなっていないのかもしれない。調査せねば。

#検索の方法（Pythonから）
こんな感じで検索します。
反応早くてびっくりです。


```python
from elasticsearch import Elasticsearch

es = Elasticsearch()
INDEX = "articles"

#ベクトルのみでの検索。もっと長文でも構わないです。
query_vector = get_vector('''「仁義なき戦い」''').tolist()

query_script = {
    "script_score": {
        'query': { 'match_all': {}},
        'script': {
            "source": "cosineSimilarity(params.query_vector, doc['article_vector'])",
            "params": {"query_vector": query_vector}
        }
    }
}

#文字列自体の部分一致と組み合わせて検索する場合
'''
query_script = {
    "script_score": {
            "query": {"wildcard": {
                "article": {
                    "value": "*深作欣二*"
                }
             }},
        'script': {
            "source": "cosineSimilarity(params.query_vector, doc['article_vector'])",
            "params": {"query_vector": query_vector}
        }
    }
}
'''

response = es.search(
    index=INDEX,
    body={
        "size": 10,
        "query": query_script,
        "_source": {"includes": ["title", "article"]}
    }
)
print(response)
```

こんなところです。まだまだ勉強ですね。


もっと勝手な環境で勝手にあれやこれやしたいんだよ、ということもあるのかな、というわけでCentOS8上に管理者権限使いまくりで環境設定する方法も書いてみました。

https://qiita.com/shiraitsukasa/items/0486f96a741ee03bffef