からっぽのしょこ

読んだら書く!書いたら読む!同じ事は二度調べ(たく)ない

ステップ39:総和の逆伝播に利用する関数の実装【ゼロつく3のノート(実装)】

はじめに

 『ゼロから作るDeep Learning 3』の初学者向け攻略ノートです。『ゼロつく3』の学習の補助となるように適宜解説を加えていきます。本と一緒に読んでください。

 本で省略されているクラスや関数の内部の処理を1つずつ解説していきます。

 この記事は、主にステップ39「和を求める関数」を補足する内容です。
 総和(配列の和)の逆伝播の計算時に配列の形状を調整する関数rehape_sum_backward()の処理を確認します。

【前ステップの内容】

www.anarchive-beta.com

【他の記事一覧】

www.anarchive-beta.com

【この記事の内容】

・総和の逆伝播に利用する関数の実装

 配列に対して総和の計算を行うと入力と出力で形状が変わります。逆伝播では、出力の形状から入力の形状に戻す必要があります。この記事では、その際に利用する関数reshape_sum_backward()の内部の処理を確認します。

・処理の確認

 dezeroフォルダ内のutils.pyに実装されている関数reshape_sum_backward()で行う処理を確認していきます。reshape_sum_backward()は、総和のクラスSumの逆伝播メソッドbackward()の中で配列の形状を調整します。

 次のライブラリを利用します。

# 利用するライブラリ
import numpy as np


・順伝播(総和)の処理

 まずは、利用するオブジェクトを作成しつつ、総和(配列の和)の計算を確認します。

 計算に用いる変数(順伝播の入力)$\mathbf{x}$を作成します。ここでは処理を確認したいだけなので、Variableインスタンスではなく、NumPy配列のまま扱います。

# 順伝播の入力を作成
x = np.arange(3 * 4 * 5).reshape((3, 4, 5))
#x = np.array(10)
#x = np.array([[0, 1, 2, 3, 4]])
print(x)

# 順伝播の入力の形状を保存
x_shape = x.shape
print(x_shape)
[[[ 0  1  2  3  4]
  [ 5  6  7  8  9]
  [10 11 12 13 14]
  [15 16 17 18 19]]

 [[20 21 22 23 24]
  [25 26 27 28 29]
  [30 31 32 33 34]
  [35 36 37 38 39]]

 [[40 41 42 43 44]
  [45 46 47 48 49]
  [50 51 52 53 54]
  [55 56 57 58 59]]]
(3, 4, 5)


 計算時の設定をオブジェクトとして残しておき、総和を計算します。

# 和をとる軸を設定
axis = 1
#axis = -2
#axis = (0, 2)
#axis = None

# 次元数を維持するかを設定
keepdims = False
#keepdims = True

# 順伝播を計算
y = np.sum(x, axis=axis, keepdims=keepdims)
print(y)
print(y.shape)
[[ 30  34  38  42  46]
 [110 114 118 122 126]
 [190 194 198 202 206]]
(3, 5)

 複数の軸を指定する場合はaxisをタプル型のオブジェクトで指定します。1つの軸でもタプル(0,)で指定できます。
 指定した軸は消えるので、入力と出力の形状が変化します。keepdimsTrueにした場合は、指定した軸(次元)の要素数が1になり次元数は変わりません。

 ここまでが順伝播の処理で、Sumクラスの順伝播メソッドforward()で行われます。

・逆伝播の処理

 続いて、総和の逆伝播の処理を確認していきます。

 「逆伝播の入力$\frac{\partial y}{\partial y}$」を作成します。$\frac{\partial y}{\partial y}$は、$y$に関する$y$の勾配で、$y$と同じ形状です。「順伝播の出力y」と同じ形状で全ての要素が1の配列を作成してgyとします。

# 逆伝播の入力を作成
gy = np.ones_like(y)
print(gy)
print(gy.shape)
[[1 1 1 1 1]
 [1 1 1 1 1]
 [1 1 1 1 1]]
(3, 5)

 順伝播の計算において更に次の計算$L = f(y)$がある場合は、この計算の逆伝播の入力は$\frac{\partial L}{\partial y}$になります。その場合は、次の計算の逆伝播メソッドから出力される値がgyとなります。

 これらの処理は、Variableクラスの逆伝播メソッドbackward()で行われます。次からがreshape_sum_backward()で行う処理です。

 「順伝播の入力x」の次元数を保存します。

