1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

前回は、PyTorchを使うための環境構築とインストールを扱いました。今回は、いよいよPyTorchの中身に入ります。

PyTorchで最初に押さえたいのは、Tensorです。Tensorは、データそのものを表す入れ物であり、モデルの入力、重み、出力、損失、勾配のすべてに関わります。Tensorの形状、データ型、デバイス、基本操作がわかると、PyTorchのコードはかなり読みやすくなります。

この記事では、Tensorの作成と操作から始めて、線形回帰を手で実装し、その流れの中で計算グラフと autograd が何をしているのかを確認します。最後に、同じ訓練ループの考え方をロジスティック回帰へ広げ、二値分類をPyTorchで実装します。

目的は、APIを丸暗記することではありません。PyTorchが「Tensorを計算し、その計算の履歴から勾配を求め、パラメータを更新する」流れを、自分の手元で追えるようになることです。

目次

この記事で扱うこと

この記事では、次の内容を扱います。

  • Tensorの次元、形状、データ型、デバイス
  • Tensorの作成方法
  • NumPy配列との共有とコピー
  • 結合、分割、インデックス参照、形状変換
  • 要素ごとの演算、ブロードキャスト、行列積
  • Tensorだけを使った線形回帰
  • requires_gradgradgrad_fn、葉Tensor
  • loss.backward() による勾配計算
  • 動的計算グラフの考え方
  • ロジスティック回帰、logit、Sigmoid、二値交差エントロピー
  • BCEWithLogitsLoss を使った二値分類の実装
  • 各知識点を手元で確認できる実行例
  • よくあるエラーの切り分け方

APIのシグネチャを読むとき、* には主に2つの意味があります。単独で出てくる * は引数そのものではなく区切りで、その後ろの引数は dtype=torch.float32device="cuda" のようにキーワード引数として渡します。一方、torch.zeros(*size) のような *size は、torch.zeros(2, 3) のように形状を表す複数の位置引数を受け取れる、という意味です。どちらもシグネチャ上の記法であり、式の中の x * y のような掛け算ではありません。

環境構築がまだ済んでいない場合は、先に第1回の内容に沿ってPyTorchをインストールしておくと読み進めやすいです。

Tensorとは何か

Tensorは、同じデータ型の値を並べた多次元配列です。NumPyの ndarray に近い感覚で扱えますが、PyTorchのTensorには次のような特徴があります。

  • CPUやGPUなど、どのデバイス上にあるかを持てる
  • 自動微分の対象にできる
  • ニューラルネットワークの重みや出力として使える
  • PyTorchの多くの演算APIで直接扱える

Tensorは次元数によって、次のように考えるとわかりやすいです。

次元 呼び方の例 形状の例 よくある用途
0次元 スカラー () 損失値、1つの数値
1次元 ベクトル (5,) 特徴量ベクトル、クラススコア
2次元 行列 (3, 4) 表形式データ、重み行列
3次元 立体的な配列 (3, 224, 224) 1枚のRGB画像
4次元 バッチ付き画像 (64, 3, 224, 224) 画像分類モデルの入力
5次元 バッチ付き動画 (N, C, T, H, W) 動画や時系列画像モデルの入力

画像データでは、よく (N, C, H, W) という形が使われます。RGB画像の C は、赤・緑・青のチャンネル軸を表します。3つの別サンプルではなく、1枚の画像を構成する3つの平面だと考えると混乱しにくくなります。

記号 意味
N バッチサイズ
C チャンネル数
T 時間方向の長さ。動画や時系列画像で使います
H 高さ
W

実際にTensorを作って確認してみます。

import torch

x = torch.randn(64, 3, 224, 224)

print(x.shape)
# torch.Size([64, 3, 224, 224])
print(x.ndim)
# 4
print(x.numel())
# 9633792

shape は各次元の長さ、ndim は次元数、numel() は要素数を表します。

Tensorの基本属性

Tensorを扱うときは、まず次の属性を見る癖をつけると便利です。

属性 意味
shape 各次元の長さ
dtype 要素のデータ型
device Tensorが置かれているデバイス
layout メモリ上のレイアウト(通常はtorch.strided
requires_grad 自動微分の対象にするかどうか
grad 逆伝播で計算された勾配
grad_fn そのTensorを作った演算の情報
is_leaf 自動微分グラフ上の葉Tensorかどうか

例を見てみます。

import torch

x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x.square().sum()

print(x.shape)
# torch.Size([2])
print(x.dtype)
# torch.float32
print(x.device)
# cpu
print(x.requires_grad)
# True
print(x.is_leaf)
# True
print(y.grad_fn)
# <SumBackward0 object at 0x...>

requires_grad=True を指定すると、そのTensorを使った計算が自動微分の対象になります。ニューラルネットワークの重みのように、あとで勾配を求めたい値に指定します。

PyTorchでは、Tensor自体が自動微分の情報を持てます。勾配を求めたいTensorには requires_grad=True を指定し、そのまま計算に使います。

自動微分の対象にできるのは、主に浮動小数点型や複素数型のTensorです。学習する重みやバイアスは、通常 torch.float32 などの浮動小数点Tensorとして作ります。

Tensorを作る

Tensorにはいくつもの作り方があります。最初は、どの関数で何が作れるかをざっくり把握しておけば大丈夫です。

Pythonのリストから作る

torch.tensor() を使うと、Pythonの数値やリストからTensorを作れます。

torch.tensor(data, *, dtype=None, device=None, requires_grad=False, pin_memory=False)
import torch

x = torch.tensor([[1, 2], [3, 4]], dtype=torch.float32)

print(x)
# tensor([[1., 2.],
#         [3., 4.]])
print(x.dtype)
# torch.float32

よく使う引数は次のとおりです。

引数 意味
data 元になるデータ
dtype データ型
device 作成先のデバイス
requires_grad 勾配計算の対象にするか
pin_memory CPUの固定ページメモリを使うか

返り値は新しいTensorです。torch.tensor() は基本的に data をコピーして作るため、元データを書き換えても作成済みのTensorには反映されません。また、返されたTensorは通常、新しく作られた葉Tensorとして扱われ、入力側にあったautogradの履歴は引き継ぎません。

pin_memory=True を指定すると、CPU上にページアウトされない固定ページメモリを確保します。これは主にCUDAへの非同期データ転送を速くするためのオプションで、「とりあえず付けておけば速くなる」という汎用的な性能フラグではない点に注意してください。

すでにTensorがある場合に型やデバイスだけを変えたいなら、.to() を使うほうが自然です。勾配計算の対象にするかだけを変えるなら .requires_grad_()、計算グラフとのつながりを切りたいなら .detach()、コピーをできるだけ避けたいなら次に見る torch.as_tensor() を使う、というように目的で使い分けます。

x = torch.tensor([1, 2, 3])
y = x.to(dtype=torch.float32)

既存データをなるべくコピーせずTensorにする

torch.as_tensor() は、入力をTensorに変換します。型とデバイスがそのままでよい場合は、可能な限りコピーを避けます。

torch.as_tensor(data, dtype=None, device=None)
引数 意味
data リスト、NumPy配列、既存のTensorなど、変換したいデータ
dtype 変換後のデータ型。元データと異なる場合はコピーが発生することがあります
device 配置先のデバイス。元データと異なる場合はコピーが発生することがあります

返り値はTensorです。入力がすでにTensorで、型やデバイスを変える必要がなければ、同じTensorがそのまま返ることがあります。NumPy配列ではCPUメモリを共有する場合があります。関数の引数が「リストかもしれないし、すでにTensorかもしれない」という汎用コードでは、型を手作業で分岐するより torch.as_tensor() を使うと簡潔に書けます。

0や1で埋めたTensorを作る

形だけを決めて、値を0や1で埋めたい場合は次の関数を使います。

import torch

zeros = torch.zeros(2, 3)
ones = torch.ones(2, 3)
filled = torch.full((2, 3), 7.0)

print(zeros)
# tensor([[0., 0., 0.],
#         [0., 0., 0.]])
print(ones)
# tensor([[1., 1., 1.],
#         [1., 1., 1.]])
print(filled)
# tensor([[7., 7., 7.],
#         [7., 7., 7.]])

torch.empty() はメモリだけを確保し、中身を初期化しません。

x = torch.empty(2, 3)
print(x)
# 出力例(値は不定です。実行するたびに変わることがあります)
# tensor([[0., 0., 0.],
#         [0., 0., 0.]])

表示される値は以前のメモリ内容に依存するため、そのまま意味のある値として使ってはいけません。読み出す前に必ず全要素を書き込みます。あとで全要素を上書きする前提で、バッファだけ先に確保したいときに使います。

固定値で作る関数の代表的なシグネチャは次のとおりです。

torch.zeros(*size, *, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False)
torch.full(size, fill_value, *, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False)
torch.empty(*size, *, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False, pin_memory=False)
引数 意味
size 作りたい形状。torch.zeros(2, 3) のようにも、torch.zeros((2, 3)) のようにも書けます
fill_value torch.full() だけで使う埋め込み値
dtypedevice データ型と配置先デバイス
requires_grad 作成したTensorをautogradの対象にするか
out 結果を書き込む出力先Tensor。形状、型、デバイスが合っている必要があります
layout メモリレイアウト。通常は torch.strided です
pin_memory torch.empty() のCPU固定ページメモリ指定です

返り値は、指定した形状の新しいTensorです。zerosonesfull は値が初期化されていますが、empty は初期化されていません。

既存のTensorと同じ形のTensorを作りたい場合は、*_like 系の関数が便利です。

base = torch.randn(2, 3)

z = torch.zeros_like(base)
o = torch.ones_like(base)
f = torch.full_like(base, 5.0)

print(z)
# tensor([[0., 0., 0.],
#         [0., 0., 0.]])
print(o)
# tensor([[1., 1., 1.],
#         [1., 1., 1.]])
print(f)
# tensor([[5., 5., 5.],
#         [5., 5., 5.]])

zeros_likeones_like は、形状、データ型、レイアウト、デバイスを元のTensorから引き継ぎます。ただし、requires_grad は通常自動では引き継がれないため、必要な場合は明示します。memory_format を指定すると、可能な範囲でメモリ形式を制御できます。形状を明示的に書きたくない汎用コードや、バッチごとに形が変わる処理で、元Tensorと同じ形のマスクや一時Tensorを作るときによく使います。

数列を作る

連番や等間隔の値を作るときは、arangelinspacelogspace を使います。

import torch

a = torch.arange(0, 10, 2)
b = torch.linspace(0, 1, steps=5)
c = torch.logspace(0, 3, steps=4, base=10)

print(a)
# tensor([0, 2, 4, 6, 8])
print(b)
# tensor([0.0000, 0.2500, 0.5000, 0.7500, 1.0000])
print(c)
# tensor([   1.,   10.,  100., 1000.])

torch.arange(start, end, step) は、通常 [start, end) の半開区間として考えます。浮動小数点の刻み幅では丸め誤差が出ることがあるため、要素数を重視する場合は linspace() のほうが扱いやすいです。

代表的な使い分けは次のとおりです。

関数 主な引数 返り値・用途
torch.arange(start, end, step) startendstep [start, end) の1次元Tensor。整数インデックスや単純な等差列に向いています
torch.linspace(start, end, steps) startendsteps 両端を含む区間を steps 個に等分した1次元Tensor。要素数を正確に決めたいときに向いています
torch.logspace(start, end, steps, base=10.0) startendstepsbase 指数部分を等間隔にした1次元Tensor。値は base ** exponent です

logspace() は、学習率や正則化係数のように、値を線形ではなく桁で変えながら試したい場合に便利です。

単位行列を作るには torch.eye() を使います。

identity = torch.eye(3)
rectangle = torch.eye(2, 4)

print(identity)
# tensor([[1., 0., 0.],
#         [0., 1., 0.],
#         [0., 0., 1.]])
print(rectangle)
# tensor([[1., 0., 0., 0.],
#         [0., 1., 0., 0.]])

torch.eye(n, m=None)m(列数)を省略するとnと同じ値になります。nmに異なる値を指定すると、主対角線が1でそれ以外が0の長方形の行列になります。

torch.eye(n, m=None, *, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False)

返り値は形状が (n, m) のTensorです。m を省略した場合は (n, n) になります。線形代数の確認、単位行列の作成、簡単なone-hot表現の土台として使えます。

乱数で作る

機械学習では、乱数でTensorを作る場面がよくあります。

import torch

normal = torch.randn(2, 3)
uniform = torch.rand(2, 3)
integers = torch.randint(0, 10, (2, 3))
perm = torch.randperm(10)

print(normal)
# 出力例(乱数のシードを固定していないため、実行するたびに値が変わります)
# tensor([[ 0.0315, -0.5394,  0.3649],
#         [ 0.3852,  0.8223,  0.0245]])
print(uniform)
# tensor([[0.8656, 0.9225, 0.7434],
#         [0.4509, 0.4714, 0.1997]])
print(integers)
# tensor([[0, 6, 5],
#         [8, 1, 7]])
print(perm)
# tensor([7, 5, 4, 0, 1, 8, 9, 2, 6, 3])
関数 意味
torch.randn() 標準正規分布からサンプリング
torch.rand() [0, 1) の一様分布からサンプリング
torch.randint() 整数を一様にサンプリング
torch.normal() 指定した平均と標準偏差の正規分布からサンプリング
torch.randperm() 0 から n-1 までのランダムな並び替え
torch.bernoulli() 各要素を確率として0または1をサンプリング

torch.randn(*size) は標準正規分布、torch.rand(*size)[0, 1) の一様分布から、指定した形状のTensorを返します。torch.randint(low, high, size)[low, high) の整数を返し、既定では torch.int64 になります。

torch.normal() は、平均と標準偏差を指定して正規分布からサンプリングします。平均と標準偏差はTensorでもスカラーでも指定できます。両方がスカラーの場合は、返り値の形を size で指定します。

mean = torch.zeros(3)
std = torch.tensor([1.0, 2.0, 0.5])
x = torch.normal(mean, std)

y = torch.normal(0.0, 1.0, size=(3,))

torch.randperm(n) は、0 から n-1 までの整数を1回ずつ含むランダムな1次元Tensorを返します。サンプル番号をシャッフルするときに便利ですが、非常に大きな n では並び替え全体を保持するメモリも必要になります。

torch.bernoulli() は、入力Tensorの各要素の値をそのまま確率として扱い、0か1をサンプリングします。確率値は [0, 1] の範囲である必要があります。

p = torch.tensor([0.2, 0.5, 0.9])
sample = torch.bernoulli(p)

print(sample)
# 出力例(乱数のシードを固定していないため、実行するたびに値が変わります)
# tensor([0., 1., 1.])

torch.randn()torch.rand()torch.randint() などの乱数系関数には、共通して generatortorch.Generator)という引数も渡せます。乱数の状態をグローバルなシードとは切り離して管理したい場合に使います。

