ニューラルネットワークでフランス語の"covid"の名詞の性別を予測

文字情報のみでフランス語の名詞の性を学習したニューラルネットワークモデルによって、"covid"の性を予測してみた話です。

# はじめに
新型コロナウイルスはCoronavirus Diseaseの略でCOVID-19と名付けられています。そして名詞に性があるフランス語において、次の記事によるとcovidが**女性名詞**であると定められたそうです。（[フランス語の「ＣＯＶＩＤ」は女性名詞、学術機関が裁定 @cnn_co_jp ](https://www.cnn.co.jp/fringe/35153948.html)）

> ＣＯＶＩＤは英語の「ｃｏｒｏｎａｖｉｒｕｓ　ｄｉｓｅａｓｅ」の略語で、フランス語にすると「ｍａｌａｄｉｅ　ｐｒｏｖｏｑｕｅｅ　ｐａｒ　ｌｅ　ｃｏｒｏｎａ　ｖｉｒｕｓ（コロナウイルスが引き起こす疾病）」と翻訳される。

> 「Ｍａｌａｄｉｅ」は女性名詞なので、定冠詞は「ｌａ」を使う。従って、ＣＯＶＩＤ―１９も「ｌａ　ＣＯＶＩＤ―１９」とすべきだと判断した。

略語のもともとの単語をたどって得た結論ということですが、"covid"という字面だけで考えた場合、果たしてどちらの性の名詞っぽいのか、機械学習モデルに投げてみることにしました。

# フランス語の名詞の性について
[How to Easily Guess the Gender of French Nouns with 80% Accuracy](https://frenchtogether.com/french-nouns-gender/)によると、
> According to a study by McGill University, a noun’s ending indicates its gender in 80% of cases

ということで、語尾の文字のパターンによって8割は分類できるとのことで、今回の文字情報だけで予測を行うことは、ある程度妥当だと考えることとします。ちなみにフランス語でmasculineを男性、feminineを女性を意味します。

# 学習と予測
1単語を1つのデータとして、文字をone-hotエンコードした入力の系列をRNNに入力し、男性名詞か女性名詞かを予測するモデルを作ります。

|c|o|v|i|d||
|:-:|:-:|:-:|:-:|:-:|:-:|
|↓|↓|↓|↓|↓||
|[0010...0]|[0000...0]|[0000...0]|[0000...0]|[0001...0]||
|↓|↓|↓|↓|↓||
|RNN→|RNN→|RNN→|RNN→|RNN→|予測（Masc/Fem）|

## データ
### コーパス
[REDAC](http://redac.univ-tlse2.fr/corpora/wikipedia_en.html)と呼ばれる、単語にタグ付けされているフランス語のコーパスを使用します。`POS-tagged corpus [.tag.7z] (612 MB)`のデータが1行ごとに、`単語 \t 品詞 \t 基本形`の形で記されています。解答した後、名詞（NOM）が含まれており、基本形が書かれている行のみを取り出し、ファイルに出力しています。

```bash
cat wikipediaFR-2008-06-18.tag | grep NOM | grep -v unknown | sort | uniq -i > wikipediaFR_NOM_uniq  
```
中身はこのような状態です。

```bash
...
Acridine        NOM     acridine
Acrobate        NOM     acrobate
Acrobates       NOM     acrobate
Acrobatie       NOM     acrobatie
Acrobaties      NOM     acrobatie
...
```

https://www.keyxl.com/aaaa6c6/383/French-Accents-Alt-Codes-keyboard-shortcuts.htm によると、フランス語で用いられる文字はアルファベットのほかâäèéêëîïôœùûüÿçの41文字になるそうです。

```python
import string

all_chars = string.ascii_lowercase + 'âäèéêëîïôœùûüÿç' 
# abcdefghijklmnopqrstuvwxyzâäèéêëîïôœùûüÿç
```

先ほどのファイルから、これらの文字のみを含まれているもの、今回使用する単語として抽出しておきます。各列の基本形を取り出して`words`に格納しています。（単数・複数を混ぜると訓練データと評価データの分割が面倒になるためです）

```python
import re
p = re.compile("[^abcdefghijklmnopqrstuvwxyzâäèéêëîïôœùûüÿç]")

words = []
with open('./wikipediaFR_NOM_uniq', errors='ignore') as f:
    for line in tqdm(f):
        word = line.split('\tNOM\t')[-1].lower().replace('\n', '') 
        if p.search(word) == None:
            words.append(word)
```

### 名詞の性
以上によってフランス語の名詞を取り出したので、それらの性別を調べる必要があります。
https://github.com/sammous/spacy-lefff で公開されているフランス語のPOSタグ予測のライブラリを使用します。

```python
import spacy
from spacy_lefff import LefffLemmatizer, POSTagger

nlp = spacy.load('fr')
french_lemmatizer = LefffLemmatizer()
nlp.add_pipe(french_lemmatizer, name='lefff')

doc = nlp("Apple cherche a acheter une startup anglaise pour 1 milliard de dollard")
for d in doc:
    # 1単語ごとにtag_で品詞情報を得る
    print(d.text, '\t\t', d.tag_)

#=>
Apple 		 NOUN__Gender=Masc|Number=Sing
cherche 		 NOUN__Gender=Fem|Number=Sing
a 		 AUX__Mood=Ind|Number=Sing|Person=3|Tense=Pres|VerbForm=Fin
acheter 		 VERB__VerbForm=Inf
une 		 DET__Definite=Ind|Gender=Fem|Number=Sing|PronType=Art
startup 		 ADJ__Number=Sing
anglaise 		 NOUN__Gender=Fem|Number=Sing
pour 		 ADP
1 		 NUM__NumType=Card
milliard 		 NOUN__Gender=Masc|Number=Sing|NumType=Card
de 		 ADP
dollard 		 PROPN__Gender=Masc|Number=Sing

```

1単語入力したときの品詞を得る関数を作っておきます。spacy-lefffでは、男性名詞を` NOUN__Gender=Masc`、女性名詞を` NOUN__Gender=Fem`と出力するようです。

```python
def pos_of_word(word):
    return nlp(word)[0].tag_

print(word_gender['voyage']) #=> NOUN__Gender=Masc
```

### エンコードによる前処理
モデルへの入力と出力として、文字情報と名詞の性別を数値にエンコードする必要があります。まず、予測するクラスである名詞の性別ラベルをエンコードします。

```python
import pandas as pd

def encode_label(label, labels):
    return pd.get_dummies(list(labels))[label].values

all_genders =  ['NOUN__Gender=Masc', 'NOUN__Gender=Fem']
gender = 'NOUN__Gender=Fem'
encode_label(gender, all_genders) 
#=>
array([0, 1], dtype=uint8) # 女性名詞
```

続いて、単語を文字ごとにOne-hotエンコードしたベクトルの系列に変換します。41文字
をエンコードするため、1文字が41次元のベクトルとなります。

```python
# 1文字のエンコード
encode_label('è', all_chars)
# => 
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
      dtype=uint8)

# 1単語のエンコード
def encode_word(text, all_chars):
    return np.array([encode_label(char, all_chars) for char in text if char in all_chars])

encode_word("apple", all_chars)
#=>
array([[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
      dtype=uint8)
```

### 系列パディング
今回、kerasを用いて予測を行うのですが、RNNの入力の系列長は単語の文字数となり、もちろん単語によって文字数は異なるので、入力の際に特定のマスク値でパディングして、系列長を揃える必要があります。今回、系列長を100とすることで、全てのデータ（単語）が(100, 41)のshapeのnumpy配列となります。

```python
from keras.preprocessing import sequence

def pad_sequence(x, maxlen, value):
    return sequence.pad_sequences(x.T, maxlen=maxlen, value=value).T

input_dim = len(all_chars) #=> 41
hidden_num = 100
mask_value = -1

x = encode_word("apple", all_chars)

pad_sequence(x, hidden_num, mask_value).shape #=> (100, 41)
```

### 入力データ
以上の処理をすべての単語に対して行います

```python
x_list, y_list = [], []

for word in tqdm(set(words)):
    pos = pos_of_word(word)
    if 'NOUN__Gender' in pos:
        gender = pos.split('|')[0]
        
        x = encode_word(word, all_chars)
        x_padded = pad_sequence(x, hidden_num, mask_value)
        x_list.append(x_padded)        
        y = encode_label(gender, all_genders)
        y_list.append(y)
```

`len(x_list)`は`25666`となったので、25666単語が重複なしで抽出できたことになります。
全体の7割を訓練データに、残りを評価データに回すことにします。

```python
from sklearn.model_selection import train_test_split

x_train, x_val, y_train, y_val = train_test_split(x_list, y_list, train_size=0.7)
```

## モデル
### generatorによる入力データ
入力データが（無駄に系列長を100にしたこともあって）大きくなると、バッチがメモリに乗らなくなってしまうので、kerasのfit_generatorを使った学習を行うことにしました。バッチサイズ分のnumpy配列を返すgeneratorを定義します。
参考（https://hironsan.hatenablog.com/entry/2017/09/09/130608）

```python
class Generator():
    def __init__(self, datas, labels, batch_size):
        self.datas = datas
        self.labels = labels
        self.batch_size = batch_size
        self.data_size_ = len(self.datas)
        self.steps_per_epoch_ = int((len(self.datas) - 1) / self.batch_size) + 1
    
    def generator(self):
        while True:
            for batch_num in range(self.steps_per_epoch_):
                start_index = batch_num * self.batch_size
                end_index = min((batch_num + 1) * self.batch_size, self.data_size_)
                inputs = self.datas[start_index: end_index]
                labels = self.labels[start_index: end_index]
                x, y = encode(inputs, labels)
                yield x, y
                
def encode(inputs, labels):
    global all_chars, genders
    input_dim = len(all_chars)
    hidden_num = 100
    
    x, y = np.empty((0, hidden_num, input_dim)), np.empty((0, 2))
    for input_, label in zip(inputs, labels):
            x_ = input_[np.newaxis, :, :]
            x = np.append(x, x_, axis=0)
            y = np.append(y, label[np.newaxis, :], axis=0)
    return x, y
```

### 学習
kerasでRNNを構築します。

```python
from keras.layers import Input, Masking
from keras.layers.core import Dense, Activation
from keras.layers.recurrent import SimpleRNN, LSTM
from keras.models import Model
from keras.optimizers import Adam
from keras.callbacks import EarlyStopping
from keras.preprocessing import sequence
import matplotlib.pyplot as plt

# モデル
hidden_dim = 10
batch_size = 512

inputs = Input(shape=(hidden_num, input_dim))
mask = Masking(mask_value, input_shape=(hidden_num, input_dim))(inputs)
rnn = SimpleRNN(hidden_dim)(mask)
output = Dense(2)(rnn)

model = Model(inputs=inputs, outputs=output)
model.summary()

#=>
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_21 (InputLayer)        (None, 100, 41)           0         
_________________________________________________________________
masking_21 (Masking)         (None, 100, 41)           0         
_________________________________________________________________
simple_rnn_14 (SimpleRNN)    (None, 10)                520       
_________________________________________________________________
dense_21 (Dense)             (None, 2)                 22        
=================================================================
Total params: 542
Trainable params: 542
Non-trainable params: 0
_________________________________________________________________
```

学習します

```python
optimizer = Adam()
model.compile(loss="binary_crossentropy", optimizer=optimizer, metrics=['acc'])

generator_train = Generator(x_train, y_train, batch_size)
generator_val = Generator(x_val, y_val, batch_size)

history = model.fit_generator(generator_train.generator(), generator_train.steps_per_epoch_, epochs=150, validation_data=generator_val.generator(), validation_steps=generator_val.steps_per_epoch_, shuffle=True)
```

lossとaccuracyは以下のような感じで、評価データで8割届かないぐらいです...

<img width="444" alt="スクリーンショット 2020-07-24 20.08.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/def5a829-0f1a-aa70-c710-4722412f039d.png">


### "covid"を予測してみる
ここまで少し長くなりましたが、ようやくこのモデルが"covid"をどちらの性と予測するのか、試してみます。

```python
def predict_gender(model, word): # モデルと単語から性別を予測する
    global al_chars, hidden_num, mask_value
    x_target = [pad_sequence(encode_word(word, all_chars), hidden_num, mask_value)]
    y_target = [np.array([0, 0])]
    x_target, _ = encode(x_target, y_target)
    return model.predict(x_target)
```

結果

<img width="473" alt="スクリーンショット 2020-07-24 20.09.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/f818a664-decb-e471-d7bf-9da4b817dfe5.png">

`np.array([1, 0])`が男性名詞、`np.array([0, 1])`が女性名詞ということで結果的には女性名詞らしいですが、ほぼ差はありませんでした

<img width="486" alt="スクリーンショット 2020-07-24 20.31.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/0725998b-e790-c07c-569a-ce38cc6881b2.png">

"coronavirus"は男性名詞っぽいらしいです。

# おわりに
ちなみにフランス語のGoogle検索でのヒット数的には、正式に女性名詞と決まったこともあるのか、
<img width="223" alt="スクリーンショット 2020-07-24 19.12.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/a6fa0a4d-9a1d-7f4b-ba6b-2d7d398af7bb.png">

<img width="214" alt="スクリーンショット 2020-07-24 19.12.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/18852128-d4e5-f58c-8a4b-ffca9f7770b9.png">

と、女性名詞の冠詞であるlaを使ったページのほうが割合的に若干多くなっていました。

人間にとっても微妙な差なので、モデル的にも微妙という結論でしょうか