# 順伝播の入力の次元数を保存
ndim = len(x_shape)
print(ndim)
3

 スカラの場合は0になります。

 和をとった軸の番号をタプル型のオブジェクトとして保存します。

# 和をとった軸番号をタプル型で保存
tupled_axis = axis
print(tupled_axis)

if axis is None: # 軸を指定しなかった場合
    # Noneを再代入
    tupled_axis = None
elif not isinstance(axis, tuple): # スカラで指定した場合
    # タプルに変換
    tupled_axis = (axis,)

print(tupled_axis)
1
(1,)

 複数の軸を指定した場合はaxisの時点でタプルです。つまり、1つの軸をスカラで指定していた場合にタプルに変換する処理と言えます。

 和をとることによって消去された軸を復元します。

# 順伝播時に消去された軸を復元
if not (ndim == 0 or tupled_axis is None or keepdims): # 一部の軸のみが消去された場合
    # 和をとった軸の番号を(前から数えた番号に変換して)抽出
    actual_axis = [a if a >= 0 else a + ndim for a in tupled_axis]
    print(actual_axis)
    
    # 順伝播の出力の形状をリスト型で保存
    shape = list(gy.shape)
    print(shape)
    
    # 和をとったことによって消えた軸に1を追加
    for a in sorted(actual_axis):
        shape.insert(a, 1)
else: # 調整が不要な変形の場合
    # 現在の形状を保存
    shape = gy.shape

print(shape)
[1]
[3, 5]
[3, 1, 5]

 or演算子は、どれか1つでもTrueならTrueを返します。言い換えると、全てがFalseのときFalseを返します。

 ここでは、次の3つの条件により分岐して処理します。

  • 「順伝播の入力x」がスカラの場合は、ndim == 0Trueになります。
  • 和をとる軸を指定しなかった場合は、tupled_axis is NoneTrueになります。
  • 次元数を維持した場合は、keepdimsTrueになります。

 この3つの条件を「全て満たさない」場合はif文の処理を、「1つでも該当する」場合はelse文の処理を行います。

 3つの条件をそれぞれ確認しておきましょう。

# 条件の確認
print(ndim == 0)
print(tupled_axis is None)
print(keepdims)
False
False
False
  • 入力がスカラの場合は、出力もスカラであり和をとらないので形状が変わりません。
  • 軸を指定しない場合は、全ての要素の和をとりスカラになります。
  • 次元数を維持した場合は、指定した軸の要素数が1になります。

 この3つパターンであれば、微調整の必要はなくこのままブロードキャストにより順伝播の入力の形状に戻せます。
 どのパターンでもない場合は、和をとることにより一部の軸のみが消えています。ブロードキャストを行うには、消去された軸を要素数を1として復元させる必要があります。

 では、消去された軸を復元する処理(if文の処理)を確認していきます。

 リスト内包表記の処理を確認しましょう。for文と条件分岐を分けると次になります。

# リストを初期化
actual_axis = []

# 条件に従ってリストに追加
for a in tupled_axis:
    if a >= 0: # 前から数えた軸番号の場合
        actual_axis.append(a)
    else: # 後から数えた軸番号の場合
        actual_axis.append(a + ndim)

print(actual_axis)
[1]

 tupled_axisは和をとった軸の番号でした。軸は負の値を使って指定することもできます。-1は最後の軸を表し、-nは後からn番目の軸を表します。
 この例だと、軸の数(次元数)ndim3ですね。つまり、-1は(0から数えるので)2番目の軸を表します。これは、-1 + ndimで求められます。
 同様に、-nは前から数えると-n + ndim番目の軸です。

 このことを考慮して、和をとった軸の番号を取得しています。前から数えた番号で軸を指定している場合はa >= 0なので、そのままactual_axisに追加します。後から数えた番号で軸を指定している場合はa < 0なので、ndimの値を加えてactual_axisに追加します。

 次に、現在の形状(順伝播の出力の形状)をリスト型に変換してshapeとして保存します。

 さらに、shapeに対してリストのメソッドinsert()を使って、和をとることによって消去した軸actual_axisを追加してその軸の要素数を1とします。
 insert()は、第1引数に指定したインデックスに、第2引数に指定した要素を追加します。

 以上で調整後の形状shapeが得られました。

 「逆伝播の入力gy」の形状をshapeに変形します。

# 逆伝播の入力の形状を調整
gy = gy.reshape(shape)
print(gy)
print(gy.shape)
[[[1 1 1 1 1]]

 [[1 1 1 1 1]]

 [[1 1 1 1 1]]]