乱数を固定したい場合は、torch.manual_seed() を使います。

import torch

torch.manual_seed(42)
x = torch.randn(3)

print(x)
# tensor([0.3367, 0.1288, 0.2345])

ただし、乱数シードを固定しても、ハードウェア、バックエンド、PyTorchのバージョン、並列実行の条件によって完全に同じ結果にならないことがあります。研究や検証で厳密な再現性が必要な場合は、PyTorch公式ドキュメントの再現性に関する説明も確認してください。

Tensor作成コードをまとめて確認する

ここまでの内容を、1つのコードでまとめて確認します。各ブロックを順に実行すると、Tensorの作成方法の違いが確認できます。

import numpy as np
import torch

torch.manual_seed(1)

# torch.tensor(): PythonやNumPyのデータからTensorを作る
array = np.ones((3, 3))
tensor_from_array = torch.tensor(array, dtype=torch.float32)
print("NumPy dtype:", array.dtype)
# NumPy dtype: float64
print(tensor_from_array)
# tensor([[1., 1., 1.],
#         [1., 1., 1.],
#         [1., 1., 1.]])

# GPUが使える環境なら、作成時にdeviceを指定できる
if torch.cuda.is_available():
    tensor_on_gpu = torch.tensor(array, dtype=torch.float32, device="cuda")
    print(tensor_on_gpu.device)

# 固定値で作る
zeros = torch.zeros((3, 3))
ones = torch.ones((3, 3))
full = torch.full((3, 3), 1.0)

print(zeros)
# tensor([[0., 0., 0.],
#         [0., 0., 0.],
#         [0., 0., 0.]])
print(ones)
# tensor([[1., 1., 1.],
#         [1., 1., 1.],
#         [1., 1., 1.]])
print(full)
# tensor([[1., 1., 1.],
#         [1., 1., 1.],
#         [1., 1., 1.]])

# outを指定すると、出力先Tensorに結果を書き込める
out_tensor = torch.empty((3, 3))
torch.zeros((3, 3), out=out_tensor)
print(out_tensor)
# tensor([[0., 0., 0.],
#         [0., 0., 0.],
#         [0., 0., 0.]])

# 数列を作る
arange_tensor = torch.arange(2, 10, 2)
linspace_tensor = torch.linspace(2, 10, steps=6)

print(arange_tensor)
# tensor([2, 4, 6, 8])
print(linspace_tensor)
# tensor([ 2.0000,  3.6000,  5.2000,  6.8000,  8.4000, 10.0000])

# 正規分布から作る
mean = torch.arange(1, 5, dtype=torch.float32)
std = torch.ones(4)
normal_tensor = torch.normal(mean, std)

print("mean:", mean)
# mean: tensor([1., 2., 3., 4.])
print("std:", std)
# std: tensor([1., 1., 1., 1.])
print(normal_tensor)
# tensor([1.6614, 2.2669, 3.0617, 4.6213])

# 平均がTensor、標準偏差がスカラーでも作れる
normal_with_scalar_std = torch.normal(mean, 1.0)
print(normal_with_scalar_std)
# tensor([0.5481, 1.8339, 1.4772, 4.3817])

# 平均と標準偏差がどちらもスカラーの場合はsizeを指定する
standard_normal = torch.normal(0.0, 1.0, size=(4,))
print(standard_normal)
# tensor([-1.0276, -0.5631, -0.8923, -0.0583])

NumPy配列との関係

PyTorchはNumPyと相性がよく、相互変換も簡単です。ただし、コピーされるのか、メモリを共有するのかは重要です。

torch.from_numpy() はメモリを共有する

torch.from_numpy() で作ったTensorは、多くの場合、元のNumPy配列とCPUメモリを共有します。

import numpy as np
import torch

array = np.array([1, 2, 3], dtype=np.float32)
tensor = torch.from_numpy(array)

array[0] = 99

print(tensor)
# tensor([99.,  2.,  3.])

この例では、NumPy配列を書き換えるとTensor側にも反映されます。逆方向も同じです。

共有関係は、次のように考えるとわかりやすいです。

NumPy ndarray ---- CPUメモリを共有 ---- torch.from_numpy()で作ったTensor
    |                                      |
    +------ どちらかをインプレース変更 -----+

共有されるのはCPU上のメモリです。そのため、NumPyで読み込んだ画像や表形式データを、余計なコピーを作らずPyTorch側で扱いたいときに便利です。ただし、元のNumPy配列が読み取り専用の場合、その配列から作ったTensorへ書き込むと未定義の動作につながることがあります。書き換える可能性があるなら、先にコピーしてからTensorに変換します。

完全に独立したTensorがほしい場合は、clone() でコピーします。

tensor = torch.from_numpy(array).clone()

torch.tensor() はコピーする

torch.tensor() は、基本的に入力データをコピーして新しいTensorを作ります。

import numpy as np
import torch

array = np.array([1, 2, 3], dtype=np.float32)
tensor = torch.tensor(array)

array[0] = 99

print(tensor)
# tensor([1., 2., 3.])

元の配列を書き換えても、作成済みのTensorには反映されません。

torch.as_tensor() は必要に応じてコピーを避ける

torch.as_tensor() は、可能であればコピーを避けてTensorを作ります。

tensor = torch.as_tensor(array)

ただし、データ型やデバイスを変える必要がある場合はコピーが発生することがあります。共有されるかどうかが重要なコードでは、from_numpy()tensor()as_tensor()clone()detach() の違いを意識しておくと安心です。

作り方 コピーするか メモリ共有 autogradとの関係
torch.tensor(data) 基本的にコピーする 共有しない 新しい葉Tensorとして作られます
torch.as_tensor(data) 可能なら避ける 入力によっては共有する 既存Tensorなら履歴を保つことがあります
torch.from_numpy(array) 通常はコピーしない CPUメモリを共有する 新しいTensorとして作られます
tensor.clone() コピーする 共有しない 微分可能なコピーとして履歴に残ります
tensor.detach() 通常はコピーしない 元Tensorとストレージを共有します 計算グラフから切り離します

Tensor側を書き換えるとNumPy配列側も変わることを、実際に試しておくと共有メモリの感覚がつかみやすくなります。

import numpy as np
import torch

array = np.array([[1, 2, 3], [4, 5, 6]])
tensor = torch.from_numpy(array)

tensor[0, 0] = -1

