ニューラルネットワーク(torch)
torch パッケージは linalg の上に載る PyTorch スタイルのレイヤーです。どう計算されたかを記憶するテンソルと、その履歴を逆向きに辿って勾配を書き込む torch:backward からなります。手書きの誤差逆伝播が行っていたこと -- どの配列がどこに流れたかの追跡、演算ごとの随伴の導出、ブロードキャスト軸での勾配の合計 -- が、演算ひとつずつ自動で行われます。
パッケージは Lisp ソースで一度だけ実装され、すべてのバックエンドで同一に動作します。各演算は linalg カーネルを通じて計算するため、torch プログラムは --simd でそのまま加速され、数値結果も linalg の結果そのものです。どれだけ速くなるかはモデルがどのカーネルに依存するかで変わります。ランク 3 以上のバッチ行列積はまだ加速対象に入っていないため、attention 層がほぼその呼び出しで占められる Transformer は、素の多層パーセプトロンに比べて恩恵がかなり小さくなります。linalg の加速 を参照してください。
テンソル
torch:tensor は数値、リスト、配列、linalg 配列から葉テンソルを作ります。:requires-grad t はパラメータ、すなわち backward が勾配を書き込むべきテンソルの印です。テンソルは #<TENSOR データ> (パラメータなら :REQUIRES-GRAD T 付き) と印字されます。印字するのはデータだけなので、どのバックエンドでも同じテキストです。値は torch:data (配列)、torch:item (要素 1 個のテンソルの中の数値)、torch:shape で読み戻します:
演算はテンソル、数値、生の配列、リストを区別なく受け取ります。テンソル以外は勾配の流れない定数になります:
記録と backward パス
自動微分に参加するオペランドを持つ演算は、その演算をテープに記録します。スカラー (要素 1 個の) テンソルに対する torch:backward は勾配を 1.0 でシードし、記録された演算を逆位相順に訪れて各入力の勾配を蓄積します。2 回使われたテンソル (残差接続や再利用された埋め込み行) は両方の経路の合計を受け取ります。結果は torch:grad で読みます:
勾配は backward の呼び出しをまたいで蓄積 (+=) されます。これはミニバッチのループが求める動作で、ステップの間は torch:zero-grad でスロットをクリアします:
ブロードキャストと勾配
要素ごとの演算は numpy と同じようにブロードキャストし、backward はブロードキャストされた軸で合計することで勾配を各オペランドの形に縮約します。(n d) の活性に足した (d) のバイアスは、バッチ軸で合計された (d) の勾配を受け取ります:
テープの外に出る
torch:no-grad は記録を無効にして本体を実行します。値は計算されますが、何も記憶されません。学習ループのパラメータ更新 (そして推論一般) をテープの外に置く方法です。テンソル単位の綴りが torch:detach で、同じデータを共有しつつ履歴から切り離した葉を返します:
学習ループ: y = 2x をフィットする
勾配降下法に必要なのは上記だけです。損失を組み立てる順伝播、torch:backward、そして torch:no-grad の中での更新。平均二乗誤差を最小化して y = 2x をフィットします (すべての量が正確な 2 進有理数になる値を選んであるため、印字結果はどのバックエンドでも同一です):
モジュール
モジュールはパラメータを保持し、合成でき、順伝播を持ちます。torch:module は kind キーワード、フィールドのプロパティリスト、forward 関数からモジュールを作り、torch:forward がそれを実行します。フィールドのプロパティリストがパラメータ登録そのもので (torch:parameters がこれを走査します)、レイヤーの forward はクロージャに閉じ込めた変数ではなく torch:field でパラメータを読み戻します。存在するパラメータが走査から漏れることはありません:
走査はサブモジュールおよびそのリストへ降りていき、同一性で重複を排除します。2 つのレイヤーで共有された重みはパラメータ 1 個であり、N 段のブロックのリストに ModuleList 型は要りません。requires-grad を持たないテンソルのフィールドはバッファ扱いで飛ばされます:
torch:train と torch:eval は同じ走査で学習フラグを切り替え、torch:zero-grad はモジュールも受け取ってすべてのパラメータの勾配をクリアします。
組み込みレイヤー
torch:linear、torch:embedding、torch:layer-norm、torch:dropout、torch:sequential は torch:module の普通の呼び出し元です。パラメータの初期化は PyTorch とまったく同じ方式で、シード可能な linalg:seed の生成器から引かれるため、シードを固定した実行はどのバックエンドでも再現します。torch:set-field でパラメータを差し替えると、レイヤーを特定の重みに固定できます:
torch:sequential は引数を各要素に順番に通します。要素はモジュールでも素の関数でもよく、これが活性化関数専用のモジュール型を持たない理由であり、reshape を連鎖の中に置ける理由でもあります:
活性化関数は torch:relu、torch:tanh、torch:gelu で、いずれもテンソルを受け取る普通の関数です。torch:gelu の既定は厳密形 x * (1 + erf(x / sqrt(2))) / 2 (nn.GELU 自身の既定) で、微分可能な torch:erf の上に構築されています。:approximate :tanh を指定すると GPT/BERT の定式化になります。
torch:fields はモジュールのフィールド plist 全体を返します。これがツリーをパッケージの外から走査可能にするものです。nn.Module.apply と nn.Module.named_parameters に対応するものがないのは、走査をこの plist と torch:module-kind — ドット区切りのパラメータ名の部分文字列ではなく、レイヤーが「何であるか」 — で書くためです。
損失関数
torch:mse-loss と torch:cross-entropy-loss はスカラーテンソルを返す普通の関数です。交差エントロピーは生のロジットを取り (softmax の出力ではありません。数値的に安定な形である -log-softmax から計算します)、最終軸以外を平坦化するので (batch seq vocab) がそのまま使えます。ターゲットは整数のクラスインデックス (:ignore-index がパディング位置を総和からも平均の分母からも除きます) か、ロジットと同じ形の確率分布 (PyTorch のソフトラベル形式、-sum(target * log-softmax(logits))) のどちらかです:
リストのターゲットは常にクラスインデックスとして読むため、確率で渡すにはテンソルか配列が必要です。one-hot の分布と対応するインデックスは同じ損失になります。
オプティマイザ
オプティマイザは更新則とその状態を持ちます。torch:sgd、torch:adam、torch:adamw はモデル (またはパラメータのリスト) を受け取り、ハイパーパラメータとバッファをモジュールとまったく同じ fields plist に保持し、torch:step ですべてのパラメータに更新則を適用します:
更新は各パラメータのデータをその場で書き換え、torch の演算を一切使いません。したがってテープには何も記録されず、torch:set-data で手書きした更新と違って torch:no-grad で囲む必要がありません。状態 (モーメンタムバッファ、Adam の 2 つのモーメント、バイアス補正が割るステップ数) はパラメータではなくオプティマイザが持つので、同じ重みに対する 2 つのオプティマイザは別々の状態を保ちます。
ハイパーパラメータは普通のフィールドで、学習率スケジュールに必要なのはそれだけです。torch:zero-grad はモデルだけでなくオプティマイザも受け取ります:
3 つが乗っているコンストラクタが torch:optimizer です。種別キーワード、パラメータ、fields plist、ステップ関数からなるので、このパッケージが用意していない更新則も同じレコードの上の素の defun として書けます。
torch:adam と torch:adamw は同じ規則で、減衰の位置だけが違います。Adam の :weight-decay は勾配に wd * param を加え、AdamW はパラメータを直接縮めるので適応的な分母で再スケールされません。パラメータグループというオブジェクトはありません。互いに素なパラメータリストに対する 2 つのオプティマイザがここでのグループであり、Transformer が重み行列だけを減衰させ、バイアス・LayerNorm のゲイン・埋め込みテーブルには手を付けないのはこの方法です。
torch:clip-grad-norm は torch:backward と torch:step の間に置きます。全勾配の L2 ノルム (測定値なので、そのままログに出せます) を返し、それが上限を超えていればその場でスケールします。
ネットワークを学習させる
以上を組み合わせると、PyTorch が書くのと同じループになります。順伝播、損失、torch:zero-grad、torch:backward、torch:step です:
更新を手書きする場合は torch:no-grad で囲む必要があります。パラメータに対する torch:sub はテープに記録されてしまうからです。torch:set-data はレイヤーのフィールドが指しているテンソルそのものに新しい値を書き込むため、モデルは同じテンソルを使い続けます:
バッチ化、パディング、マスク
Dataset/DataLoader の階層はありません。バッチは普通のリストです。torch:shuffled-batches は例のリスト (または整数 n。インデックスリスト 0..n-1 を表し、複数の並行した配列を同時にバッチ化するときの書き方です) をミニバッチに切り分けます。順序はシード付き生成器から得られるので、エポックはどのバックエンドでも再現します:
torch:pad-sequence は可変長のインデックス列のバッチを、バッチ先頭のパディング済みランク 2 テンソル 1 つにまとめます。2 つのマスク構築関数は、アテンションが -infinity で埋める定数を作ります:
どちらのマスクも生の linalg 配列です (マスクは勾配を運びません)。形は (batch query-length key-length) のスコアにブロードキャストするよう決めてあり、パディングマスクは (batch 1 length)、因果マスクは (1 n n) です。torch:masked-fill は 0 でない値をすべてマスク扱いするので、2 つは linalg:add で合成できます。ここで選んだパディング値は、そのまま torch:cross-entropy-loss の :ignore-index に渡す値でもあり、パディング位置は損失に寄与しなくなります。
マスク付きアテンションスコア
torch:masked-fill はマスクが非ゼロの位置に定数を書き込みます。torch:softmax の前に -infinity で埋めるのがマスク付きアテンションのイディオムで、マスクされた重みは backward パスを含めてちょうど 0.0 になります:
実例
examples/llm-from-scratch/ は『作ってわかる大規模言語モデルの仕組み』第2章をこのパッケージで書き直したものです。スケール内積注意とマルチヘッド注意、正弦波位置エンコーディング、パディングマスクと因果マスクを備えたエンコーダ・デコーダ Transformer、そして日英の学習ループと貪欲デコードまでが入っています。PyTorch と torch の対応表は同ディレクトリの README にあります。
パッケージ
torch は cl を使用しないため、プログラムは cl-user のまま修飾名で呼び出します。#'torch:name も使えます (すべての関数は普通の defun です)。微分可能な演算は対応する linalg の関数を鏡写しにしています。全リストは関数リファレンスに、torch:no-grad はマクロのページにあります。