(3, 1, 5)

 else文の処理によってshapeを作成した場合は何もしないことになります。

 ここまでが、reshape_sum_backward()で行う処理です。形状を調整したgybroadcast_to()に渡すことで、総和の逆伝播を計算できます。

 ここで簡易的に、np.broadcast_to()を使って「順伝播の入力x」の形状に戻してみましょう。

 形状を調整した「逆伝播の入力gy」の対応する要素を複製(ブロードキャスト)して、「順伝播の入力のx」の形状にします。

# 総和の逆伝播を計算
gx = np.broadcast_to(gy, x.shape)
print(gx)
print(gx.shape)
[[[1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]]

 [[1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]]

 [[1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]]]
(3, 4, 5)

 この処理が総和の逆伝播の計算であり、「逆伝播の出力gx」は「順伝播の入力xの勾配」です。ブロードキャストについては、次のステップで確認します。

 以上がreshape_sum_backward()に関連する処理です。

・実装した関数の確認

 実装した関数を試してみましょう。

 dezeroフォルダのutils.pyを読み込みます。dezeroフォルダの親フォルダまでのパスをsys.path.append()に指定します。

# 実装済みモジュールの読込用の設定
import sys
sys.path.append('..')

# 実装済み関数を読込
from dezero import utils


 入力データを作成します。

# 順伝播の入力を作成
x = np.arange(3 * 4 * 5).reshape((3, 4, 5))
#x = np.array(10)
#x = np.array([[0, 1, 2, 3, 4]])
print(x)
print(x.shape)
[[[ 0  1  2  3  4]
  [ 5  6  7  8  9]
  [10 11 12 13 14]
  [15 16 17 18 19]]

 [[20 21 22 23 24]
  [25 26 27 28 29]
  [30 31 32 33 34]
  [35 36 37 38 39]]

 [[40 41 42 43 44]
  [45 46 47 48 49]
  [50 51 52 53 54]
  [55 56 57 58 59]]]
(3, 4, 5)


 順伝播(総和)を計算します。

# 和をとる軸を設定
axis = 1
#axis = -2
#axis = (0, 2)
#axis = None

# 次元数を維持するかを設定
keepdims = False
#keepdims = True

# 順伝播を計算
y = np.sum(x, axis=axis, keepdims=keepdims)
print(y)
print(y.shape)
[[ 30  34  38  42  46]
 [110 114 118 122 126]
 [190 194 198 202 206]]
(3, 5)


 逆伝播の入力を作成します。

# 逆伝播の入力を作成
gy = np.ones_like(y)
print(gy)
print(gy.shape)
[[1 1 1 1 1]
 [1 1 1 1 1]
 [1 1 1 1 1]]
(3, 5)


 順伝播の計算において消去した軸を追加します。

# 逆伝播の入力の形状を調整
gy = utils.reshape_sum_backward(gy, x.shape, axis, keepdims)
print(gy)
print(gy.shape)
[[[1 1 1 1 1]]

 [[1 1 1 1 1]]

 [[1 1 1 1 1]]]
(3, 1, 5)


 総和の逆伝播を計算(ブロードキャスト)します。

# 総和の逆伝播を計算
gx = np.broadcast_to(gy, x.shape)
print(gx)
print(gx.shape)
[[[1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]]

 [[1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]]

 [[1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]
  [1 1 1 1 1]]]
(3, 4, 5)

 「順伝播の入力$\mathbf{x}$」と同じ形状の「逆伝播の出力($\mathbf{x}$の勾配)$\frac{\partial y}{\partial \mathbf{x}}$」が得られました。

 以上で、総和の逆伝播で利用する関数reshape_sum_backward()の内部で行われる処理を確認できました。この関数を用いて、総和クラスSumを実装します。また、今回確認した一連の処理が、Sumクラスで行う処理です。

参考文献

  • 斎藤康毅『ゼロから作るDeep Learning 3 ――フレームワーク編』オライリー・ジャパン,2020年.

おわりに

 何してるのか分かりにくい部分は細かく設定に対応してるためですね。やりたいことは1つ「消えた軸を戻す」です。

 この記事の投稿前日に公開された(半セルフ)カバー動画をご視聴ください!

 (お二人とも既に卒業されていますが)ハロプロ新旧エースペアの歌唱が強すぎる♪♪
 原曲も強いです。

 こ、興奮が収まらんー

【次ステップの内容】

www.anarchive-beta.com