print("NumPy array:\n", array)
# NumPy array:
#  [[-1  2  3]
#  [ 4  5  6]]
print("Tensor:\n", tensor)
# Tensor:
#  tensor([[-1,  2,  3],
#         [ 4,  5,  6]])

Tensorを結合・分割する

複数のTensorをまとめたり、1つのTensorを分けたりする操作は、データ前処理やバッチ処理でよく使います。

torch.cat() は既存の次元に沿ってつなげる

torch.cat() は、既存の次元に沿ってTensorを連結します。

import torch

a = torch.ones(2, 3)
b = torch.zeros(2, 3)

c0 = torch.cat([a, b], dim=0)
c1 = torch.cat([a, b], dim=1)

print(c0.shape)
# torch.Size([4, 3])
print(c1.shape)
# torch.Size([2, 6])

dim=0 なら行方向に増え、dim=1 なら列方向に増えます。連結する次元以外の形状はそろっている必要があります。

torch.cat(tensors, dim=0, *, out=None)
引数 意味
tensors 連結したいTensorの並び。連結する次元以外の形状は一致している必要があります
dim 連結する次元。負のインデックスも使えます
out 任意の出力先Tensor

返り値は、dim 方向だけ長さが足し合わされた新しいTensorです。複数のバッチをまとめる、複数の特徴量を横方向に足す、複数の分岐の出力を連結する、といった場面でよく使います。

torch.stack() は新しい次元を作る

torch.stack() は、同じ形状のTensorを新しい次元に積みます。

s0 = torch.stack([a, b], dim=0)
s1 = torch.stack([a, b], dim=1)

print(s0.shape)
# torch.Size([2, 2, 3])
print(s1.shape)
# torch.Size([2, 2, 3])

cat は次元数を増やしませんが、stack は次元数を1つ増やします。

torch.stack(tensors, dim=0, *, out=None)

stack では、入力するTensorの形状がすべて完全に同じである必要があります。dim は新しい次元をどこに挿入するかを表し、元の次元数より1つ多い範囲で指定できます。返り値は、指定位置に新しい軸を1つ持つTensorです。複数のサンプルや時系列ステップを、1つのバッチや系列としてまとめるときに使います。

操作 何をするか 次元数
torch.cat() 既存の次元に沿って連結する 変わらない
torch.stack() 新しい次元を作って積む 1つ増える

torch.chunk()torch.split() で分ける

torch.chunk() は、指定した数に分けようとします。

x = torch.arange(10)
parts = torch.chunk(x, chunks=3)

for part in parts:
    print(part)
    # tensor([0, 1, 2, 3])
    # tensor([4, 5, 6, 7])
    # tensor([8, 9])

割り切れない場合、各チャンクの大きさは同じにならないことがあります。また、形状によっては要求した個数より少ない結果が返る場合もあります。必ず指定数の分割が必要なら、torch.tensor_split() も検討します。

torch.chunk(input, chunks, dim=0)

input は分割したいTensor、chunks は分けたい個数、dim は分割する次元です。返り値はTensorのタプルで、通常は元Tensorのビューです。chunk() は「指定した個数に分けようとする」関数であり、常に指定個数を保証する関数ではない点が重要です。

torch.split() は、固定サイズまたはサイズのリストで分けます。

x = torch.arange(10)

print(torch.split(x, 3))
# (tensor([0, 1, 2]), tensor([3, 4, 5]), tensor([6, 7, 8]), tensor([9]))
print(torch.split(x, [2, 5, 3]))
# (tensor([0, 1]), tensor([2, 3, 4, 5, 6]), tensor([7, 8, 9]))

分割後のTensorは、元のTensorとストレージを共有するビューになることがあります。あとでインプレース操作をする場合は注意が必要です。

torch.split(tensor, split_size_or_sections, dim=0)

split_size_or_sections に整数を渡すと、そのサイズごとに分けます。整数のリストを渡すと、各分割のサイズを個別に指定します。返り値はTensorのタプルです。chunk() が「いくつに分けたいか」を指定するのに対し、split() は「各部分をどの大きさにしたいか」を細かく指定したいときに向いています。

結合・分割の操作をまとめて確認すると、次のようになります。

import torch

torch.manual_seed(1)

t = torch.ones((2, 3))

# cat: 既存の次元に沿って連結する
t_cat_0 = torch.cat([t, t], dim=0)
t_cat_1 = torch.cat([t, t, t], dim=1)

print("cat dim=0:", t_cat_0.shape)
# cat dim=0: torch.Size([4, 3])
print("cat dim=1:", t_cat_1.shape)
# cat dim=1: torch.Size([2, 9])

# stack: 新しい次元を作って積む
t_stack = torch.stack([t, t, t], dim=0)
print("stack:", t_stack.shape)
# stack: torch.Size([3, 2, 3])

# chunk: 指定した個数に分けようとする
a = torch.ones((2, 7))
chunks = torch.chunk(a, chunks=3, dim=1)

for index, part in enumerate(chunks, start=1):
    print(f"chunk {index}: shape={part.shape}")
    # chunk 1: shape=torch.Size([2, 3])
    # chunk 2: shape=torch.Size([2, 3])
    # chunk 3: shape=torch.Size([2, 1])

# split: サイズを指定して分ける
b = torch.ones((2, 5))
splits = torch.split(b, [2, 1, 2], dim=1)

for index, part in enumerate(splits, start=1):
    print(f"split {index}: shape={part.shape}")
    # split 1: shape=torch.Size([2, 2])
    # split 2: shape=torch.Size([2, 1])
    # split 3: shape=torch.Size([2, 2])

torch.split() でサイズのリストを渡す場合、その合計は分割する次元の長さと一致している必要があります。たとえば形状が (2, 5) のTensorを dim=1 で分割するなら、[2, 1, 2] のように合計が 5 になる指定にします。

インデックス参照と条件による抽出

基本的なインデックス参照は、NumPyとかなり近い感覚で使えます。

import torch

x = torch.arange(12).reshape(3, 4)

print(x)
# tensor([[ 0,  1,  2,  3],
#         [ 4,  5,  6,  7],
#         [ 8,  9, 10, 11]])
print(x[1, 2])
# tensor(6)
print(x[:, 1:3])
# tensor([[ 1,  2],
#         [ 5,  6],
#         [ 9, 10]])

通常のスライスはビューを返すことが多く、元のTensorとメモリを共有する場合があります。一方、整数Tensorによる高度なインデックス参照やブールマスクによる抽出では、コピーに近い新しいTensorが返ることが多くなります。メモリ共有が重要な処理では、必要に応じて .clone() で独立したTensorを明示的に作ります。

特定の次元に沿ってインデックスを指定したい場合は、torch.index_select() を使えます。

x = torch.arange(12).reshape(3, 4)
index = torch.tensor([2, 0])

y = torch.index_select(x, dim=0, index=index)

print(y)
# tensor([[ 8,  9, 10, 11],
#         [ 0,  1,  2,  3]])
torch.index_select(input, dim, index, *, out=None)
引数 意味
input 選択元のTensor
dim どの次元に沿って選ぶか
index 選びたい位置を並べた1次元の整数Tensor。通常は torch.long を使います
out 任意の出力先Tensor

返り値は、dim 方向の長さが index の長さに置き換わったTensorです。index の順序や重複はそのまま反映されるため、行の並べ替えや一部サンプルの抽出にも使えます。

条件で抽出したい場合は、ブールマスクを使います。

x = torch.tensor([[1, 4], [3, 2]])
mask = x > 2

print(mask)
# tensor([[False,  True],
#         [ True, False]])
print(x[mask])
# tensor([4, 3])
print(torch.masked_select(x, mask))
# tensor([4, 3])

masked_select() の結果は1次元Tensorになります。マスクは入力Tensorの形状にブロードキャストできる必要があります。

torch.masked_select(input, mask, *, out=None)
引数 意味
input 抽出元のTensor
mask True の位置だけを選ぶブールTensor。input にブロードキャストできる形状が必要です
out 任意の出力先Tensor

返り値は常に1次元Tensorです。選ばれた要素は、入力を展開した順序で並びます。x[mask] は同じ目的でよく使われる書き方です。

index_select()masked_select() は、どちらも実務でよく使う選択操作です。次のコードでは、行番号で選ぶ場合と、条件で選ぶ場合を並べて確認できます。

import torch

torch.manual_seed(1)

t = torch.randint(0, 9, size=(3, 3))
index = torch.tensor([0, 2], dtype=torch.long)

selected_by_index = torch.index_select(t, dim=0, index=index)
mask = t <= 5
selected_by_mask = torch.masked_select(t, mask)

print("t:\n", t)
# t:
#  tensor([[4, 5, 0],
#         [5, 7, 1],
#         [2, 5, 8]])
print("index_select:\n", selected_by_index)
# index_select:
#  tensor([[4, 5, 0],
#         [2, 5, 8]])
print("mask:\n", mask)
# mask:
#  tensor([[ True,  True,  True],
#         [ True, False,  True],
#         [ True,  True, False]])
print("masked_select:\n", selected_by_mask)
# masked_select:
#  tensor([4, 5, 0, 5, 1, 2, 5])

形状を変える

Tensorの形状変換は、モデルにデータを渡す前によく使います。

reshape()view()

reshape() は、要素数を保ったまま形状を変えます。

import torch

x = torch.arange(12)
y = x.reshape(3, 4)

print(y)
# tensor([[ 0,  1,  2,  3],
#         [ 4,  5,  6,  7],
#         [ 8,  9, 10, 11]])

reshape() は、可能であればビューを返し、必要ならコピーを作ります。つまり、必ずメモリを共有するとは限りません。

view() も形状を変えるためのメソッドですが、Tensorのストライドが条件を満たしている必要があります。転置後など、メモリ上で連続していないTensorでは失敗することがあります。

x = torch.arange(12).reshape(3, 4)
y = x.t()

print(y.is_contiguous())
# False
# print(y.view(12))  # エラーになることがある
print(y.reshape(12))
# tensor([ 0,  4,  8,  1,  5,  9,  2,  6, 10,  3,  7, 11])

どうしても view() を使いたい場合は、.contiguous() で連続したTensorを作ってから使います。

z = y.contiguous().view(12)
print(z)
# tensor([ 0,  4,  8,  1,  5,  9,  2,  6, 10,  3,  7, 11])
torch.reshape(input, shape)
操作 引数・返り値の要点
reshape(input, shape) shape に合わせたTensorを返します。要素数は変えられません。可能ならビュー、必要ならコピーになります
view(*shape) ビューとして形状を変えます。メモリ配置が条件を満たさないと失敗します
contiguous() メモリ上で連続したTensorを返します。すでに連続なら同じTensorが返ることがあります

