機械学習周りのプログラミング中心。 イベント情報
ポケモンバトルAI本電子書籍通販中

Deep Learning Code Golfやってみた part02 コード解説

前回の続きです。本稿では、私が実際にDLCGに取り組んで記述したコードと、そこで用いたテクニックを解説します。

ベースライン

まずはコードを短くするテクニックを用いる前の、最も基本となるコードを示します。

from torch.nn import *
def m():
    return Sequential(Flatten(),Linear(32*32*3,32),ReLU(),Linear(32,10))

これは、全結合層2層からなるモデルです。 本稿のルールでは、コード中で関数mを定義し、これを実行すると、ニューラルネットワークの実行順序を定めるモデル構造及び学習されるパラメータを包含するモジュールオブジェクト(torch.nn.Moduleのサブクラス)が生成されるように実装する必要があります。 PyTorchでモジュールオブジェクトを生成するには、torch.nn以下に定義されているクラスを使います。*でインポートを行うと何がインポートされたのか分かりづらいため通常の開発ではあまり用いませんが、毎回torch.nn.Sequentialのような記述をすると長くなるため、基本テクニックとして使用します。本稿の範囲では、これ以外のインポートは行いません。 Sequentialは、引数で指定したレイヤー(本稿ではモジュールオブジェクトとほぼ同じ意味で使います)全てを包含し、先頭から順に実行するようなレイヤーです。枝分かれのないモデルを定義するのに有用です。枝分かれがある場合も含めた一般的なモデル定義の方法は以下のようになりますが、明らかにコードが長くなります。

from torch.nn import *
class M(Module):
    def __init__(self):
        super().__init__()
        self.conv1 = Conv2d(3, 32, 3)
        self.relu1 = ReLU()
        self.conv2 = Conv2d(32, 64, 3)
    def forward(self, x):
        h = self.conv1(x)
        h = self.relu1(h)
        return self.conv2(h)
def m():
    return M()

モデルへの入力テンソルは、32×32ピクセルのRGB画像なので[バッチサイズ, 3, 32, 32]となっています。Linear(全結合層)レイヤーは2次元のテンソルしか受け付けないため、Flattenを用いて形状を[batch_size, 3*32*32]に変換します。Linearは、Linear(入力チャンネル数,出力チャンネル数)という引数を取ります。ここでは32×32×3(=3072)チャンネル入力、32チャンネル出力のレイヤーを生成します。次に活性化関数としてReLUを挿入します。PyTorchでは、活性化関数はLinearに含まれていませんので別途挿入の必要があります。最後に、32チャンネル入力、(10クラス分類なので)10チャンネル出力のLinearを挿入しています。

正解率30%以上: 最短のコード

まずはできるだけモデルを単純化し、正解率を問わない、最短と思われるコード(43.41%, 104文字)を示します。

from torch.nn import* 
m=lambda:Sequential(Flatten(),LazyLinear(10))

モデルの構造は、全結合1層のみの線形モデルです。学習させてみたところ、正解率32.65%で文字数67でした。まず、import *のスペースは不要で、import*とすることが可能です。これで1文字削減できます。改行を;に置換可能な場合がありますが、改行文字を1文字として数えているため効果はありません。ルール上、モジュールオブジェクトを返す関数mを定義する必要がありますが、これを関数定義文def m():\n xxxとする代わりにラムダ式を使用してm=lambda:xxxとすることができます。1文字の削減となります。ただし、ラムダ「式」のため、関数内で代入文を使えなくなります。そして、Linear(3072,10)の代わりにLazyLinear(10)を用います。LazyLinearは入力チャンネル数の記載を省略可能とする、実験的機能です。文字数としては、3027,の5文字が減ってLazyの4文字が増えます。合計で1文字の削減となります。もし入力チャンネル数が3桁(999以下)ならこれで削減することはできないということになります。なお、Flattenは省略するとLinearに4次元のテンソルが入力されて実行時エラーとなりますので削減できません。何か抜け道がないか、気になるところです。

正解率40%以上: 複数レイヤーの導入

次は、正解率40%以上の部門で最短のコード(44.43%, 83文字)を示します。

