言語処理100本ノック(2020)-70: 単語ベクトルの和による特徴量(TensorFlow)


[言語処理100本ノック 2020 (Rev2)](https://nlp100.github.io/ja/)の[「第8章: ニューラルネット」](https://nlp100.github.io/ja/ch08.html)の[70本目「単語ベクトルの和による特徴量」](https://nlp100.github.io/ja/ch08.html#70-%E5%8D%98%E8%AA%9E%E3%83%99%E3%82%AF%E3%83%88%E3%83%AB%E3%81%AE%E5%92%8C%E3%81%AB%E3%82%88%E3%82%8B%E7%89%B9%E5%BE%B4%E9%87%8F)記録です。
TensorFlowの[TFRecords](https://www.tensorflow.org/tutorials/load_data/tfrecord?hl=ja)が理解し難く、非常に時間がかかってしまいました。**大規模データではデータ読込などでTFRecords使うメリットありそうですが、中規模レベルでpickleに比べて大きなメリットあるか、理解できていません**。**課題の数式は**他の方のプログラムをカンニングしながらコーディングしているうちに理解できるので、**最初に完全に理解しなくてもいい**かと思います。
[記事「まとめ: 言語処理100本ノックで学べることと成果」](https://qiita.com/FukuharaYohei/items/6f0af3c4e270d8af2d99)に[言語処理100本ノック 2015](http://www.cl.ecei.tohoku.ac.jp/nlp100/)についてはまとめていますが、追加で差分の[言語処理100本ノック 2020 (Rev2)](https://nlp100.github.io/ja/)についても更新します。

# 参考リンク

|リンク|備考|
|:--|:--|
|[70.単語ベクトルの和による特徴量.ipynb](https://github.com/YoheiFukuhara/nlp100-2020/blob/main/08.%E3%83%8B%E3%83%A5%E3%83%BC%E3%83%A9%E3%83%AB%E3%83%8D%E3%83%83%E3%83%88/70_%E5%8D%98%E8%AA%9E%E3%83%99%E3%82%AF%E3%83%88%E3%83%AB%E3%81%AE%E5%92%8C%E3%81%AB%E3%82%88%E3%82%8B%E7%89%B9%E5%BE%B4%E9%87%8F.ipynb)|回答プログラムのGitHubリンク|
|[言語処理100本ノック 2020 第8章: ニューラルネット](https://qiita.com/nymwa/items/9c8484ff511123e03ba8)|(PyTorchだけど)解き方の参考|
|[【言語処理100本ノック 2020】第8章: ニューラルネット](https://qiita.com/yamaru/items/b54989d772c4323bde69)|(PyTorchだけど)解き方の参考|
|[まとめ: 言語処理100本ノックで学べることと成果](https://qiita.com/FukuharaYohei/items/6f0af3c4e270d8af2d99)|言語処理100本ノックまとめ記事|
|[TFRecords と tf.Example の使用法](https://www.tensorflow.org/tutorials/load_data/tfrecord?hl=ja)|今回の課題メイン|
|[言語処理100本ノック(2020)-50: データの入手・整形](https://qiita.com/FukuharaYohei/items/5959535a58a3532ae586)|前提ノック(元データ作成)|
|[言語処理100本ノック(2020)-60: 単語ベクトルの読み込みと表示](https://qiita.com/FukuharaYohei/items/eef49a598b1e346fc45f)|前提ノック(単語ベクトル読込)|

# 環境
後々GPUを使わないと厳しいので、Goolge Colaboratory使いました。Pythonやそのパッケージでより新しいバージョンありますが、新機能使っていないので、プリインストールされているものをそのまま使っています。

|種類|バージョン|内容|
|:--|:--|:--|
|Python|3.7.12|Google Colaboratoryのバージョン|
|google|2.0.3|Google Driveのマウントに使用|
|tensorflow|2.6.0|ディープラーニングの主要処理|
|pandas|1.1.5|タブ区切りファイル読込とその処理|
|gensim|3.6.0|Word2Vecのデータ読込処理|
|numpy|1.19.5|Numpy Arrayにする処理|

# [第8章: ニューラルネット](https://nlp100.github.io/ja/ch08.html)
## 学習内容
> 深層学習フレームワークの使い方を学び，ニューラルネットワークに基づくカテゴリ分類を実装します．

## ノック内容
> 第6章で取り組んだニュース記事のカテゴリ分類を題材として，ニューラルネットワークでカテゴリ分類モデルを実装する．なお，この章ではPyTorch, TensorFlow, Chainerなどの機械学習プラットフォームを活用せよ．

### [70. 単語ベクトルの和による特徴量](https://nlp100.github.io/ja/ch08.html#70-%E5%8D%98%E8%AA%9E%E3%83%99%E3%82%AF%E3%83%88%E3%83%AB%E3%81%AE%E5%92%8C%E3%81%AB%E3%82%88%E3%82%8B%E7%89%B9%E5%BE%B4%E9%87%8F)
> 問題50で構築した学習データ，検証データ，評価データを行列・ベクトルに変換したい．例えば，学習データについて，すべての事例$x_i$の特徴ベクトル$\boldsymbol{x}_i$を並べた行列$X$と正解ラベルを並べた行列（ベクトル）$Y$を作成したい．
>
>```math
X = \begin{pmatrix} 
  \boldsymbol{x}_1 \\ 
  \boldsymbol{x}_2 \\ 
  \dots \\ 
  \boldsymbol{x}_n \\ 
\end{pmatrix} \in \mathbb{R}^{n \times d},
Y = \begin{pmatrix} 
  y_1 \\ 
  y_2 \\ 
  \dots \\ 
  y_n \\ 
\end{pmatrix} \in \mathbb{N}^{n}
```
>
> ここで，$n$は学習データの事例数であり，$\boldsymbol x_i \in \mathbb{R}^d$と$y_i \in \mathbb N$はそれぞれ，$i \in \{1, \dots, n\}$番目の事例の特徴量ベクトルと正解ラベルを表す．なお，今回は「ビジネス」「科学技術」「エンターテイメント」「健康」の4カテゴリ分類である．$\mathbb N_{<4}$で$4$未満の自然数（$0$を含む）を表すことにすれば，任意の事例の正解ラベル$y_i$は$y_i \in \mathbb N_{<4}$で表現できる．
> 以降では，ラベルの種類数を$L$で表す（今回の分類タスクでは$L=4$である）．
>
> $i$番目の事例の特徴ベクトル$\boldsymbol x_i$は，次式で求める．
>
> $$\boldsymbol x_i = \frac{1}{T_i} \sum_{t=1}^{T_i} \mathrm{emb}(w_{i,t})$$
>
> ここで，$i$番目の事例は$T_i$個の（記事見出しの）単語列$(w_{i,1}, w_{i,2}, \dots, w_{i,T_i})$から構成され，$\mathrm{emb}(w) \in \mathbb{R}^d$は単語$w$に対応する単語ベクトル（次元数は$d$）である．すなわち，$i$番目の事例の記事見出しを，その見出しに含まれる単語のベクトルの平均で表現したものが$\boldsymbol x_i$である．今回は単語ベクトルとして，問題60でダウンロードしたものを用いればよい．$300$次元の単語ベクトルを用いたので，$d=300$である．
> $i$番目の事例のラベル$y_i$は，次のように定義する．
>
>```math
y_i = \begin{cases}
0 & (\mbox{記事}\boldsymbol x_i\mbox{が「ビジネス」カテゴリの場合}) \\
1 & (\mbox{記事}\boldsymbol x_i\mbox{が「科学技術」カテゴリの場合}) \\
2 & (\mbox{記事}\boldsymbol x_i\mbox{が「エンターテイメント」カテゴリの場合}) \\
3 & (\mbox{記事}\boldsymbol x_i\mbox{が「健康」カテゴリの場合}) \\
\end{cases}
```
>
>なお，カテゴリ名とラベルの番号が一対一で対応付いていれば，上式の通りの対応付けでなくてもよい．
>
>以上の仕様に基づき，以下の行列・ベクトルを作成し，ファイルに保存せよ．
>
> + 学習データの特徴量行列: $X_{\rm train} \in \mathbb{R}^{N_t \times d}$
> + 学習データのラベルベクトル: $Y_{\rm train} \in \mathbb{N}^{N_t}$
> + 検証データの特徴量行列: $X_{\rm valid} \in \mathbb{R}^{N_v \times d}$
> + 検証データのラベルベクトル: $Y_{\rm valid} \in \mathbb{N}^{N_v}$
> + 評価データの特徴量行列: $X_{\rm test} \in \mathbb{R}^{N_e \times d}$
> + 評価データのラベルベクトル: $Y_{\rm test} \in \mathbb{N}^{N_e}$
>
>なお，$N_t, N_v, N_e$はそれぞれ，学習データの事例数，検証データの事例数，評価データの事例数である．

# 回答
## 回答結果
`find`コマンドでGoogle Driveへ出力したファイルを確認。ノック内容に少し反していますが、特徴量行列とラベルベクトルは同じファイルにまとめています。

```:結果
77   1640 -rw-------   1 root     root      1679352 Sep 23 01:09 /content/drive/MyDrive/ColabNotebooks/ML/NLP100_2020/08.NeuralNetworks/valid.tfrecord
82   1640 -rw-------   1 root     root      1679352 Sep 23 01:09 /content/drive/MyDrive/ColabNotebooks/ML/NLP100_2020/08.NeuralNetworks/test.tfrecord
72  13116 -rw-------   1 root     root     13429788 Sep 23 01:09 /content/drive/MyDrive/ColabNotebooks/ML/NLP100_2020/08.NeuralNetworks/train.tfrecord
```

## 回答プログラム [70_単語ベクトルの和による特徴量.ipynb](https://github.com/YoheiFukuhara/nlp100-2020/blob/main/08.%E3%83%8B%E3%83%A5%E3%83%BC%E3%83%A9%E3%83%AB%E3%83%8D%E3%83%83%E3%83%88/70_%E5%8D%98%E8%AA%9E%E3%83%99%E3%82%AF%E3%83%88%E3%83%AB%E3%81%AE%E5%92%8C%E3%81%AB%E3%82%88%E3%82%8B%E7%89%B9%E5%BE%B4%E9%87%8F.ipynb)
GitHubには確認用コードも含めていますが、ここには必要なものだけ載せています。

```python
import string

import tensorflow as tf
import pandas as pd
import numpy as np
from google.colab import drive
from gensim.models import KeyedVectors

drive.mount('/content/drive')

BASE_PATH = '/content/drive/MyDrive/ColabNotebooks/ML/NLP100_2020/'
BASE_PATH08 = BASE_PATH + '08.NeuralNetworks/'

w2v_model = KeyedVectors.load_word2vec_format(BASE_PATH+'07.WordVector/input/GoogleNews-vectors-negative300.bin.gz', binary=True)

# 変換テーブル作成: 同じ長さでないとエラーがでるので第2引数の長さ調整(置換元と置換先文字は1:1だから)
table = str.maketrans(string.punctuation, ' '*len(string.punctuation))
def vectorize(title):

    # 句読点をスペースに置換してsplit
    tokens = title.translate(table).split()

    # 各Tokenをベクトル化
    vectors = [w2v_model[token] for token in tokens if token in w2v_model]
    
    # Tokenごとの平均ベクトルを算出(DataFrameの要素にNumpy Arrayを格納)
    vectors = np.array(sum(vectors)/len(vectors))
    return vectors


def make_example(title, category):
    # Dictionary形式でfeaturesを格納
    features={
       'title' : tf.train.Feature(bytes_list=tf.train.BytesList(value=[title])),
       'category' : tf.train.Feature(bytes_list=tf.train.BytesList(value=[category]))
    }

    # ExampleとFeaturesでまとめて返す
    return tf.train.Example(features=tf.train.Features(feature=features))


def process_data(type_):
    df = pd.read_table(BASE_PATH08+'input/'+type_+'.txt')

    # タイトル: Token化しベクトル化した要素を1列にしたSeries作成
    titles = df['title'].map(vectorize).explode().astype('float32')

    # タイトル: 配列をNumpy形式にしてshape変更
    titles = titles.values.reshape(len(df), 300)

    # ラベル: 文字を数字に変更
    categories = df['category'].replace({'b':0, 't':1, 'e':2, 'm':3})

    # 本当はScikit Learnの関数が望ましいが少ないラベル(４値)種類でそこそこデータ件数があるのでkeras使う
    categories = tf.keras.utils.to_categorical(categories, dtype='int32')

    # ファイル書込
    with tf.io.TFRecordWriter(BASE_PATH08+type_+'.tfrecord') as writer:
        
        # １行ずつ取り出してExample形式にして書込
        for title, category in zip(titles, categories):
            ex = make_example(title.tobytes(), category.tobytes())
            writer.write(ex.SerializeToString())


process_data('train')
process_data('valid')
process_data('test')
```

## 回答解説
### ノック解説
まずはノック内容自体の解説からします。

> すべての事例$x_i$の特徴ベクトル$\boldsymbol{x}_i$を並べた行列$X$と，正解ラベルを並べた行列（ベクトル）$Y$を作成したい．

上記部分は以下のように整理できます。

|項目|内容|例|
|:-:|:--|:--|
|事例$x_i$|i番目の事例|Jj Abrams - Star Wars: Episode VII has started filming|
|特徴ベクトル$\boldsymbol{x}_i$|i番目の特徴ベクトル(300要素の特徴ベクトル)|[ 0.117, ..., 0.024] 300要素 |
|行列$X$|特徴ベクトル$\boldsymbol{x}_i$が事例数並んだ行列|[[ 0.117, ..., 0.024],...[0.117, ..., 0.024]]300要素×事例数|
|行列$Y$|正解ラベル$y_i$が事例数並んだ行列|[0, 1, ..., 2]事例数と同じ要素|

$\mathbb{R}^{n \times d}や\mathbb{N}^{n}$の$n$は事例数で$d$は次元数(今回はノック60で取得したWord2Vecモデルの次元数300)。

```math
X = \begin{pmatrix} 
  \boldsymbol{x}_1 \\ 
  \boldsymbol{x}_2 \\ 
  \dots \\ 
  \boldsymbol{x}_n \\ 
\end{pmatrix} \in \mathbb{R}^{n \times d},
Y = \begin{pmatrix} 
  y_1 \\ 
  y_2 \\ 
  \dots \\ 
  y_n \\ 
\end{pmatrix} \in \mathbb{N}^{n}
```

特徴ベクトル$\boldsymbol{x}_i$ の計算についてです。

```math
\boldsymbol{x}_i = \frac{1}{T_i} \sum_{t=1}^{T_i} \mathrm{emb}(w_{i,t})
```

仮に10番目の事例に「*Chariklo asteroid has two RINGS*」があったとします。

|項目|内容|例|
|:-:|:--|:--|
|事例$x_{10}$|10番目の事例|Chariklo asteroid has two RINGS|
|単語列数$T_{10}$|10番目の事例の単語列数|5(事例の文は5単語)|
|単語列$w_{10,t}$|10番目の事例の単語列|(Chariklo, asteroid, $\dots$, RINGS)|
|単語ベクトル$\mathrm{emb}(w_{10,t}$|10番目の事例の単語ベクトル|Word2Vecで取得する300次元の単語ベクトル|

### 事例のベクトル化
関数`vectorize`で事例をベクトル化しています。`pandas`の`Series`の1要素に対して`map`関数で実行されます。つまり、入力パラメータの`title`はstring型のテキスト(例: 「Jj Abrams - Star Wars: Episode VII has started filming」)です。
`len(vectors)`は単語数$T_i$です。300次元の各要素を全単語で平均化しているため、常に出力`vectors`が300次元になります。私は途中まで、各単語ごとに平均ベクトルを求めるのかと勘違いをしていました。

```python:
# 変換テーブル作成: 同じ長さでないとエラーがでるので第2引数の長さ調整(置換元と置換先文字は1:1だから)
table = str.maketrans(string.punctuation, ' '*len(string.punctuation))
def vectorize(title):

    # 句読点をスペースに置換してsplit
    tokens = title.translate(table).split()

    # 各Tokenをベクトル化
    vectors = [w2v_model[token] for token in tokens if token in w2v_model]
    
    # Tokenごとの平均ベクトルを算出(DataFrameの要素にNumpy Arrayを格納)
    vectors = np.array(sum(vectors)/len(vectors))
    return vectors
```

### TFRecord形式での保存
今回、理解するのに最も時間がかかった内容です。主に[チュートリアル「TFRecords と tf.Example の使用法」](https://www.tensorflow.org/tutorials/load_data/tfrecord?hl=ja)を見て理解しましたが、以下の点が重要。**データ読込が遅くなければ使わなくてもいい**。

> tf.data を使っていて、それでもデータの読み込みが訓練のボトルネックであるという場合でなければ、既存のコードを TFRecords を使用するために変更する必要はありません。

以下の順序でTFRecord形式で保存します。

1. `tf.train.Feature`形式に変換
2. Dictionary型データにして`tf.train.Features`メッセージに変換
3. `tf.train.Example`メッセージに変換
4. シリアライズしてTFRecord形式でファイル書込

事例およびラベルデータを`tf.train.Feature`形式に変換します。`tf.train.Feature`は以下の3種類の型を選びます。詳しくは[「tf.Example 用のデータ型」](https://www.tensorflow.org/tutorials/load_data/tfrecord?hl=ja#tfexample_%E7%94%A8%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E5%9E%8B)を参照。

1. [`tf.train.BytesList`](https://www.tensorflow.org/api_docs/python/tf/train/BytesList?hl=ja): Stringや複数のNumpy配列もこれ。
2. [`tf.train.FloatList`](https://www.tensorflow.org/api_docs/python/tf/train/FloatList?hl=ja)
3. [`tf.train.Int64List`](https://www.tensorflow.org/api_docs/python/tf/train/Int64List?hl=ja)

今回は、1行ずつ配列から取り出して、`title`および`category`は複数列なので、Byte化しています。

```python:
    # ファイル書込
    with tf.io.TFRecordWriter(BASE_PATH08+type_+'.tfrecord') as writer:
        
        # １行ずつ取り出してExample形式にして書込
        for title, category in zip(titles, categories):
            ex = make_example(title.tobytes(), category.tobytes())
            writer.write(ex.SerializeToString())
```

で、関数`make_example`内で`tf.train.Feature`に渡しています。

```python:
def make_example(title, category):

    # Dictionary形式でfeaturesを格納
    features={
       'title' : tf.train.Feature(bytes_list=tf.train.BytesList(value=[title])),
       'category' : tf.train.Feature(bytes_list=tf.train.BytesList(value=[category]))
    }

    # ExampleとFeaturesでまとめて返す
    return tf.train.Example(features=tf.train.Features(feature=features))
```