PyTorch を使って、ゼロから作る感情分析

# PyTorch を使って、ゼロから作る感情分析

https://pytorch.org/

## はじめに

はじめまして。私は大学院生です。

この記事では、[scikit-learn](https://scikit-learn.org) や [Transformers](https://huggingface.co/docs/transformers/main) を使わずに、PyTorch のみで感情分析を行います。（主に、研究室の後輩向けに書いていますので、極力簡素化しています。そのため、普段使うことが多い [Transformers の Tokenizer](https://huggingface.co/docs/transformers/main_classes/tokenizer) やモデルとは大きく異なります。ご了承ください。）

この記事が、「PyTorch と自然言語処理の学習に役立った」と思っていただけるように書かさせていただきます。

---

より簡素化できる案などがあれば[コメント](#comments)をしていただけると幸いです。

---

https://qiita.com/Natu_ja/items/4ae2b5d178389336d078

https://qiita.com/Natu_ja/items/8031fd0f07d2da86005b

類似記事もありますので、是非こちらもご覧になってください。

## 事前準備

### 用語解説

https://qiita.com/Natu_ja/items/3086f5d1577c44c7edae

基本的な用語はこちらで解説しています。

### 環境

https://colab.research.google.com/

### データセット

https://huggingface.co/datasets/stanfordnlp/sst2

## 実験コード

### ライブラリのインポート

```python
import re
from collections import Counter
import pandas as pd
import matplotlib.pyplot as plt
from tqdm import tqdm
import torch
from torch import nn, optim
from torch.utils.data import DataLoader, TensorDataset
```

### デバイスの設定

[Google Colab](https://colab.research.google.com/) では、「ランタイム」 -> 「ランタイムのタイプを変更」 -> 「T4 GPU」 に設定してください。

```python
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
```

上記の設定ができていると、`print(device)` の出力は、`device(type='cuda')` になるはずです。

### データセットのロード

今回使用するデータセットは、SST-2 [^SST-2] を使用します。

```python
splits = {'train': 'data/train-00000-of-00001.parquet', 'validation': 'data/validation-00000-of-00001.parquet'}
train_df = pd.read_parquet("hf://datasets/stanfordnlp/sst2/" + splits["train"])
val_df = pd.read_parquet("hf://datasets/stanfordnlp/sst2/" + splits["validation"])
```

学習データからラベル数を取得します。

```python
num_classes = train_df['label'].nunique()
```

### データセットの分析

`head()` メソッドを使うことで、先頭 5 行を見ることができます。

```python
train_df.head()
```

`tail()` メソッドを使うことで、末尾 5 行を見ることができます。

```python
train_df.tail()
```

`describe()` メソッドを使うことで、記述統計量を見ることができます。

```python
train_df.describe()
```

学習データのラベルの割合を以下で確認することができます。確認する意図としては、ラベルに偏りがある場合、ダウンサンプリング（あるいはアップサンプリング）が必要になるからです。

```python
train_df['label'].value_counts(normalize=True)
```

単語の出現頻度を可視化します。ただし、上位 50 件のみを表示します。

```python
all_words = []
for sentence in train_df["sentence"]:
    words = re.findall(r'\b\w+\b', sentence.lower())
    all_words.extend(words)

word_counts = Counter(all_words)

top_50_words = word_counts.most_common(50)
words, counts = zip(*top_50_words)

plt.figure(figsize=(12, 8))
plt.bar(words, counts)
plt.xticks(rotation=45)
plt.xlabel("Words")
plt.ylabel("Frequency")
plt.show()
```

![word frequency histogram](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836663/90fc7d78-793a-d9d4-89bd-49a42e360297.png)

テキストの長さを可視化します。

```python
train_df["text-length"] = train_df["sentence"].apply(lambda x: len(x.split()))

train_df["text-length"].hist(bins=50)
plt.xlabel("text length")
plt.ylabel("count")
plt.show()
```
![text length histogram](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836663/f1bbf433-329b-d854-4431-bc48e69f6012.png)

### トークナイザの作成

スペースで単語を区切り、各単語を辞書に登録していきます。この方法は、単語ベーストークナイゼーション (Word-based Tokenization) といい、単純で理解しやすいです。しかし、語彙空間が大きくなりやすく、未知語 (Out of Vocabulary: OOV) が発生しやすいです。
この時、辞書の作成には学習データのみを用います。理由としては、この辞書の作成に検証データやテストデータを含めると、再現性の確保という点で問題があるからです。また、検証データやテストデータを使わないことで、バイアスの防止も行なっています。

```python
input_ids_list = []

words_dict = {}
reversed_words_dict = {}

index = 1

for text in train_df["sentence"]:
    words = text.split()

    for word in words:
        if word not in words_dict:
            words_dict[word] = index
            reversed_words_dict[words_dict[word]] = word
            index += 1
    
    input_ids = [words_dict[word] for word in words]
    input_ids_list.append(input_ids)
```

検証データやテストデータの中には、学習データには出現しなかった単語が含まれている可能性があります。そのため、未知の単語用の ID を一つだけ用意します。

```python
unknown_token_id = len(words_dict) + 1
vocab_size = unknown_token_id + 1
```

辞書を元に `tokenize_sentence` を使って、単語を ID に変換します。この時に、未知の単語には、`unknown_token_id` が与えられます。

```python
def tokenize_sentence(sentence, words_dict):
    return [words_dict.get(word, unknown_token_id) for word in sentence.split()]

input_ids_list_train = [torch.tensor(tokenize_sentence(text, words_dict)) for text in train_df["sentence"]]
labels_tensor_train = torch.tensor(train_df['label'].values)

input_ids_list_val = [torch.tensor(tokenize_sentence(text, words_dict)) for text in val_df["sentence"]]
labels_tensor_val = torch.tensor(val_df['label'].values)
```

`pad_sequence()` は、異なる長さの文を 1 つのテンソルにまとめるための関数です。この関数を使うことで短い文の後ろに 0 を詰める（パディングする）ことができ、長さを揃えることができます。
訓練データと検証データをデータローダを用いて、ミニバッチ学習を行うための準備します。訓練用のデータローダには、シャッフルを適応させておきます。シャッフルを行うことで、エポック毎で異なるバッチが作られます。これにより、勾配の多様性を生み、より安定して収束することが期待できます。

```python
train_dataset = TensorDataset(nn.utils.rnn.pad_sequence(input_ids_list_train, batch_first=True), labels_tensor_train)
val_dataset = TensorDataset(nn.utils.rnn.pad_sequence(input_ids_list_val, batch_first=True), labels_tensor_val)
train_dataloader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_dataloader = DataLoader(val_dataset, batch_size=32)
```

`input_ids_list_train` から元の文章を生成してみます。

```python
id = 0
' '.join([reversed_words_dict[token_id.item()] for token_id in input_ids_list_train[id]])
```

同様に、`input_ids_list_val` から元の文章を生成してみます。ただし、検証データには未知語が含まれている可能性がありますので、未知語は `<UNK>` と表示させます。

```python
id = 0
' '.join([reversed_words_dict.get(token_id.item(), '<UNK>') for token_id in input_ids_list_val[id]])
```

### モデルの定義

- `vocab_size`：語彙サイズを表し、埋め込み (embedding) 層の入力サイズに対応します。モデルが何種類の単語を認識できるかを指定します。
- `embedding_dim`：埋め込み次元数で、各単語が埋め込みベクトルに変換される際の次元数を指定します。
- `output_dim`：最終的な出力の次元で、クラス数に合わせます。

`nn.Embedding` は、単語をベクトル表現（埋め込み）に変換する層です。この層を通すと、入力の各単語が埋め込み行列に基づき数値ベクトルに変換されます。
`nn.Linear` は、全結合層で、入力ベクトルと重み行列を掛け合わせて出力ベクトルを得る層です。

`self.embedding(x)` は、単語テンソル `x` を 埋め込み層に渡し、各単語を埋め込みベクトルに変換します。サイズは、入力が `[batch_size, max_seq_len]`、出力が、`[batch_size, max_seq_len, embedding_dim]` となります。
`x.mean(x)` は、埋め込みベクトルの平均を取っています。具体的には、`max_seq_len` の次元で平均を計算し、文全体を 1 つのベクトルに要約します。この処理により、出力は `[batch_size, embedding_dim]` の形になります。これは、各シーケンスが 1 つのベクトルとして表現されることを意味し、シーケンスの長さを考慮せずに全体的な意味を捉えることができます。
`self.fc(x)` は、平均化された埋め込みベクトル `x` を全結合層 (`fc`) に渡し、最終的な出力クラスのスコアを生成します。出力 `x` は `[batch_size, output_dim]` となり、各行が 1 つのサンプルの予測スコアを示します。

```python
class MODEL(nn.Module):
    def __init__(self, vocab_size, embedding_dim, output_dim):
        super(MODEL, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.fc = nn.Linear(embedding_dim, output_dim)

    def forward(self, x):
        x = self.embedding(x)
        x = x.mean(dim=1)
        x = self.fc(x)
        return x

model = MODEL(vocab_size=vocab_size, embedding_dim=100, output_dim=num_classes).to(device)
```

モデルの確認は、以下のコードで見ることができます。

```python
model
```

モデルの学習可能なパタメータ数の確認は以下のコードで見ることができます。`if p.requires_grad` の部分は、学習可能かどうかを判定するために入れています。

```python
sum(p.numel() for p in model.parameters() if p.requires_grad)
```

### 損失関数と最適化関数の定義

損失関数は、「[交差エントロピ誤差](https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html)」を使います。最適化関数は、[Adam](https://pytorch.org/docs/stable/generated/torch.optim.Adam.html) [^Adam] を使います。

```python
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-5)
```

### 評価指標の定義

予測結果と正解ラベルが正しいか判定する関数です。

```math
\mathrm{正解率} = \frac{\mathrm{真陽性} + \mathrm{真陰性}}{\mathrm{真陽性} + \mathrm{偽陽性} + \mathrm{真陰性} + \mathrm{偽陰性}}
```

```python
def calculate_accuracy(preds, labels):
    _, predicted = torch.max(preds, 1)
    correct = (predicted==labels).float()
    accuracy = correct.sum() / len(correct)
    return accuracy.item()
```

### 学習ループ

学習ループです。`loss.backward()` が誤差逆伝播法を行なっています。評価時の `torch.no_grad()` は勾配計算をしない設定にしています。これをすることで、メモリの使用量の削減につながります。

```python
result_df = pd.DataFrame(columns=["Train Loss", "Train Acc", "Val Loss", "Val Acc"])
num_epochs = 300

for epoch in tqdm(range(num_epochs)):
    model.train()
    train_loss = 0
    train_accuracy = 0

    for batch in train_dataloader:
        inputs, labels = batch
        inputs, labels = inputs.to(device), labels.to(device)
        
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        train_loss += loss.item()
        train_accuracy += calculate_accuracy(outputs, labels)

    avg_train_loss = train_loss / len(train_dataloader)
    avg_accuracy = train_accuracy / len(train_dataloader)
    result_df.loc[epoch, "Train Loss"] = avg_train_loss
    result_df.loc[epoch, "Train Acc"] = avg_accuracy
    print(f'Epoch {epoch+1}/{num_epochs}, Train Loss: {avg_train_loss:.4f}, Train Accuracy: {avg_accuracy:.4f}')

    model.eval()
    val_loss = 0
    val_accuracy = 0
    with torch.no_grad():
        for batch in val_dataloader:
            inputs, labels = batch
            inputs, labels = inputs.to(device), labels.to(device)

            outputs = model(inputs)
            loss = criterion(outputs, labels)
            val_loss += loss.item()
            val_accuracy += calculate_accuracy(outputs, labels)
            avg_val_accuracy = val_accuracy / len(val_dataloader)

    avg_val_loss = val_loss / len(val_dataloader)
    val_accuracy = val_accuracy / len(val_dataloader)
    result_df.loc[epoch, "Val Loss"] = avg_val_loss
    result_df.loc[epoch, "Val Acc"] = val_accuracy
    print(f'Epoch {epoch+1}/{num_epochs}, Val Loss: {avg_val_loss:.4f}, Val Accuracy: {val_accuracy:.4f}')
```

### 損失と精度の可視化

```python
plt.plot(result_df["Train Loss"], label="Train Loss")
plt.plot(result_df["Val Loss"], label="Val Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.legend()
plt.show()
```

![loss](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836663/6e55c417-d241-aab8-9c13-953c01273fcd.png)

```python
plt.plot(result_df["Train Acc"], label="Train Accuracy")
plt.plot(result_df["Val Acc"], label="Val Accuracy")
plt.xlabel("Epoch")
plt.ylabel("Accuracy")
plt.legend()
plt.show()
```

![accuracy](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836663/1193fd00-5bea-3deb-bd0b-27d99ac4e7b3.png)


## 精度をより高めるための案

- データ前処理
    - テキストの正規化
        - 大小文字の統一
        - 句読点や特殊文字の除去
    - ストップワード（「a」、「and」や「I」など ）の除去
- データの増強
    - データ拡張
    - 事前学習済みモデル（[BERT](https://github.com/google-research/bert) [^BERT] や [T5](https://github.com/google-research/text-to-text-transfer-transformer) [^T5] など）の使用
- トークナイザの作成戦略の変更
    - サブワードトークナイゼーション (Subword Tokenization)
        - [BPE (Byte-Pair Encoding)](https://github.com/rsennrich/subword-nmt) [^BPE]
        - WordPiece [^WordPiece]
        - [SentencePiece](https://github.com/google/sentencepiece) [^SentencePiece]
- モデルの工夫
    - 層を変更（追加）する
        - Dropout [^Dropout] 層の追加
        - Attention 層の追加
    - 活性化関数の導入
        - [Sigmoid](https://pytorch.org/docs/stable/generated/torch.nn.Sigmoid.html)
        - [Softmax](https://pytorch.org/docs/stable/generated/torch.nn.Softmax.html)
- ハイパーパラメータの最適化
    - スケジューラ (scheduler) の導入
    - バッチサイズや学習率の調整
    - 正則化の導入

## 評価指標の追加

- 適合率 (Precision)
- 再現率 (Recall)
- F 値 (F1-Score)
- ROC-AUC
- PR-AUC
- 混同行列 (Confusion Matrix)

## おわりに

この記事では、[scikit-learn](https://scikit-learn.org) や [Transformers](https://huggingface.co/docs/transformers/main) を使わずに、PyTorch のみで感情分析を行いました。

この記事が、「PyTorch と自然言語処理の学習に役立った」と思っていただけたら幸いです。

## 参考文献

[^Adam]: Diederik P. Kingma and Jimmy Ba. Adam: A Method for Stochastic Optimization. In *3rd International Conference on Learning Representations*, 2015.

[^BERT]: Jacob Devlin, Ming-Wei Chang, Kenton Lee, and Kristina Toutanova. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. In *Proceedings of the 2019 Conference of the North American Chapter of the Association for Computational Linguistics*, 2019.

[^BPE]: Rico Sennrich, Barry Haddow and Alexandra Birch. Neural Machine Translation of Rare Words with Subword Units. In *Proceedings of the 54th Annual Meeting of the Association for Computational Linguistics*, 2016.

[^Dropout]: Nitish Srivastava, Geoffrey E. Hinton, Alex Krizhevsky, Ilya Sutskever, and Ruslan Salakhutdinov. Dropout: a simple way to prevent neural networks from overfitting. *J. Mach. Learn. Res.*, 2014.

[^SentencePiece]: Taku Kudo and John Richardson. SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing. In *Proceedings of the 2018 Conference on Empirical Methods in Natural Language Processing*, 2018.

[^SST-2]: Richard Socher, Alex Perelygin, Jean Wu, Jason Chuang, Christopher D. Manning, Andrew Y. Ng, and Christopher Potts. Recursive Deep Models for Semantic Compositionality Over a Sentiment Treebank. In *Proceedings of the 2013 Conference on Empirical Methods in Natural Language Processing*, 2013.

[^T5]: Colin Raffel, Noam Shazeer, Adam Roberts, Katherine Lee, Sharan Narang, Michael Matena, Yanqi Zhou, Wei Li, and Peter J. Liu. Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer. *J. Mach. Learn. Res.*, 2020.

[^WordPiece]: Mike Schuster and Kaisuke Nakajima. Japanese and Korean voice search. In *2012 IEEE International Conference on Acoustics*, 2012.