from torch.nn import* 
L=LazyLinear 
m=lambda:Sequential(Flatten(),L(99),ELU(),L(10))

線形モデルでは流石に精度が低いため、2層以上のモデルが必要となります。中間層99チャンネルの2層モデルで40%以上を達成することができました。LazyLinearを2回使いたいので、変数Lに代入します。LazyLinearLazyLinearの20文字の代わりに、L=LazyLinear\nLLの15文字となり5文字の削減となります。2つの全結合層の間には非線形の活性化関数が必要です。もし活性化関数がないと、2連続の行列積となりますが、これは1つの行列積と等価となり線形モデルと変わりません。深層学習が最初に流行した際の活性化関数はReLU(ReLU(x)=max(x,0))でしたが、さまざまな亜種が提案されています。そのうちPyTorchで文字数最短はELUです。これは、ELU(x)=x\; (\mathrm{if}\; x > 0),\; \exp(x)-1 (\mathrm{otherwise})で表されます。中間層のチャンネル数が99なのは、2文字でできるだけ大きなパラメータ数を得るためです。普通は32、256など2の冪乗を使いますが、DLCG特有の制約で珍しい数値が出現しています。

正解率50%以上: 畳み込みの導入

正解率50%以上の部門で最短のコード(52.76%, 88文字)を示します。

from torch.nn import*
m=lambda:Sequential(Conv2d(3,9,3),ReLU(),Flatten(),LazyLinear(10))

線形モデル2層では実現が困難なため、ここで畳み込み層を導入しました。画像に対する畳み込みはConv2dクラスで実現されます。引数は、Conv2d(入力チャンネル,出力チャンネル,カーネル幅,ストライド=1,パディング=0,...)となります。ストライド以降の引数は省略可能です。正解率50%以上という条件であれば、畳み込み層の出力チャンネル数は9チャンネルで実現可能でした。60%以上とするにはチャンネル数2桁が必要でした。その場合のコード例(60.78%, 89文字)を示します。

from torch.nn import*
m=lambda:Sequential(Conv2d(3,99,3),ReLU(),Flatten(),LazyLinear(10))

1文字の増加で精度が8ポイント改善するというのもコードゴルフ的には奇妙な感じですが、ハイパーパラメータの性質上仕方ありません。活性化関数ではELUが最短ではあるのですが、正解率が低いという問題があり、ReLUを使用しています。ELUでは入力値が0付近の場合にほぼ恒等関数となるため、長い学習時間をとって非線形性が出るような領域まで使われるようにする必要があるのかもしれません。

正解率70%以上: ループの導入

最後に、正解率70%以上の部門です。コード(74.69%, 119文字)を示します。

from torch.nn import*
S=Sequential
m=lambda:S(*[S(LazyConv2d(99,3,i),BatchNorm2d(99),ReLU())for i in[1,2]*3],Flatten())

2層のモデルでは不十分なため、3層以上のモデルをできるだけ短いコードで定義します。文法が複雑になっているため、1つずつ解きほぐしていきます。 まず、Sequentialが2回使われるため、変数に代入しています。[f(i) for i in X]という構文ですが、リスト内包表記です。Xにはリスト等の列挙可能なオブジェクトを与えます。例えば、[f(i) for i in [1,3,5]]は、[f(1), f(3), f(5)]という記述と等価です。関数fに対して異なる引数を与えて得た結果を、リストとして取得することができます。[1,2]*3は、リストに対する掛け算で、繰り返しを意味します。すなわち、[1,2]*3==[1,2,1,2,1,2]です。リスト内包表記では、通常forの前、inの後ろにはスペースが必要ですが、変数名として使えない文字の場合はスペースを省略してもエラーとなりません。これにより、...)for i in[...というようにスペースを2文字分削減できます。次に、リストの前の*ですが、リストの要素を関数の引数として展開する役割です。例えば、f(*[1,3,5])f(1,3,5)と等価となります。Sequentialは、可変長の引数としてレイヤーを受け取るため、この構文を使用してリスト内包表記で動的生成したレイヤーの列を与えます。これらの文法を展開した結果は以下のようになります。

from torch.nn import*
def m():
    return S(
        S(LazyConv2d(99,3,1),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,2),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,1),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,2),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,1),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,2),BatchNorm2d(99),ReLU()),
        Flatten()
    )