実務では、ビューであることに強く依存しないなら reshape() のほうが扱いやすいです。一方、共有ストレージを前提にした低レベルな処理では、view()is_contiguous() の関係を意識します。

転置と次元の並べ替え

2つの次元を入れ替えるには transpose() を使います。

x = torch.randn(2, 3, 4)
y = torch.transpose(x, 0, 2)

print(x.shape)
# torch.Size([2, 3, 4])
print(y.shape)
# torch.Size([4, 3, 2])

複数の次元をまとめて並べ替えるには permute() を使います。

z = x.permute(2, 0, 1)
print(z.shape)
# torch.Size([4, 2, 3])

2次元Tensorだけを転置したい場合は .t() も使えます。バッチ付き行列の最後の2次元を入れ替えたい場合は .mT が読みやすい場面もあります。

.T はすべての次元を逆順にするプロパティです。2次元Tensorでは転置と同じ結果になりますが、3次元以上のバッチ付きTensorでは「最後の2次元だけを入れ替える」動作にはならないため、バッチ付き行列に対しては .mT と混同しないよう注意してください。

操作 何を入れ替えるか 返り値
torch.transpose(input, dim0, dim1) 2つの次元 多くの場合、元Tensorとストレージを共有するビュー
tensor.t() 0〜2次元向け。2次元では0軸と1軸 ビュー。3次元以上では別の操作を使います
tensor.permute(dims) 複数次元を指定した順序に並べ替え 多くの場合ビュー
tensor.T すべての次元を逆順にする 2次元では通常の転置と同じですが、高次元では注意が必要です
tensor.mT 行列またはバッチ付き行列の最後の2次元 バッチ付き行列の転置に向いています

転置や permute() の結果は非連続になることが多いため、後続で view() が必要な場合は .contiguous() を挟みます。

squeeze()unsqueeze()

長さ1の次元を削除するには squeeze()、長さ1の次元を追加するには unsqueeze() を使います。

x = torch.randn(1, 3, 1, 5)

a = x.squeeze()
b = x.squeeze(0)
c = b.unsqueeze(0)

print(x.shape)
# torch.Size([1, 3, 1, 5])
print(a.shape)
# torch.Size([3, 5])
print(b.shape)
# torch.Size([3, 1, 5])
print(c.shape)
# torch.Size([1, 3, 1, 5])

引数なしの squeeze() は、長さ1の次元をすべて削除します。バッチサイズが1のときにバッチ次元まで消えてしまうことがあるため、実務コードでは squeeze(dim) のように次元を明示するほうが安全です。

torch.squeeze(input, dim=None)
torch.unsqueeze(input, dim)

squeeze() は、dim を省略すると長さ1の次元をすべて削除します。dim を指定した場合、その次元の長さが1なら削除し、1でなければ形状は変わりません。unsqueeze(dim) は指定した位置に長さ1の次元を挿入します。どちらも通常は元Tensorとストレージを共有するビューを返します。

形状変換の操作をまとめて確認してみます。

import torch

torch.manual_seed(1)

# reshape: 要素数を保ったまま形状を変える
t = torch.randperm(8)
t_reshape = torch.reshape(t, (-1, 2, 2))

print("t:", t)
# t: tensor([5, 4, 2, 6, 7, 3, 1, 0])
print("reshape:\n", t_reshape)
# reshape:
#  tensor([[[5, 4],
#          [2, 6]],
#
#         [[7, 3],
#          [1, 0]]])

# reshapeの結果がビューなら、元Tensorの変更が反映されることがある
t[0] = 1024
print("after update t:", t)
# after update t: tensor([1024,    4,    2,    6,    7,    3,    1,    0])
print("after update reshape:\n", t_reshape)
# after update reshape:
#  tensor([[[1024,    4],
#          [   2,    6]],
#
#         [[   7,    3],
#          [   1,    0]]])

# transpose: 2つの次元を入れ替える
x = torch.rand((2, 3, 4))
x_transpose = torch.transpose(x, dim0=1, dim1=2)

print("x shape:", x.shape)
# x shape: torch.Size([2, 3, 4])
print("transpose shape:", x_transpose.shape)
# transpose shape: torch.Size([2, 4, 3])

# squeeze: 長さ1の次元を削除する
y = torch.rand((1, 2, 3, 1))

print("original:", y.shape)
# original: torch.Size([1, 2, 3, 1])
print("squeeze():", torch.squeeze(y).shape)
# squeeze(): torch.Size([2, 3])
print("squeeze(dim=0):", torch.squeeze(y, dim=0).shape)
# squeeze(dim=0): torch.Size([2, 3, 1])
print("squeeze(dim=1):", torch.squeeze(y, dim=1).shape)
# squeeze(dim=1): torch.Size([1, 2, 3, 1])

squeeze(dim=1) のように、指定した次元の長さが1ではない場合、形状は変わりません。意図せず次元を消さないためにも、どの次元を操作しているかを常に確認します。

Tensorの数学演算

PyTorchでは、Tensor同士の演算をPythonの演算子で自然に書けます。

import torch

x = torch.tensor([1.0, 2.0, 3.0])
y = torch.tensor([4.0, 5.0, 6.0])

print(x + y)
# tensor([5., 7., 9.])
print(x - y)
# tensor([-3., -3., -3.])
print(x * y)
# tensor([ 4., 10., 18.])
print(x / y)
# tensor([0.2500, 0.4000, 0.5000])
print(x ** 2)
# tensor([1., 4., 9.])

* は要素ごとの掛け算です。行列積ではありません。

代表的な逐要素演算APIは次のように書けます。

torch.add(input, other, *, alpha=1, out=None)
torch.sub(input, other, *, alpha=1, out=None)
torch.mul(input, other, *, out=None)
torch.div(input, other, *, rounding_mode=None, out=None)
torch.pow(input, exponent, *, out=None)

inputother はTensorでもスカラーでもよく、型変換とブロードキャストの規則に従います。返り値は、ブロードキャスト後の形状を持つ新しいTensorです。mul* は要素ごとの積で、行列積ではない点を何度も確認しておくと、線形層のコードを読むときに混乱しにくくなります。

torch.add()alpha

torch.add() は、次の計算を行います。

input + alpha * other
out = torch.add(x, y, alpha=2)
print(out)
# tensor([ 9., 12., 15.])

単純な足し算なら x + y で十分ですが、alpha を使うと other 側に係数を掛けられます。

alpha を使う場合は、次のようにキーワード引数で渡すと、式の意味が読み取りやすくなります。

import torch

torch.manual_seed(1)

t0 = torch.randn((3, 3))
t1 = torch.ones_like(t0)

t_add = torch.add(t0, t1, alpha=10)

print("t0:\n", t0)
# t0:
#  tensor([[ 0.6614,  0.2669,  0.0617],
#         [ 0.6213, -0.4519, -0.1661],
#         [-1.5228,  0.3817, -1.0276]])
print("t1:\n", t1)
# t1:
#  tensor([[1., 1., 1.],
#         [1., 1., 1.],
#         [1., 1., 1.]])
print("t0 + 10 * t1:\n", t_add)
# t0 + 10 * t1:
#  tensor([[10.6614, 10.2669, 10.0617],
#         [10.6213,  9.5481,  9.8339],
#         [ 8.4772, 10.3817,  8.9724]])

torch.sub() にも同じように alpha を渡せ、input - alpha * other を計算します。また、torch.div() には rounding_mode という引数があり、None(デフォルト、通常の除算)、"trunc"(0方向への切り捨て)、"floor"(負の無限大方向への切り捨て)を指定できます。整数Tensor同士を割り算する場合や、剰余っぽい計算をしたい場合は、このrounding_modeの違いを意識しておくと安心です。

addcmul()addcdiv()

複合的な要素ごとの計算には、addcmul()addcdiv() があります。

base = torch.ones(3)
a = torch.tensor([1.0, 2.0, 3.0])
b = torch.tensor([4.0, 5.0, 6.0])

m = torch.addcmul(base, a, b, value=0.5)
d = torch.addcdiv(base, a, b, value=0.5)

print(m)
# tensor([ 3.,  6., 10.])
print(d)
# tensor([1.1250, 1.2000, 1.2500])

意味は次のとおりです。

addcmul: input + value * tensor1 * tensor2
addcdiv: input + value * tensor1 / tensor2
torch.addcmul(input, tensor1, tensor2, *, value=1, out=None)
torch.addcdiv(input, tensor1, tensor2, *, value=1, out=None)

input は足し込む基準のTensor、tensor1tensor2 は要素ごとの掛け算または割り算に使うTensor、value はその結果に掛ける係数です。返り値はブロードキャスト後の形状を持つTensorです。最適化アルゴリズムの内部や、重み付きの更新式を自分で書くときに出てくることがあります。

よく使う関数

要素ごとの関数も多く用意されています。

分類
対数 torch.logtorch.log2torch.log10
指数 torch.exp
べき乗 torch.pow
絶対値 torch.abs
三角関数 torch.sintorch.cos
逆三角関数 torch.acostorch.asintorch.atantorch.atan2
双曲線関数 torch.sinhtorch.cosh

対数では0以下の入力、割り算では0除算、指数関数ではオーバーフローに注意します。

これらの関数は基本的に、入力Tensorの各要素に同じ関数を独立に適用し、入力と同じ形状のTensorを返します。atan2(input, other) のように第2引数が必要な関数もあります。対数、指数、三角関数などは損失関数や確率計算の中でも使われるため、定義域と数値安定性を意識しておくとデバッグしやすくなります。

ブロードキャスト

形状が完全には一致していないTensor同士でも、ルールを満たせば自動的に形状がそろえられます。これをブロードキャストと呼びます。

x = torch.randn(4, 3)
bias = torch.randn(3)

y = x + bias

print(y.shape)
# torch.Size([4, 3])

ブロードキャストでは、末尾の次元から順に比較します。各次元について、次のいずれかを満たせば互換性があります。

  1. 次元の長さが同じ
  2. どちらかの長さが1
  3. 片方にその次元がない

実際には全要素をコピーしているわけではないため、効率よく書けることが多いです。ただし、インプレース操作ではブロードキャストに関する制約が厳しくなるため注意します。

行列積

行列積には @ または torch.matmul() を使います。

A = torch.randn(2, 3)
B = torch.randn(3, 4)

C = A @ B
D = torch.matmul(A, B)

print(C.shape)
# torch.Size([2, 4])
print(D.shape)
# torch.Size([2, 4])

