Backpropagationをスクラッチから:エンジン、そしてネットワークへ
純粋なPythonで120行のautodiffエンジンを書き、PyTorchと16桁精度で照合し、zero_gradの意味を削除して学びます。
このページの内容
4章まで進んだところで、このコースの真ん中に穴があります。
第3章ではgradient descentを扱いました。parameterを改善するには、それに対するlossの傾きを見つけ、下り坂へ一歩進みます。第4章では、下る価値のあるlossを手に入れました。しかしどちらでも、derivativeは手計算でした。1つのmodel、1つのparameter、1行の微積分で、1ページに収まっていました。
ではlayerを2つ積み重ねてみます。1つ目の出力が2つ目に入るので、1つ目のすべてのweightは、2つ目のすべてのneuronを通じてlossに影響します。100 unitのhidden layerを2つ持つnetworkには、およそ2万個のparameterがあり、その1つひとつについて同じlossのpartial derivativeが必要です。これを手でやるのは退屈なのではありません。不可能です。そして、このコースの残りで扱うどのarchitectureでも不可能なままです。
抜け道は、よりよい記法ではありません。合成関数のderivativeは、計算そのものの構造から、プログラムによって機械的に計算できるという気づきです。そして正しい向きに行えば、lossを1回計算するのとほぼ同じコストで、2万個すべてのderivativeが得られます。
その仕組みがreverse-mode automatic differentiationです。neural networkに適用するとbackpropagationと呼ばれます。この章の終わりまでに、ライブラリなしのPython約120行でそれを書き、PyTorchと照合し、第1章でperceptronを打ち負かしたXOR問題を解くために使います。
First: why there has to be a nonlinearity at all
セクション「First: why there has to be a nonlinearity at all」へのリンク機械を作る前に、1つの問いを片づける必要があります。もし答えが逆なら、作るものなど何もないからです。
perceptronがXORで失敗したのは、1本の直線では4点を分離できないからでした。明らかな修正は、積み重ねることです。入力を1つのlinear layerに通し、さらに別のlinear layerに通す。これは役に立つでしょうか。
いいえ。証明は2行です。linear layerはです。これを別のlayer、に渡して代入します。
この合成は、かつであるです。linear layerを積み重ねても、単一のlinear layerです。 10個でも、1000個でも同じです。やはり1本の直線であり、XORは解けません。
信じるだけでなく、実際にそうなる様子を見る価値があります。
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)
two_layers = W2 @ (W1 @ x + b1) + b2
one_layer = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))-4.612963371048 -4.612963371048 0.00e+00近似的に等しいのではありません。bit単位で完全に同一です。同じ算術を並べ替えただけだからです。
つまり、depthだけでは何も得られません。価値を生むのは、layerの間にnonlinear functionを置くことです。これこそがactivation functionが存在する理由のすべてです。生物学的な飾りでも、normalisationの小技でもありません。それがなければ、2つ目のlayerはただの装飾です。
The chain rule, on paper, with a shared node
セクション「The chain rule, on paper, with a shared node」へのリンク次は数学です。とはいえ、すでに知っている1つのruleを、少し見慣れない場所に適用するだけです。
1変数のchain ruleは、がに依存し、がに依存するなら、だと言います。chainに沿ってderivativeは掛け合わされます。
ここで重要なのは、あるvariableが下流の複数のpathへ流れ込むと何が起きるかです。がを通じてに影響し、さらにを通じても影響するなら、それぞれの寄与は足し合わされます。
pathに沿って掛け、pathをまたいで足す。これがbackpropagationのすべてです。この章の残りに出てくる実装上の細部はすべて、コード内の+=も、初めてtraining loopを書く人が必ずつまずくzero_grad()呼び出しも含めて、その2語目の直接の帰結です。
とを持つ、5つのoperationからなる具体的なcircuitを考えます。
が3回現れることに注意してください。の中、の中、そしての中に直接現れます。から始めて、右から左へ、紙の上でbackward passを行います。
Through the addition
セクション「Through the addition」へのリンクなので、となり、直接のpathはを寄与します。additionは入力gradientを変えずに両方の入力へ分配します。
Through the tanh
セクション「Through the tanh」へのリンクを持つなので、です。
Through the multiplication
セクション「Through the multiplication」へのリンクなので、かつです。multiplicationは入れ替えます。各入力のgradientは、もう一方の入力によってscaleされます。
Collect the three paths into x
セクション「Collect the three paths into x」へのリンクを通ると。を通ると。直接ならです。
この数を覚えておいてください。数ページ後、プログラムが、ここまでのどれも教えられずにこれを出します。
Building the engine
セクション「Building the engine」へのリンクこれをプログラム可能にする洞察は、上のすべてのstepがlocalだったことです。multiplication nodeを通じてgradientを押し戻すには、入力gradientと、保存された2つの入力値だけが必要でした。circuitの残りについては何もいりません。各operationは、自分自身をどう微分するかを知っています。
そこで、自分を何が生み出したかを覚えているnumberを作ります。
class Value:
"""A number that remembers where it came from."""
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _op4つのfieldがあります。dataは値です。gradはをaccumulateします。_prevは、この値が計算される元になったValueたちのset、つまりgraphのedgeです。そして_backwardは各operationがinstallするclosureです。このnodeのgradientを、1step戻して入力へ押し戻す方法を知っています。
どのoperatorも同じ形です。出力を計算し、parentを記録し、local ruleをinstallします。
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return out4つの_backward bodyをtableとして読むと、紙の上で導いたflow patternがそのままそこにあります。
| operation | gradientに対して何をするか |
|---|---|
+ | 分配する — 同じgradientをすべての入力へ渡す |
* | 入れ替える — 各入力を相手の値でscaleする |
relu | routeする — 通すか、完全に遮断する |
tanh | 弱める — でscaleする。これは最大でも1で、たいていはそれ未満 |
どれも+=を使い、=は決して使いません。これが「pathをまたいで足す」ruleをcode化したものです。2つのconsumerへ流れ込むnodeは2回呼ばれ、その2つの寄与は自然に足し合わされます。
次にdriverです。ここだけがglobalな知識を持っています。
def backward(self):
order, seen = [], set()
def build(v):
if v in seen:
return
seen.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0
for v in reversed(order):
v._backward() buildはgraphのtopological orderingを作ります。すべてのnodeは、自分の入力すべての後に現れます。このlistを逆向きにたどると、nodeの_backwardを呼ぶ時点で、そのnode自身のgradientがすでに完成していることが保証されます。下流のすべてのconsumerがすでに寄与しているからです。順序を間違えると、未完成のgradientを後ろへ押し戻すことになり、error messageなしで間違った答えが出ます。
Does it agree with the paper?
セクション「Does it agree with the paper?」へのリンクx = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)forward: a=0.7000 b=1.9000 c=1.3300 d=0.8692 L=1.3692
backward: dL/dd=1.0000 dL/dc=0.2444 dL/da=0.4644 dL/db=0.1711
dL/dx=1.8212 dL/dy=0.40331.8212。同じ数です。プログラムには、+のrule、*のrule、tanhのruleだけが教えられており、このcircuitについては何も教えられていません。
独立したcheckを2つ行います。「自分で導いたものと一致した」は、導いたのも同じ人なら弱いtestだからです。
Numerical differentiation。 inputを少し動かして測ります。centred difference は、微積分を一切使わずにderivativeを推定します。
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12PyTorchとの比較。 これは、この仕事を本職にしている人たちが書いたindustrialなautodiff engineです。
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16で一致しています。これは64-bit floatのmachine epsilonです。2つのengineは同一の算術を実行しています。numerical checkを手元に置いておいてください。新しいlayerのbackward passをdebugする道具であり、間違ったgradientを見つけられる理由そのものです。
Saturation, measured
セクション「Saturation, measured」へのリンク同じcircuit、異なるinputです。とを設定すると、になります。
x=0.5, y=1.4: dL/dc = 0.244400 three paths into x: 0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025 three paths into x: 0.0001 + -0.0001 + 1.0000 = 0.9999nodeを横切るgradientは、9,945分の1に落ちました。その上流にあるすべて、実際のnetworkならその前のすべてのlayerは、実質的に何も受け取りません。circuitを通る2つのpathは沈黙しました。をskipする直接接続だけが、まだsignalを運んでいます。
これがvanishing gradient problemを1つのnodeで見たものです。のlayerを40個積み、そのようなfactorを40個掛け合わせれば、初期のlayerは完全に学習を止めます。ついでに言えば、これはskip connectionの主張をミニチュアで見せてもいます。nonlinearityを迂回したpathだけが生き残ったからです。
What zero_grad actually does, and why the bug hides
セクション「What zero_grad actually does, and why the bug hides」へのリンクすべての_backwardは+=を使います。それは正しいことです。pathがsumされる仕組みだからです。しかし、その結果として誰もが引っかかることが起きます。gradientはbackward()へのcallをまたいでもaccumulateします。engineには、2回目のcallが同じgraph内の別pathではなく、新しいtraining stepであることは分かりません。
だからtraining loopでは、それをclearする必要があります。
for step in range(steps):
ys = [model(x) for x, _ in DATA]
loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.gradPyTorchではこれがoptimizer.zero_grad()です。そして通常の助言は、これを忘れるとtrainingが壊れる、というものです。ではその2行を削除して、どれほど壊れるか見てみましょう。同じseed、同じすべて、XORを200stepです。
| learning rate | seed | resetあり | resetなし |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
小さいlearning rateでは、bugのあるversionが全行で勝っています。正しいversionが停滞するところでconvergeします。
これは偶然ではなく、理解する価値があります。このbugが見つけにくい理由を説明してくれるからです。gradientをclearしない場合、step でのparameter更新は、それまでに計算されたすべてのgradientの合計になります。lossがおおむね同じ向きを指し続けるなら、その合計は着実に大きくなり、learning rateが勝手に増えていくのと同じ効果になります。正しいalgorithmが這うように進むでは、暴走するstep sizeがまさに修正のように見えます。
次に下の3行を見てください。では、同じ仕組みがmodelを吹き飛ばします。loss 8.0は、modelがconstant にcollapseしたときのscoreです。4つの答えが最大限に間違ったときの16の半分です。一方で、正しいversionはここで綺麗にconvergeします。
したがって正直な言い方は、「必ずzero_gradを呼ばないとmodelはtrainしない」ではありません。こうです。それなしでは、もうgradient descentを実行していません。 誰も選んでいない速度でstep sizeが上向きにdriftする何かを実行しています。そしてそれは、うまくいくように見え、ときには本物よりよく見えることさえあります。うまくいかなくなるその瞬間までは。そのときあなたはlearning rate、initialisation、dataのどれかを責めるでしょう。machine learningで最悪のbugはこういう形をしています。crashしません。algorithmを別のalgorithmに変え、しかもたまにより高いscoreを出すのです。
The network, and XOR at last
セクション「The network, and XOR at last」へのリンクengineができれば、neural networkはごくわずかなcodeです。neuronはdot productとbiasとactivationです。layerはneuronのlistです。networkはlayerのlistです。
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]そこにはbackward passがありません。1行もありません。Value classは、これらのclassがたまたま構築するものを微分する方法をすでに知っています。これこそ、先にそれを書いた理由です。autodiff engineは、自分がneural networkに使われていることを知りません。
では第1章の問題です。2入力、2 hidden unit、1出力、9 parameterです。
step 1: loss 4.156690
step 10: loss 4.005572
step 50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597
[0, 0] -> -0.9081 (target -1) ok
[0, 1] -> +0.8934 (target +1) ok
[1, 0] -> +0.8906 (target +1) ok
[1, 1] -> -0.9207 (target -1) ok4つ中4つです。perceptronには計算できない関数、第1章でが正であり負でもなければならないという4つの不等式によって証明した関数が、自動的に見つかった9つの数によって計算されました。
What the hidden layer did
セクション「What the hidden layer did」へのリンク満足すべきところは、動いたことではありません。どう動いたかを見られることです。hidden unitが2つなら、intermediate representationは平面上の点であり、ただprintできます。
loss 0.001241までtrainしたとき、各inputがhidden layerの後にどこへ着地し、output neuronがそれに何をするかは次の通りです。
| input | hidden layer output | output score | label |
|---|---|---|---|
1行目と4行目を見てください。input とは正方形の対角のcornerで、この問題で2点が取り得る最大距離だけ離れています。それなのにhidden layerは、それらをとへmapします。ほとんど同じ点です。 layerは平面を折りたたみ、rejectされる2つのcornerを互いの上に着地させました。いったん同じ場所に来れば、1本の直線でそれらを他の2つから分離できます。
そしてoutput neuronは、まさにその直線です。学習されたparameterは、なので、そのdecision boundaryは
というstraight lineです。第1章と同じperceptronであり、同じ物体で、何も変わっていません。当時XORを解けなかったし、今もそのままでは解けません。変わったのは、もはやinputを見ていないことです。1つ目のlayerが作ったspaceを見ており、そのspaceでは問題がlinearly separableになっています。
これがlearned representationです。このphraseはこのコースの残りでも、この分野全体でもゆるく使われるので、正確にしておく価値があります。これはcompressionでもsummaryでも、神秘的な意味でのembeddingでもありません。設計されたのではなく学習された座標変換であり、その唯一の仕事は、次のlayerの仕事を簡単にすることです。
The universal approximation theorem, and what it does not say
セクション「The universal approximation theorem, and what it does not say」へのリンクここには定理があります。そしてたいてい、よくない引用をされます。
1989年のCybenkoと1991年のHornikは、単一のhidden layerと適切なactivation functionを持つfeedforward networkが、十分な数のhidden unitを与えられれば、compact set上の任意のcontinuous functionを好きな精度でapproximateできることを証明しました。34 これは本物の、重要な結果です。architectureが制約ではないと言っているからです。
次に、それが何を省いているかを読んでください。何unit必要かは言っていません。そのboundは天文学的に大きくなり得ます。weightを見つけられるとは言っていません。存在を主張しているだけで、random startからのgradient descentはoracleではありません。そして、見ていないdata上での振る舞いについては何も言っていません。これは第6章の後半です。
「存在する」と「見つけられる」の間のgapは、学術的な細部ではありません。同じXOR問題で、各50回のrandom initialisation、1000 step、hidden layer sizeだけを変えた結果がこちらです。
| hidden units | 4/4に到達したinitialisation |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
最小限のviable architectureでは、4回に1回は到達しません。第3章で1次元surface上に示したlocal minimumそのもの、つまり抜け出せないconfigurationに落ち着きます。1 unit足すと失敗はほぼ消えます。networkの表現力が増したからではありません(2 unitで十分です。38回のrunがそれを証明しています)。余分なdimensionが、descentに抜け道となる方向をより多く与えるからです。
そして8 unitは4 unitよりわずかに悪くなります。固定されたlearning rateとstep budgetでは、capacityを増やせば単調によくなるわけではありません。詰まったnetworkの修正は常に大きなnetworkだと言う人は、このtableの真ん中から外挿しているだけです。
これは第1章のconvergence theoremと同じlessonです。そして第10章のscaling lawでも、その章の形で同じlessonになります。lossの予測は、あなたが対価を払っているcapabilityの予測ではありません。その2つの距離にengineeringがあります。
詳細を表示
Optional: matrix形式と、上のcodeがそれを使わない理由。
ここまでのすべてはscalarを1つずつ書いてきました。これはmechanismを見るには最も明快で、実行するには最も遅い方法です。実際にはlayerはmatrix multiplyであり、のbackward passは
です。transposeは覚えるためのtrickではありません。pathをまたいでsumするruleが、pathをmatrix entryでindexしたときにこう見えるのです。一般的なobjectはJacobian、つまりすべてのoutputのすべてのinputに対するpartial derivativeを並べたmatrixです。そしてreverse modeとは、Jacobianを決して作らずにvector-Jacobian productを計算することにほかなりません。これが重要なのは、4096 inputと4096 outputを持つlayerでは、そのmatrixが1600万entryを持ち、作る価値が決してないからです。
次の章を追うのに、これは不要です。scalar版はmatrix版がすることをすべて、より遅く行います。shapeが明らかでなくなる第9章で必要になります。
Where this goes next
セクション「Where this goes next」へのリンクこれで、trainするnetworkを手に入れました。これは感じるほど大きな達成ではありません。あなたのnetworkは4つのexampleでtrainされ、同じ4つで測られているからです。
同じcodeを実際のdatasetで走らせると、新しい問題が現れます。そのどれもgradientそのものの問題ではありません。lossがしばらく下がってから止まる。あるいはtraining dataでは下がり、それ以外では上がる。あるいは最初のstepからまったく動かず、原因は初期random weightのrangeだったと判明する。あるいは3 epoch目に、あるunitのinputがすべてのexampleで負にdriftし、それ以来ずっと静かにdeadで、model capacityの一部を道連れにしている。
これらは珍しい失敗ではありません。書いたばかりのnetworkの通常状態です。そして、そのどれも自分から名乗り出ません。gradientは正しい。PyTorchと16桁まで照合しました。それでもmodelは学習しません。
第6章はその話です。initialisation、normalisation、overfitting、regularisation、そして何かを変える前にどれが起きているのかを問う診断の習慣についてです。走るnetworkと、働くnetworkの違いです。
Sources and method
セクション「Sources and method」へのリンクこの章のValue classは、Andrej Karpathyのmicrogradを直接の源流としています。彼のvideo The spelled-out intro to neural networks and backpropagation: building micrograd は、この題材を別の人からもう一度説明してもらいたいなら、費やす価値のある最高の3時間です。2016年のpost Yes you should understand backprop は、自分で1つ書くべき理由を論じており、StanfordのCS224nの指定読書になっています。backpropagationに関するCS231n notes(cs231n.github.io/optimization-2)は、上でtable化したflow patternのcanonicalな解説です。neural-network folkloreとしてではなくgraph上のcalculusとして数学を見たいなら、Deisenroth、Faisal、OngによるMathematics for Machine Learningの5.6章が例外的に明快です。そしてBaydin、Pearlmutter、Radul、Siskindのsurvey Automatic Differentiation in Machine Learning: a Survey(arXiv:1502.05767)は、上で論じたforward/reverseのtrade-offを含む、この分野全体のreferenceです。
参考文献
セクション「参考文献」へのリンク-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Master's thesis, University of Helsinki (1970). この分野に到達する16年前、まったく別の動機のもとでのreverse-mode accumulationです。 ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). このmethodを知らしめた論文であり、hidden unitをlearned representationとして読む見方のsourceです。この章のWhat the hidden layer did sectionは、その見方に測定を費やしました。 ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). Cybenkoを一般化しています。結果はactivationがnon-polynomialであることに依存し、sigmoidalであることには依存しません。 ↩