結局のところ、畳み込み6層のモデルということになります。

次に、深層学習のモデルとしてのテクニックを解説します。ここでは新たに2つのクラスLazyConv2dBatchNorm2dが登場しています。BatchNorm2dはバッチ正規化層で、入力の各チャンネルについて、ミニバッチ内の値の平均を0、分散を1となるようアフィン変換することで学習を促進するものです。本稿のルールではこれがないと学習イテレーション数に対する正解率の向上速度が低く、所定イテレーション数内に正解率70%を達成できませんでした。文字数が長いですが採用せざるを得ません。チャンネル数指定不要のLazyBatchNorm2dもありますが、このコードでは文字数削減に寄与しません。LazyConv2dは、入力チャンネル数を省略できる畳み込み層です。畳み込み層の入力チャンネル数は3桁以下なので、Lazyよりもチャンネル数を明示的に記述して999,とするほうが短いのですが、入力チャンネル数が最初の層では3なのに対し他の層では99となり、条件分岐が必要となります。ループ変数jが仮に0,1,2,3...となる場合、[3,99][j>0]のように場合分けを短く記述することはできなくはないですが、Lazyを用いる方がより短いです。 最後にLazyConv2dの引数ですが、LazyConv2d(出力チャンネル,カーネル幅,ストライド)となります。ストライド指定の別の実装方法として、j%2+1 for j in range(6)も考えられますが、j for j in[1,2]*3の方が短いです。文法上の定義とは別に、ストライドが1と2に交互に設定されている点が重要です。ストライドは、出力のピクセルが1つ移動するたびに入力に対する畳み込みの窓の移動距離を表すものです。畳み込み層の画像の出力サイズの計算式は(入力ピクセル幅 + 2 \times パディング - カーネル幅)/ストライド+1のようになります。これにより、画像サイズが32→30→14→12→5→3→1と変化し、ちょうどピクセル数が1になるところがポイントです。最後の畳み込み層の出力テンソルの形状は[バッチサイズ, 99, 1, 1]となります。これをFlattenに入力すると、[バッチサイズ, 99]の出力が得られます。出力は10クラス分類なので[バッチサイズ, 10]となるのが正しいのですが、過剰なチャンネルがあってもエラーなく動きます。11個目以降のクラスは学習データに出現しないので、負の無限大を出力するように学習が進んでいくと考えられます。逆にクラスが10個あるのに9チャンネル以下の出力を与えると実行時エラーになりますので、精度不問のコードで10チャンネルの出力のところを9チャンネルに減らしてコードを短くすることには使えません。画像サイズが1になることも重要で、もしサイズが2となって[バッチサイズ, 99, 2, 2]という出力となると、これをFlattenに与えた結果が[バッチサイズ, 396]となります。チャンネル数が過剰という点だけでは画像サイズが1ピクセルの場合と変わりませんが、[バッチサイズ, 0, 0, 0]がクラス0に対応、[バッチサイズ, 0, 0, 1]がクラス1に対応ということになります。しかしこれはチャンネル方向ではなく空間方向の別ピクセルが別のクラスに対応するという正しくない構造となり、うまく学習できません。画像サイズをうまく調節することにより、最後の全結合層を省略することができています。また、通常のモデルでは最後の畳み込み層や全結合層には活性化関数を用いませんが、リスト内包表記の都合で最終層だけ場合分けすることができません。これでも学習が進むので、DLCG特有のテクニックとして使うことができます。 簡潔さの裏に、深層学習初心者には決してお勧めできない黒魔術が入っておりますのでご注意ください。

なお、チャンネル数を2桁の99より増加させると、以下のコード(75.16%, 121文字)のように若干正解率が向上します。

from torch.nn import* 
S=Sequential 
m=lambda:S(*[S(LazyConv2d(256,3,i),BatchNorm2d(256),ReLU())for i in[1,2]*3],Flatten())

