日本語wikiからランダムに２行を抽出し、関連があるかを判定


##### 環境
Python 3.7.4
pytorch 1.3.1
コーパス：[日本語 wiki](https://dumps.wikimedia.org/jawiki/latest/)：[jawiki-latest-pages-articles.xml.bz2](https://dumps.wikimedia.org/jawiki/latest/jawiki-latest-pages-articles.xml.bz2)


##### 参考
[黒橋・河原研究室: BERT日本語Pretrainedモデル](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)
[Orizuru: BERTによる文書分類](https://orizuru.io/blog/machine-learning/bert/)

## 概要
２つの文章に関連があるか判断する物を作りました。

つまり
Q. 「『文章A』と『文章B』は何か関係がありますか？」 に
A. yes / no
で答えています。

wikiペディアに存在する文章からランダムに選んだ２行で相関があると判定されたものを抽出しています。
学習は、日本語wikiペディアの同じ記事(例えば「ビートたけし」という一つの記事)から前後n行以内の２行を`相関あり`の教師データとし、別々の記事の文章から選んだ２行を`相関なし`の教師データとして文章の相関を学習しました。
***ユーザーの行動に適用すると、CV予測、ユーザーセグメントの抽出に利用できそう***

## 結果
以下が抽出されたもの。まず自動で２行のペアを200,000万件ランダムに作成し、その中からペアとして相応しいと判断された２行を抽出しました。ペアとなる２行はそれぞれ独立に wikipedia の記事から選択したものです。共通する単語はほとんどないにも関わらず関連を判定していて、文章の内容に基づいて判断されているように見えます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375986/70df9ef5-6dba-11a2-5a9a-e6d2c2a5ba23.png)




```
岐阜市鏡島から岐阜市日野南（国道156号交点）までは片側2車線である。
ダンス七福神は、『乃木坂って、どこ?』の乃木坂46・ダンススキルチェックで選出された7名。
```

ランダムなペアはこのようにほとんどの場合に脈略のない２文になります。その中から、関係がありそうなものが偶然ペアになったものを判定しました。

#### 関連した文章が抽出されているもの
###### ex.1
```
販売はユニバーサルミュージック株式会社。
「守ってあげたい」（まもってあげたい）は、伊藤由奈の15枚目のシングル。
　　　-> 音楽
```
１行目は音楽レーベルに関する記述。
２行目はシングルのタイトルに関する記述。
どちらもCD/音楽タイトルに関する記述で関連があることがわかります。

###### ex.2
```
バフチサライ条約（バフチサライじょうやく）
1935年10月3日にペラゲーヤ・シャインがシメイズで発見した。
　　　-> ロシア・ウクライナの地理
```
バフチサライ条約は露土戦争の条約。[wikiの記事](https://ja.wikipedia.org/wiki/%E3%83%90%E3%83%95%E3%83%81%E3%82%B5%E3%83%A9%E3%82%A4%E6%9D%A1%E7%B4%84_(1681%E5%B9%B4))を見ると、割譲したウクライナ地方の地理をメインに記述されています。ペラゲーヤ・シャインはロシアの人名。シメイズはクリミアの地名。
ロシア・ウクライナの地名で関連があります。

<details><summary>他の example</summary><div>

###### ex.3
```
塩盆駅（ヨンブンえき）は朝鮮民主主義人民共和国咸鏡南道利原郡に位置する朝鮮民主主義人民共和国鉄道庁平羅線の駅である。
面積は6,200 km。
　　　-> 地理
```
地理に関する記述

###### ex.5
```
大正大学の人物一覧（たいしょうだいがくのじんぶついちらん）は、大正大学に関係する人物の一覧記事である。
Ph.D.（ボルドー大学）。
　　　-> 大学・大学の人物
```
１行目、２行目共に大学に関する記述

###### ex.6
```
9月17日（くがつじゅうななにち、くがつじゅうしちにち）はグレゴリオ暦で年始から260日目（閏年では261日目）にあたり、年末まであと105日ある。
延和とも作られるが、これは本来延の内部が正という征の異体字であると考えられている。
　　　-> 暦
```
延和は中国の元号で歴史的な暦に関する記述という共通点があります。

###### ex.7
```
チェコスロバキア（当時）のクレット天文台 (Hvězdárna Kleť) でラディスラフ・ブローチェクが発見した。
ガイアが測定した年周視差に基づく太陽からの距離は、約38.5光年である。
　　　-> 天文学
```

</div></details>

### できそうなこと
言語モデルはユーザーのアクションを「語彙」、ユーザーの流入から退出までの１セッションを「文章」とみなすとユーザーの行動モデルに利用。

:[word2vec を用いて商品のレコメンドに応用した例](https://www.slideshare.net/recruitcojp/ss-56150629):

　　　-> ***CV予測、ユーザーセグメントの抽出*** 


## BERT

[BERT](https://arxiv.org/pdf/1810.04805.pdf)
[tensorflow版](https://github.com/google-research/bert)


- 2018/11 google が発表した言語モデル
- `SQuAD v1.1` や `MNLI` `GLUE` などの様々なタスクで高得点を得た
- 基本的には `Transformer` の encoder モデルを何層にも重ねた構造
- `pretrain` と `fine-tune` の２段階でモデルを学習する
- 教師なし（人間の手でデータを作らないという意味で）の大量のデータに対し `pretrain` を実施し、少数の教師ありデータで `fine-tuning` する。pretraining には時間がかかるが、fine-tuning は比較的手軽にできる。また一度 pretraining すれば同じ言語に対しては様々なタスクに対して fine-tuning だけで良い結果を出すことができる
- bert は bi-directional (lert-to-right: 文章の単語を順に入力する。まだ input されていない情報は使えない。bi-directional: 文章全体を位置情報と一緒に与えるので文章の後の情報を文書の最初の単語が意識することができる)。
特に言語の解析では単語の意味は単語だけでは決まらず、文脈を理解する必要がある。後から出てくる単語も含めて文章全体の文脈を決定できる点が重要。

ex.

```
We went to the river bank.
I need to go to bank to make a deposit.
```

`bank` の意味は文脈で変わる

- BERT は自然言語での ImageNet に相当する。ImageNet のモデルは画像認識において、革新的な進歩をもたらした。ImageNet は犬猫の判別をする NN モデルだが、学習済みのモデルを別のタスクに応用することができた。ImageNet の途中のネットワークが「画像の輪郭を抽出する」「色を抽出する」など画像を認識する上で汎用的な能力を獲得しており、単に犬と猫を判別する以上のモデルとなっていたから。BERT も同様に自然言語において汎用的な役割を期待される。
- 公開されているものは Wikipedia で pretraining されており、個々のタスク固有で fine-tuning するだけで高い精度を達成できる
- 今回利用したものは日本語 wiki で pretrain されたものを利用した。





## コード


<details><summary>実行コード</summary><div>

入力した文章は、JUMANで形態素解析を行い、単語に分割しています。
分割した単語を「トークン」として、先頭に"[CLS]"トークン、文章の最後に"[SEP]"トークンを付加して BERT の入力とします。BERTは入力された各々のトークンに対し、それぞれ768次元のベクトルを出力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375986/dc7025e9-28e3-0a20-61cd-1b78cc9dc77e.png)

分類タスクでは、個々の単語に対応する出力は使わず、"[CLS]"トークンの出力（上図の"C"）のみを利用します。

このコードでは、
TEXT1: "[CLS] + 文章A + [SEP]"
TEXT2: "[CLS] + 文章B + [SEP]"
TEXT3: "[CLS] + 文章A + [SEP] + 文章B + [SEP]"
の三つを BERT に入力し、"[CLS]"トークンに対する出力をNNに入力し、答えの yes/no に対応する２次元の値にしました。

loss は CrossEntropy, 予測は"yes"/"no"スコアの高い方で行いました。
学習データは wiki の記事から100,000タイトルを選択し、その中から同じ記事の2行以内の２行をラベル1,別々の記事の行をラベル0とし、半々の割合で作成しました。
学習データ数1,000,000、epoch数200, ミニバッチサイズは1000

最終結果は
accuracy: 0.9717
loss: 0.0885
です。



学習データ作成


```python:learn.v4.1.create.py
# -*- coding: utf-8 -*-
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

import datetime
import random

from alldata import *

from bert_juman import BertWithJumanModel

BERT_PATH = os.environ['BERT_PATH']
bert = BertWithJumanModel(BERT_PATH, use_cuda=True)
DOC_PATH = os.environ['DOC_DIR']

TRAIN_DATA_DIR = os.environ['TRAIN_DATA_DIR']
FILE_PREFIX = "train.v4.1."


MIN_LINE = 20
DOC_COUNT = 100000

CREATE_COUNT = 1000

def main():

  print(str(datetime.datetime.now()) + ": loading txt")
  data = all_data(DOC_PATH, DOC_COUNT, MIN_LINE)

  def to_bert(rec):
    l1, l2, value = rec
    emb = bert.get_multi_embedding([l1, l2], pooling_strategy="CLS_TOKEN")
    l1_emb = bert.get_sentence_embedding(l1, pooling_strategy="CLS_TOKEN")
    l2_emb = bert.get_sentence_embedding(l2, pooling_strategy="CLS_TOKEN")
    return [np.concatenate([emb, l1_emb, l2_emb]), value] 


  for i in range(3000):
      recs = create_recs(data)
      labels = [to_bert(rec) for rec in recs]
      file_name = TRAIN_DATA_DIR + "/" + FILE_PREFIX + str(i)
      arr = np.array(labels)
      np.save(file_name, arr)
      print(str(datetime.datetime.now()) + ":saved to " + file_name)


def create_recs(data):
  id_list = list(data.keys())
  def to_rec():
    if not random.getrandbits(2):
      d1_id = random.choice(id_list)
      d2_id = d1_id
      lines = data[d1_id]['lines']
      l1_ind = random.randrange(len(lines) - 3)
      l2_ind = l1_ind + random.randrange(2) + 1
      l1 = lines[l1_ind]
      l2 = lines[l2_ind]
      label = 1
    else:
      d1_id, d2_id = random.sample(id_list, 2)
      l1 = random.choice(data[d1_id]['lines'])
      l2 = random.choice(data[d2_id]['lines'])
      label = 0

    return  (l1, l2, label)

  return [to_rec() for i in range(CREATE_COUNT)]

if  __name__ == "__main__":
    main()

```

```python:bert_juman.py
# -*- coding: utf-8 -*-

from pathlib import Path
import numpy as np
import torch
from pyknp import Juman
import sys
import re
# sys.path.append("../../../pytorch-pretrained-BERT/")
from pytorch_pretrained_bert import BertTokenizer, BertModel  # noqa


class JumanTokenizer():

    def __init__(self):
        self.juman = Juman()

    def tokenize(self, text):
        result = self.juman.analysis(text)
        return [mrph.midasi for mrph in result.mrph_list()]


class BertWithJumanModel():

    def __init__(self, bert_path, vocab_file_name="vocab.txt", use_cuda=False):
        # 京大の形態素解析器
        self.juman_tokenizer = JumanTokenizer()

        # 訓練済みモデル(configの設定はどこ？)
        # モデルのロードの仕方には2種類ある？
        self.model = BertModel.from_pretrained(bert_path)

        # Bertの形態素解析器 do_lower_caseとかの意味
        # do_basic_tokenize=Falseは必須。
        self.bert_tokenizer = BertTokenizer(Path(bert_path) / vocab_file_name,
                                            do_lower_case=False, do_basic_tokenize=False)
        self.use_cuda = use_cuda

    def _preprocess_text(self, text):
        return text.replace(" ", "")  # for Juman

    def get_sentence_embedding(self, in_text, pooling_layer=-2, pooling_strategy="REDUCE_MEAN"):

        text = re.sub(r'[#@＃＠]', "", in_text)

        preprocessed_text = self._preprocess_text(text)

        # Juman++で形態素解析を行う。
        tokens = self.juman_tokenizer.tokenize(preprocessed_text)

        # Jumanを通したあとBertのトークナイザを通す。
        # Bertに登録されてないトークンは[UKN]に置換される。
        # Bertのトークナイザは空白区切をするだけ。
        # Bertのトークナイザはサブワード分割もする。
        bert_tokens = self.bert_tokenizer.tokenize(" ".join(tokens))

        ids = self.bert_tokenizer.convert_tokens_to_ids(["[CLS]"] + bert_tokens[:126] + ["[SEP]"])  # max_seq_len-2
        tokens_tensor = torch.tensor(ids).reshape(1, -1)

        if self.use_cuda:
            tokens_tensor = tokens_tensor.to('cuda')
            self.model.to('cuda')

        self.model.eval()
        with torch.no_grad():
            # 12層の隠れ層を取り出す。
            all_encoder_layers, _ = self.model(tokens_tensor)
        # assert(12 == len(all_encoder_layers))

        # 適当な層を取り出す。[トークン数,次元数]
        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_multi_embedding(self, in_texts, pooling_layer=-2, pooling_strategy="REDUCE_MEAN"):
        text_counts = len(in_texts)

        texts = [re.sub(r'[#@＃＠]', "", in_text) for in_text in in_texts]
        # Juman++で形態素解析を行う。
        tokens_list = [self.juman_tokenizer.tokenize(self._preprocess_text(text)) for text in texts]


        # Jumanを通したあとBertのトークナイザを通す。
        # Bertに登録されてないトークンは[UKN]に置換される。
        # Bertのトークナイザは空白区切をするだけ。
        # Bertのトークナイザはサブワード分割もする。
        bert_sentense_max = int(126 / text_counts)
        bert_tokens_list = [self.bert_tokenizer.tokenize(" ".join(tokens)) for tokens in tokens_list]
        joined_tokens = ["[CLS]"]
        for bert_tokens in bert_tokens_list:
            joined_tokens = joined_tokens + bert_tokens[:bert_sentense_max] + ["[SEP]"]

        ids = self.bert_tokenizer.convert_tokens_to_ids(joined_tokens)
        tokens_tensor = torch.tensor(ids).reshape(1, -1)

        if self.use_cuda:
            tokens_tensor = tokens_tensor.to('cuda')
            self.model.to('cuda')

        self.model.eval()
        with torch.no_grad():
            # 12層の隠れ層を取り出す。
            all_encoder_layers, _ = self.model(tokens_tensor)
        # assert(12 == len(all_encoder_layers))

        # 適当な層を取り出す。[トークン数,次元数]
        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}")

```

```python:alldata.py
# -*- coding: utf-8 -*-

import os
import sys
import numpy as np
import glob
from os.path import *

def all_data(input_dir, count=None, line_min=0):
  excls = ['Wikipedia:', 'Help:', 'ファイル:', 'Category:']
  
  files = glob.glob(input_dir + "/*")

  data = {}

  c=0

  for file in files:
    d_id = os.path.basename(file)
    in_f = input_dir + "/" + str(d_id)
    with open(in_f) as f:
      title = f.readline().rstrip('\n')
      lines = f.readlines()    
                                 
    line_list = [l.rstrip('\n') for l in lines]
    if len(line_list) <= line_min:
        continue

    flg = False
    for ex in excls:
        if ex in title:
            flg = True
    if flg:
        continue

    data[d_id] = {}
    data[d_id]['title'] = title
    data[d_id]['lines'] = line_list 

    c += 1
    if (count and c >= count):
      return data

  return data  
```


学習

```python:learn.v4.3.py
# -*- coding: utf-8 -*-
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

import datetime
import random

from net import Net

MODEL_PATH =  os.environ['OUTPUT_DIR'] + "/meta/Net.v4.3.model"
TRAIN_DATA_DIR = os.environ['TRAIN_DATA_DIR']
FILE_PREFIX = "train.v4.1."


TRAIN_SET_COUNT = 1000

IN_SIZE = 768 * 3

TRAIN_RATE = 0.9
EPOCH_COUNT = 200
LEARN_RATE = 0.01

def main():

  net = Net(IN_SIZE).cuda()
  criterion = nn.CrossEntropyLoss()
  opt = optim.SGD(net.parameters(), lr=LEARN_RATE, momentum=0.9)

  for epoch in range(EPOCH_COUNT):
      print(str(datetime.datetime.now()) + ":EPOCH " + str(epoch))
      for i in range(TRAIN_SET_COUNT):
        file_name = TRAIN_DATA_DIR + "/" + FILE_PREFIX + str(i) + ".npy"
        batch = np.load(file_name, allow_pickle=True)
        embs = torch.tensor([b[0] for b in batch]).cuda()
        labels = torch.tensor([int(b[1]) for b in batch]).cuda()
        input = embs
        out = net(input)

        opt.zero_grad()
        loss = criterion(out, labels)
        loss.backward()
        opt.step()
        torch.cuda.empty_cache()

        if (i % int(TRAIN_SET_COUNT / 2)) == 0:
          print(str(datetime.datetime.now()) + ":epoch {} loop {}: loss {}".format(epoch, i, loss))


      c = 0
      c_all = 0
      for i in range(10):
        file_i = i + TRAIN_SET_COUNT
        file_name = TRAIN_DATA_DIR + "/" + FILE_PREFIX + str(i) + ".npy"
        batch = np.load(file_name, allow_pickle=True)
        embs = torch.tensor([b[0] for b in batch]).cuda()
        labels = torch.tensor([int(b[1]) for b in batch]).cuda()
        input = embs
        out = net(input)

        _, predicted = torch.max(out, 1)
        c += (predicted == labels).sum().item()
        c_all += len(predicted)
    
      torch.save(net.state_dict(), MODEL_PATH)
      if (epoch % 50) == 0:
          torch.save(net.state_dict(), MODEL_PATH + "." + str(epoch))
      loss = criterion(out, labels)


      print("accuracy: " + str(c / c_all))
      print("loss: " + str(loss))

      batch = None
      embs = None
      labels = None
      input = None
      out = None
      loss = None

      torch.cuda.empty_cache()

  torch.save(net.state_dict(), MODEL_PATH)


if  __name__ == "__main__":
    main()
```

```python:net.py
# -*- coding: utf-8 -*-
import torch
import torch.nn as nn
import torch.nn.functional as F

IN_SIZE = 768 * 2
INNER_SIZE = 50
OUT_SIZE = 2


class Net(nn.Module):

    def __init__(self, in_size=IN_SIZE):
        super(Net, self).__init__()
        # an affine operation: y = Wx + b
        self.fc1 = nn.Linear(in_size, INNER_SIZE)  # 6*6 from image dimension
        self.fc2 = nn.Linear(INNER_SIZE, INNER_SIZE)
        self.fc3 = nn.Linear(INNER_SIZE, INNER_SIZE)
        self.fc4 = nn.Linear(INNER_SIZE, INNER_SIZE)
        self.fc5 = nn.Linear(INNER_SIZE, OUT_SIZE)

        self.in_size = in_size

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = F.relu(self.fc3(x))
        x = F.relu(self.fc4(x))
        x = self.fc5(x)
        return x

    def num_flat_features(self, x):
        size = x.size()[1:]  # all dimensions except the batch dimension
        num_features = 1
        for s in size:
            num_features *= s
        return num_features

```


```bash:
$ time python bin/learn.v4.3.py
....
....
....
2020-01-26 01:02:13.907091:EPOCH 198
2020-01-26 01:02:14.091112:epoch 198 loop 0: loss 0.07122626155614853
2020-01-26 01:03:46.419897:epoch 198 loop 500: loss 0.08971706032752991
accuracy: 0.9686
loss: tensor(0.1030, device='cuda:0', grad_fn=<NllLossBackward>)
2020-01-26 01:05:20.406954:EPOCH 199
2020-01-26 01:05:20.590984:epoch 199 loop 0: loss 0.05285603925585747
2020-01-26 01:06:52.919082:epoch 199 loop 500: loss 0.08384775370359421
accuracy: 0.9717
loss: tensor(0.0885, device='cuda:0', grad_fn=<NllLossBackward>)

real    621m16.838s
user    615m52.858s
sys     5m33.089s
```

予測

```python:evaluate.v4.3.py
# -*- coding: utf-8 -*-
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

import datetime
import random

from net import Net
from alldata import *

from bert_juman import BertWithJumanModel

MODEL_PATH =  os.environ['OUTPUT_DIR'] + "/meta/Net.v4.3.model"
TRAIN_DATA_DIR = os.environ['TRAIN_DATA_DIR']
BERT_PATH = os.environ['BERT_PATH']

DOC_PATH = os.environ['DOC_DIR']

DOC_COUNT = 150000
OUT_COUNT = 200000

IN_SIZE = 768 * 3

LEARN_RATE = 0.01

bert = BertWithJumanModel(BERT_PATH, use_cuda=True)

def main():

  print(str(datetime.datetime.now()) + ": loading data", file=sys.stderr)
  data = all_data(DOC_PATH, DOC_COUNT)
  doc_ids = list(data.keys())
  print(str(datetime.datetime.now()) + ": preparing", file=sys.stderr)


  net = Net(IN_SIZE).cuda()
  net.load_state_dict(torch.load(MODEL_PATH))

  for i in range(OUT_COUNT):
      l1_did = random.choice(doc_ids)
      l2_did = random.choice(doc_ids)
      l1 = random.choice(data[l1_did]['lines'])
      l2 = random.choice(data[l2_did]['lines'])

      emb = bert.get_multi_embedding([l1, l2], pooling_strategy="CLS_TOKEN")
      l1_emb = bert.get_sentence_embedding(l1, pooling_strategy="CLS_TOKEN")
      l2_emb = bert.get_sentence_embedding(l2, pooling_strategy="CLS_TOKEN")
      input = torch.tensor([np.concatenate([emb, l1_emb, l2_emb])]).cuda()


      out = net(input)
      _, predicted = torch.max(out, 1)

      score = out[0][1]
      if predicted[0] == 1:
        if score < 0:
            score_i = 0
        else:
          score_i = int(score * 100000)
        print(f"{score} {score_i} {l1_did} {l2_did}\t{l1}\t{l2}")


if  __name__ == "__main__":
    main()
```

```bash:
time python bin/evaluate.v4.3.py > evaluated/positive.v4.3.eval
```

</div></details>