{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# 再帰型ニューラルネットワーク\n", "\n", "前のモジュールでは、テキストの豊かな意味表現を使用し、その埋め込みの上に単純な線形分類器を構築してきました。このアーキテクチャは、文中の単語の集約された意味を捉えることができますが、単語の**順序**を考慮していません。埋め込みの上での集約操作によって、元のテキストからこの情報が失われてしまうためです。このようなモデルは単語の順序をモデル化することができないため、テキスト生成や質問応答のようなより複雑で曖昧なタスクを解くことができません。\n", "\n", "テキストシーケンスの意味を捉えるためには、**再帰型ニューラルネットワーク**(RNN)と呼ばれる別のニューラルネットワークアーキテクチャを使用する必要があります。RNNでは、文をネットワークに1つずつシンボルを通過させ、ネットワークはある**状態**を生成します。そして次のシンボルとともにその状態を再びネットワークに渡します。\n", "\n", "与えられたトークンの入力シーケンス $X_0,\\dots,X_n$ に対して、RNNはニューラルネットワークブロックのシーケンスを作成し、このシーケンスをバックプロパゲーションを使用してエンドツーエンドで学習します。各ネットワークブロックは入力として $(X_i,S_i)$ のペアを受け取り、結果として $S_{i+1}$ を生成します。最終状態 $S_n$ または出力 $X_n$ は線形分類器に渡され、結果を生成します。すべてのネットワークブロックは同じ重みを共有し、1回のバックプロパゲーションパスでエンドツーエンドで学習されます。\n", "\n", "状態ベクトル $S_0,\\dots,S_n$ がネットワークを通過するため、単語間の順序的な依存関係を学習することができます。例えば、シーケンスのどこかに単語 *not* が現れる場合、状態ベクトル内の特定の要素を否定することを学習し、結果として否定を表現することができます。\n", "\n", "> 図中のすべてのRNNブロックの重みが共有されているため、同じ図を1つのブロック(右側)として表現することができ、再帰的なフィードバックループがネットワークの出力状態を入力に戻します。\n", "\n", "では、再帰型ニューラルネットワークがニュースデータセットの分類にどのように役立つか見てみましょう。\n" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Loading dataset...\n", "Building vocab...\n" ] } ], "source": [ "import torch\n", "import torchtext\n", "from torchnlp import *\n", "train_dataset, test_dataset, classes, vocab = load_dataset()\n", "vocab_size = len(vocab)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## シンプルなRNN分類器\n", "\n", "シンプルなRNNの場合、各リカレントユニットは単純な線形ネットワークで構成されており、結合された入力ベクトルと状態ベクトルを受け取り、新しい状態ベクトルを生成します。PyTorchでは、このユニットを`RNNCell`クラスで表現し、そのようなセルのネットワークを`RNN`レイヤーとして表現します。\n", "\n", "RNN分類器を定義するには、まず埋め込み層を適用して入力語彙の次元を下げ、その上にRNNレイヤーを配置します。\n" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class RNNClassifier(torch.nn.Module):\n", " def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):\n", " super().__init__()\n", " self.hidden_dim = hidden_dim\n", " self.embedding = torch.nn.Embedding(vocab_size, embed_dim)\n", " self.rnn = torch.nn.RNN(embed_dim,hidden_dim,batch_first=True)\n", " self.fc = torch.nn.Linear(hidden_dim, num_class)\n", "\n", " def forward(self, x):\n", " batch_size = x.size(0)\n", " x = self.embedding(x)\n", " x,h = self.rnn(x)\n", " return self.fc(x.mean(dim=1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "> **Note:** ここでは簡単さを重視して未学習の埋め込み層を使用していますが、より良い結果を得るためには、前のユニットで説明したように、Word2VecやGloVe埋め込みを使用した事前学習済みの埋め込み層を利用することができます。より深く理解するために、このコードを事前学習済みの埋め込みに対応するように適応させることを検討してください。\n", "\n", "今回の場合、パディングされたデータローダーを使用します。そのため、各バッチには同じ長さのパディングされたシーケンスが含まれます。RNN層は埋め込みテンソルのシーケンスを受け取り、次の2つの出力を生成します:\n", "* $x$ は各ステップでのRNNセルの出力シーケンス\n", "* $h$ はシーケンスの最後の要素に対する最終的な隠れ状態\n", "\n", "その後、全結合の線形分類器を適用してクラス数を取得します。\n", "\n", "> **Note:** RNNの訓練は非常に難しいです。RNNセルがシーケンスの長さに沿って展開されると、逆伝播に関与する層の数が非常に多くなります。そのため、小さい学習率を選択し、より大きなデータセットでネットワークを訓練する必要があります。良い結果を得るには時間がかかる可能性があるため、GPUの使用が推奨されます。\n" ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "3200: acc=0.3090625\n", "6400: acc=0.38921875\n", "9600: acc=0.4590625\n", "12800: acc=0.511953125\n", "16000: acc=0.5506875\n", "19200: acc=0.57921875\n", "22400: acc=0.6070089285714285\n", "25600: acc=0.6304296875\n", "28800: acc=0.6484027777777778\n", "32000: acc=0.66509375\n", "35200: acc=0.6790056818181818\n", "38400: acc=0.6929166666666666\n", "41600: acc=0.7035817307692308\n", "44800: acc=0.7137276785714286\n", "48000: acc=0.72225\n", "51200: acc=0.73001953125\n", "54400: acc=0.7372794117647059\n", "57600: acc=0.7436631944444444\n", "60800: acc=0.7503947368421052\n", "64000: acc=0.75634375\n", "67200: acc=0.7615773809523809\n", "70400: acc=0.7662642045454545\n", "73600: acc=0.7708423913043478\n", "76800: acc=0.7751822916666666\n", "80000: acc=0.7790625\n", "83200: acc=0.7825\n", "86400: acc=0.7858564814814815\n", "89600: acc=0.7890513392857142\n", "92800: acc=0.7920474137931034\n", "96000: acc=0.7952708333333334\n", "99200: acc=0.7982258064516129\n", "102400: acc=0.80099609375\n", "105600: acc=0.8037594696969697\n", "108800: acc=0.8060569852941176\n" ] } ], "source": [ "train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16, collate_fn=padify, shuffle=True)\n", "net = RNNClassifier(vocab_size,64,32,len(classes)).to(device)\n", "train_epoch(net,train_loader, lr=0.001)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 長短期記憶(LSTM)\n", "\n", "古典的なRNNの主な問題の1つは、いわゆる**勾配消失**問題です。RNNは1回の逆伝播でエンドツーエンドに学習するため、ネットワークの最初の層に誤差を伝播させるのが難しくなり、その結果、遠く離れたトークン間の関係を学習することができません。この問題を回避する方法の1つは、**ゲート**と呼ばれる仕組みを使用して**明示的な状態管理**を導入することです。この種のアーキテクチャで最もよく知られているものは、**長短期記憶(LSTM)**と**ゲート付きリレー単位(GRU)**です。\n", "\n", "![長短期記憶セルの例を示す画像](../../../../../lessons/5-NLP/16-RNN/images/long-short-term-memory-cell.svg)\n", "\n", "LSTMネットワークはRNNに似た構造で組織されていますが、層から層へ渡される2つの状態があります。それは、実際の状態$c$と隠れベクトル$h$です。各ユニットでは、隠れベクトル$h_i$が入力$x_i$と結合され、それらが**ゲート**を介して状態$c$に何が起こるかを制御します。各ゲートはシグモイド活性化関数(出力範囲は$[0,1]$)を持つニューラルネットワークであり、状態ベクトルと掛け算することでビットマスクのように考えることができます。以下のゲートがあります(上記の図で左から右へ):\n", "* **忘却ゲート**は隠れベクトルを受け取り、ベクトル$c$のどの成分を忘れるべきか、どの成分を通過させるべきかを決定します。\n", "* **入力ゲート**は入力と隠れベクトルから情報を取り出し、それを状態に挿入します。\n", "* **出力ゲート**は状態を$\\tanh$活性化を持つ線形層を通して変換し、その後、隠れベクトル$h_i$を使用してその成分の一部を選択し、新しい状態$c_{i+1}$を生成します。\n", "\n", "状態$c$の成分は、オン・オフを切り替えられるフラグのように考えることができます。例えば、シーケンス内で*Alice*という名前に出会ったとき、それが女性キャラクターを指していると仮定し、文中に女性名詞があるというフラグを状態に立てることができます。その後、*and Tom*というフレーズに出会ったときには、複数名詞があるというフラグを立てます。このようにして、状態を操作することで文の文法的な特性を追跡できると考えられます。\n", "\n", "> **Note**: LSTMの内部構造を理解するための素晴らしいリソースとして、Christopher Olahによる[Understanding LSTM Networks](https://colah.github.io/posts/2015-08-Understanding-LSTMs/)という記事があります。\n", "\n", "LSTMセルの内部構造は複雑に見えるかもしれませんが、PyTorchはこの実装を`LSTMCell`クラス内に隠しており、LSTM層全体を表す`LSTM`オブジェクトを提供しています。そのため、LSTM分類器の実装は、前述の単純なRNNと非常に似たものになります。\n" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "class LSTMClassifier(torch.nn.Module):\n", " def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):\n", " super().__init__()\n", " self.hidden_dim = hidden_dim\n", " self.embedding = torch.nn.Embedding(vocab_size, embed_dim)\n", " self.embedding.weight.data = torch.randn_like(self.embedding.weight.data)-0.5\n", " self.rnn = torch.nn.LSTM(embed_dim,hidden_dim,batch_first=True)\n", " self.fc = torch.nn.Linear(hidden_dim, num_class)\n", "\n", " def forward(self, x):\n", " batch_size = x.size(0)\n", " x = self.embedding(x)\n", " x,(h,c) = self.rnn(x)\n", " return self.fc(h[-1])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "3200: acc=0.259375\n", "6400: acc=0.25859375\n", "9600: acc=0.26177083333333334\n", "12800: acc=0.2784375\n", "16000: acc=0.313\n", "19200: acc=0.3528645833333333\n", "22400: acc=0.3965625\n", "25600: acc=0.4385546875\n", "28800: acc=0.4752777777777778\n", "32000: acc=0.505375\n", "35200: acc=0.5326704545454546\n", "38400: acc=0.5557552083333334\n", "41600: acc=0.5760817307692307\n", "44800: acc=0.5954910714285714\n", "48000: acc=0.6118333333333333\n", "51200: acc=0.62681640625\n", "54400: acc=0.6404779411764706\n", "57600: acc=0.6520138888888889\n", "60800: acc=0.662828947368421\n", "64000: acc=0.673546875\n", "67200: acc=0.6831547619047619\n", "70400: acc=0.6917897727272727\n", "73600: acc=0.6997146739130434\n", "76800: acc=0.707109375\n", "80000: acc=0.714075\n", "83200: acc=0.7209134615384616\n", "86400: acc=0.727037037037037\n", "89600: acc=0.7326674107142858\n", "92800: acc=0.7379633620689655\n", "96000: acc=0.7433645833333333\n", "99200: acc=0.7479032258064516\n", "102400: acc=0.752119140625\n", "105600: acc=0.7562405303030303\n", "108800: acc=0.76015625\n", "112000: acc=0.7641339285714286\n", "115200: acc=0.7677777777777778\n", "118400: acc=0.7711233108108108\n" ] }, { "data": { "text/plain": [ "(0.03487814127604167, 0.7728)" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "net = LSTMClassifier(vocab_size,64,32,len(classes)).to(device)\n", "train_epoch(net,train_loader, lr=0.001)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## パックされたシーケンス\n", "\n", "この例では、ミニバッチ内のすべてのシーケンスをゼロベクトルでパディングする必要がありました。この方法ではメモリの無駄が発生するだけでなく、RNNではパディングされた入力項目のために追加のRNNセルが作成されることが問題となります。これらのセルは学習に参加しますが、重要な入力情報を持っていません。そのため、RNNを実際のシーケンスサイズに合わせて学習させる方がはるかに効率的です。\n", "\n", "これを実現するために、PyTorchではパディングされたシーケンスを特別な形式で保存する方法が導入されています。例えば、以下のようなパディングされたミニバッチがあるとします:\n", "```\n", "[[1,2,3,4,5],\n", " [6,7,8,0,0],\n", " [9,0,0,0,0]]\n", "```\n", "ここで、0はパディングされた値を表し、入力シーケンスの実際の長さのベクトルは `[5,3,1]` です。\n", "\n", "パディングされたシーケンスでRNNを効率的に学習させるためには、まず最初のRNNセルのグループを大きなミニバッチ(`[1,6,9]`)で学習を開始し、次に3番目のシーケンスの処理を終了して、短縮されたミニバッチ(`[2,7]`、`[3,8]`)で学習を続ける必要があります。このようにして、パックされたシーケンスは1つのベクトルとして表されます。この場合、`[1,6,9,2,7,3,8,4,5]` と長さのベクトル(`[5,3,1]`)で表現され、これを使って元のパディングされたミニバッチを簡単に再構築することができます。\n", "\n", "パックされたシーケンスを生成するには、`torch.nn.utils.rnn.pack_padded_sequence` 関数を使用します。RNN、LSTM、GRUを含むすべての再帰層は、入力としてパックされたシーケンスをサポートしており、パックされた出力を生成します。この出力は、`torch.nn.utils.rnn.pad_packed_sequence` を使用してデコードできます。\n", "\n", "パックされたシーケンスを生成するには、ネットワークに長さのベクトルを渡す必要があるため、ミニバッチを準備するための別の関数が必要です。\n" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "def pad_length(b):\n", " # build vectorized sequence\n", " v = [encode(x[1]) for x in b]\n", " # compute max length of a sequence in this minibatch and length sequence itself\n", " len_seq = list(map(len,v))\n", " l = max(len_seq)\n", " return ( # tuple of three tensors - labels, padded features, length sequence\n", " torch.LongTensor([t[0]-1 for t in b]),\n", " torch.stack([torch.nn.functional.pad(torch.tensor(t),(0,l-len(t)),mode='constant',value=0) for t in v]),\n", " torch.tensor(len_seq)\n", " )\n", "\n", "train_loader_len = torch.utils.data.DataLoader(train_dataset, batch_size=16, collate_fn=pad_length, shuffle=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "実際のネットワークは上記の `LSTMClassifier` に非常に似ていますが、`forward` パスではパディングされたミニバッチとシーケンス長のベクトルの両方を受け取ります。埋め込みを計算した後、パックされたシーケンスを計算し、それを LSTM レイヤーに渡し、結果を再びアンパックします。\n", "\n", "> **Note**: 実際にはアンパックされた結果 `x` を使用しません。なぜなら、後続の計算では隠れ層からの出力を使用するためです。したがって、このコードからアンパック処理を完全に削除することも可能です。ここにアンパック処理を残している理由は、もしネットワークの出力をさらに計算で使用する必要がある場合に、このコードを簡単に修正できるようにするためです。\n" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [], "source": [ "class LSTMPackClassifier(torch.nn.Module):\n", " def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):\n", " super().__init__()\n", " self.hidden_dim = hidden_dim\n", " self.embedding = torch.nn.Embedding(vocab_size, embed_dim)\n", " self.embedding.weight.data = torch.randn_like(self.embedding.weight.data)-0.5\n", " self.rnn = torch.nn.LSTM(embed_dim,hidden_dim,batch_first=True)\n", " self.fc = torch.nn.Linear(hidden_dim, num_class)\n", "\n", " def forward(self, x, lengths):\n", " batch_size = x.size(0)\n", " x = self.embedding(x)\n", " pad_x = torch.nn.utils.rnn.pack_padded_sequence(x,lengths,batch_first=True,enforce_sorted=False)\n", " pad_x,(h,c) = self.rnn(pad_x)\n", " x, _ = torch.nn.utils.rnn.pad_packed_sequence(pad_x,batch_first=True)\n", " return self.fc(h[-1])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [] }, { "cell_type": "code", "execution_count": 8, "metadata": { "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "3200: acc=0.285625\n", "6400: acc=0.33359375\n", "9600: acc=0.3876041666666667\n", "12800: acc=0.44078125\n", "16000: acc=0.4825\n", "19200: acc=0.5235416666666667\n", "22400: acc=0.5559821428571429\n", "25600: acc=0.58609375\n", "28800: acc=0.6116666666666667\n", "32000: acc=0.63340625\n", "35200: acc=0.6525284090909091\n", "38400: acc=0.668515625\n", "41600: acc=0.6822596153846154\n", "44800: acc=0.6948214285714286\n", "48000: acc=0.7052708333333333\n", "51200: acc=0.71521484375\n", "54400: acc=0.7239889705882353\n", "57600: acc=0.7315277777777778\n", "60800: acc=0.7388486842105263\n", "64000: acc=0.74571875\n", "67200: acc=0.7518303571428572\n", "70400: acc=0.7576988636363636\n", "73600: acc=0.7628940217391305\n", "76800: acc=0.7681510416666667\n", "80000: acc=0.7728125\n", "83200: acc=0.7772235576923077\n", "86400: acc=0.7815393518518519\n", "89600: acc=0.7857700892857142\n", "92800: acc=0.7895043103448276\n", "96000: acc=0.7930520833333333\n", "99200: acc=0.7959072580645161\n", "102400: acc=0.798994140625\n", "105600: acc=0.802064393939394\n", "108800: acc=0.8051378676470589\n", "112000: acc=0.8077857142857143\n", "115200: acc=0.8104600694444445\n", "118400: acc=0.8128293918918919\n" ] }, { "data": { "text/plain": [ "(0.029785829671223958, 0.8138166666666666)" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "net = LSTMPackClassifier(vocab_size,64,32,len(classes)).to(device)\n", "train_epoch_emb(net,train_loader_len, lr=0.001,use_pack_sequence=True)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "> **注意:** トレーニング関数に渡すパラメータ `use_pack_sequence` に気付いたかもしれません。現在、`pack_padded_sequence` 関数は長さシーケンステンソルがCPUデバイス上にあることを要求しているため、トレーニング関数はトレーニング時に長さシーケンスデータをGPUに移動することを避ける必要があります。[`torchnlp.py`](../../../../../lessons/5-NLP/16-RNN/torchnlp.py) ファイル内の `train_emb` 関数の実装を確認することができます。\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 双方向および多層RNN\n", "\n", "これまでの例では、すべてのリカレントネットワークがシーケンスの始まりから終わりに向かって一方向に動作していました。それは自然に感じられます。なぜなら、私たちが文章を読んだり音声を聞いたりする方法に似ているからです。しかし、実際のケースでは入力シーケンスにランダムアクセスできることが多いため、リカレント計算を両方向で実行する方が理にかなっている場合があります。このようなネットワークは**双方向**RNNと呼ばれ、RNN/LSTM/GRUのコンストラクタに`bidirectional=True`パラメータを渡すことで作成できます。\n", "\n", "双方向ネットワークを扱う場合、各方向に対して1つずつ、2つの隠れ状態ベクトルが必要になります。PyTorchではこれらのベクトルを2倍のサイズの1つのベクトルとしてエンコードします。これは非常に便利です。なぜなら、通常は結果として得られる隠れ状態を全結合の線形層に渡すため、このサイズの増加を考慮して層を作成するだけで済むからです。\n", "\n", "リカレントネットワーク(単方向でも双方向でも)は、シーケンス内の特定のパターンを捉え、それを状態ベクトルに保存したり、出力に渡したりすることができます。畳み込みネットワークと同様に、最初の層によって抽出された低レベルのパターンを基に、より高次のパターンを捉えるために、もう1つのリカレント層をその上に構築することができます。これにより、**多層RNN**という概念が生まれます。これは2つ以上のリカレントネットワークで構成され、前の層の出力が次の層の入力として渡されます。\n", "\n", "![多層長短期記憶RNNを示す画像](../../../../../translated_images/ja/multi-layer-lstm.dd975e29bb2a59fe.webp)\n", "\n", "*Fernando Lópezによる[素晴らしい投稿](https://towardsdatascience.com/from-a-lstm-cell-to-a-multilayer-lstm-network-with-pytorch-2899eb5696f3)からの画像*\n", "\n", "PyTorchでは、このようなネットワークの構築が簡単です。RNN/LSTM/GRUのコンストラクタに`num_layers`パラメータを渡すだけで、複数のリカレント層を自動的に構築できます。この場合、隠れ状態ベクトルのサイズも比例して増加するため、リカレント層の出力を処理する際にこの点を考慮する必要があります。\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 他のタスクにおけるRNN\n", "\n", "このユニットでは、RNNがシーケンス分類に使用できることを学びましたが、実際にはテキスト生成や機械翻訳など、さらに多くのタスクを処理することができます。これらのタスクについては次のユニットで検討します。\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n---\n\n**免責事項**: \nこの文書は、AI翻訳サービス [Co-op Translator](https://github.com/Azure/co-op-translator) を使用して翻訳されています。正確性を追求しておりますが、自動翻訳には誤りや不正確な部分が含まれる可能性があることをご承知ください。元の言語で記載された文書が正式な情報源とみなされるべきです。重要な情報については、専門の人間による翻訳を推奨します。この翻訳の使用に起因する誤解や誤解釈について、当方は一切の責任を負いません。\n" ] } ], "metadata": { "interpreter": { "hash": "16af2a8bbb083ea23e5e41c7f5787656b2ce26968575d8763f2c4b17f9cd711f" }, "kernelspec": { "display_name": "Python 3.8.12 ('py38')", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.12" }, "coopTranslator": { "original_hash": "522ee52ae3d5ae933e283286254e9a55", "translation_date": "2025-08-30T10:30:30+00:00", "source_file": "lessons/5-NLP/16-RNN/RNNPyTorch.ipynb", "language_code": "ja" } }, "nbformat": 4, "nbformat_minor": 2 }