一方、他のモデル構造も検討しましたが、今回のルールで正解率80%以上を実現するモデルは残念ながら見つかりませんでした。分岐があるモデル構造としてResidual構造などを試しましたが、エポック数を5に固定した状態では正解率が高くなりませんでした。数十分かけて学習を進めれば本稿で掲載したモデルでも80%以上の正解率は得られますし、より複雑なモデルで90%以上の正解率を目指すことも可能ではあります。しかし細かい数値を変更してコードを短くするゲームとして遊ぶには計算時間がかかりすぎますので、ここまでとしました。私が開発したコードは以上となります。深層学習という課題上、ライブラリに定義されたクラス名の記述が避けられず、また大量のクラスを複雑に組み合わせることが最適解とはならなかったため、コードを見れば何をしているかはわかりやすい解答となりました。DLCGに興味を持ってくださった方が、従来のコードゴルフのような、コードを見ても何をやってるかわからないような奇怪なコードが最適解となるような面白い課題設定を開発してくれることを期待しております。

ConvMixerのテクニック

今回私が定義したルールに対してはモデルが複雑すぎて活用できませんでしたが、DLCGのアイデア元となったConvMixerのコードを短くすることに使われたテクニックについて、私なりに解釈して解説します。

def ConvMixr(h,d,k,p,n):
 S,C,A=Sequential,Conv2d,lambda x:S(x,GELU(),BatchNorm2d(h))
 R=type('',(S,),{'forward':lambda s,x:s[0](x)+x})
 return S(A(C(3,h,p,p)),*[S(R(A(C(h,h,k,groups=h,padding=k//2))),A(C(h,h,1))) for i in range(d)],AdaptiveAvgPool2d((1,1)),Flatten(),Linear(h,n))

A=lambda x:S(x,GELU(),BatchNorm2d(h))は、ラムダ式定義です。引数xには例えばConv2d()が入ります。S=Sequentialなので、xに続いて、GELU()BatchNorm2d()が実行されるようなモジュールオブジェクトが生成されることになります。つまり、畳み込みなどの処理に活性化関数を追加する作用を持ちます。

次の行は見慣れない構文が用いられています。R=type('',(S,),{'forward':lambda s,x:s[0](x)+x})は、Residual構造を作るためのクラス定義となります。組み込み関数typeは、引数が1つのときと3つ以上のときで全く役割が異なります。今回は、type(name, bases, dict, **kwds)という引数になり、クラス定義を意味します。nameはクラス名ですが空文字列でもエラーになりません。basesは、基底クラスをタプルで指定します。ここでは、S=Sequentialが指定されています。dictは、クラスのメソッドを定義します。ここでは、forwardメソッドをラムダ式の形で与えています。lambda s,x:s[0](x)+xを解説します。引数sは、普通selfと書かれるもので、クラスのインスタンスを指します。xが通常の引数で、テンソルを受け取ります。テンソルxをあるレイヤーに与えた出力s[0](x)と、元々のxを足したものを返すことでResidual構造を実現します。さらに、s[0]を解説します。Sequentialクラスは、コンストラクタの引数を、配列のインデックス指定のように取り出すことができます。例えば、m=Sequential(Conv2d(),ReLU())と定義したとき、m[0]==Conv2d()m[1]==ReLU()のようになります。クラスRSequentialを継承したものであり、コンストラクタは上書きしていないため、R(Conv2d())というコンストラクタ呼び出しに対して、そのメソッド内ではself[0]Conv2d()を取り出すことができます。結局、forward(x)メソッドでは、クラスRのコンストラクタに与えたレイヤーオブジェクトにxを与えて呼び出した結果と、xを足した結果を返すという処理が行われることになります。

最後の行は前の章で解説したテクニックと同じで、リスト内包表記による深いモデルの定義が行われています。なお、) for i in range(d)]は、)for i in[0]*d]と短縮できます。また、AdaptiveAvgPool2d((1,1))AdaptiveAvgPool2d(1)に短縮できます。

私はコードゴルフのために、通常あり得ない構造のモデルを定義しました。一方でこの論文のように、あくまで実用性のあるモデルを短く書き換えるという遊びも考えられますので、気が向いたら考えてみてはいかがでしょうか。