書いて理解するPytorchのforwardとbackward

forwardは一言で言えば順伝搬の処理を定義しています。

元々はkerasを利用していましたが、時代はpytorchみたいな雰囲気に呑まれpytorchに移行中です。
ただkerasに比べて複雑に感じる時があります。
今回はforwardを書いていて、「なんだっけこれ」と初心者してしまっておりますので、
pytorch.org
今回はこちらの公式のexampleを実行してみて、理解に努めようと思います。

データセットの用意

必要なライブラリは読み込んでおきます

import numpy as np
import pandas as pd
import torch
from sklearn.model_selection import train_test_split
from sklearn.datasets import load_iris
import matplotlib.pyplot as plt

データセットは今回アヤメを利用しようと思います。

data = pd.DataFrame(iris.data, columns=iris.feature_names)
y = iris.target

バッチサイズは25、隠れ層の次元数も適当に3にしておきます。

iris = load_iris()
data = pd.DataFrame(iris.data, columns=iris.feature_names)
y = iris.target
y = np.identity(3, dtype=np.int64)[y]
X_train, X_test, y_train, y_test = train_test_split(data.values, y, train_size=0.67, shuffle=True)
print(f"X_train: {X_train.shape}, X_test: {X_test.shape}, y_train: {y_train.shape}, y_test: {y_test.shape}")
X_train = torch.from_numpy(X_train).float()
y_train = torch.from_numpy(y_train).float()
X_test = torch.from_numpy(X_test).float()
y_test = torch.from_numpy(y_test).float()


# N is batch size; D_in is input dimension;
# H is hidden dimension; D_out is output dimension.
N, D_in, H, D_out = 25, X_train.shape[1], 3, y_train.shape[1]

まずは適当に学習させてみる

class TwoLayerNet(torch.nn.Module):
    def __init__(self, D_in, H, D_out):
        """
        In the constructor we instantiate two nn.Linear modules and assign them as
        member variables.
        """
        super(TwoLayerNet, self).__init__()
        self.linear1 = torch.nn.Linear(D_in, H)
        self.linear2 = torch.nn.Linear(H, D_out)

    def forward(self, x):
        """
        In the forward function we accept a Tensor of input data and we must return
        a Tensor of output data. We can use Modules defined in the constructor as
        well as arbitrary operators on Tensors.
        """
        h_relu = self.linear1(x).clamp(min=0)
        y_pred = self.linear2(h_relu)
        return y_pred

# Construct our model by instantiating the class defined above
model = TwoLayerNet(D_in, H, D_out)

# Construct our loss function and an Optimizer. The call to model.parameters()
# in the SGD constructor will contain the learnable parameters of the two
# nn.Linear modules which are members of the model.
criterion = torch.nn.MSELoss(reduction='sum')
optimizer = torch.optim.SGD(model.parameters(), lr=1e-4)

losses = []
testlosses = []

for t in range(50):
    # Forward pass: Compute predicted y by passing x to the model

    y_pred = model(X_train)

    # Compute and print loss
    loss = criterion(y_pred, y_train)
    losses.append(loss.item())
    if t % 10 == 0:
        print(t, loss.item())

    # Zero gradients, perform a backward pass, and update the weights.
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    with torch.no_grad():
        y_test_pred = model(X_test)
        testloss = criterion(y_test_pred, y_test)
        testlosses.append(testloss)
plt.plot(losses)
plt.plot(testlosses)

f:id:hirasakanai:20200919151917p:plain
無事学習は進んでいるようです。
モデルもexampleから適当に見繕いました。
※分類問題なのでクロスエントロピーを利用するのがベストプラクティスかもしれませんが、なぜかうまくいかなかったことと本題から逸れるため、このままMSEで行きます。

forwardを理解したい

モデルの中のforwardを理解したい

上のコードの中から抜粋

