spaCyによる文章のベクトル化で二項分類をしたい(希望)

# はじめに

この記事は[ユニークビジョン株式会社 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/uniquevision) 6日目の記事です。

[spaCyを使った先進的な自然言語処理](https://course.spacy.io/ja/) というWebサイトを読みましたので、文章の二項分類を試してみます。

# spaCyについて

[上記のページ](https://course.spacy.io/ja/)によると、「spaCyは産業応用向きの自然言語処理用Pythonライブラリです。」とのことです。

怒られそうですが、雑な説明を書きます。

一般的に、文章をソフトウェアで解析したいと考えた時に行うことには、以下のような工程があります。それぞれの工程の作業を行うためのライブラリも開発されてきました。

* 文字の正規化、その他前処理を行う → neologdn など
* 文章を単語に分解する(形態素解析) → MeCab、Sudachi など
* 単語の係り受けを解析する(依存関係の解析) → CaboCha など
* 単語を数値で表す(単語のベクトル化) → word2vec など
* 文章を数値で表す(文章のベクトル化)

例えば「私はツリーカンガルーとイルカが好きです。」という文章を数値、すなわちベクトルで表したいとした場合、少なくとも4つのライブラリを使うなどしてこれらの工程を実現する必要があります。

spaCyは、これらの工程を、

```python
doc = nlp('私はツリーカンガルーとイルカが好きです。')
```

の1行で実現できる、というものです。

# 文章の二項分類を試す

## 学習用データを用意する

Twitterのユーザの自己紹介文から、そのユーザが男性であるか女性であるかを推測する、ということをしてみます。

まず学習データを用意します。Twitterユーザの自己紹介文を読んで、その人の性別を人間が見分けるのは、意外と大変です。いい加減に用意することにします。

以下の5個のアカウントのフォロワーを、一律男性だとみなすことにします。

|スクリーン名|アカウント名|フォロワー数|
|---|---|---|
|TOYOTA_PR|トヨタ自動車株式会社|27.3万|
|TokyoGiants|読売巨人軍（ジャイアンツ）|52万|
|fctokyoofficial|🇯🇵🗼FC東京【公式】🔜12.6北京国安戦 #ACL #STAYWITHTOKYO|22.2万|
|takerusegawa|武尊 takeru|26.6万|
|enako_cos|えなこ|124.4万|

以下の5個のアカウントのフォロワーを、一律女性だとみなすことにします。

|スクリーン名|アカウント名|フォロワー数|
|---|---|---|
|disneyjp|ディズニー公式|91.2万|
|J_Jr_Ch|ジャニーズJr.チャンネル公式|51.1万|
|Popteen_jp|Popteen(ポップティーン)|35.9万|
|Godiva_JPN|ゴディバ|18万|
|Lancome_JP|LANCÔME（ランコム）公式|14.4万|

フォロワーを全件取得するのは大変なので、それぞれのアカウントから3,000件ずつ取得し、そのうち自己紹介文が20文字以上のユーザの自己紹介文を使うことにします。

男性の文章

```
>>> import pandas as pd
>>> data = pd.read_pickle('./data.pkl')
>>> data[data['gender'] == 0]
      gender                                        description
6613       0  前垢@ats_ss_kochi_02がアカウント乗っ取りされたので、急遽、アカウント削除を実...
6614       0  HND.NRT 修行解脱！RAV4 adventure！I'm waiting for yo...
6615       0  @mond_wasser (水月@本と映画)\nの日常アカ おしゃべりアカ\nみなさん仲良く...
6616       0  成人済み。ふつうのツイートもあります。他の呟きも多いので、フォロー解除はブロックかましてください。
6617       0  横浜に住んでいるが野球は詳しくない。1985 Hirakata Breakers,Off-s...
...      ...                                                ...
12051      0          主にジャイアンツや亀ちゃん、家族のことを呟きます。 こっちもよろしくお願いします。
12052      0  出演作品\tプロ野球スピリッツシリーズ パワプロアプリ\n性別\t不明\n誕生日\t????...
12053      0  @G_naomatu2518のいわゆるサブ垢って奴です。\n(避難垢でもあります)\n100...
12054      0  ２０１９年４月、武蔵村山市議会議員に初当選。元社会福祉協議会勤務。消防団２５年在籍（都大会３...
12055      0                        ALC dance family🥤\n06' (14)

[5443 rows x 2 columns]
```

女性の文章

```
>>> data[data['gender'] == 1]
     gender                                        description
0         1                               95 / めっめと拓実くんがダイスキ❗️
1         1  日本のドラマ、アニメなど、エンタメ大好き💕　年齢を重ねたせいか、昔ほどの情熱はありませんが、...
2         1  | 綾野剛 | スノストトラ | |渡辺翔太＊目黒蓮＊佐久間大介|京本大我|川島如恵留＊松田...
3         1                            松倉天才　松倉不憫　松倉優勝　ややちゃかまちゅ
4         1          拓哉くんがだいすきです 仲良くしてください！！！ 垢作ったばっかですよろしくです〜
...     ...                                                ...
6608      1  20代🙌育児👧🏻👶🏻/料理🍳/食べ歩き🎶/旅行🏞/スキンケア🌱/コスメ💄/美容💗/パチ🎰/競...
6609      1  20代後半システムエンジニア🙋♀️ 💻懸賞やっている方、仲良くさせてください😌🙌無言フォロ...
6610      1  Twitterプレゼント企画当選祈願 ʕ•̫͡•ʕ*̫͡*ʕ•͓͡•ʔ-̫͡-...
6611      1                 今年はしっかり食べながらダイエットに挑戦し、結果を出すのが目標です🌟
6612      1              アーティスティックな人が好き。お絵かき用のアカウント！フォローはご自由に！

[6613 rows x 2 columns]
```

合計 12,056件のデータを用意しました。

## 文章のベクトル化

spaCyを使って、それぞれの文章のベクトル値を取得します。spaCyに、単語のベクトル値が保存されている学習済みのモデルがあるので、それをそのまま使用します。

* MODELS > [Japanese](https://spacy.io/models/ja)

ja_core_news_sm はファイルサイズが小さくてよいのですが、ベクトル値が含まれていないので、ja_core_news_md を使うことにします。

```python:show_vector.py
import sys
import pickle
import numpy as np
import pandas as pd
import spacy

data = pd.read_pickle(sys.argv[1])
nlp = spacy.load(sys.argv[2])
rows = []
for description, gender in zip(data['description'], data['gender']):
  try:
    doc = nlp(description)
    rows.append(np.append(doc.vector, gender))
  except Exception as e:
    print(e)
print(pd.DataFrame(data=rows))
```

```
python show_vector.py ./data.pkl ja_core_news_md
            0         1         2         3         4    ...       296       297       298       299  300
0      0.071246 -0.013238  0.075496 -0.032384  0.069176  ... -0.022124 -0.024525 -0.101720 -0.026496  1.0
1      0.031978 -0.067378 -0.001967 -0.102377 -0.022193  ... -0.089431 -0.038405 -0.003322 -0.035309  1.0
2      0.067861 -0.045422  0.082382  0.018325  0.054086  ... -0.014198 -0.005755 -0.007246  0.007447  1.0
3      0.039764 -0.026593  0.204978 -0.027663  0.038920  ... -0.112173  0.174062  0.035841 -0.084377  1.0
4      0.078497 -0.076581  0.050423 -0.068022  0.044663  ... -0.011023  0.011126 -0.039922 -0.025941  1.0
...         ...       ...       ...       ...       ...  ...       ...       ...       ...       ...  ...
12049  0.071674 -0.132975  0.020406 -0.079001 -0.050042  ... -0.071203 -0.091636 -0.024595 -0.038117  0.0
12050  0.015146 -0.075366  0.003468 -0.031981 -0.022432  ... -0.006856  0.013503  0.001361  0.000780  0.0
12051  0.026438 -0.072266 -0.009105 -0.046717 -0.028714  ... -0.064054 -0.048795 -0.055677 -0.032200  0.0
12052  0.085437 -0.061499  0.048066 -0.087439 -0.025244  ... -0.051426 -0.006603 -0.014577  0.011526  0.0
12053 -0.037473 -0.034445 -0.007315  0.011928  0.017477  ...  0.028057 -0.065218 -0.036564  0.054022  0.0

[12054 rows x 301 columns]
```

12,056件の文章のうち、12,054件について、300次元のベクトルに変換することができました。

## 二項分類を実行する

ベクトル化できたので、ロジスティック回帰により二項分類してみます。

```python:main.py
import sys
import pickle
import pandas as pd
import numpy as np
import spacy
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn import metrics

class DescriptionModel:
  def __init__(self, spacy_model_path, max_iter):
    self.max_iter = max_iter
    self.nlp = spacy.load(spacy_model_path)
    self.lr = LogisticRegression(C=1.0, max_iter=self.max_iter)

  def do_train(self, X_train, y_train):
    df = self.__make_doc_df(X_train, y_train)
    y = df.iloc[:, -1]
    X_df = df.iloc[:, 0:-2]
    self.lr.fit(X_df.values, y.values)

  def do_test(self, X_test, y_test):
    df = self.__make_doc_df(X_test, y_test)
    y = df.iloc[:, -1]
    X_df = df.iloc[:, 0:-2]
    predicted = self.lr.predict(X_df.values)
    return pd.DataFrame(data={'y': y, 'predicted': predicted})

  def __make_doc_df(self, X_src, y_src):
    rows = []
    for description, gender in zip(X_src, y_src):
      try:
        doc = self.nlp(description)
        rows.append(np.append(doc.vector, gender))
      except Exception as e:
        print(e)
    return pd.DataFrame(data=rows)


class DescriptionMetrics:
  def __init__(self, df, max_iter):
    self.df = df
    self.max_iter = max_iter

  def execute(self):
    matched_df = self.df[self.df['y'] == self.df['predicted']]
    y = self.df['y']
    predicted = self.df['predicted']
    print('{}\t{}\t{}\t{:.3f}\t{:.3f}\t{:.3f}\t{:.3f}'.format(
      self.max_iter,
      len(self.df),
      len(matched_df),
      metrics.accuracy_score(y, predicted),
      metrics.precision_score(y, predicted),
      metrics.recall_score(y, predicted),
      metrics.f1_score(y, predicted)))


class Training:
  def __init__(self, data_file_path, spacy_model_path, max_iter):
    self.data_file_path = data_file_path
    self.spacy_model_path = spacy_model_path
    self.max_iter = int(max_iter)

  def execute(self):
    data = pd.read_pickle(self.data_file_path)
    X_train, X_test, y_train, y_test = train_test_split(
      data['description'],
      data['gender'],
      test_size=0.33,
      random_state=42)
    model = DescriptionModel(self.spacy_model_path, self.max_iter)
    model.do_train(X_train, y_train)
    result_df = model.do_test(X_test, y_test)
    print(result_df)
    description_metrics = DescriptionMetrics(result_df, self.max_iter)
    description_metrics.execute()


if __name__ == '__main__':
  training = Training(sys.argv[1], sys.argv[2], sys.argv[3])
  training.execute()
```

```
time python main.py ./data.pkl ja_core_news_md 100
        y  predicted
0     0.0        0.0
1     1.0        0.0
2     1.0        0.0
3     1.0        1.0
4     0.0        0.0
...   ...        ...
3973  1.0        1.0
3974  1.0        1.0
3975  0.0        0.0
3976  1.0        1.0
3977  0.0        0.0

[3978 rows x 2 columns]
100     3978    2909    0.731   0.741   0.799   0.769

real    16m14.114s
user    15m16.598s
sys     0m6.979s
```

正解率 0.731、f1スコア 0.769 でした。いろいろいい加減なので、よいモデルにはならなかったようですが、二項分類の実行ができました。

# できなかったこと

## EMOJIラベルの追加

### spacymoji をパイプラインに追加できない

[spaCyを使った先進的な自然言語処理](https://course.spacy.io/ja/) に従って、新しいラベルを学習させて、二項分類の正解率が変わるかを試してみます。

例えば、絵文字に対して、「これは絵文字である」ということを表すラベルを与えてみます。

デフォルトではどのようなラベルがあるのかは以下のページに記載があります。

* [Annotation Specifications](https://spacy.io/api/annotation)

絵文字を表すラベルはなさそうです。そもそも用途が違いそうですが、練習ということで進めます。

絵文字についての拡張属性を追加してくれるパイプラインのコンポーネントがあるようです。

* [spacymoji](https://spacy.io/universe/project/spacymoji)

使い方に従って入れてみます。

```python:sample_spacymoji.py
import sys
import pickle
import numpy as np
import pandas as pd
import spacy
from spacymoji import Emoji

nlp = spacy.load('ja_core_news_md')
emoji = Emoji(nlp)
nlp.add_pipe(emoji, first=True)
```

```sh
pip install spacymoji
python sample_spacymoji.py
Traceback (most recent call last):
  File "/shared/apps/training/emoji_label.py", line 9, in <module>
    emoji = Emoji(nlp)
  File "/usr/local/lib/python3.9/site-packages/spacymoji/__init__.py", line 60, in __init__
    emoji_patterns = list(nlp.tokenizer.pipe(EMOJI.keys()))
AttributeError: 'JapaneseTokenizer' object has no attribute 'pipe'
```

エラーが発生しました。Japanese には使えないのでしょうか。悲しい。

### 文字範囲の不一致でラベルの割り当てが無視される

例えば以下の文章があったとします。

```
ドーナツやケーキ、アイス甘いもの大好きです(*´˘`*)♡でもついつい食べ過ぎて後悔することも… みなさん、よろしくお願いします！こちら懸賞垢ですがちゃんとつぶやきます( ⁎ᵕᴗᵕ⁎ )❤︎
```

最後のハートマークが絵文字です。 [spaCyを使った先進的な自然言語処理](https://course.spacy.io/ja/) に従って、学習データを用意します。

```
('ドーナツやケーキ ... 中略 ... つぶやきます( ⁎ᵕᴗᵕ⁎ )❤︎', {'entities': [(93, 94, 'EMOJI')]})
```

93文字目がハートマークの絵文字である、と指定しています。

さて、このデータを含む学習データで、 `nlp.update()` による学習を実行します。

```
/usr/local/lib/python3.9/site-packages/spacy/language.py:482: 
UserWarning: [W030] Some entities could not be aligned in the text 
"ドーナツやケーキ、アイス甘いもの大好きです(*´˘`*)♡でもついつい食べ過ぎて後悔することも… み..." 
with entities "[(93, 94, 'EMOJI')]". 
Use `spacy.gold.biluo_tags_from_offsets(nlp.make_doc(text), entities)` 
to check the alignment. 
Misaligned entities ('-') will be ignored during training.
  gold = GoldParse(doc, **gold)
```

警告が出力されました。93文字目に対してラベルを割り当てられないということのようです。？？？

ためしに元の文章をspaCyでトークンに分解してみます。

```python
>>> import spacy
>>> nlp = spacy.load("ja_core_news_md")
>>> doc = nlp('ドーナツやケーキ ... 中略 ... つぶやきます( ⁎ᵕᴗᵕ⁎ )❤︎')
>>> print([token.text for token in doc])
['ドーナツ', 'や', 'ケーキ', ... 中略 ..., 'つぶやき', 'ます', '(', '⁎', 'ᵕᴗᵕ', '⁎', ')❤', '︎']
```

spaCyは「)❤」の2文字が一つのトークンであると認識しました。指定した範囲とトークンの範囲が一致していません。これが原因でしょうか。

元の文章を自由に区切ってラベルを割り当てようとしましたが、うまくいかなさそうです。一度トークンに分解して、トークンが指し示す範囲にラベルを割り当てることにします。

### トークンの開始位置がわからない

ラベルの定義は、元の文章における、開始位置と終了位置を指定します。ただしその範囲はトークンの区切りと一致していると無難のようです。ということで、各トークンが元の文章のどの位置に配置されているのかがわかるとよいです。

* [Token](https://spacy.io/api/token)

しかし、トークンには元の文章のどの位置に配置されているのかを表す属性がなさそうです。いや、ありそうなものですが、見つけられませんでした。

では、先頭のトークンから順番に文字数を合計していけば、それぞれのトークンの開始位置がわかるでしょうか？

例えば以下の文章があったとします。

```
93 / #SixTONES 新規🔰 / 掛け持ち有
```

spaCyでトークンに分解してみます。

```python
>>> import spacy
>>> nlp = spacy.load("ja_core_news_md")
>>> doc = nlp('93 / #SixTONES 新規🔰 / 掛け持ち有')
>>> tokens = [token.text for token in doc]
>>> print(tokens)
['93', '/', '#', 'SixTONES', '新規', '🔰', '/', '掛け持ち', '有']
```

空白文字がなくなっているように見えます。すべてのトークンを再結合した時の文字数は、元の文章の文字数と一致するでしょうか？

```python
>>> print(len(''.join(tokens)))
21
>>> print(len(doc.text))
26
```

一致しませんでした。トークンの文字数を合計しても開始位置にはならないようです。

．．．できないじゃん。

### それでも学習させてみる

どうにかして絵文字の範囲を特定して、新しいラベルの定義を追加するための学習データを用意しました。

```python
import sys
import numpy as np
import pandas as pd
import spacy
import emoji
import regex
import random
import functools

class TrainingEmoji:
  def __init__(self, data_file_path):
    self.data_file_path = data_file_path
    self.emoji_set = self.pop_emoji_dict()

  def execute(self):
    nlp = spacy.load("ja_core_news_md")
    ner = nlp.get_pipe("ner")
    ner.add_label("EMOJI")
    data = pd.read_pickle(self.data_file_path)
    training_data = list(functools.reduce(lambda x, y: x + self.make_training_data(y, nlp), data['description'], []))
    nlp.begin_training()
    for i in range(10):
      random.shuffle(training_data)
      for batch in spacy.util.minibatch(training_data):
        # テキストとアノテーションのバッチを分割する
        texts = [text for text, annotation in batch]
        annotations = [annotation for text, annotation in batch]
        # モデルを更新する
        try:
          nlp.update(texts, annotations)
        except Exception as e:
          print(e)
    print(nlp.vector)
    nlp.to_disk('./ja_core_news_md_emoji')

  def make_training_data(self, text, nlp):
    entities = []
    try:
      doc = nlp(text)
      last_idx = 0
      for token in doc:
        if self.is_emoji(token.text):
          pos = text.find(token.text, last_idx)
          if pos >= last_idx:
            entities.append((pos, pos + len(token.text), 'EMOJI'))
            last_idx = pos + len(token.text)
    except Exception as e:
      print(e)
      return []
    return [(text, {'entities': entities})]

  # populate EMOJI_DICT
  def pop_emoji_dict(self):
    emoji_set = set()
    for moji in emoji.UNICODE_EMOJI:
      emoji_set.add(moji)
    return emoji_set

  # check if emoji
  def is_emoji(self, s):
    for letter in s:
      if letter in self.emoji_set:
        return True
    return False

if __name__ == '__main__':
  training_emoji = TrainingEmoji(sys.argv[1])
  training_emoji.execute()
```

できあがったspaCyの学習済みモデルで二項分類を実行してみます。

```
time python main.py ./data.pkl ./ja_core_news_md_emoji 100
100     3978    2909    0.731   0.741   0.799   0.769
```

正解率 0.731、f1スコア 0.769。

**結果変わらず(涙**

結果が変わらなかったということは、ラベルの定義が影響しなかったか、出力したモデルのファイルにベクトル値を反映できていないか、ということになります。ためしに ja_core_news_md ではなく、空のモデルから学習を実行させてみます。

```python
    nlp = spacy.load("ja_core_news_md")
    ner = nlp.get_pipe("ner")
    ner.add_label("EMOJI")
    :
    training_data = list(functools.reduce(lambda x, y: x + self.make_training_data(y, nlp), data['description'], []))
    :
    nlp.to_disk('./ja_core_news_md_emoji')
```

この部分を、以下のように書き替えます。

```python
    nlp0 = spacy.load("ja_core_news_md")
    nlp = spacy.blank("ja")
    ner = nlp.create_pipe("ner")
    nlp.add_pipe(ner)
    ner.add_label("EMOJI")
    :
    training_data = list(functools.reduce(lambda x, y: x + self.make_training_data(y, nlp0), data['description'], []))
    :
    nlp.to_disk('./ja_blank_emoji')
```

できあがったモデルで文章のベクトル値を出力します。

```python:show_vector_blank.py
import sys
import pandas as pd
import numpy as np
import spacy

nlp = spacy.load("./ja_blank_emoji")
data = pd.read_pickle(sys.argv[1])
for description in data['description']:
  try:
    doc = nlp(description)
    print(doc.vector)
  except:
    pass
```

```sh
python show_vector_blank.py ./data.pkl
[]
[]
[]
[]
[]
:
```

ベクトル値は空のままでした。単純に `nlp.update()` と `nlp.to_disk()` ではベクトル値を保存できないのでしょうか。無念。

# 終わりに

結果はまったく振るいませんでしたが、文章の二項分類を試すことができました。もう一つ、ラベルの追加によるspaCyの学習済みモデルの作成を試みましたが、こちらは失敗でした。

引き続き正しい使い方について調べていくことにします。

# その他

## 実行環境

* Python 3.9
* requirements.txt

```
pandas==1.1.4
scikit-learn==0.23.2
spacy==2.3.2
```

## 参考

* [spaCy](https://spacy.io/)
* [spaCyを使った先進的な自然言語処理](https://course.spacy.io/ja/)
* [テキストからすべての絵文字を抽出する方法は？](https://www.it-swarm-ja.tech/ja/python/%E3%83%86%E3%82%AD%E3%82%B9%E3%83%88%E3%81%8B%E3%82%89%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E7%B5%B5%E6%96%87%E5%AD%97%E3%82%92%E6%8A%BD%E5%87%BA%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%E3%81%AF%EF%BC%9F/830934348/)
* [sklearn.linear_model.LogisticRegression](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html)