PyTorchに入る前に、Pythonの参照と合成関数を確かめる(1章 前半)
研究では、分布ラマン増幅の励起条件を深層展開で最適化する。数値シミュレーションで励起条件と受信SNRの対応データを作り、そこからPyTorchで励起光のパワーと周波数を決める。実装に入る前に、赤石雅典「最短コースでわかる PyTorch&深層学習プログラミング」の1章を、手を動かしながら読み直している。その記録を残す。
1章はPyTorch本体に入る前の準備にあたる。この記事ではそのうち、NumPyの代入、テンソルとの関係、合成関数まで扱う。数値微分とクラス定義は次の記事に回す。
なお、NumPyは数値計算のライブラリで、配列をまとめて高速に計算するために使う。機械学習ではデータをこの配列の形で持つことが多い。
y = x は中身を複製しない
まずこのコードを見てほしい。
import numpy as np
x = np.array([5, 7, 9])
y = x
x[1] = -1
print(x) # [ 5 -1 9]
print(y) # [ 5 -1 9]
yには代入以降なにも触れていない。それでもxと一緒に値が変わる。最初に見たとき、なぜyまで動くのか分からなかった。
理由はPythonの変数の持ち方にある。変数は中身そのものではなく、中身が置かれている場所を指す名札のようなものである。この「指す」という関係を参照と呼ぶ。y = xは中身を複製する命令ではなく、yという名札にもxと同じ場所を指させる命令になる。同じ場所を指しているので、片方から書き換えれば両方から見える。
id()で場所を確認できる。
x = np.array([5, 7, 9])
y = x
z = x.copy()
print(id(x) == id(y)) # True 同じ場所
print(id(x) == id(z)) # False 別の場所
切り離したいときはcopy()を挟む。これで中身が別の場所に複製され、yは独立する。
x = np.array([5, 7, 9])
y = x.copy()
x[1] = -1
print(x) # [ 5 -1 9]
print(y) # [5 7 9]
テンソルとNumPyの間でも同じことが起きる
ここで一つ用語を挟む。PyTorchが扱うデータはテンソルと呼ばれる形式で持つ。中身はNumPyの配列とよく似た多次元の数値の並びで、これにGPUでの計算や自動微分といったPyTorch向けの機能が付いたものだと思えばよい。
テンソルとNumPy配列は互いに変換できる。ところが、numpy()で変換して作った配列は、元のテンソルと同じ場所を指したままになる。
import torch
x1 = torch.ones(5) # 1 が5個並んだテンソル
x2 = x1.data.numpy() # テンソルからNumPy配列に変換
x1[1] = -1
print(x1) # tensor([ 1., -1., 1., 1., 1.])
print(x2) # [ 1. -1. 1. 1. 1.]
テンソルとNumPyはクラスが違うので、別物として扱ってしまいそうになる。それでも指している場所は同じで、片方を書き換えれば連動する。対策は先ほどと同じで、copy()を付ければ切れる。
x2 = x1.data.numpy().copy()
自分の研究に当てはめてみる。シミュレーションで作った励起条件とSNRのデータはNumPy配列で持ち、学習に渡すところでテンソルに変換する。前処理で正規化をかけたつもりが、この連動で元の配列まで書き換わっていたとする。学習が収束しない原因を学習側から探しても、まず見つからない。1章の最初にこの話が置かれている理由が分かる。
合成関数は、関数をつないだもの
合成関数という言葉を先に確認する。ある関数の出力を、そのまま別の関数の入力に渡してつないだものを合成関数と呼ぶ。
本では f(x) = 2x^2 + 2 を、あえて三つの小さな関数に分解する。
$$f_1(x) = x^2,\quad f_2(x) = 2x,\quad f_3(x) = x + 2$$
これをPythonで書くと次のようになる。
def f1(x):
return x ** 2
def f2(x):
return x * 2
def f3(x):
return x + 2
x1 = f1(x) # x を2乗
x2 = f2(x1) # その結果を2倍
y = f3(x2) # さらに2を足す
x を入れて x1、x1 を入れて x2、x2 を入れて y。前の出力を次の入力にそのまま渡している。これが合成関数の実装の形である。
なぜこんな当たり前のことを分解するのか。ニューラルネットワークの中身が、まさにこの形だからである。層を一つの関数と見れば、ネットワーク全体は関数をいくつもつないだ合成関数になる。この代入の連鎖に見慣れておくと、後で出てくるコードが読みやすくなる。
自分の研究の深層展開も同じ見方ができる。深層展開は繰り返し計算を有限回で区切り、各回のパラメータを学習で決める手法である。1回分を関数と見れば、全体はやはり関数を並べた合成関数になる。学習でパラメータを調整できるのは、この合成関数をさかのぼって調整しているからだ、と読める。
まとめ
1章の前半で確認したことを振り返る。
Pythonの変数は中身そのものではなく、中身のある場所を指す名札である。だから y = x では中身が複製されず、片方の変更が両方に及ぶ。独立させたいときは copy() を挟む。
テンソルはPyTorchが扱うデータ形式で、NumPy配列に計算機能を足したものと考えればよい。テンソルとNumPyの間で変換したときも、同じ場所を指したまま連動することがある。ここでも copy() で切れる。
合成関数は、ある関数の出力を次の関数の入力につないだものである。Pythonでは前の出力を次に渡す代入の連鎖として書ける。ニューラルネットワークの中身はこの形をしている。
参照の共有は、機械学習のデータ前処理でそのまま事故につながる。合成関数は、この先のモデル実装を読むための土台になる。どちらも派手さはないが、飛ばすと後で効いてくる部分だった。
次の記事では、1章の残りである数値微分とクラス定義を扱う。
参考書籍:赤石雅典「最短コースでわかる PyTorch&深層学習プログラミング」日経BP