class TwoLayerNet(torch.nn.Module):
    def __init__(self, D_in, H, D_out):
        """
        In the constructor we instantiate two nn.Linear modules and assign them as
        member variables.
        """
        super(TwoLayerNet, self).__init__()
        self.linear1 = torch.nn.Linear(D_in, H)
        self.linear2 = torch.nn.Linear(H, D_out)

    def forward(self, x):
        """
        In the forward function we accept a Tensor of input data and we must return
        a Tensor of output data. We can use Modules defined in the constructor as
        well as arbitrary operators on Tensors.
        """
        h_relu = self.linear1(x).clamp(min=0)
        y_pred = self.linear2(h_relu)
        return y_pred

`torch.nn.Linear` は重みとバイアスだけを持つ簡単な結合層です。(Linear — PyTorch 1.6.0 documentation)

隠れ層は一層だけで、今回適当に3を設定しています。入力層・隠れ層・出力層をこの2行で表現しています。

        self.linear1 = torch.nn.Linear(D_in, H)
        self.linear2 = torch.nn.Linear(H, D_out)

次に本題のforwardです。

    def forward(self, x):
        h_relu = self.linear1(x).clamp(min=0)
        y_pred = self.linear2(h_relu)
        return y_pred

forwardはデータxをtensor型で受け取って、3層のレイヤーを通って出力しているだけです。
clampはデータに最小値・最大値を与えてその範囲内にデータを納めます。(torch.clamp — PyTorch 1.6.0 documentation
以下がexampleです。

>>> a = torch.randn(4)
>>> a
tensor([-1.7120,  0.1734, -0.0478, -0.0922])
>>> torch.clamp(a, min=-0.5, max=0.5)
tensor([-0.5000,  0.1734, -0.0478, -0.0922])

上記の学習コードでは最低値を0として他をそのままの値で出力しています。つまりReLU関数と同等です。そのための出力の変数もh_reluとなっています。

なぜforwardに書く必要があるのか?

本題です。私はこれに悩みました。
特にこの後実験しようとしている損失関数や活性化関数の中でもforwardメソッドは定義されます。
この辺りがすごく不思議に思えてしまったのです。

ここでニューラルネットワークの基本的な構造を思い出します。
f:id:hirasakanai:20200919154921p:plain
ニューラルネットワークの基礎を学んだなら当たり前のことなのですが、それぞれの層・活性化関数・損失関数も全て入力から出力までを順伝搬する仕組みです。
全てデータを左から受け取って右に流していく役割を持っているので、定義するのは至極当たり前でした。
それぞれをレイヤーとして認識してnn.Moduleクラスを基底として全てに順伝搬を定義しているだけです。


さらに深く理解するため、backwardの役割を改めて見ておきます。

optimizer.zero_grad()
loss.backward()
optimizer.step()

ニューラルネットワークの重みを更新するためには誤差逆伝搬法を利用します。(誤差逆伝搬法が何かについては探せばいくらでも記事が出てきますのでこちらでは割愛)
端的にいうとそれぞれの重みの勾配を計算しそれが損失関数にどれくらい影響を与えているのか計算できます。
重みの更新には多くの手法があり上記ではSGDを利用しています。

それでは上のコードが何をしているか確認していきます。
optimizer.zero_grad()は一旦「計算した勾配の初期化」だと思っておいて下さい。

次にloss.backwordを見ていきます。
lossは損失関数でここではMSE(Mean Square Error)を利用しています。ですが、損失関数と名のついた関数には変わりありません。
別の簡素な関数を用意して、事例を見ていきます。

backwardは何をしているのか。PytochのAutogradという概念。
x = torch.tensor(3.0, requires_grad=True)

簡単な関数を用意しました。 x = 3です。これを入力だと意識します。
requires_gradは勾配を自動で計算することを定義する引数です。ここでTrueとしておくと、その先にある様々の層の計算に大して、どれくらい寄与するのかその勾配を計算します。

そして次に出力としてy = 2xを定義します。

y = 2*x
print(y)
# tensor(4., grad_fn=<MulBackward0>)

この時y = 2xのxの勾配を出力します。
その値はx.gradに入っています。ただし先に勾配を計算する必要があります。

y.backward()

です。こうすることでx.gradは計算されます。xを定義する時にrequires_grad=Trueとしていないと、このタイミングでエラーが発生します。
結果はy = 2xにおけるxの勾配は2となります。

print(x.grad)
# tensor(3.)

次に伝搬のイメージをするために新しくz=5yを用意します。

z = 5*y

これは合成関数の微分を使って解くと(代入してもさくっと解けてしまいますが)zをxで微分すると15となります。これがzに対するxの勾配です。
あたらためて全て書き直すと以下のようになります。

x = torch.tensor(2.0, requires_grad=True)
y = 3*x
z = 5*y
z.backward()
print(x.grad)
# tensor(15.)

なぜ全て書き直したかというと、この時4行目のz.backwardをy.backwardにすると3が出力されるからです。ぜひ丸っとコピペして試してみてください。
つまりbackwardはrequires_grad=Trueとした変数に対して(ここではx)、目的の関数(ここではy, z)に対して微分を行った時の勾配を計算する事ができます。

(個人的に、まだ自分自身が慣れてないだけですが、z.gradとかy.gradと表示された方がみやすい気がしてしまいます。ただ実際のニューラルネットワークの各層に対してこの勾配を持つので、各層の変数から取り出せた方がいいというのも理解できます。)

そしてこの更新に対して注意が必要なのは、計算された重みは追加されていくという事です。つまりz.backward()をn実行すると、x.gradはn * 15となります。
そのためoptimizer.zero_grad()で勾配を初期化する必要が出てくるわけです。

最後にoptimizer.step()

これは重みの更新を行っています。
この行をコメントアウトすると学習の結果は以下のようになります。
f:id:hirasakanai:20200919170453p:plain
何も更新されません。

optimizer.step()で学習率と最適化手法に基づいて重みを更新しているわけです。
さらにschedulerなどを設定している時は、学習率の動的な変更もこのタイミングで行われています。

損失関数を新しく定義したい時もforward

class RMSELoss(nn.Module):
    def __init__(self, eps=1e-6):
        super().__init__()
        self.mse = nn.MSELoss()
        self.eps = eps

    def forward(self, yhat, y):
        loss = torch.sqrt(self.mse(yhat, y) + self.eps)
        return loss

これはMSEをRMSEに変更したい時のコードです。
(ちなみにこれを見て理解はできるものの本当に説明できるか不安になり、本記事につながりました)
これを利用し、上記のコードで学習し直すと。
f:id:hirasakanai:20200919171134p:plain
無事学習できました!
RMSEはMSEの平方根を取る形です。MSEに比べて外れ値に鈍感になる上損失は数字上小さくなるので(単位も異なりますので)学習を回す回数は増えましたが、ちゃんと収束しました。

活性化関数を新しく定義したい時もforward

class ReLU(Module):
    def __init__(self, inplace: bool = False):
        super(ReLU, self).__init__()
        self.inplace = inplace

    def forward(self, input: Tensor) -> Tensor:
        return F.relu(input, inplace=self.inplace)

まとめ

  • PyTorchはnn.Moduleクラスを基底とし、順伝搬の処理をforwardの中に書いている。
  • さらにnn.Moduleを基底として、それらの入力層・隠れ層・出力層・活性化関数・損失関数などを組み合わせる(つなぎ合わせる)ことでモデルを作成し学習する。
  • 学習はAutogradという勾配を自動で計算する仕組みに支えられている。
  • そのAutogradを利用して、optimizer.zero_grad()で勾配を初期化し、loss.barkward()でそのエポックにおける損失に対する勾配を計算し、optimizer.step()で重みの更新を行う。この繰り返しで学習が進む。


ご覧いただきありがとうございました!

下記書籍とブログも参考にしています。
つくりながら学ぶ!PyTorchによる発展ディープラーニング | 小川 雄太郎 | 工学 | Kindleストア | Amazon
PyTorchは誤差逆伝播とパラメータ更新をどうやって行っているのか? - け日記

NNの中間層の出力ベクトルを特徴量として利用してみたい

学習済のニューラルネットワークの中間層の出力はそれ自体が特徴量となります。
頭では理解しているものの、今まで実装したことはありませんでした。

今回はtomaCup第5回のときの
しみしげ (@nyanchdayooon) | Twitter さんの 1D-CNN & Extract Feature Vectors
katsu1110 (@kk1110tt) | Twitter さんの GBDTと昆布の物語(Private 2位、Public 5位の解法) を参考に、
probspaceのスプラトゥーンコンペを題材に実装してみたいと思います。

(ただこちらのコンペ、開始から2週間以上経ってもTOPのaccuracyが0.569090なので、可視化した時に対して大きな差が目に見えない可能性もあり、すでに題材ミスの可能性も・・・)

下準備

データの用意

TRAIN_DATA_PATH = "./data/train_data.csv"
TEST_DATA_PATH = "./data/test_data.csv"
# e-toppoさんの申請してくださった武器詳細データ
# https://prob.space/competitions/game_winner/discussions/e-toppo-Post0082a60376ef134af3a4
WEAPON_DATA_PATH = "./data/weapon.csv"

データはコンペで提出されているテストデータ、訓練データ、トピック(ディスカッション)で申請し許可が降りていた外部データを利用します。

欠損値の補完、不要な列データの削除

データの前処理を行います。日付などは勝敗と無関係だと判断したためいくつかの特徴量を削りました。
また欠損値は−1や、最も頻度の高い値で埋めました。本題と逸れるため、詳細なコードはprobspaceに記載した物をご確認ください。
対戦ゲームデータ分析甲子園 | ProbSpace

学習

学習の前に数値データとカテゴリデータに分類します。

nums = np.stack([X[col].astype(np.float16).values for col in num_features], 1)
nums = torch.tensor(nums, dtype=torch.float)
cats = np.stack([X[col].values for col in cat_features], 1)
cats = torch.tensor(cats, dtype=torch.int64)

学習させるクラスは以下のように定義しています。

class TabularModel(nn.Module):

    def __init__(self, emb_szs, n_cont, out_sz, layers, p=0.5):
        super().__init__()
        self.embeds = nn.ModuleList([nn.Embedding(ni, nf) for ni,nf in emb_szs])
        self.emb_drop = nn.Dropout(p)
        self.bn_cont = nn.BatchNorm1d(n_cont)
        
        layerlist = []
        n_emb = sum((nf for ni,nf in emb_szs))
        n_in = n_emb + n_cont
        
        for i in layers:
            layerlist.append(nn.Linear(n_in,i)) 
            layerlist.append(nn.ReLU(inplace=True))
            layerlist.append(nn.BatchNorm1d(i))
            layerlist.append(nn.Dropout(p))
            n_in = i
        layerlist.append(nn.Linear(layers[-1],out_sz))
            
        self.layers = nn.Sequential(*layerlist)
    
    def forward(self, x_cat, x_cont):
        embeddings = []
        for i,e in enumerate(self.embeds):
            embeddings.append(e(x_cat[:,i]))
        x = torch.cat(embeddings, 1)
        x = self.emb_drop(x)
        
        x_cont = self.bn_cont(x_cont)
        x = torch.cat([x, x_cont], 1)
        x = self.layers(x)
        return x

コードは PyTorch for Deep Learning with Python Bootcamp | Udemy をベースとしています。

学習した結果は以下のようになりました。
f:id:hirasakanai:20200830172617p:plain

学習は無事進んでいるようです。

モデルの確認

デルレイヤーは現在このようになっています。

print(model)
TabularModel(
  (embeds): ModuleList(
    (0): Embedding(2, 1)
    (1): Embedding(5, 3)
    (2): Embedding(23, 12)
    (3): Embedding(13, 7)
    (4): Embedding(13, 7)
    (5): Embedding(13, 7)
    (6): Embedding(13, 7)
    (7): Embedding(13, 7)
    (8): Embedding(13, 7)
    (9): Embedding(13, 7)
    (10): Embedding(13, 7)
    (11): Embedding(13, 7)
    (12): Embedding(15, 8)
    (13): Embedding(48, 24)
    (14): Embedding(10, 5)
    (15): Embedding(13, 7)
    (16): Embedding(15, 8)
    (17): Embedding(48, 24)
    (18): Embedding(10, 5)
    (19): Embedding(13, 7)
    (20): Embedding(15, 8)
    (21): Embedding(48, 24)
    (22): Embedding(10, 5)
    (23): Embedding(13, 7)
    (24): Embedding(15, 8)
    (25): Embedding(48, 24)
    (26): Embedding(10, 5)
    (27): Embedding(13, 7)
    (28): Embedding(15, 8)
    (29): Embedding(48, 24)
    (30): Embedding(10, 5)
    (31): Embedding(13, 7)
    (32): Embedding(15, 8)
    (33): Embedding(48, 24)
    (34): Embedding(10, 5)
    (35): Embedding(13, 7)
    (36): Embedding(15, 8)
    (37): Embedding(48, 24)
    (38): Embedding(10, 5)
    (39): Embedding(13, 7)
    (40): Embedding(15, 8)
    (41): Embedding(48, 24)
    (42): Embedding(10, 5)
  )
  (emb_drop): Dropout(p=0.5, inplace=False)
  (bn_cont): BatchNorm1d(8, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (layers): Sequential(
    (0): Linear(in_features=432, out_features=200, bias=True)
    (1): ReLU(inplace=True)
    (2): BatchNorm1d(200, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (3): Dropout(p=0.5, inplace=False)
    (4): Linear(in_features=200, out_features=100, bias=True)
    (5): ReLU(inplace=True)
    (6): BatchNorm1d(100, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (7): Dropout(p=0.5, inplace=False)
    (8): Linear(in_features=100, out_features=2, bias=True)
  )
)

大量のカテゴリ変数をベクトルに置き換えるためのemdded層と数値データを取り込むBatchNorm1d、
そのあと

(0): Linear(in_features=432, out_features=200, bias=True)
(1): ReLU(inplace=True)
(2): BatchNorm1d(200, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(3): Dropout(p=0.5, inplace=False)

のReLUを活性化関数にして全結合層を二層繰り返しています。

中間層のの出力を取得する

    def forward(self, x_cat, x_cont):
        embeddings = []
        for i,e in enumerate(self.embeds):
            embeddings.append(e(x_cat[:,i]))
        x = torch.cat(embeddings, 1)
        x = self.emb_drop(x)
        
        x_cont = self.bn_cont(x_cont)
        x = torch.cat([x, x_cont], 1)
        x = self.layers(x)
        return x

これを実行するためには、クラスの中のforwardと同様のことを実施すると良いです。
例えば上記のコード3行目の部分、 self.embededの部分をmodel.embedsのようにして属性にアクセスできます。

# catsはカテゴリ変数のみを入れたテンソルです。
# cats = np.stack([X[col].values for col in cat_features], 1)
# cats = torch.tensor(cats, dtype=torch.int64)

print(cats.shape)
embeddings = []
for i, emded in enumerate(model.embeds):
    embeddings.append(emded(cats[:,i]))
embeddings = torch.cat(embeddings, 1)

print(embeddings.shape)

出力
無事43列のカテゴリデータが424次元のベクトルに変換されました。

torch.Size([66125, 43])
torch.Size([66125, 424])

次元削減してみる

最後上記で取得したembeddingsを2次元空間にプロットしてみようと思います。

from sklearn.decomposition import PCA 
pca = PCA()
embeddings = embeddings.detach().numpy().copy()
pca.fit(embeddings)
feature = pca.transform(embeddings)

df = pd.DataFrame(feature).head()
plt.figure(figsize=(6, 6))
plt.scatter(feature[:, 0], feature[:, 1], alpha=0.8, c=y)
plt.grid()
plt.xlabel("PC1")
plt.ylabel("PC2")
plt.show()

f:id:hirasakanai:20200830180137p:plain


・・・・・・・題材ミスですね!こういう僅差のデータを可視化するのは一体どうしたらいいのやら。。。

pytorch.nn.Embeddingが何をしているのか理解したい【入門】

embeddingを直訳すると「埋め込み・嵌め込み」みたいな意味です。
ここで行っているembeddingはWord embeddings(単語埋め込み)など、自然言語処理などで言われるembeddingの意味で、
何かの特徴を特定のベクトルに変換する意味です。

実際にコードを実行してみようと思います。

下準備

hours = np.random.randint(0, 24, 5)
dayofweek = np.random.randint(0, 7, 5)
catz = torch.tensor(np.concatenate([hours, dayofweek]).reshape(2,5).T)
catz
tensor([[ 4,  3],
        [16,  5],
        [18,  2],
        [18,  0],
        [ 7,  5]])

上記のような5*2のテンソルを作成しました。hoursとdayofweekである必要はないですけれども、なんとなくのイメージです。

Embeddingでベクトルに埋め込む

実際に一つのクラスに埋め込むことを想定してコードを書いてみます。
複数の列をembeddingしたいことをイメージして、nn.ModuleListに格納します。

# 24時間を12次元(tensor)に7つの曜日を4時限tensorに変化を試みる
emb_szs = [(24, 6), (7, 4)]
# torch.nn.ModuleListに格納する ni, nf = inputするカテゴリ数, 変換するベクトルの次元数
selfembeds = nn.ModuleList([nn.Embedding(ni, nf) for ni,nf in emb_szs])
selfembeds
ModuleList(
  (0): Embedding(24, 6)
  (1): Embedding(7, 4)
)

nn.ModuleListに格納することで、nn.Moduleのクラス内で定義した時にModuleの一つのレイヤーとして確認できるようになります。


これを実行に移すときはnn.Embedding(ni, nf)(<テンソル>)として実行するだけです。

embeddingz = []
for i,e in (selfembeds):
    embeddingz.append(e(catz[:,i]))
embeddingz
[tensor([[ 0.0934, -0.6163, -0.8566, -0.2958, -0.2675,  0.7492],
         [ 0.2193,  0.2933, -0.3107,  0.5948,  0.6845,  3.0315],
         [-0.5964,  0.0574,  0.0242, -0.0439, -0.5735, -0.5559],
         [-0.9389,  0.7380, -0.4325, -0.0264, -0.5290,  0.2135],
         [ 0.2193,  0.2933, -0.3107,  0.5948,  0.6845,  3.0315]],
        grad_fn=<EmbeddingBackward>),
 tensor([[-0.9179, -0.3926,  0.2028,  0.5319],
         [-0.9692, -0.5956,  3.2422, -0.9217],
         [ 0.5377,  0.1080, -1.4133,  0.2854],
         [-1.0105, -0.4767, -0.2607,  0.3381],
         [-0.9692, -0.5956,  3.2422, -0.9217]], grad_fn=<EmbeddingBackward>)]

上記のように6次元のベクトルと4次元のベクトルに変換されます。

embeddingの使い所

本コードの元はudemyの
PyTorch for Deep Learning with Python Bootcamp | Udemy
で登場したコードでした。その際も似たような変数に対して上記embeddingを実装していました。

実際に調べてみると自然言語処理の領域で利用されることの多いレイヤーのようです。

class NGramLanguageModeler(nn.Module):

    def __init__(self, vocab_size, embedding_dim, context_size):
        super(NGramLanguageModeler, self).__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.linear1 = nn.Linear(context_size * embedding_dim, 128)
        self.linear2 = nn.Linear(128, vocab_size)

    def forward(self, inputs):
        embeds = self.embeddings(inputs).view((1, -1))
        out = F.relu(self.linear1(embeds))
        out = self.linear2(out)
        log_probs = F.log_softmax(out, dim=1)
        return log_probs

上記のコードではnn.Embeddingを利用してN-gramモデルを実装しています。
自然言語に置いて単語をone-hot-encodingすると次元数は大きくなり、
ある一定の文章量から計算が不可能となります。
そこで次元数を限定し言語をベクトルで表現することで、計算量を低く抑えることができます。
またEmbeddingは学習の対象となり、重みを持ちます。
学習済の重みを反映させることも可能で、その重みは単語の特徴量として捉えることもできます。

pandasで行方向の重複をカウントする

コード

data.apply(lambda x: len(set(x)), axis=1)

apply(function, axis=1)で、行方向の集計を決める。
関数をlambda式でxとして、その集合setに変換し、最後にlenで長さを取得する。