※本記事は、Stanford CS224N NLP with Deep Learning | 2023 のPyTorch Tutorial(講師:Drew Kaul)の内容を基に作成されています。この講義の詳細情報は https://www.youtube.com/watch?v=Uv0AIRr3ptg でご覧いただけます。コース全体の情報は https://online.stanford.edu/courses/c... で、またコーススケジュールとシラバスは http://web.stanford.edu/class/cs224n/ でご確認いただけます。本記事では、講義の内容を要約しております。なお、本記事の内容は原講義の内容を正確に反映するよう努めていますが、要約や解釈による誤りがある可能性もありますので、正確な情報や文脈については、オリジナルの講義をご視聴いただくことをお勧めいたします。
CS224N NLP with Deep Learningコースは、Christopher Manning教授(スタンフォード大学人工知能研究所(SAIL)所長、Thomas M. Siebel機械学習教授、言語学・コンピュータサイエンス教授)が指導しています。
より多くのスタンフォード大学の人工知能プロフェッショナルおよび大学院プログラムについては、https://stanford.io/ai をご覧ください。スタンフォードオンラインを通じて、スタンフォード大学全体の学校やユニットによって提供される学術的・専門的教育にアクセスすることができます。
1. PyTorch の基礎
1.1. PyTorch とは何か
今日は PyTorch の基礎について話していきたいと思います。PyTorch とは何かというと、ディープラーニングのためのフレームワークで、主に2つの重要な機能を提供しています。
1つ目は、テンソルの作成と操作を非常に簡単にし、GPUを活用できるようにすることです。これにより、計算能力を最大限に活用することができます。
2つ目は、ニューラルネットワークの作成プロセスを大幅に簡素化することです。線形層や様々な損失関数などの基本的な構成要素を利用し、それらを組み合わせることで、特定のユースケースに必要なモデルを作成することができます。
PyTorch はディープラーニングの2大フレームワークの一つで、もう一つは TensorFlow です。このクラスでは PyTorch に焦点を当てますが、両者は非常に似ています。
1.2. PyTorch の主な機能
PyTorch の主な機能は大きく分けて2つあります。まず1つ目は、テンソルの作成と操作を非常に簡単にし、GPUの能力を活用できるようにすることです。これにより、大規模な計算処理を効率的に行うことができます。
実際に大きなニューラルネットワークを構築し始め、より多くの計算を必要とするようになると、プロセッサにGPUを活用させることが大きな利点となります。PyTorchはそれを簡単に実現できるように設計されています。
2つ目の重要な機能は、ニューラルネットワークを簡単に作成できるようにすることです。線形層や各種損失関数などの基本的な構成要素が用意されており、それらを組み合わせて特定のユースケースに合わせたモデルを作成できます。
さらに、PyTorchでは、線形層の実装やバックプロパゲーション、最適化アルゴリズムなどを一から実装する必要がありません。これらの機能は全て組み込まれており、適切なAPIを呼び出すだけで利用できます。Pythonやnumpyだけで作業する場合、これらを自分でコーディングする必要がありますが、PyTorchではその手間を省くことができます。
1.3. Numpy との類似点
PyTorchを理解する上で重要なのは、NumPyとの類似点です。皆さんはすでにNumPy配列に慣れていると思いますが、PyTorchのテンソルは基本的にNumPy配列と同等のものだと考えることができます。どちらも本質的には多次元配列であり、様々な方法で操作できます。
データを表現し、様々な行列演算を実行するためにテンソルを使用することになりますが、これはニューラルネットワークの基礎となる操作です。ですので、もしNumPyに慣れていれば、PyTorchへの移行はかなり直感的に行えるでしょう。
実際、NumPyコードがあり、NumPy配列を扱っている場合、それらを直接PyTorchテンソルに変換することができますし、逆にテンソルをNumPy配列に戻すこともできます。この互換性により、既存のNumPyベースのコードからスムーズに移行することが可能です。
PyTorchの優れている点は、基本的にNumPy配列を扱うための便利で効率的な方法を提供しつつ、GPUを活用できること、そして勾配を素早く計算できることです。これらの機能がPyTorchの強みとなっています。
2. テンソル (Tensors)
2.1. テンソルの定義と概念
このチュートリアルの最初の部分では、テンソルについて話していきます。皆さんがすでに慣れ親しんでいるNumPy配列と同様に、テンソルは本質的にPyTorchにおけるNumPy配列の等価物だと考えることができます。テンソルは基本的に多次元配列であり、様々な方法で操作することができます。
ニューラルネットワークの中で扱うデータを表現し、さまざまな行列演算を実行するためにテンソルを使用することになります。例えば、画像を考えた場合、それを256×256のテンソルとして表現することができます。このテンソルは幅256ピクセル、高さ256ピクセルを持ちます。また、画像のバッチとそれぞれの画像に赤、緑、青のチャンネルがある場合、バッチサイズ×チャンネル数×幅×高さという4次元テンソルになるでしょう。
今日見ていくことのすべては、テンソル(多次元配列と考えてください)として表現されます。テンソルの直感的な理解を深めるために、リストのリストからテンソルに変換し、それを様々な操作で操作する方法を少し見ていきましょう。
2.2. テンソルの作成方法
テンソルを作成する最も基本的な方法を見ていきましょう。まず始めに、皆さんが馴染みのある単純なリストのリストを用意します。例えば、2×3のリストがあるとします。
このリストからテンソルを作成するには、torch.tensor
を使用し、先ほど書いたのと同じ構文でリストのリストを記述するだけです。すると、同じ形状の同じデータを含むテンソルオブジェクトが返されます。
例えば、以下のようにして2×3のテンソルを作成できます:
data = [[1, 2, 3], [4, 5, 6]]x = torch.tensor(data)
こうして作成されたテンソルオブジェクトは、元のリストと同じ形状で同じデータを持つことになります。テンソルの操作方法を学ぶ前に、まずはこの基本的な作成方法を理解することが重要です。
2.3. データ型の指定
テンソルの2つ目の重要な点は、データ型を持っているということです。テンソルには様々なデータ型があります。例えば、異なる精度の浮動小数点数を使用できますし、整数など様々なデータ型をテンソルに格納することができます。
デフォルトでは、私の理解ではfloat32が使われますが、明示的にd_type引数を渡すことでテンソルのデータ型を指定することができます。例えば:
x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.float32)
この場合、整数を入力したにもかかわらず、小数点が付いていることから浮動小数点数であることがわかります。
同様に、別のテンソルをデータ型float32で作成することもできます。3つ目の例では、データ型を明示的に指定していませんが、浮動小数点数をテンソルに渡しているため、PyTorchは暗黙的にデータ型を浮動小数点と解釈します。
要するに、高いレベルでは、テンソルは多次元配列のようなもので、データ型を指定でき、NumPy配列と同じようにそれらを設定することができます。これがテンソルの基本的な概念です。
2.4. テンソルの基本的なユーティリティ関数
torch.zeros と torch.ones
さて、テンソルを作成できることがわかりましたが、これからデータを操作するための関数について見ていきましょう。テンソルを簡単に初期化するためのいくつかの基本的なユーティリティ関数があります。
特に、torch.zeros
とtorch.ones
は、特定の形状のテンソルを簡単に作成するための2つの方法です。これらの関数を使うと、すべての要素がゼロのテンソル、またはすべての要素が1のテンソルを作成できます。
zeros_tensor = torch.zeros(2, 3) # 2x3の全て0のテンソルones_tensor = torch.ones(4, 5) # 4x5の全て1のテンソル
これは宿題に取り組む際に非常に役立ちます。通常、ゼロ行列を作成する必要があり、ここで形状を指定するだけで簡単に作成できるため、すべてを明示的に書く必要がなくなります。そして必要に応じてそのテンソルを更新することができます。
torch.arange
もう一つの便利な関数はtorch.arange
です。Pythonでは、一連の数値をループするためにrange
を指定できますが、同様にtorch.arange
を使って特定の範囲のテンソルを初期化することができます。
range_tensor = torch.arange(1, 11) # 1から10までの数値を含むテンソル
この例では、1から10までの数値をループし、それを再形成して1から5と6から10のテンソルにすることもできます。
最後に注意すべき点として、加算や乗算などの単純なPython演算を適用すると、デフォルトでは要素ごとに適用されます。つまり、テンソル内のすべての要素に対して適用されます。例えば、テンソルに2を足すと、そのテンソル内のすべての要素に2が加算されます。同様に、テンソルに2をかけると、すべての要素が2倍になります。
PyTorchのブロードキャスティングのセマンティクスは基本的にNumPyのセマンティクスと同じです。異なる次元でバッチ処理を行う必要がある場合、PyTorchは適切な次元でブロードキャストするように賢く処理してくれます。ただし、ブロードキャスティングのルールに基づいて形状が互換性を持つようにする必要があります。
3. テンソルの操作
3.1. 要素ごとの演算
テンソルに対して行える基本的な操作を見ていきましょう。先ほど少し触れましたが、加算や乗算などの単純なPython演算を適用すると、デフォルトではそれらは要素ごと(element-wise)に適用されます。これはテンソル内のすべての要素に対して操作が行われることを意味します。
例えば、以前に作成したテンソルがあるとして、それに対して2を足すと、テンソル内のすべての要素に2が加算されます。
x = torch.tensor([[1, 2, 3], [4, 5, 6]])x + 2 # 結果: tensor([[3, 4, 5], [6, 7, 8]])
同様に、テンソルに2をかけると、すべての要素が2倍になります。
x * 2 # 結果: tensor([[2, 4, 6], [8, 10, 12]])
基本的に、PyTorchのブロードキャスティングのセマンティクスはNumPyのセマンティクスとほぼ同じように動作します。異なる行列演算を行う際に特定の次元でバッチ処理する必要がある場合、PyTorchは適切な次元でブロードキャストするように賢く処理してくれます。
ただし、形状がブロードキャスティングのルールに基づいて互換性を持つようにする必要があります。これについては後ほど、リシェイプとブロードキャスティングのセマンティクスを見るときにもう少し詳しく説明します。
3.2. テンソルのリシェイプ (reshape)
テンソルを操作する上で重要なのは、リシェイプ(形状の変更)です。例えば、15次元のテンソル(1から15までの数値)を作成し、それを5×3のテンソルに変形することができます。
x = torch.arange(1, 16) # 1から15までのテンソルx_reshaped = x.reshape(5, 3) # 5×3のテンソルに変形
「これの何が重要なの?」と思うかもしれませんが、機械学習ではバッチで学習することが多いからです。データを取得して、それを単なる長いフラットなリストではなく、バッチのセットとして再形成することがあります。
場合によっては、バッチのセット、特定の長さの文のセット、そしてそのシーケンス内の各要素が特定の次元の埋め込みを持つといったデータ構造を扱うこともあります。実行しようとしている操作の種類に応じて、これらのテンソルを再形成したり、場合によっては次元を転置して、データを再編成する必要があることもあります。
view
とreshape
の違いは、view
は基本的に元のテンソルの「ビュー」を作成するので、元のテンソルは同じ形状を維持します。一方、reshape
は実際にテンソルの形状を変更します。
最初に言ったように、PyTorchテンソルについての直感は単純に、NumPy配列を扱うための素晴らしく簡単な方法であるということです。ただし、GPUで使用できるという優れた特性や、勾配を迅速に計算できるといった利点があります。
3.3. Numpy との互換性
PyTorchの素晴らしい点の一つは、NumPyとの互換性があることです。すでにNumPyのコードがあり、NumPy配列を扱っている場合、それらを直接PyTorchテンソルに変換することができます。逆に、それらのテンソルをNumPy配列に変換し直すこともできます。
# NumPy配列からPyTorchテンソルへの変換import numpy as npnp_array = np.array([1, 2, 3])tensor = torch.from_numpy(np_array)# PyTorchテンソルからNumPy配列への変換np_array_back = tensor.numpy()
これにより、既存のNumPyベースのコードがある場合、PyTorchに移行するのが容易になります。また、特定の操作にNumPyを使用し、その結果をPyTorchに戻すといった柔軟な処理も可能になります。
この互換性は実用的な観点から非常に重要です。多くのデータ処理パイプラインやライブラリがNumPyをベースにしているため、PyTorchとシームレスに統合できることで、既存のワークフローを大幅に変更することなく、PyTorchの利点を活用できます。
3.4. ベクトル化された操作
sum 関数
テンソルが優れている理由の一つは、ベクトル化された操作を非常に簡単にサポートしていることです。基本的に、多くの計算を並列化し、例えばデータのバッチ全体に対して一度に行うことができます。
そのような操作の一つが合計(sum)です。例えば、5×7の形状のテンソルを取り、次元を縮小するさまざまな操作を実行できます。最初の操作はsum(合計)です。行と列の両方にわたって合計することができます。
x = torch.randn(5, 7) # 5×7のランダムテンソルrow_sum = x.sum(dim=0) # 各列の合計(結果は7要素のテンソル)col_sum = x.sum(dim=1) # 各行の合計(結果は5要素のテンソル)
次元の縮小
私がこれを覚えやすくするために考えている方法は、sumで指定する次元は「縮小している次元」だということです。例えば、データを取り、次元0で合計すると(テンソルの形状が5×7であることを考えると)、0番目の次元を縮小したことになるので、形状7のものだけが残ります。
例えば、上記のコードで実際のテンソルを見ると、[75, 80, 85, 90...] といった形状7のテンソルが得られます。あるいは、行全体を合計するか列全体を合計するかを考えることもできますが、これは他の操作にも当てはまります。
標準偏差を計算したり、データを正規化したり、データのセット全体にバッチ処理を行う他の操作も実行できます。これらは一次元だけでなく、何も次元を指定しない場合、デフォルトで操作は全テンソルに適用されます。この場合、テンソル全体の合計が計算されます。
行平均と列平均の計算
0次元は行数(5行)、1次元は列数(7列)です。行を合計すると、実際には列全体を合計していることになり、7つの値だけが残ります。しかし、私は行や列という言葉ではなく、次元という観点で考える方がわかりやすいと思います。次元0を合計すると、次元0×次元1の形状のものから、次元1の形状のものだけになります。そこから、実際にどのように合計したかを確認できます。
NumPyも多くのこのベクトル化を実装しており、現在取り組んでいる宿題の一部は、多くのことをベクトル化することだと思います。PyTorchの大きな利点は、GPUを活用できるように最適化されていることです。実際に大きなニューラルネットワークの構築を始め、より多くの計算を行うとき、これらの行列乗算操作の多くはGPUを使用できると、プロセッサにとって非常に有利になります。
さらに、PyTorchは基本的な線形層やバックプロパゲーション、オプティマイザーなどのニューラルネットワークモジュールを定義しているため、それらをゼロから実装する必要がないというメリットもあります。PythonとNumPyだけで作業している場合、多くのコーディングを自分で行う必要がありますが、PyTorchではそれぞれのAPIを呼び出すだけで利用できます。
4. テンソルのインデックス操作
4.1. 基本的なインデックス指定
次はインデックス操作について見ていきましょう。これは少し複雑になるかもしれませんが、基本的にNumPyと非常に似たセマンティクスを持っています。
NumPyでは、NumPy配列を取得し、さまざまな方法でスライスしたり、コピーを作成したり、特定の次元にわたってインデックスを作成して、特定の要素、行、または列を選択したりすることができます。PyTorchのテンソルでも同様のことが可能です。
例として、3×2×2の形状を持つテンソルを考えてみましょう。新しいテンソルを扱うときに最初にすべきことは、その形状を出力して、実際に何を扱っているのかを理解することです。
x = torch.tensor([[[1, 2], [3, 4]], [[5, 6], [7, 8]], [[9, 10], [11, 12]]])print(x.shape) # torch.Size([3, 2, 2])
このテンソルの最初の要素をインデックス指定すると何が出力されるでしょうか?x[0]
を実行すると何が起こるでしょうか?
結果は2×2のテンソルになります。なぜなら、テンソルは実際には3つの要素のリストであり、それぞれが2×2のテンソルだからです。したがって、最初の要素は「1, 2, 3, 4」という2×2のオブジェクトです。
NumPyと同様に、特定の次元にコロン(:)を指定すると、その次元をコピーすることを意味します。x[0]
と書くと、暗黙的に他のすべての次元にコロンを置いていることになります。つまり、0番目の次元に沿って最初のものを取得し、他の2つの次元に沿ってすべてを取得するということです。
4.2. スライシング
テンソルのスライシングは、NumPyと非常に似た方法で行うことができます。NumPyと同様に、コロン(:)を使って範囲を指定することで、テンソルの部分集合を取得できます。
例えば、5×3の新しいテンソル(1から15までの数値を並べ替えたもの)を取り、0から3行目(3は含まない)を取得してみましょう。
x = torch.arange(1, 16).reshape(5, 3) # 1から15までの数値を5×3のテンソルにfirst_three_rows = x[0:3] # 最初の3行を取得
また、複数の次元にわたってスライシングを行うこともできます。例えば、先ほどの3×2×2のテンソルから、最初の次元の最初の要素と、次の2つの次元の最初の要素を取得したい場合:
x = torch.tensor([[[1, 2], [3, 4]], [[5, 6], [7, 8]], [[9, 10], [11, 12]]])value = x[0, 0, 0] # 結果: tensor(1)
これにより、テンソルの最初の位置(インデックス[0,0,0])にある値である1が取得されます。
スライシングは複数の次元で同時に行うこともできます。例えば:
subset = x[0:2, 1:, :] # 最初の2つの「チャンク」から、2行目以降のすべての列を取得
このような方法でテンソルをスライスすることで、必要な部分だけを操作したり、特定のパターンに焦点を当てたりすることができます。テンソルが大きく複雑になるにつれて、効率的なスライシングの能力は非常に重要になります。
4.3. リストインデックス
リストインデックスもNumPyに存在し、複数の要素を一度に選択するための非常に賢い省略形です。例えば、行列の0番目、2番目、4番目の要素を取得したい場合、特定の数値やその集合でインデックスを指定する代わりに、インデックスのリストでインデックスを指定できます。
x = torch.arange(1, 16).reshape(5, 3) # 1から15までの数字を5×3のテンソルに
selected_rows = x[[0, 2, 4]] # 0番目、2番目、4番目の行を選択
これにより、指定した3つの行が取得されます。
もう一つの例として、3×2×2のテンソルを考えてみましょう。最初と2番目の次元それぞれの0番目の要素のみを取得したい場合:
x = torch.tensor([[[1, 2], [3, 4]], [[5, 6], [7, 8]], [[9, 10], [11, 12]]])selected = x[:, 0, 0] # すべての「チャンク」の最初の行の最初の列
ここで上にスクロールすると、この操作の結果として[1, 5, 9]が得られることがわかります。なぜなら、0次元全体を通じて、1番目と2番目の次元では0番目の要素だけを取っているからです。テンソルの形状を考えると、1、5、9はそれぞれの「チャンク」の左上の要素ということになります。
もちろん、すべての位置にコロンを指定することで、元のテンソル全体を取得することもできます。
full_tensor = x[:, :, :] # または単に x と同じ
このようなリストインデックスの機能は、特定のパターンやサブセットのデータを扱う際に非常に便利で、データの処理や分析をより効率的に行うことができます。
4.4. テンソルからスカラー値への変換
インデックス操作に関する最後のポイントは、テンソルからスカラー値への変換です。ニューラルネットワークのコードを書く際、通常はネットワークを通じてデータを処理し、損失を得て、その損失に関して勾配を計算する必要があります。この損失はスカラー値である必要があります。
時々、操作が失敗することがあります。その理由は、実際にはスカラー値を期待しているのに、テンソルが渡されているためです。このような場合、1×1のテンソルからスカラー値を抽出するには、.item()
メソッドを使用します。
x = torch.tensor([42]) # 単一の値を持つテンソルscalar_value = x.item() # 結果: 42 (Pythonのスカラー値)
この例では、テンソルが文字通り1つの値だけを持っている場合、それに対応するPythonのスカラー値を.item()
を呼び出すことで取得できます。これは、損失値の記録や他のPythonコードとの統合など、スカラー値が必要な場面で非常に便利です。
この機能は、複雑なニューラルネットワーク操作の中で単一の値を扱う必要がある場合に特に重要です。テンソルをPythonの基本型に変換することで、通常のPython演算や条件分岐をスムーズに実行できるようになります。
5. 自動微分 (Autograd)
5.1. Autograd の概念
ここからは、より興味深い話題に入ります。PyTorchの素晴らしい機能の一つに自動微分(Autograd)があります。Autogradとは何かというと、PyTorchが提供する自動微分パッケージです。
ニューラルネットワークを定義するとき、基本的には何らかの関数を計算する多くのノードを定義しています。順伝播(フォワードパス)では、データをこれらのノードを通して実行しますが、PyTorchがバックエンドで行っていることは、各ポイントで勾配を保存し、累積することです。
逆伝播(バックワードパス)を行うたびに、チェーンルールを適用して、これらのさまざまな勾配を計算します。PyTorchはこれらの勾配をキャッシュし、それらすべてにアクセスできるようにするので、好みのオプティマイザーを実行して、SGDやAdamなど、選んだオプティマイザーで最適化を行うことができます。
これが素晴らしい機能の一つです。実際にすべての勾配を計算するコードを書いたり、それらを適切にキャッシュしたり、チェーンルールを適用したり、これらすべてのステップを行ったりする必要がありません。.backward()
を呼び出すだけで、すべてを抽象化できます。
これから、勾配が自動的に計算される小さな例を見ていきましょう。これにより、PyTorchが提供する自動微分の強力さと便利さを理解できるでしょう。
5.2. 勾配の計算と保存
Autogradを使って勾配を計算し保存する方法を具体的な例で見ていきましょう。まず、テンソルを初期化し、requires_grad=True
を設定します。このパラメータは、デフォルトではPyTorchがそのテンソルに関連する勾配を保存することを意味します。
x = torch.tensor([2.0], requires_grad=True)
なぜこのrequires_grad
というパラメータがあるのか疑問に思うかもしれません。答えは、トレーニング時には勾配が必要ですが、推論時には勾配を無効にしたいことがあるからです。推論時には勾配計算は必要なく、余分な計算が発生するだけだからです。
この時点では、勾配はまだ計算されていません。なぜなら、このテンソルに関連して何らかの量を計算し、その勾配を計算するために.backward()
を呼び出していないからです。したがって、現時点では勾配を格納する.grad
属性は存在しません。
次に、簡単な関数を定義してみましょう。xを取り、y = 3x^2という関数を定義します。
y = 3 * (x ** 2)
ここで、y.backward()
を呼び出すと何が起こるでしょうか。この操作で、PyTorchはyをxで微分し、その勾配をx.gradに保存します。
y.backward()print(x.grad) # tensor([12.])
x.grad
を出力すると、12という数値が得られます。なぜかというと、関数y = 3x^2の導関数は6xであり、xの値が2なので、実際の勾配は12になるからです。
PyTorchは自動的に適切な微分規則を適用し、結果を保存してくれるので、手動で導関数を計算する必要がありません。これがAutograd機能の強力さであり、複雑なニューラルネットワークでの勾配計算を大幅に簡素化します。
5.3. requires_grad パラメータ
テンソルを作成するとき、requires_grad
パラメータを設定することで、PyTorchにそのテンソルの勾配を追跡するかどうかを指示します。デフォルトでは、この値はFalseに設定されていますが、明示的にTrueに設定することで勾配の計算と保存を有効にできます。
x = torch.tensor([2.0], requires_grad=True)
このパラメータの重要性は、ニューラルネットワークのトレーニングと推論の違いにあります。トレーニング時には、モデルのパラメータを更新するために勾配が必要です。しかし、推論時(モデルがトレーニング済みで予測だけを行う時)には、勾配計算は不要であり、計算リソースの無駄遣いになります。
# トレーニングモード - 勾配を計算model.train()x = torch.tensor([2.0], requires_grad=True)# 推論モード - 勾配を計算しないmodel.eval()with torch.no_grad(): predictions = model(x)
トレーニング中は、勾配を計算してモデルのパラメータを更新する必要があるため、requires_grad=True
が重要です。推論中に勾配計算を無効にすることで、メモリ使用量を減らし、計算速度を上げることができます。
パラメータの管理においても、requires_grad
は重要な役割を果たします。ニューラルネットワークでは、一部のパラメータを固定したまま、他のパラメータだけを更新したい場合があります(例:転移学習)。そのような場合、更新したくないパラメータに対してはrequires_grad=False
を設定します。
# 特定のレイヤーのパラメータを凍結する例for param in model.feature_extractor.parameters(): param.requires_grad = False
このように、requires_grad
パラメータを適切に管理することで、効率的なトレーニングと推論が可能になります。
5.4. backward() 関数の使用
勾配計算を開始するには、.backward()
関数を使用します。この関数はテンソルに対して呼び出され、そのテンソルに依存するすべての変数の勾配が計算されます。具体的な例を見てみましょう。
x = torch.tensor([2.0], requires_grad=True)y = 3 * (x ** 2)y.backward()
ここで何が起きているかというと、y.backward()
を呼び出した時点で、PyTorchはyからxへの勾配を計算します。この例では、y = 3x^2の導関数は6xなので、x=2のとき勾配は12になります。
基本的に、.backward()
を呼び出すと、計算グラフを遡り、チェーンルールを適用して各変数の勾配を計算します。これにより、複雑なネットワークでも各パラメータがどの方向にどれだけ更新されるべきかを自動的に計算できます。
複数の変数が関わる場合も、すべての変数の偏導関数(勾配)が計算されます。例えば:
x = torch.tensor([2.0], requires_grad=True)
z = torch.tensor([3.0], requires_grad=True)
y = x**2 + z**3
y.backward()
この場合、x.grad
とz.grad
にはそれぞれの勾配が格納されます。
一つ注意点として、.backward()
は通常、スカラー値(単一の値を持つテンソル)に対して呼び出す必要があります。複数の値を持つテンソルに対して勾配を計算する場合は、勾配の重みを指定する必要があります。
# ベクトルに対するbackwardv = torch.tensor([1.0, 1.0], requires_grad=True)y = v * v # [1, 1]# 勾配の重みを指定external_grad = torch.tensor([1.0, 1.0])y.backward(gradient=external_grad)
このようにして、.backward()
関数を使って計算グラフ上のあらゆる変数の勾配を効率的に計算することができます。
5.5. 勾配の累積と初期化
勾配に関する重要な点として、PyTorchではデフォルトで勾配が累積されることを理解する必要があります。つまり、勾配を計算するたびに、その勾配は上書きされるのではなく、加算されていきます。
これを実験で確認してみましょう。まず、テンソルxを作成し、関数y = 3x²を定義して、勾配を計算します。
x = torch.tensor([2.0], requires_grad=True)
y = 3 * (x ** 2)
y.backward()
print(x.grad) # tensor([12.])
次に、同じxに対して別の関数z = 3x²を定義し、その勾配も計算します。
z = 3 * (x ** 2)
z.backward()
print(x.grad) # tensor([24.])
2回目の出力を見ると、値が24になっていることがわかります。「同じことを2回行ったのに、なぜまた12ではなく24になるのか?」と疑問に思うかもしれません。答えは、PyTorchがデフォルトで勾配を累積するからです。
PyTorchの仕組みでは、xテンソルに.grad
という属性があり、これは別の独立したテンソルです。xと同じ形状を持ち、xに依存する量に対して.backward()
を呼び出すたびに累積される勾配を格納します。最初の呼び出しで勾配は12(6x×2)になり、2回目も同じく12ですが、両者は加算されて24になります。
この累積の理由は、ニューラルネットワークのトレーニングで、損失に関する勾配を計算する際、その損失は多くの異なる例から構成されているからです。すべての例からの勾配を累積して1回の更新を行う必要があります。
ただし、データの各バッチに対して新しいエポックを開始するときは、前のバッチの勾配が新しいバッチの更新に影響しないように、勾配をゼロにリセットする必要があります。そのため、トレーニングループでは.zero_grad()
メソッドを呼び出して勾配をリセットします。
optimizer.zero_grad() # すべてのパラメータの勾配をゼロにリセット
このように、勾配の累積メカニズムを理解し、適切なタイミングで勾配を初期化することは、ニューラルネットワークの効果的なトレーニングにとって非常に重要です。
6. ニューラルネットワークの構築
6.1. torch.nn モジュール
ここからはパズルの最後のピースとも言える、ニューラルネットワークの構築について説明します。PyTorchでは実際にニューラルネットワークをどのように使用するのか、そして一度構築と最適化の方法を理解すれば、ニューラルネットワークのトレーニング方法、つまりPyTorchでそれがいかにクリーンで効率的に行えるかを理解できるでしょう。
最初にすべきことは、既存の構成要素、つまり線形層や異なる活性化関数などを実装するAPIを使用してニューラルネットワークを定義することです。そのために、ニューラルネットワークパッケージであるtorch.nn
をインポートします。
import torch.nn as nn
torch.nn
パッケージには、ニューラルネットワークを構築するために必要な様々なモジュールやレイヤーが含まれています。例えば線形層、畳み込み層、バッチ正規化層、プーリング層などです。これらのコンポーネントを組み合わせることで、複雑なネットワークアーキテクチャを構築できます。
torch.nn
モジュールの設計思想は、ニューラルネットワークをモジュール化して構築することです。各レイヤーやコンポーネントはnn.Module
クラスを継承しており、それらを組み合わせて大きなネットワークを作成します。この構造により、コードの再利用性が高まり、ネットワークの管理も容易になります。
torch.nn
モジュールを使用することで、ネットワークの定義から最適化、訓練までの一連のプロセスを効率的に行うことができ、これからその詳細を見ていきます。
6.2. 線形層 (Linear Layer) の使用
まずは線形層の使い方から見ていきましょう。PyTorchでの線形層の働き方は、2つの引数を取ります。入力の次元とそして出力の次元です。
基本的に、線形層は任意の数の次元を持つ入力を受け取り、最後の次元が入力次元となり、出力は同じ次元セットになりますが、最後の位置が出力次元になります。
線形層は本質的に単純なAx + bを実行すると考えることができます。デフォルトではバイアスが適用されますが、必要に応じてバイアスを無効にすることもできます。
小さな例を見てみましょう。ここでは入力を定義し、線形層を作成します。この場合、入力サイズは4、出力サイズは2です。
input = torch.randn(2, 3, 4) # 形状が2×3×4のランダムテンソルlinear = nn.Linear(4, 2) # 入力次元4、出力次元2の線形層
線形層を定義したら、nn.Linear
でインスタンス化したレイヤーを関数のように入力に適用するだけで、この線形層を通して実際の順伝播(フォワードパス)を実行できます。
output = linear(input)print(output.shape) # torch.Size([2, 3, 2])
元の形状は2×3×4でした。それを出力次元が2の線形層に通したので、最終的な出力は2×3×2になります。これは期待通りであり、シェイプエラーは発生しません。
しかし、よくあることとして、混乱して間違った次元を指定してしまうこともあります。例えば、2×2の線形層を作成すると、シェイプエラーが発生します。
wrong_linear = nn.Linear(2, 2)# output = wrong_linear(input) # エラー発生
エラーメッセージはあまり役に立たないこともあります。なぜなら、PyTorchは内部で最適化を行い、テンソルの形状を変更している可能性があるからです。形状を明示的に持っている場合は明らかですが、形状がない場合は、単純に形状を出力してみるとよいでしょう。そうすれば、最後の次元がサイズ4であることがわかり、線形層の入力次元を4に変更する必要があることがわかります。
また、出力にはgrad_fn
という表示がありますが、これはテンソルの勾配を計算して保存しているためです。
6.3. 活性化関数の適用
ニューラルネットワークには線形層だけでなく、非線形性を導入するための活性化関数も必要です。PyTorchのnn
モジュールには、さまざまな活性化関数が用意されています。
例として、シグモイド関数を使った活性化関数を定義してみましょう。
activation = nn.Sigmoid()
これで活性化関数を定義できました。次に、シンプルなネットワークを構築するために、先ほど定義した線形層とこの活性化関数を組み合わせてみましょう。
input = torch.randn(2, 3, 4)
linear = nn.Linear(4, 2)
activation = nn.Sigmoid()
# 順伝播を行う
output1 = linear(input) # 線形変換
output2 = activation(output1) # 活性化関数の適用
このように、線形層の出力に活性化関数を適用することで、ニューラルネットワークの基本的な構成要素を作成できます。
実際には、各レイヤーを個別に適用する代わりに、それらを連結して一度に適用することができます。これについては次のセクションで説明します。
活性化関数はニューラルネットワークに非線形性を導入するために重要です。これにより、ネットワークは複雑な関数を学習することができます。シグモイド以外にも、ReLU、Tanh、Softmaxなど様々な活性化関数があり、タスクに応じて適切なものを選択します。
6.4. nn.Sequential を使ったレイヤーの連結
レイヤーを構築するときに、各レイヤーを個別に適用していくのは少し面倒かもしれません。実際には、これらのレイヤーを一緒に結合する方法があります。毎回各レイヤーを次のレイヤーに適用する一行一行のコードを書く必要はありません。
PyTorchではnn.Sequential
を使用して、レイヤーをスタックすることができます。この方法を使えば、すべてのレイヤーを一度にリストアップできます。以下の例では、線形層とシグモイド活性化関数を連結しています。
block = nn.Sequential( nn.Linear(4, 2), nn.Sigmoid())
これで、このブロック全体を入力に対して一度に適用することができます。
input = torch.randn(2, 3, 4)output = block(input)
ここでは、入力を取り、この一連のレイヤー全体を通して渡し、出力を得ています。
nn.Sequential
を使用すると、複数のレイヤーを順番に適用するプロセスが非常にクリーンで読みやすくなります。これは特に深いネットワークを構築する際に役立ちます。例えば:
deep_network = nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10), nn.Softmax(dim=1))
このようにして、複雑なネットワークアーキテクチャを簡潔に定義できます。各レイヤーは順番に適用され、最終的な出力が返されます。
6.5. カスタムネットワークの定義
nn.Module の拡張
ニューラルネットワークを定義する際に、単にレイヤーを連結するだけでなく、より複雑な構造やカスタム動作が必要な場合があります。そのような場合、nn.Module
クラスを拡張して独自のネットワークを定義することができます。
カスタムネットワークを定義するには、nn.Module
クラスを継承します。これにより、PyTorchのすべての機能(パラメータの追跡、GPUへの移動など)を利用できるようになります。
class MultiLayerPerceptron(nn.Module): def __init__(self, input_size, hidden_size, output_size): super().__init__() # ここでネットワークのレイヤーを定義
このように、クラスを作成してPyTorchのnn.Module
を拡張します。これにより、独自のニューラルネットワークを定義できます。
init メソッドと forward メソッドの実装
独自のネットワークを定義する際に実装する必要があるのは主に2つの関数です。まず、__init__
関数では、必要なすべてのパラメータを初期化します。
def __init__(self, input_size, hidden_size, output_size): super().__init__() # 入力サイズ、隠れ層サイズを初期化 self.input_size = input_size self.hidden_size = hidden_size # モデル自体を定義 self.model = nn.Sequential( nn.Linear(input_size, hidden_size), nn.ReLU(), nn.Linear(hidden_size, output_size), nn.Sigmoid() )
2つ目に実装する必要があるのはforward
関数で、これがネットワークの順伝播を行います。この関数は入力x
を受け取り、どのように出力を計算するかを定義します。
def forward(self, x): # 入力をネットワークに通す output = self.model(x) return output
この例では非常にシンプルですが、もっと複雑にすることもできます。先ほどのように各レイヤーを個別に定義し、それぞれを別々に適用する代わりに、1つのオブジェクトにラップして、それから行ごとに各レイヤーの操作を実行することもできます。
このようにクラスを定義したら、使用するのは非常に簡単です。入力をインスタンス化し、モデルをインスタンス化して、それを通して入力を渡すだけです。
# モデルのインスタンス化input_size = 784hidden_size = 128output_size = 10model = MultiLayerPerceptron(input_size, hidden_size, output_size)# 入力データinput_data = torch.randn(32, 784) # バッチサイズ32、入力次元784# 順伝播output = model(input_data)
これで、ニューラルネットワークを自分で定義し、入力データを通して出力を得る方法がわかりました。
7. 最適化とトレーニング
7.1. torch.optim パッケージ
ニューラルネットワークを定義できるようになりましたが、次に重要なのはそのネットワークをトレーニングする方法です。ここでtorch.optim
パッケージが役立ちます。
PyTorchのtorch.optim
パッケージには、ニューラルネットワークのパラメータを最適化するための様々なアルゴリズムが含まれています。これによって、勾配を計算した後に実際にパラメータを更新する部分を抽象化することができます。
import torch.optim as optim
torch.optim
パッケージを使用すると、バックワードパスでこれらの勾配を計算するbackward
関数があり、最後のステップは実際にこれらの勾配を使用してパラメータを更新することです。PyTorchには組み込みの最適化パッケージがあり、SGD(確率的勾配降下法)やAdam、RMSpropなど、さまざまな最適化アルゴリズムを提供しています。
基本的な使い方は次のようになります。まずモデルを定義し、次に最適化アルゴリズムを選択して、モデルのパラメータと学習率などのハイパーパラメータを渡します。
model = MultiLayerPerceptron(input_size, hidden_size, output_size)
optimizer = optim.Adam(model.parameters(), lr=0.001)
最適化アルゴリズムはモデルのパラメータを取得し、それらのパラメータに対して最適化ステップを実行します。このパッケージによって、複雑な最適化ロジックを自分で実装する必要がなくなり、効率的にニューラルネットワークをトレーニングできるようになります。
7.2. 最適化アルゴリズム (Adam)
ニューラルネットワークを最適化するためには様々なアルゴリズムがありますが、その中でもAdamは非常に人気のある選択肢です。Adamは「Adaptive Moment Estimation」の略で、学習率を適応的に調整する最適化アルゴリズムです。
Adamオプティマイザーを使用するには、まずtorch.optim
からインポートし、モデルのパラメータと一緒に初期化します。
optimizer = optim.Adam(model.parameters(), lr=0.001)
ここで、model.parameters()
は最適化したいパラメータを指定し、lr
は学習率を設定します。学習率は通常0.001などの小さな値から始めますが、タスクや問題の複雑さによって調整が必要です。
Adamは以下のような特徴を持っています:
- モーメンタムを使用して、勾配の方向性を安定させる
- RMSpropのように、各パラメータの学習率を適応的に調整する
- バイアス補正により、学習初期の挙動を改善する
これらの特性により、Adamはトレーニングの収束が速く、多くの場合でデフォルトの選択として良好に機能します。
他の最適化アルゴリズムと比較すると、SGDは単純でメモリ効率が良いですが、収束に時間がかかる場合があります。RMSpropはAdamと似ていますが、モーメンタムが組み込まれていません。各アルゴリズムにはそれぞれ長所と短所があり、問題に応じて選択する必要があります。
Adamオプティマイザーのハイパーパラメータは以下のように設定できます:
optimizer = optim.Adam( model.parameters(), lr=0.001, # 学習率 betas=(0.9, 0.999), # モーメンタムの減衰率 eps=1e-08, # 数値安定性のための項 weight_decay=0 # L2正則化パラメータ)
通常はデフォルト値で十分機能しますが、特定の問題に対しては調整が必要な場合もあります。
7.3. 損失関数の定義
ニューラルネットワークのトレーニングでは、モデルの予測と実際の値(ターゲット)の間の差を測定するために損失関数が必要です。PyTorchには様々な一般的な損失関数がnn
モジュールに含まれています。
クロスエントロピー損失は分類タスクでよく使われる損失関数の一つです。次のように定義できます:
loss_fn = nn.CrossEntropyLoss()
クロスエントロピー損失は、モデルが予測したクラスの確率分布と実際のクラスラベルの間の差を測定します。これは特に多クラス分類問題に適しています。
損失関数を使用するには、モデルの予測とターゲットを渡します:
predictions = model(inputs)
loss = loss_fn(predictions, targets)
損失値はモデルの現在のパフォーマンスを示し、この値を最小化することがトレーニングの目標です。
PyTorchには他にも様々な損失関数が用意されています:
nn.MSELoss()
: 平均二乗誤差損失(回帰問題に使用)nn.BCELoss()
: バイナリクロスエントロピー損失(二値分類に使用)nn.NLLLoss()
: 負の対数尤度損失(log_softmax
と組み合わせて使用)
タスクに応じて適切な損失関数を選択することが重要です。例えば、回帰問題では通常MSE損失を使用し、分類問題ではクロスエントロピー損失を使用します。
損失関数は、予測とターゲットの間の差を測定するだけでなく、その勾配を計算するためのスタート地点としても機能します。損失からbackward()
を呼び出すことで、逆伝播プロセスが開始され、各パラメータに対する勾配が計算されます。
7.4. トレーニングループの実装
勾配のゼロ化
トレーニングループの最初のステップは、勾配をゼロにすることです。これは、オプティマイザーのzero_grad()
メソッドを呼び出して行います。これが必要な理由は、前述したように、PyTorchでは勾配が累積されるからです。私たちは各エポックで勾配を累積させたいのではなく、新しいミニバッチごとに新たに計算したいのです。
optimizer.zero_grad()
これにより、前回の反復からの勾配がクリアされ、新しいバッチのための計算が準備されます。
順伝播
次のステップは順伝播(フォワードパス)です。ここでは、入力データをモデルに通して予測を得ます。
predictions = model(inputs)
このステップでは、データがネットワークの各層を通過し、最終的な出力(予測)が生成されます。
損失の計算
モデルの予測が得られたら、それと実際のターゲット値を比較して損失を計算します。
loss = loss_fn(predictions, targets)scalar_loss = loss.item() # スカラー値として損失を取得
損失値は、モデルの現在のパフォーマンスを示す指標となります。この値を最小化することがトレーニングの目標です。
逆伝播
損失が計算されたら、その損失に対してbackward()
メソッドを呼び出し、逆伝播(バックワードパス)を実行します。
loss.backward()
この呼び出しにより、損失から始まって計算グラフを遡り、チェーンルールを適用して各パラメータの勾配が計算されます。これが、バックワードパスで実際に全ての勾配を計算するステップです。
パラメータの更新
最後のステップは、計算された勾配を使用してモデルのパラメータを更新することです。これはオプティマイザーのstep()
メソッドを呼び出して行います。
optimizer.step()
この呼び出しにより、計算された勾配に基づいてモデルのパラメータが更新されます。今回の例ではAdamオプティマイザーを使用していますが、同じ手順はどのオプティマイザーでも適用できます。
これらのステップを組み合わせると、完全なトレーニングループは次のようになります:
for epoch in range(num_epochs): # 勾配をゼロにリセット optimizer.zero_grad() # モデルの予測を取得(順伝播) predictions = model(inputs) # 損失を計算 loss = loss_fn(predictions, targets) # 勾配を計算(逆伝播) loss.backward() # パラメータを更新 optimizer.step() # 進捗を表示 print(f'Epoch {epoch}, Loss: {loss.item()}')
このコードを実行すると、初期のトレーニング損失が比較的高い値から始まり、数エポック後には大幅に減少するのを確認できるでしょう。モデルのパラメータを表示すると、トレーニング前と比べて変化していることがわかります。これは、最適化プロセスによってパラメータが更新されたことを示しています。
8. デモ:NLPタスクでの実践例
時間の関係で、ここではデモをかいつまんで説明していきます。これまで学んだ内容をすべて統合して、実際のNLPタスクにどのように適用できるか見ていきましょう。
実際のNLPタスクでは、学習したPyTorchの要素を組み合わせて使用します。例えば、テキストデータをテンソルに変換し、単語の埋め込み表現を作成し、それらをニューラルネットワークモデルに通して予測を行います。
基本的な流れとしては:
- テキストデータを前処理し、数値のインデックスに変換
- これらのインデックスをPyTorchテンソルとして表現
- 埋め込み層を使用して単語をベクトル表現に変換
- RNNやTransformerなどのアーキテクチャを使用してシーケンスを処理
- 出力層を通じて予測を生成(例:感情分析ならポジティブ/ネガティブの分類)
- 損失関数を使用してモデルの予測とターゲットを比較
- 最適化アルゴリズムを使用してモデルのパラメータを更新
これらのステップを実装するには、PyTorchのnn.Embedding
、nn.LSTM
またはnn.Transformer
などのモジュールを使用し、それらをnn.Module
を継承したカスタムクラスで組み合わせます。
各バッチのデータを処理するときは、先ほど説明したトレーニングループのパターンに従います:勾配のゼロ化、順伝播、損失計算、逆伝播、パラメータ更新の5ステップです。
PyTorchは特にNLPタスクに非常に適しており、動的な計算グラフとオートグラッドの機能により、可変長のシーケンス処理や複雑なアーキテクチャの実装が容易になります。
このチュートリアルでは基本的な要素を学びましたが、これらの概念を理解することで、より複雑なNLPモデルの構築にも応用できます。実際にはより多くのコードと複雑なアーキテクチャが必要ですが、基本的な考え方は同じです。