A * B は要素ごとの掛け算、A @ B は行列積です。この違いは、線形層や回帰モデルを読むときにとても重要です。torch.matmul()@ は行列やバッチ付き行列の積に対応します。一方、torch.mm(A, B) は2次元行列同士専用で、バッチ次元やブロードキャストには対応しません。nn.Linear の内部でも、本質的には x @ weight.T + bias という行列積が行われています。

線形回帰をTensorで実装する

ここまでのTensor操作を使って、簡単な線形回帰を実装してみます。

線形回帰は、入力と出力の関係を直線で表すモデルです。1変数の場合は次のように書けます。

y_hat = w * x + b

複数の特徴量を持つ場合は、行列で次のように書けます。

y_hat = Xw + b

ここで、w は重み、b はバイアス、y_hat は予測値です。

損失関数には、平均二乗誤差を使います。

MSE = mean((y_hat - y)^2)

訓練ループの流れは、次のように整理できます。

  1. 入力から予測値を計算する
  2. 予測値と正解から損失を計算する
  3. backward() で各パラメータの勾配を計算する
  4. torch.no_grad() の中でパラメータを更新する
  5. 前回の勾配をリセットする
  6. これを繰り返す

流れだけを図にすると、次のようになります。

入力 x
    -> y_hat = xw + b
    -> MSE(y_hat, y)
    -> backward()
    -> w.grad と b.grad を得る
    -> no_grad の中で w と b を更新する
    -> 勾配をリセットする
    -> 次の反復へ

パラメータ更新は、概念的には w = w - learning_rate * w.gradb = b - learning_rate * b.grad です。重みは重み自身の勾配、バイアスはバイアス自身の勾配で更新します。

手動で学習ループを書く

まずは、nn.Module や最適化クラスを使わず、Tensorとautogradだけで学習ループを書きます。

import torch

torch.manual_seed(42)

x = torch.linspace(-1, 1, steps=100).unsqueeze(1)
y = 2 * x + 3 + 0.1 * torch.randn_like(x)

w = torch.randn(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
learning_rate = 0.1

for epoch in range(200):
    y_hat = x * w + b
    loss = ((y_hat - y) ** 2).mean()

    loss.backward()

    with torch.no_grad():
        w -= learning_rate * w.grad
        b -= learning_rate * b.grad

    w.grad = None
    b.grad = None

print("w:", w.item())
# w: 1.997050404548645
print("b:", b.item())
# b: 3.0059762001037598
print("loss:", loss.item())
# loss: 0.00962903629988432

ポイントは3つあります。

  1. wbrequires_grad=True を指定する
  2. loss.backward()w.gradb.grad を計算する
  3. パラメータ更新は torch.no_grad() の中で行う

パラメータ更新そのものは、モデルの前向き計算ではありません。そのため、更新処理まで計算グラフに入らないように torch.no_grad() を使います。

また、PyTorchでは勾配が既存の .grad に加算されます。毎回リセットしないと、前のステップの勾配が残ります。ここでは w.grad = Noneb.grad = None としてリセットしています。

学習の進み方は、matplotlib で可視化すると理解しやすくなります。

import torch
import matplotlib.pyplot as plt

torch.manual_seed(10)

learning_rate = 0.05

x = torch.rand(20, 1) * 10
y = 2 * x + (5 + torch.randn(20, 1))

w = torch.randn(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)

for iteration in range(1000):
    y_pred = w * x + b
    loss = (0.5 * (y - y_pred) ** 2).mean()

    loss.backward()

    with torch.no_grad():
        w -= learning_rate * w.grad
        b -= learning_rate * b.grad

    w.grad = None
    b.grad = None

    if iteration % 20 == 0:
        plt.cla()
        plt.scatter(x.detach().numpy(), y.detach().numpy())
        plt.plot(x.detach().numpy(), y_pred.detach().numpy(), "r-", lw=5)
        plt.text(2, 20, f"Loss={loss.item():.4f}", fontdict={"size": 20, "color": "red"})
        plt.xlim(1.5, 10)
        plt.ylim(8, 28)
        plt.title(f"Iteration: {iteration}\nw: {w.item():.3f} b: {b.item():.3f}")
        plt.pause(0.1)

        if loss.item() < 1:
            break

plt.show()

値をNumPyに渡したいときは、detach() で計算グラフから切り離してから .numpy() を呼びます。パラメータ更新は、更新処理が計算グラフに記録されないように torch.no_grad() の中で行います。

nn.Linear と最適化クラスを使う

実際のプロジェクトでは、モデル、損失関数、パラメータ更新をPyTorchの標準機能に任せることが多いです。

import torch
from torch import nn

torch.manual_seed(42)

x = torch.linspace(-1, 1, steps=100).unsqueeze(1)
y = 2 * x + 3 + 0.1 * torch.randn_like(x)

model = nn.Linear(in_features=1, out_features=1)
criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

for epoch in range(200):
    optimizer.zero_grad(set_to_none=True)

    y_hat = model(x)
    loss = criterion(y_hat, y)

    loss.backward()
    optimizer.step()

print(model.weight.detach())
# tensor([[1.9971]])
print(model.bias.detach())
# tensor([3.0060])

この書き方では、次の役割分担になります。

部品 役割
nn.Linear 線形モデルを表す
nn.MSELoss 平均二乗誤差を計算する
torch.optim.SGD 勾配を使ってパラメータを更新する
optimizer.zero_grad() 前回の勾配をリセットする
loss.backward() 勾配を計算する
optimizer.step() パラメータを更新する

最初は手動実装で仕組みを理解し、そのあと nnoptim を使う流れに移ると、PyTorchの訓練ループが自然に読めるようになります。

nn.Linear は全結合層、つまり入力に対して次のアフィン変換を行う層です。

y = x @ weight.T + bias
nn.Linear(in_features, out_features, bias=True, device=None, dtype=None)
引数 意味
in_features 入力の特徴量数
out_features 出力の特徴量数
bias 学習可能なバイアス項を持つかどうか
devicedtype パラメータTensorを作るデバイスとデータ型

返り値は nn.Module の一種です。model(x) と呼び出すと、最後の次元が out_features になったTensorを返します。内部では weight(形状は (out_features, in_features))と、bias=True の場合は bias(形状は (out_features,))が自動的に作られます。どちらも学習対象のパラメータなので、自分で requires_grad=True を付ける必要はありません。手書きで wb を管理するより、形状管理やパラメータ登録のミスを減らせます。

optimizer.zero_grad(set_to_none=True) は、勾配を0で埋めるのではなく .gradNone に戻します。多くの場合はメモリ効率と速度の面で有利です。ただし、次の backward() の前に手作業で .grad を読むと、0ではなく None が入っている点に注意します。

nn.MSELossSGD の引数を確認する

nn.MSELoss の完全なシグネチャは次のとおりです。

nn.MSELoss(size_average=None, reduce=None, reduction="mean")

通常は reduction"none"(要素ごとの値をそのまま返す)、"mean"(デフォルト、平均を取る)、"sum"(合計する)のいずれかを指定します。size_averagereduce は互換性のために残る引数なので、基本的には使いません。

nn.MSELoss() の返り値は、呼び出し可能な損失関数オブジェクトです。criterion(y_hat, y) のように呼び出すと、reduction に応じて、要素ごとの二乗誤差、平均値、または合計値を返します。通常の訓練では、平均損失のスカラーTensorを最小化します。

torch.optim.SGD の完全なシグネチャは次のとおりです。

torch.optim.SGD(params, lr=0.001, momentum=0, dampening=0,
                 weight_decay=0, nesterov=False, *, maximize=False,
                 foreach=None, differentiable=False, fused=None)
引数 意味
params 最適化対象のパラメータ(イテラブル、またはパラメータグループを表す辞書)
lr 学習率。明示的に指定するのがおすすめです
momentum モーメンタム係数(デフォルトは0)
dampening モーメンタムに対する減衰係数
weight_decay 重み減衰(L2正則化)の係数
nesterov Nesterovモーメンタムを使うかどうか(momentum > 0 かつ dampening = 0 が必要)
maximize 最小化ではなく最大化の方向にパラメータを更新するか
foreachfused 内部実装をバッチ化・融合するかどうかを制御する、性能に関わるオプション
differentiable 最適化処理自体を微分可能にするか(通常の学習では不要です)

torch.optim.SGD(...) の返り値は最適化器オブジェクトです。step() は現在の .grad を使って1回パラメータを更新し、zero_grad() は前回の勾配を消します。momentum を0より大きくすると、更新に慣性のような効果が加わり、単純な勾配下降より収束が安定したり速くなったりすることがあります。

実務で意識したいポイント

  • 特徴量を標準化しておくと、最適化問題の条件数が改善し、収束が速くなることがあります。
  • 訓練データ・検証データ・テストデータは必ず分離し、テストデータを見ながら繰り返しチューニングしないようにします。
  • MSEは外れ値の影響を受けやすい損失です。明らかな外れ値があるデータでは、L1、SmoothL1、Huberなど、より頑健な損失関数も検討します。
  • MAE、RMSE、決定係数($R^2$)を報告するときは、データのスケールも合わせて示すと安心です。スケールを示さずに異なるタスク間の誤差の値だけを比較すると、誤った結論につながることがあります。

計算グラフとautograd

PyTorchの自動微分は、計算グラフをもとに勾配を求めます。

計算グラフとは、Tensorと演算の依存関係を表すグラフです。Tensorが値を持ち、演算がTensor同士をつなぎます。通常の前向き計算では、有向非巡回グラフとして考えられます。

autograd の視点では、図の中には大きく3種類の要素があります。学習対象になりやすい葉Tensor、演算で作られる中間Tensor、そして加算や乗算などに対応するbackward用の関数ノードです。逆伝播では、前向き計算をもう一度実行するのではなく、この依存関係を逆向きにたどりながら上流の勾配を伝えます。同じパラメータが複数の経路で使われている場合、それぞれの経路から来た勾配は合計されます。

次の式を見てみます。

a = x + w
b = w + 1
y = a * b

これは次の式と同じです。

y = (x + w)(w + 1)

w で微分すると、連鎖律により次のようになります。

dy/dw = (dy/da)(da/dw) + (dy/db)(db/dw)
      = b * 1 + a * 1
      = (w + 1) + (x + w)
      = 2w + x + 1

この依存関係を簡単な図にすると、次のようになります。

x ----+
    +--> a = x + w ----+
w ----+                  |
                  * --> y = a * b
w ----+                  |
    +--> b = w + 1 ----+

前向き計算では a = x + wb = w + 1y = a * b の順に値を作ります。逆向きには、dy/da = bdy/db = ada/dw = 1db/dw = 1 が使われ、最終的に dy/dw = b + a = 2w + x + 1 になります。

x = 2w = 1 なら、勾配は 5 です。

PyTorchで確認します。

import torch

x = torch.tensor(2.0)
w = torch.tensor(1.0, requires_grad=True)

a = x + w
b = w + 1
y = a * b

y.backward()

print(y.item())
# 6.0
print(w.grad)
# tensor(5.)

w.gradtensor(5.) が入っていれば、PyTorchが計算グラフをたどって勾配を求めたことがわかります。

葉Tensor、勾配、grad_fn をまとめて確認するコードも見ておきます。

import torch

w = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([2.0], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)
y = torch.mul(a, b)

y.backward()

print("w.grad:", w.grad)
# w.grad: tensor([5.])
print("x.grad:", x.grad)
# x.grad: tensor([2.])
print("is_leaf:", w.is_leaf, x.is_leaf, a.is_leaf, b.is_leaf, y.is_leaf)
# is_leaf: True True False False False
print("grad_fn:", w.grad_fn, x.grad_fn, a.grad_fn, b.grad_fn, y.grad_fn)
# grad_fn: None None <AddBackward0 object at 0x...> <AddBackward0 object at 0x...> <MulBackward0 object at 0x...>

wx は葉Tensorなので、逆伝播後に .grad が入ります。aby は演算によって作られたTensorなので、通常は葉Tensorではありません。

葉Tensorとは何か

ユーザーが直接作成し、ほかの微分対象の演算結果ではないTensorは、葉Tensorとして扱われます。学習対象のパラメータは通常、葉Tensorです。

print(w.is_leaf)
# True
print(a.is_leaf)
# False

w はユーザーが直接作ったTensorなので葉Tensorです。一方、ax + w の結果なので葉Tensorではありません。

葉Tensorかどうかは、requires_grad の設定と、そのTensorがどのように作られたかの両方に依存します。迷ったときは、推測ではなく .is_leaf の値を確認します。

逆伝播後、通常 .grad が保存されるのは、requires_grad=True の葉Tensorです。中間Tensorの勾配を確認したい場合は、retain_grad() を呼びます。

a.retain_grad()

通常の訓練では、モデルのパラメータの勾配がわかれば十分なことが多いため、中間Tensorの勾配を常に保存する必要はありません。

grad_fn とは何か

自動微分の対象になる演算によって作られたTensorには、grad_fn が付くことがあります。

print(a.grad_fn)
# <AddBackward0 object at 0x...>
print(y.grad_fn)
# <MulBackward0 object at 0x...>

これは、そのTensorがどの演算によって作られたかを表す手がかりです。ただし、表示されるクラス名は実装の詳細なので、プログラムの分岐条件として使うものではありません。

非スカラーのTensorで backward() する

loss.backward() のように、出力がスカラーであればそのまま逆伝播できます。

一方、出力がベクトルや行列の場合は、上流から流れてくる勾配を指定する必要があります。これは、ベクトルとヤコビアンの積を計算していると考えると理解しやすいです。

x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x ** 2

y.backward(torch.ones_like(y))

print(x.grad)
# tensor([2., 4.])

この例では、y の各要素に対して上流勾配1を渡しています。

backward() の引数を確認する

loss.backward() は、Tensor.backward() というメソッドの呼び出しです。完全なシグネチャは次のとおりです。

Tensor.backward(gradient=None, retain_graph=None, create_graph=False, inputs=None)
引数 意味
gradient Tensorが非スカラーのときに必要な上流勾配。スカラーであれば省略できます
retain_graph 逆伝播に使った計算グラフを保持するかどうか
create_graph 高階微分のために、求めた勾配自体をさらに微分可能にするかどうか
inputs 勾配を累積したい入力を絞りたい場合に指定します

返り値はありません。計算された勾配は、関連する葉Tensorの .grad に副作用として書き込まれます。

Tensor.backward() は、内部的には torch.autograd.backward() の便利な呼び出し方です。そちらの完全なシグネチャは次のようになっています。

torch.autograd.backward(
    tensors,
    grad_tensors=None,
    retain_graph=None,
    create_graph=False,
    inputs=None,
)
引数 意味
tensors 逆伝播の出発点にする出力Tensor、またはその並び
grad_tensors 非スカラー出力に対応する上流勾配。スカラー出力なら None にできます
retain_graph 同じ前向き計算グラフを後でもう一度使うかどうか
create_graph 勾配計算そのものをさらに微分可能にするかどうか
inputs どの入力へ勾配を累積するかを絞りたい場合に指定します

こちらも返り値はなく、勾配を .grad に累積します。retain_graph=True は同じ前向きグラフにもう一度逆伝播したい場合、create_graph=True は一階微分をさらに微分したい場合に使います。どちらもメモリ使用量を増やすため、エラーを消すために機械的に付けるものではありません。create_graph=True.backward() と組み合わせると、パラメータと勾配の間に参照が残ってメモリ使用量が増えることがあるため、高階微分では次に説明する torch.autograd.grad() を使うほうが一般的です。

勾配は累積される

PyTorchでは、backward() を呼ぶたびに勾配が .grad に加算されます。

x = torch.tensor(2.0, requires_grad=True)

(x ** 2).backward()
print(x.grad)
# tensor(4.)

(x ** 2).backward()
print(x.grad)
# tensor(8.)

2回目の結果は、1回目の勾配にさらに加算された値になります。訓練ループで毎回 optimizer.zero_grad(set_to_none=True) を呼ぶのは、この累積をリセットするためです。

計算グラフは通常1回の逆伝播で解放される

PyTorchでは、前向き計算で作られた計算グラフは、逆伝播後に通常解放されます。同じグラフに対してもう一度 backward() しようとすると、エラーになることがあります。

多くの場合、正しい対応は retain_graph=True を付けることではなく、もう一度前向き計算を実行して新しいグラフを作ることです。高階微分や複数回の逆伝播が本当に必要なときだけ、retain_graph=Truecreate_graph=True を検討します。

ここで解放されるのは、その前向き計算で逆伝播のために保存された中間状態です。モデル、パラメータTensor、Pythonのコードそのものが消えるわけではありません。通常の訓練では、次の反復で前向き計算をもう一度実行し、その回の計算グラフを新しく作ります。

勾配計算を止める

推論やパラメータ更新では、計算グラフを作る必要がありません。そのような場面では torch.no_grad() を使います。

with torch.no_grad():
    output = model(x)

純粋な推論では、torch.inference_mode() も使えます。no_grad() より制約は強いですが、余分な追跡を減らせる場合があります。

with torch.inference_mode():
    output = model(x)

inference_mode() は、バージョンカウンタの管理やautogradの追跡処理そのものを一部省略するため、no_grad() より高速になる場合があります。ただし、このコンテキスト内で作られたTensor(inference tensor)は、コンテキストを抜けたあとにautogradが必要な処理へそのまま渡すと問題が起きることがあるため、基本的には推論専用の処理経路の中で使うようにしてください。

既存のTensorを計算グラフから切り離したい場合は、detach() を使います。

detached = output.detach()

計算グラフから切り離す必要があるときは、detach()torch.no_grad() を使います。Tensorの値だけを無理に書き換えると、勾配計算と実際の値の関係がずれやすくなります。特に .data でautogradを迂回する書き方は、エラーにならずに誤った勾配を生むことがあるため、現在のコードでは避けます。

autogradを小さなコードで確認する

autograd の挙動は、短い実験コードで確認すると理解しやすくなります。ここでは、特に重要なものを順番に見ていきます。

まず、同じ計算グラフに対して複数回 backward() したい場合です。

import torch

w = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([2.0], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)
y = torch.mul(a, b)

y.backward(retain_graph=True)
print(w.grad)
# tensor([5.])

y.backward()
print(w.grad)
# tensor([10.])

この例では、1回目の backward()retain_graph=True を指定しているため、2回目の逆伝播も実行できます。ただし、勾配は累積されます。通常の訓練では、同じグラフに2回逆伝播するより、前向き計算をもう一度行うほうが自然です。

次に、出力が複数ある場合の逆伝播です。

import torch

w = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([2.0], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)

y0 = torch.mul(a, b)
y1 = torch.add(a, b)

loss = torch.cat([y0, y1], dim=0)
grad_outputs = torch.tensor([1.0, 2.0])

loss.backward(gradient=grad_outputs)

print(w.grad)
# tensor([9.])

gradient は、非スカラー出力に対する上流勾配です。この例では、y0 側の勾配を1倍、y1 側の勾配を2倍して合計しています。

高階微分を取りたい場合は、torch.autograd.grad()create_graph=True を使います。

import torch

x = torch.tensor([3.0], requires_grad=True)
y = torch.pow(x, 2)

grad1 = torch.autograd.grad(y, x, create_graph=True)[0]
grad2 = torch.autograd.grad(grad1, x)[0]

print(grad1)
# tensor([6.], grad_fn=<MulBackward0>)
print(grad2)
# tensor([2.])

y = x^2 なので、1階微分は 2xx = 3 では 6 です。さらにもう一度微分すると 2 になります。

torch.autograd.grad() の主な引数は次のとおりです。

torch.autograd.grad(
    outputs,
    inputs,
    grad_outputs=None,
    retain_graph=None,
    create_graph=False,
    only_inputs=True,
    allow_unused=None,
    is_grads_batched=False,
    materialize_grads=False,
)
引数 意味
outputs 微分したい出力
inputs それに対して微分を求めたい入力
grad_outputs ベクトル・ヤコビアン積のベクトル部分。スカラー出力なら省略できます
retain_graph あとで再度逆伝播できるように計算グラフを保持するか
create_graph 返された勾配自体をさらに微分可能にするか(高階微分用)
only_inputs 歴史的な互換用の引数で、現在はinputsに対する勾配だけを返す動作が基本です
allow_unused 出力の計算に使われていない入力を許容するか
is_grads_batched grad_outputsの先頭次元をバッチとして扱い、複数のベクトル・ヤコビアン積をまとめて計算する上級者向けオプション
materialize_grads 使われなかった入力の勾配をNoneではなくゼロTensorとして返すか

loss.backward() は勾配を葉Tensorの.gradに累積しますが、torch.autograd.grad() は勾配を戻り値としてそのまま返し、.gradには書き込みません。返り値は inputs に対応する勾配Tensorのタプルです。特定の入力だけの勾配がほしい場合や、高階微分・関数型の勾配計算を行いたい場合に向いています。

なお、Jacobian、Hessian、JVP、VJPのような関数型の微分をまとめて求めたい場合は、torch.autograd.grad() を手作業で何度も呼ぶ以外に、torch.autograd.functional や、より新しい torch.func を使う方法もあります。バッチ化のしやすさや関数の純粋性、性能要件に応じて選びます。

勾配が累積されることも、短いコードで確認できます。

import torch

w = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([2.0], requires_grad=True)

for step in range(4):
    y = (w + x) * (w + 1)
    y.backward()

    print(f"step={step}, grad={w.grad}")
    # step=0, grad=tensor([5.])
    # step=1, grad=tensor([5.])
    # step=2, grad=tensor([5.])
    # step=3, grad=tensor([5.])

    w.grad = None

w.grad = None を消して実行すると、勾配が加算され続けることがわかります。

requires_grad は、微分対象のTensorを含む演算へ伝播します。

import torch

w = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([2.0], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)
y = torch.mul(a, b)

print(a.requires_grad, b.requires_grad, y.requires_grad)
# True True True

最後に、インプレース操作の注意です。勾配を必要とする葉Tensorをそのままインプレース変更すると、エラーになることがあります。

import torch

w = torch.tensor([1.0], requires_grad=True)

try:
    w.add_(1)
except RuntimeError as error:
    print(error)
    # 葉Tensorへのインプレース操作に関するRuntimeErrorが出ます。

パラメータを更新したい場合は、次のように torch.no_grad() の中で行います。

with torch.no_grad():
    w.add_(1)

動的計算グラフとPyTorchの実行方式

PyTorchの標準的な実行方式では、Pythonコードを実行しながら計算グラフが作られます。このような仕組みは、動的計算グラフまたはeager実行と呼ばれます。

たとえば、Pythonの条件分岐をそのまま使えます。

import torch

x = torch.randn(3, requires_grad=True)

if x.sum() > 0:
    y = x.square().sum()
else:
    y = x.abs().sum()

y.backward()

print(x.grad)
# 出力例(乱数のシードを固定していないため、実行するたびに値や分岐先が変わります)
# tensor([ 0.2538, -0.8726,  3.8009])

実際に通った処理だけが、その回の計算グラフになります。この柔軟さが、PyTorchの書きやすさにつながっています。

Eager実行と、計算グラフを事前にキャプチャ・定義してから実行する方式は、次のようないくつかの観点で比較できます。

観点 Eager動的実行 キャプチャ・事前定義されたグラフ
グラフ構築のタイミング 演算を実行した瞬間 実行前、または実行中にキャプチャ
Pythonの制御構文 そのまま自然に使える グラフキャプチャの制約を受けることがある
デバッグ 直接的 グラフの境界を理解する必要がある
全体最適化 比較的少ない 通常より多く行える
PyTorchでの使い分け 通常の開発と学習で扱いやすい torch.compiletorch.export が担う

PyTorchでは、通常の開発ではeager実行で動きを直接確認し、必要に応じて torch.compiletorch.export で計算をキャプチャし、最適化やエクスポートにつなげます。大切なのは、1つの言葉で固定的に分類することではなく、実行、勾配計算、キャプチャ、最適化、エクスポートを分けて考えることです。

torch.compile() は、対応するモデルや環境で計算の一部をキャプチャし、最適化された実行につなげるための入口です。

compiled_model = torch.compile(model)

ただし、torch.compile() はどんなコードでも必ず速くする魔法ではありません。モデル、入力形状、ハードウェア、バックエンド、初回コンパイルのコストによって効果は変わります。まずは通常のeager実行を理解し、必要になったらコンパイルやエクスポートを調べる、という順番で十分です。

eager、compile、exportの関係は、次のように整理できます。

Eager:
Pythonコードをすぐ実行 -> 必要な計算だけをその場で記録 -> デバッグしやすい

compile:
対応できる領域をキャプチャ -> 最適化された前向き計算と逆伝播を生成 -> graph breakが起こることもある

export:
制約の強いグラフ表現を作る -> デプロイや下流の変換に渡しやすい

ここでいう計算グラフも、すべて同じものではありません。autograd の計算グラフは、その回の前向き計算から逆伝播するための一時的な依存関係です。torch.compile が扱うのは、最適化のためにキャプチャされた実行領域です。torch.export が作るのは、より制約の強い、下流ツールへ渡すためのプログラム表現です。この3つを分けておくと、PyTorchの実行方式をかなり整理して読めます。

torch.compile はキャプチャした領域に対して最適化済みの前向き計算と逆伝播を生成しますが、そのautogradやautocastまわりの挙動はeagerと完全に同じとは限りません。混合精度学習(AMP)を使う場合も、基本的には前向き計算と損失計算だけをautocastの中に入れ、backwardはautocastの外で呼ぶという原則は変わりません。

torch.export は、torch.compile よりもさらに制約の厳しい、デプロイや下流の変換を目的としたグラフ表現を生成します。torch.compile と組み合わせて使うことも、単独で使うこともできます。

ロジスティック回帰の基礎

ロジスティック回帰は、線形モデルを使って二値分類を行う手法です。名前には「回帰」とありますが、ここで扱う出力は連続値そのものではなく、「正例である確率」です。

まず、入力特徴量から線形スコアを計算します。この値を logit と呼びます。

z = Xw + b

logit は範囲に制限のない実数です。これを Sigmoid 関数に通すと、0から1の範囲の確率になります。

p = sigmoid(z) = 1 / (1 + exp(-z))

記号の意味は次のとおりです。

記号 意味
X 入力特徴量
w 重み
b バイアス
z logit。範囲に制限のない線形出力
p 正例である推定確率。範囲は (0, 1)

たとえば閾値を0.5にするなら、次のようにクラスへ変換できます。

p >= 0.5 -> 1 と予測
p < 0.5  -> 0 と予測

ただし、閾値は常に0.5でよいわけではありません。クラス不均衡がある場合や、偽陽性と偽陰性のコストが違う場合は、精度、適合率、再現率などの指標を見ながら検証データで決めます。

logit、確率、デフォルト閾値でのクラスの関係は、おおまかに次のように整理できます。

logit z 確率 p 閾値0.5での扱い
大きな負の値 0に近い 0
0 0.5 1とするか、規則で決めます
大きな正の値 1に近い 1

対数オッズとしての見方

正例である確率を p とすると、オッズは次のように書けます。

odds = p / (1 - p)

ロジスティック回帰では、このオッズの対数が特徴量の線形結合で表せると考えます。

log(p / (1 - p)) = Xw + b

これを p について解くと、先ほどのSigmoidの形になります。

p = 1 / (1 + exp(-(Xw + b)))

以降では、次の記号を使います。

記号 意味
y 正解ラベル
z logit
p 予測確率

線形回帰との違い

線形回帰は連続値を予測し、損失関数にはMSEをよく使います。一方、ロジスティック回帰はクラスの確率を予測し、Sigmoidと二値交差エントロピーを使います。決定境界は元の特徴量空間では線形ですが、確率そのものは入力の線形関数ではありません。

二値交差エントロピー損失

二値分類では、二値交差エントロピー(BCE: Binary Cross Entropy)がよく使われます。1サンプルについては次の形です。

BCE = -[y * log(p) + (1 - y) * log(1 - p)]

ここで y は通常0または1、p は正例である予測確率です。正解が1なら p が1に近いほど損失は小さくなり、正解が0なら p が0に近いほど損失は小さくなります。

BCEWithLogitsLoss を優先して使う

PyTorchでは、二値分類の訓練では基本的に nn.BCEWithLogitsLoss を使います。これは Sigmoid と二値交差エントロピーを1つにまとめた損失関数です。モデルは確率ではなくlogitsを直接返す形にします。

criterion = torch.nn.BCEWithLogitsLoss()
loss = criterion(logits, targets)

完全なシグネチャは次のとおりです。

nn.BCEWithLogitsLoss(
    weight=None,
    size_average=None,
    reduce=None,
    reduction="mean",
    pos_weight=None,
)
引数 意味
入力 logits Sigmoidを通す前のモデル出力。target と同じ形状にします
target 正解ラベル。値は [0, 1] の範囲で、通常は浮動小数点Tensorです
weight バッチ要素や損失要素に対する重み付け
reduction "none""mean""sum" のいずれか。既定は "mean" です
pos_weight 正例側の重み。クラス不均衡がある場合に使います
size_averagereduce 互換性のために残る引数です。通常は reduction を使います

返り値は呼び出し可能な損失関数オブジェクトです。criterion(logits, targets) と呼ぶと、reduction に応じて要素ごとの損失、平均損失、合計損失を返します。内部ではlog-sum-expの工夫を使って数値的に安定に計算するため、自分で Sigmoid + BCELoss を書くより安全です。

nn.BCELoss を使う場合、入力はすでに確率である必要があります。

probabilities = torch.sigmoid(logits)
loss = torch.nn.BCELoss()(probabilities, targets)
nn.BCELoss(weight=None, size_average=None, reduce=None, reduction="mean")

nn.BCELoss も二値交差エントロピーを計算しますが、入力はlogitsではなく確率です。公式実装では対数計算が無限大にならないよう制限が入っていますが、ロジスティック回帰や二値分類の訓練では、通常は BCEWithLogitsLoss を選ぶほうが安定です。

クラス不均衡と pos_weight

正例が少ないデータでは、pos_weight で正例側の損失を強めることがあります。二値分類では、よく次のような目安を使います。

pos_weight = negative_count / positive_count
pos_weight = torch.tensor([3.0])
criterion = torch.nn.BCEWithLogitsLoss(pos_weight=pos_weight)

pos_weight は適合率と再現率のバランスに影響します。ただし、予測閾値を直接変えるものではありません。最終的な閾値は、検証データ上で指標を見ながら決めます。

PyTorchでロジスティック回帰を実装する

ここからは、PyTorchでロジスティック回帰を一通り実装します。まず、2次元の特徴量を持つ簡単な二値分類データを作り、nn.Linear(2, 1) でlogitを出力します。

import torch
from torch import nn

# 1. 二値分類用の簡単なデータを作る
torch.manual_seed(42)
num_samples = 200
x0 = torch.randn(num_samples // 2, 2) - 1.5
x1 = torch.randn(num_samples // 2, 2) + 1.5

X = torch.cat([x0, x1], dim=0)
y = torch.cat([
    torch.zeros(num_samples // 2),
    torch.ones(num_samples // 2),
]).unsqueeze(1)

# 2. モデルを定義する。出力は確率ではなくlogits
model = nn.Linear(2, 1)

# 3. 損失関数と最適化器
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

# 4. 訓練
for epoch in range(200):
    optimizer.zero_grad(set_to_none=True)

    logits = model(X)
    loss = criterion(logits, y)

    loss.backward()
    optimizer.step()

# 5. 推論と評価
model.eval()
with torch.inference_mode():
    logits = model(X)
    probabilities = torch.sigmoid(logits)
    predictions = (probabilities >= 0.5).float()
    accuracy = (predictions == y).float().mean()

print("loss:", loss.item())
print("accuracy:", accuracy.item())

上の二値分類の書き方では、次の形状をそろえることが重要です。

対象 形状・型
logits (N, 1)
targets (N, 1)
targets.dtype 浮動小数点型

正解ラベルが (N,) で、モデル出力が (N, 1) の場合は、y = y.float().view_as(logits) のように明示的に形状を合わせます。暗黙のブロードキャストに任せると、意図しない形で損失が計算されたり、インターフェース上のエラーになったりします。

次に、同じ流れを少し長めのデータ生成例でも確認します。

import torch
from torch import nn

torch.manual_seed(10)

sample_count = 100
mean_value = 1.7
bias = 1.0

base = torch.ones(sample_count, 2)
x0 = torch.normal(mean_value * base, 1.0) + bias
y0 = torch.zeros(sample_count, 1)
x1 = torch.normal(-mean_value * base, 1.0) + bias
y1 = torch.ones(sample_count, 1)

train_x = torch.cat([x0, x1], dim=0)
train_y = torch.cat([y0, y1], dim=0)


class LogisticRegression(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear = nn.Linear(2, 1)

    def forward(self, x):
        return self.linear(x)


model = LogisticRegression()
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

for iteration in range(1000):
    optimizer.zero_grad(set_to_none=True)

    logits = model(train_x)
    loss = criterion(logits, train_y)

    loss.backward()
    optimizer.step()

    if iteration % 20 == 0:
        with torch.inference_mode():
            probabilities = torch.sigmoid(logits)
            predictions = (probabilities >= 0.5).float()
            accuracy = (predictions == train_y).float().mean()

        print(f"iteration={iteration}, loss={loss.item():.4f}, accuracy={accuracy.item():.2%}")
        # iteration=0, loss=0.3947, accuracy=92.00%
        # iteration=20, loss=0.2071, accuracy=94.00%
        # iteration=40, loss=0.1486, accuracy=94.00%
        # iteration=60, loss=0.1247, accuracy=95.00%
        # iteration=80, loss=0.1098, accuracy=97.00%
        # iteration=100, loss=0.0990, accuracy=97.50%
        # iteration=120, loss=0.0907, accuracy=98.00%
        # iteration=140, loss=0.0840, accuracy=98.00%
        # iteration=160, loss=0.0784, accuracy=98.50%
        # iteration=180, loss=0.0737, accuracy=98.50%
        # iteration=200, loss=0.0697, accuracy=98.50%
        # iteration=220, loss=0.0663, accuracy=98.50%
        # iteration=240, loss=0.0633, accuracy=98.50%
        # iteration=260, loss=0.0606, accuracy=99.00%

        if accuracy.item() > 0.99:
            break

線形回帰と比べると、モデルの出力、損失関数、評価方法が変わっています。一方で、zero_grad()、前向き計算、損失計算、backward()step() という訓練ループの骨格は同じです。

標準的なモデル訓練フロー

実務に近い流れでは、ロジスティック回帰に限らず、モデル訓練はおおむね次の順番で進めます。

  1. 訓練データ、検証データ、テストデータを用意して分割する
  2. 必要に応じて特徴量をスケーリングまたは標準化する
  3. モデルを定義する
  4. 損失関数を選ぶ
  5. 最適化器を作る
  6. 訓練モードで前向き計算を行う
  7. 損失を計算する
  8. 前回の勾配を消す
  9. 逆伝播する
  10. パラメータを更新する
  11. 検証データで評価し、分類閾値を選ぶ
  12. 最後にテストデータで汎化性能を報告する

訓練ループは、典型的には次の形になります。

model.train()
for X_batch, y_batch in train_loader:
    optimizer.zero_grad(set_to_none=True)
    logits = model(X_batch)
    loss = criterion(logits, y_batch)
    loss.backward()
    optimizer.step()

model.train()model.eval() は、モデルの訓練モードと評価モードを切り替えます。DropoutやBatchNormのように、訓練時と推論時で動作が変わる層に影響します。今回の単純な nn.Linear だけなら結果は変わりませんが、実務コードでは明示しておく習慣が大切です。

検証やテストでは、通常 model.eval()torch.inference_mode() を組み合わせます。

model.eval()
with torch.inference_mode():
    logits = model(X_valid)
    probabilities = torch.sigmoid(logits)

model.eval() は層の動作モードを変えるだけで、勾配計算を自動的に止めるわけではありません。勾配が不要な評価では、torch.no_grad()torch.inference_mode() も併用します。

ロジスティック回帰を評価する

正例と負例の数がある程度そろっているなら、accuracyは直感的で使いやすい指標です。ただし、クラス不均衡があるとaccuracyだけでは性能を見誤ることがあります。たとえば、正例が1%しかないデータでは、すべてを負例と予測してもaccuracyは99%になります。

二値分類では、次の指標を組み合わせて見ます。

指標 見ること
混同行列 TP、FP、TN、FNの内訳
適合率(Precision) 正例と予測したもののうち、本当に正例だった割合
再現率(Recall) 本当の正例のうち、正例として拾えた割合
F1スコア 適合率と再現率の調和平均
ROC-AUC 閾値を動かしたときの識別性能
PR-AUC 正例が少ない場合に特に見たいPrecision-Recall曲線下の面積
確率校正 出力確率が実際の発生頻度と対応しているか

閾値は検証データで業務上のコストに合わせて選びます。テストデータを何度も見ながら閾値やモデルを調整すると、テストデータに対して過剰に合わせ込んでしまいます。

ロジスティック回帰でよくあるつまずき

BCEWithLogitsLoss を使うときに、モデル出力へ先にSigmoidをかけてしまうのは典型的なミスです。

# 誤り: criterionがBCEWithLogitsLossなのに、入力を確率にしている
probabilities = torch.sigmoid(model(X))
loss = criterion(probabilities, y)

正しくは、logitsをそのまま渡します。

logits = model(X)
loss = criterion(logits, y)

Sigmoidは、推論時に確率を表示したいときや、閾値でクラスへ変換したいときに使います。

ラベルの型と形状にも注意します。BCEWithLogitsLosstarget は、入力と同じ形状の浮動小数点Tensorにします。

y = y.float().view_as(logits)

確率をそのままクラス結論として扱うのもよくある誤解です。確率は閾値を通して初めてクラスに変換されます。0.5は便利な初期値ですが、あらゆる問題で最適な閾値ではありません。

よくあるつまずきどころ

CPUとGPUのTensorを混ぜている

CPU上のTensorとCUDA上のTensorは、通常そのまま一緒に計算できません。

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

x = x.to(device)
model = model.to(device)

入力データ、モデル、損失計算に使うTensorが同じデバイスにあるか確認します。

データ型が合っていない

線形層に整数Tensorを渡すと、期待通りに動かないことがあります。多くのニューラルネットワーク層では、入力は浮動小数点Tensorである必要があります。

print(x.dtype)
x = x.to(dtype=torch.float32)

分類タスクのラベルでは torch.long が必要になる場面もあります。入力とラベルで求められる型が違うことを意識します。

形状が合っていない

PyTorchのエラーで最も多いものの1つが、形状の不一致です。まずは入力、重み、出力の形状を表示します。

print(x.shape)
print(weight.shape)

バッチ次元、特徴量次元、チャンネル次元を混同しないことが大切です。

squeeze() でバッチ次元を消してしまう

x.squeeze() は長さ1の次元をすべて消します。バッチサイズが1のとき、バッチ次元まで消えてしまうことがあります。

x = x.squeeze(dim=1)

どの次元を消したいのかが明確なら、dim を指定します。

勾配をリセットしていない

PyTorchでは勾配が累積されます。訓練ループでは、基本的に各ステップで勾配をリセットします。

optimizer.zero_grad(set_to_none=True)

手動でパラメータを更新する場合は、.grad = None としても構いません。

NumPyに変換できない

CUDA上のTensorや、勾配計算の対象になっているTensorは、そのままNumPy配列に変換できないことがあります。

array = tensor.detach().cpu().numpy()

detach() で計算グラフから切り離し、cpu() でCPUに移してから .numpy() を呼びます。

同じグラフで2回 backward() している

同じ前向き計算の結果に対して2回 backward() すると、計算グラフがすでに解放されていてエラーになることがあります。

多くの場合は、もう一度前向き計算を実行します。retain_graph=True は、必要性がはっきりしている場合だけ使います。

ビューと非連続Tensorを混同している

スライス、transpose()permute()squeeze() などは、元Tensorとストレージを共有するビューを返すことがあります。ビューを書き換えると元Tensorにも影響する場合があります。また、転置後のTensorは非連続になりやすく、そのまま view() できないことがあります。

print(x.is_contiguous())
x = x.contiguous()

形状を変えたいだけなら reshape() を使い、ビューである必要がある場合だけ view().contiguous() の関係を意識します。

学習の進め方

この回の内容は、次の順番で復習するとつながりやすいです。

  1. shapedtypedevice を確認する習慣をつける
  2. Tensorの作成、インデックス参照、分割、形状変換に慣れる
  3. 要素ごとの演算、ブロードキャスト、行列積を区別する
  4. requires_grad、葉Tensor、勾配の累積を理解する
  5. Tensorだけで線形回帰を一度手書きする
  6. nn.Linear、損失関数、最適化器に置き換える
  7. logit、Sigmoid、二値交差エントロピーの関係を理解する
  8. BCEWithLogitsLoss でロジスティック回帰を訓練する
  9. ビュー、連続性、インプレース操作のリスクを確認する
  10. 必要になった段階で torch.compiletorch.export、関数型微分APIを調べる

参考リンク

おわりに

今回は、PyTorchの中心になるTensor、自動微分、線形回帰、ロジスティック回帰を一続きの流れとして扱いました。

Tensorでは、shapedtypedevice を確認する習慣がとても大切です。形状、型、デバイスがそろっていれば、多くのエラーはかなり切り分けやすくなります。

また、線形回帰とロジスティック回帰の例を通して、PyTorchの訓練ループの基本も見ました。前向き計算で予測を出し、損失を計算し、backward() で勾配を求め、パラメータを更新し、勾配をリセットする。この流れは、より大きなニューラルネットワークでも基本的には同じです。

ロジスティック回帰では、モデルが返すlogit、Sigmoidを通した確率、BCEWithLogitsLoss による安定した損失計算、そして閾値によるクラス変換を分けて考えることが重要です。ここまで見通せると、分類モデルの訓練ループもかなり読みやすくなります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?