SONUB
JA English 한국어 日本語 中文 हिन्दी বাংলা اردو Español Português Français Deutsch Русский العربية Bahasa Indonesia Tiếng Việt ไทย Türkçe

通知を有効にする方法

このブラウザーまたはアドレスでは通知を利用できません。対応ブラウザーで安全な接続のSONUBサイトを開いてください。

通知がブロックされている場合は、ブラウザーの設定で許可してください。

  1. アドレスバー横のサイト設定、またはブラウザーのサイト設定を開き、このサイトの通知を許可に変更してください。
  2. 端末の設定でブラウザーの通知を許可し、おやすみモードや集中モードを確認してください。
  3. SONUBに戻ってページを再読み込みしてください。通知が有効になるとベルは表示されなくなります。

iPhoneやiPadではSONUBをホーム画面に追加し、そこから開いて通知を許可してください。

ログイン ログアウト 表示テーマ 標準テーマ ダークテーマ ピンクテーマ チャット 1:1 チャット グループチャット 掲示板 お知らせ ニュース掲示板 ご意見掲示板 自己紹介 メインメニュー

さあ、あなただけの人工知能を作ってみましょう。

小さなと()をすべて自分で書きます。engine.py と model.py で・因果・を作り、出力の形を確かめましょう。

単語のブロックが一列に並び、最後に空きマスがある。その上に高さのちがう棒がついた候補ブロックが3つあり、そのうち1つが選ばれて列の最後につながり、矢印が最初に戻ってまた次を選ぶ。
5

GPTの構造とコード

と位置のを足します。とKeyで関連度を求め、Valueを混ぜます。

1ヘッド・1ブロックで、現在までのkeysだけを見るため未来は見えません。・・2層・を通ります。簡潔にするため、変数なしの正規化、なしです。

全体の構造 — トークン番号から次のトークンの確率まで

の は、入力トークンごとに次の計算を1回ずつ行います。構造は と同じ専用の で、ブロックが1個・ヘッドが1個なだけです。

  • で埋め込みの表から(8マス)を取り出し、位置番号で位置ベクトルを取り出して足します。
  • 正規化してから因果で前の位置の情報を混ぜ、元のベクトルに足します(残差)。
  • もう一度正規化してから MLP で加工し、また元のベクトルに足します(残差)。
  • 最後に正規化し、出力層(head)で語彙40個ごとに点数()を出します。
  • 点数を softmax でに変えると、次のトークンの確率です。

実際の GPT は、真ん中のブロック(アテンション + MLP)を何回も積み重ねます。GPT-2 Small は12回、GPT-3 は96回です。各ブロックは前のブロックが作ったベクトルを受け取り、少しずつさらに磨きます。ブロックのの形が同じなので、何回でも積めます。

GPT 1回分の計算 — トークン番号から次のトークンの確率まで
  1. トークン番号
  2. 埋め込み + 位置
  3. 因果アテンション
  4. MLP
  5. 出力層の点数
  6. 次のトークンの確率

埋め込み — 番号で表の1行を取り出す

埋め込み(embedding)は、語彙の数 × 幅 の大きさの表です。XSLM は 40行 × 8マスです。トークン番号が5なら、5番目の行(0から数える)の数字8個を取り出すだけです。掛け算も検索もない、表の参照(lookup)です。

なぜ番号をそのまま使わずベクトルに変えるのでしょうか。番号には大きさと順序しかないので、「9番と23番が似ている」のような関係を入れられません。数字8個でできたベクトルは、いろいろな方向で互いに近かったり遠かったりできるので、学習が、似た使われ方をするトークンのベクトルを互いに近くへ動かせます。

埋め込みの表ものです。最初はランダムな数字で、学習が損失を減らす方向に直します。別に作ったを持ってくることはしません。下の実験で、ランダムに始まった5番トークンのベクトルと位置0のベクトルを直接見て、9個のの大きさを数えてみてください。

自分で確かめる lab/embedding.py
from model import GPT

m = GPT(40, 8, 12)
print('token 5   :', [round(v.data, 3) for v in m.weights['token'][5]])
print('position 0:', [round(v.data, 3) for v in m.weights['position'][0]])
for name, matrix in m.weights.items():
    rows, cols = len(matrix), len(matrix[0])
    print(name, rows, 'x', cols, '=', rows * cols)
print('total', len(m.parameters))
cd ~/xslm
mkdir -p lab
micro lab/embedding.py
python3 -m lab.embedding

実行結果

token 5   : [-0.081, 0.024, 0.043, -0.149, 0.005, 0.131, -0.201, -0.032]
position 0: [-0.102, -0.036, 0.089, 0.01, -0.085, -0.084, 0.267, 0.114]
token 40 x 8 = 320
position 12 x 8 = 96
query 8 x 8 = 64
key 8 x 8 = 64
value 8 x 8 = 64
project 8 x 8 = 64
expand 16 x 8 = 128
contract 8 x 16 = 128
head 40 x 8 = 320
total 1248

埋め込み空間 — 近いほど似ている

埋め込みは、物や言葉をいくつかの数で書き表したものです。数ひとつが軸ひとつです。下の地図は、速さ(横軸)と大きさ(縦軸)の 2 つの数で物を書きました。0 がいちばん遅くて小さく、1 がいちばん速くて大きいです。牛は [0.1, 0.8] なので、遅くて大きいです。ワシは [0.9, 0.4] なので、速くて小さいです。

数で書くと、「どれくらい似ているか」を距離ではかれます。2 つの点 [x₁, y₁] と [x₂, y₂] のあいだのは √((x₁ − x₂)² + (y₁ − y₂)²) です。距離が短いほど似ています。目覚まし時計 [0, 0] からはかると、こうなります。

  • 自転車 [0.3, 0.3]: √(0.09 + 0.09) = 0.424
  • 牛 [0.1, 0.8]: √(0.01 + 0.64) = 0.806
  • ワシ [0.9, 0.4]: √(0.81 + 0.16) = 0.985

だから、目覚まし時計にいちばん似ているのは自転車です。地図で点を押すとその点が基準になり、何もないところを押すとその場所が基準になります。近い順はすぐに決め直されます。

の埋め込みも同じ考え方です。ちがいは 2 つです。軸が 2 つではなく数百から数千あること(XSLM は 8 つ)、そして軸の意味を人ではなく学習が決めることです。だから軸に「速さ」のような名前はありませんが、似た使われ方をするトークンのベクトルどうしが近くに集まります。

距離をはかることは、AI サービスのあちこちで使われています。は質問の埋め込みにいちばん近い文書を探し、 はそうして見つけた文書をにいっしょに渡します。MariaDB のようなも、VECTOR 列と VEC_DISTANCE_EUCLIDEAN 関数で同じ計算をします。下の実験は、同じ地図を で計算します。

速さと大きさ、2 つの数で書いた埋め込みの地図です。点や何もないところを押すと、そこから近い順をはかり直します。

大きさ (Size)

1 大きい0.50 小さい

0 遅い0.51 速い

速さ (Speed)

近い順

基準点 目覚まし時計 [0, 0]

  1. 1 目覚まし時計 0.000
  2. 2 自転車 0.424
  3. 3 わたし 0.680
  4. 4 牛 0.806
  5. 5 ゾウ 0.906
  6. 6 船 0.949
  7. 7 ワシ 0.985
  8. 8 ウマ 0.990
  9. 9 自動車 1.238
  10. 10 ロケット 1.372

距離 = √((x₁ − x₂)² + (y₁ − y₂)²)

自分で確かめる lab/distance.py
import math

# A 2-number embedding: [speed, size]. 0 is the slowest and smallest, 1 the fastest and biggest.
things = {
    'alarm clock': [0.0, 0.0],
    'cow': [0.1, 0.8],
    'bicycle': [0.3, 0.3],
    'eagle': [0.9, 0.4],
    'elephant': [0.1, 0.9],
    'me': [0.2, 0.65],
    'horse': [0.7, 0.7],
    'car': [0.9, 0.85],
    'ship': [0.3, 0.9],
    'rocket': [0.99, 0.95],
}


def distance(a, b):
    return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)


for query in [[0.0, 0.0], [0.1, 0.8], [0.5, 0.5]]:
    print('from', query)
    nearest = sorted(things, key=lambda name: distance(things[name], query))
    for name in nearest[:4]:
        print(f'  {name:12} {distance(things[name], query):.3f}')
cd ~/xslm
mkdir -p lab
micro lab/distance.py
python3 -m lab.distance

実行結果

from [0.0, 0.0]
  alarm clock  0.000
  bicycle      0.424
  me           0.680
  cow          0.806
from [0.1, 0.8]
  cow          0.000
  elephant     0.100
  me           0.180
  ship         0.224
from [0.5, 0.5]
  horse        0.283
  bicycle      0.283
  me           0.335
  eagle        0.412

位置埋め込み — 何番目の位置かを知らせる

アテンションは前の位置を混ぜるとき、順序を考えません。位置の情報がなければ "dog can run" と "run can dog" のトークンベクトルは同じ集まりになります。そこで位置ごとに学習する位置ベクトルを1つずつ置き、トークンベクトルに足します。

XSLM の位置の表は (12)行 × 8マスです。行が12個しかないので、一度に入れられるトークンも12個までです。forward の最初の行の がこれを検査し、generate はそのために ids[-12:] のように最後の12トークンだけを入れます。この限界を文脈長()といいます。GPT-2 は1,024トークンで、最近のモデルは数十万トークンです。

GPT-2 と XSLM は、位置ごとにベクトルを学習します。最初の Transformer の論文は sin・cos で位置ベクトルを計算し、最近のモデルは Query・Key を位置の分だけ回転させる をよく使います。方法は違っても、目的は順序をわからせることです。

線形層・正規化・ReLU — モデルを作る3つの計算

engine.py の3つが、モデルの計算のほとんどです。

  • linear(x, W): 行列 W の行ごとに x との(同じマス同士を掛けてすべて足す)をとります。W が3行なら結果も3マスです。実験では x = [1, 2] と行 [0.5, 0.5] の内積は 0.5 + 1.0 = 1.5 です。行数を変えると、ベクトルのマスの数を増やしたり減らしたりできます。
  • norm(x): マスの平均を引いて標準偏差で割り、平均0・分散1にそろえます。[2, 4, 6] は [-1.225, 0, 1.225] になります。計算が重なっても数字が大きくなりすぎたり小さくなりすぎたりしないようにします。0で割らないよう、分散に 1e-5 を足します。
  • relu(v): 負の数は0、正の数はそのままにします。

ReLU のような非線形の関数は必ず必要です。線形層を2つつなげても結局は線形層1つと同じなので、いくら積んでも直線のような関係しか表せません。間に折れ曲がる関数を入れてこそ、複雑な関係を学べます。

XSLM の linear には足す値(bias)がなく、norm には学習する大きさ・移動の値(γ・β)がありません。 の nn.Linear・nn.LayerNorm には両方あります。

自分で確かめる lab/linear.py
from engine import linear, norm

x = [1.0, 2.0]
W = [[1.0, 0.0], [0.5, 0.5], [-1.0, 1.0]]
print('linear:', linear(x, W))
print('norm  :', [round(v, 3) for v in norm([2.0, 4.0, 6.0])])
print('relu  :', [max(0.0, v) for v in [-2.0, 0.5, 3.0]])
cd ~/xslm
mkdir -p lab
micro lab/linear.py
python3 -m lab.linear

実行結果

linear: [1.0, 1.5, 1.0]
norm  : [-1.225, 0.0, 1.225]
relu  : [0.0, 0.5, 3.0]

アテンション — Query・Key・Value で前の位置を混ぜる

アテンションは、今の位置が前の位置のうち何をどれだけ参考にするかを決め、その情報を混ぜます。位置ごとにベクトルを3つ作ります。

  • Query(q): 今の位置が探すもの。
  • Key(k): 各位置が「自分にはこんな情報がある」と見せる特徴。
  • Value(v): 各位置が実際に渡す内容。

計算は4ステップです。

  • 点数: 今の位置の q と各位置の k の内積を √幅(XSLM は √8 ≈ 2.83)で割ります。
  • 比重: 点数を softmax で合計1の比重に変えます。
  • 混ぜる: 各位置の v に比重を掛けて足します。
  • 整える: 混ぜた結果を project 行列でもう一度変換し、元のベクトルに足します。

式で書くと softmax(q·kᵀ / √d)·v です。下の実験は、手で作った key 3個と query 1個でこの計算をします(幅2なので √2 で割る)。query [2, 0] は1つ目の特徴を探すので、その特徴がある1・3番目の位置が 0.446 ずつ、ない2番目の位置が 0.108 を受け取ります。結果は1・3番目の value 側に傾いた [6.687, 3.313] です。

例えば "the cat sat because it was tired" で、it の位置のアテンションは cat の位置に大きな比重を置くように学習されることがあります。実際のモデルはヘッドを複数(GPT-2 Small は12個)置き、ヘッドごとに違う関係(指し示す言葉・すぐ前の単語・形式の印など)を見ます。

自分で確かめる lab/attention.py
from engine import Value, softmax

keys = [[1, 0], [0, 1], [1, 1]]
values = [[10, 0], [0, 10], [5, 5]]
query = [2, 0]
scores = [sum(q * k for q, k in zip(query, key)) / 2 ** 0.5 for key in keys]
weights = softmax([Value(s) for s in scores])
mixed = [sum(w.data * v[i] for w, v in zip(weights, values)) for i in range(2)]
print('scores ', [round(s, 3) for s in scores])
print('weights', [round(w.data, 3) for w in weights])
print('mixed  ', [round(m, 3) for m in mixed])
cd ~/xslm
mkdir -p lab
micro lab/attention.py
python3 -m lab.attention

実行結果

scores  [1.414, 0.0, 1.414]
weights [0.446, 0.108, 0.446]
mixed   [6.687, 3.313]

因果マスク — 未来のトークンを見せない

学習するときは区間全体を一度に入れ、位置ごとに次のトークンを当てさせます。ある位置が自分より後ろのトークンを見られたら、正解をのぞき見するのと同じなので、何も学べません。そこで各位置には自分と前の位置だけを見せます。これを因果(causal)アテンションといいます。

PyTorch の実装では、点数の表の対角線より上(未来)を −∞ で埋め、softmax の後で0になるようにします(下三角マスク)。XSLM はもっと単純です。トークンを前から1つずつ処理し、keys・values のにこれまでのものだけを積むので、未来のトークンはまだリストにありません。

だから後ろにトークンを足しても、前の位置の結果は変わらないはずです。下の実験は [1, 2] と [1, 2, 3] を入れ、前の2つの位置の点数がまったく同じかを確かめます。True が出れば、未来を見ていない証拠です。

この keys・values のリストは、実際のモデルの KV と同じ考え方です。生成するとき前のトークンの key・value を保存しておけば、新しいトークン1つだけを計算すればすみます。XSLM の generate は単純に、トークンごとに文脈全体を計算し直します。

自分で確かめる lab/causal.py
from model import GPT

m = GPT(10, 8, 12)
short = m.forward([1, 2])
long = m.forward([1, 2, 3])
same = [[v.data for v in r] for r in short] == [[v.data for v in r] for r in long[:2]]
print('first two positions unchanged:', same)
print('scores at position 3:', [round(v.data, 3) for v in long[2][:4]], '...')
cd ~/xslm
mkdir -p lab
micro lab/causal.py
python3 -m lab.causal

実行結果

first two positions unchanged: True
scores at position 3: [0.159, 0.011, -0.239, 0.428] ...

softmax — 点数を確率に、そして数値の安定

softmax は点数のリストを、0〜1の間で合計1の確率に変えます。各点数に exp をかけてから、全体の合計で割ります。exp はいつも正で、大きい点数をずっと大きくするので、順序を守りながら差を広げます。実験では点数 [2, 1, 0.1] が確率 [0.659, 0.242, 0.099] になります。

問題は、exp がとても速く大きくなることです。exp(1000) はコンピューターの実数が表せる範囲を超え、OverflowError になります(実験の math range error)。そこで engine.py の softmax は、まず最も大きい点数(peak)をすべての点数から引きます。すべての点数から同じ数を引いても確率は変わらず、最も大きい値が exp(0) = 1 になるのであふれません。[1000, 999] も [0.731, 0.269] と正しく出ます。

softmax は、アテンションの比重を出すときと、出力層の点数を次のトークンの確率に変えるときの2回使われます。

自分で確かめる lab/softmax.py
import math
from engine import Value, softmax

probs = softmax([Value(s) for s in [2.0, 1.0, 0.1]])
print([round(p.data, 3) for p in probs], round(sum(p.data for p in probs), 3))
try:
    print([math.exp(s) for s in [1000.0, 999.0]])
except OverflowError as error:
    print('without max:', error)
print('with max:', [round(p.data, 3) for p in softmax([Value(1000.0), Value(999.0)])])
cd ~/xslm
mkdir -p lab
micro lab/softmax.py
python3 -m lab.softmax

実行結果

[0.659, 0.242, 0.099] 1.0
without max: math range error
with max: [0.731, 0.269]

MLP — 位置ごとに情報を加工する

アテンションが「どこを見るか」を決めて情報を集めたなら、MLP(多層パーセプトロン、 ともいう)は「集めたものをどう変えるか」を担当します。位置ごとに別々に、同じ重みで計算します。

  • expand: 8マスを16マスに広げます。
  • ReLU: 負の数を0に折り曲げます。
  • contract: 16マスをまた8マスに縮め、元のベクトルに足します。

広げてから縮める理由は、広い空間で複数の特徴の組み合わせを別々にオン・オフする余裕を与えるためです。大きな GPT ののほとんどはこの MLP にあり、学習した事実のような知識は主にここにたまると考えられています。

XSLM は2倍に広げて ReLU を使います。GPT-2 は4倍に広げて を使います。GELU は0の近くでなめらかに折れ曲がる関数で、大きなモデルでは学習が少しよく進みます。XSLM は、を手で読みやすい ReLU を選びました。

残差接続と事前正規化 — 深く積んでも学習できるように

model.py の x = [a + b for a, b in zip(x, …)] の行が残差接続(residual connection)です。層の結果で x を置き換えず、元の x に足します。

  • 元の情報がそのまま残り、層は直すべき差だけを学べばよくなります。
  • backward のとき、足し算はをそのまま伝えるので、勾配が近道を通って前の層まで流れます。ブロックを数十個積んでも勾配が消えません。

正規化の場所も大事です。XSLM は GPT-2 と同じく、アテンションの前・MLP の前・出力層の前で norm をします(Pre-LN)。層に入る値だけを正規化し、残差の道はそのままにする方式なので、深いモデルも安定して学習できます。最初の Transformer は、足した後で正規化していました(Post-LN)。

1つのブロックを式で書くとこうなります。

  • x = x + Attention(norm(x))
  • x = x + MLP(norm(x))

出力層 — 語彙ごとに点数(logit)を出す

ブロックを通ったベクトルを最後に正規化してから head 行列(40行 × 8マス)と掛けると、語彙40個ごとに点数が1つずつ出ます。この生の点数を logit といいます。logit は負にもなり、合計も1ではなく、softmax を通してはじめて確率になります。

forward は入力の位置ごとに点数のリストを1つずつ返します。トークンを2個入れると 2 × 40 の点数が出ます。学習はすべての位置の点数を使い(位置ごとに次のトークンを当ててみる)、生成は最後の位置の点数 [-1] だけを使います。

GPT-2 は出力層の行列を別に置かず、トークン埋め込みの表を共用します(weight tying)。パラメーターが減り、トークンを読むときと出すときに同じベクトルを使うことになります。XSLM は2つの役割を区別して見せるために別々に置きます。

自動微分 — 計算グラフを逆向きにたどる

学習するには、重みを1つずつ少し変えたときに損失がどれだけ変わるか(勾配、gradient)を知る必要があります。1,248個を1つずつ揺らしてみるのは遅すぎるので、微分ので一度に求めます。これを(backpropagation)といい、その仕事を自動でしてくれる仕組みが(autograd)です。engine.py の Value がまさにその仕組みです。

  • Value は計算するたびに、結果と一緒に parents に(入力、その入力に対する局所微分)を書き残します。足し算は1、掛け算は相手の値、exp は結果自身、log は 1/x、ReLU は正なら1・そうでなければ0です。
  • backward は損失から出発して勾配 1.0 を置き、グラフを逆向きに歩きながら「自分の勾配 × 局所微分」を入力の勾配に足します(連鎖律)。
  • 1つの値が複数の場所で使われたら、道ごとの勾配をすべて足します。だから grad は = ではなく += で積み上げます。

下の実験の y = x² + 2x は、手で微分すると 2x + 2 で、x = 3 では8です。autograd が出した 8.0 と、x をほんの少し動かして測った 8.0 が同じです。c = a·b + a のように a が2回使われると dc/da = b + 1 = 6 です。2つの道の勾配が足されました。

PyTorch の loss.backward() も同じことをします。ただし数字1つではなく、テンソル単位で記録して計算します。

自分で確かめる lab/grad.py
from engine import Value

x = Value(3)
y = x * x + 2 * x
y.backward()
f = lambda v: v * v + 2 * v
h = 1e-6
print('autograd:', x.grad)
print('numeric :', round((f(3 + h) - f(3 - h)) / (2 * h), 6))
a, b = Value(2), Value(5)
c = a * b + a
c.backward()
print('dc/da =', a.grad, ' dc/db =', b.grad)
cd ~/xslm
mkdir -p lab
micro lab/grad.py
python3 -m lab.grad

実行結果

autograd: 8.0
numeric : 8.0
dc/da = 6.0  dc/db = 2.0

パラメーターを数える — 1,248個はどこにあるのか

パラメーターは学習が直す数字1つ1つです。重み(weight)とも呼びます。XSLM は9個の行列にすべてのパラメーターを入れ、行列のマスの数をすべて足すと1,248個です。下の表と前の埋め込みの実験の出力が同じかを比べてみてください。

表からいくつかのことが読み取れます。

  • 語彙に結びついた token・head の2つの表が640個で、半分を超えます。語彙を増やすと、この2つの表が大きくなります。
  • アテンションの4個(query・key・value・project)が256個、MLP の2個が256個です。幅(width)を2倍にすると、この部分は4倍になります。
  • 実際のモデルでは、ブロック(アテンション + MLP)が数十回積まれ、パラメーターのほとんどを占めます。

「 モデル」の B は billion、つまりパラメーターが80億個という意味です。保存サイズは パラメーター数 × 数字1つの大きさ です。32の実数(4バイト)なら 32GB、16ビットなら 16GB、8ビットに減らせば()、4ビットなら 4GB ほどです。実行するときは、これに計算中の値と KV キャッシュのがさらに必要です。XSLM の model.json は数字を文字で書いた なので、1,248個なのに約35KB あります。

XSLM の重み9種 — 基本資料(語彙40個)・width 8・context 12 の場合
重みの名前 形(行 × 列) 数字の個数 役割
token 40 × 8 320 トークン番号ごとにベクトル1行(トークン埋め込み)
position 12 × 8 96 位置ごとにベクトル1行()
query 8 × 8 64 今の位置が探すもの(Query)
key 8 × 8 64 各位置が見せる特徴(Key)
value 8 × 8 64 各位置が渡す内容(Value)
project 8 × 8 64 アテンションの結果をもう一度整える
expand 16 × 8 128 MLP の前の層 — 8マスを16マスに広げる
contract 8 × 16 128 MLP の後ろの層 — 16マスを8マスに縮める
head 40 × 8 320 語彙ごとの次のトークンの点数(出力層)

XSLM が減らしたもの — 実際の GPT との違い

XSLM は読みやすさのために、いくつかを省いたり減らしたりしました。GPT-2 Small と比べると次のとおりです。

  • ブロック数: XSLM 1個、GPT-2 Small 12個。ブロックを積むほど、より複雑な関係を学びます。
  • : XSLM 1個、GPT-2 Small 12個(ヘッドごとに幅 768 ÷ 12 = 64マス)。複数のヘッドが、それぞれ違う関係を同時に見ます。
  • MLP: XSLM は2倍・ReLU、GPT-2 は4倍・GELU。
  • bias と正規化の γ・β: XSLM にはなく、GPT-2 にはあります。
  • dropout: XSLM にはありません。学習中に値の一部(ふつう10%)をランダムに0にして、特定の道に頼らないようにする防止の仕組みです。生成するときはオフにします。
  • 初期値: XSLM は標準偏差 0.1、GPT-2 は 0.02 の正規分布。
  • 計算方法: XSLM は数字1つずつ、PyTorch は(束 B、長さ T、幅 C)の形のテンソルを でまとめて計算します。

省いたものはすべて、より大きく、より安定して学習するための仕組みです。次のトークンの確率を出す核心の計算は同じです。

埋め込みを検索用のベクトルとしてだけ知っていると、「GPT は次の単語に最も近いベクトルを探して選ぶ」と誤解しがちです。実際はこうです。

  • ベクトル検索(RAG など): 文書をベクトルに変えて保存しておき、質問のベクトルに近い文書を探して取り出します。結果は、すでにあった文書です。
  • GPT の予測: 入力ベクトルをブロックで計算して変えたあと、出力層が語彙全体に点数を出し、その確率から1つを引きます。結果は、計算で新しく作った分布です。

アテンションも検索ではありません。入力文の中の前の位置同士で比重を計算して混ぜるだけで、外の文書を探しません。

2つは一緒に使われます。実際のサービスは、質問で関連する文書をベクトル検索で探し、その文書をにつけて GPT に答えを生成させます(RAG)。検索は最新の情報を、GPT は文を作ることを担当します。

入力して実験

engine.py、model.pyの順に入力します。Valueは局所微分を記憶し、backwardは逆順に勾配を加算します。

cd ~/xslm
micro engine.py
micro model.py
python3 engine.py
python3 -c "from model import GPT; m=GPT(10, 8, 12); print(len(m.forward([1, 2])), len(m.forward([1, 2])[0]))"
ターミナルで実習する
engine.py手入力する完全なソース

は値、gradは勾配、parentsは局所微分です。softmaxは確率化します。

import math


class Value:
    def __init__(self, data, parents=()):
        self.data = float(data)
        self.grad = 0.0
        self.parents = parents

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, ((self, 1), (other, 1)))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, power):
        return Value(self.data ** power,
                     ((self, power * self.data ** (power - 1)),))

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * (other ** -1)

    __radd__ = __add__
    __rmul__ = __mul__

    def exp(self):
        result = math.exp(self.data)
        return Value(result, ((self, result),))

    def log(self):
        return Value(math.log(self.data), ((self, 1 / self.data),))

    def relu(self):
        return Value(max(0, self.data), ((self, float(self.data > 0)),))

    def backward(self):
        order, seen, stack = [], set(), [(self, False)]
        while stack:
            node, expanded = stack.pop()
            if expanded:
                order.append(node)
            elif node not in seen:
                seen.add(node)
                stack.append((node, True))
                stack.extend((parent, False) for parent, _ in node.parents)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, derivative in node.parents:
                parent.grad += derivative * node.grad


def softmax(values):
    peak = max(v.data for v in values)
    exps = [(v - peak).exp() for v in values]
    total = sum(exps)
    return [v / total for v in exps]


def linear(x, weights):
    return [sum(a * b for a, b in zip(x, row)) for row in weights]


def norm(x):
    mean = sum(x) / len(x)
    variance = sum((v - mean) ** 2 for v in x) / len(x)
    return [(v - mean) * (variance + 1e-5) ** -0.5 for v in x]


if __name__ == '__main__':
    x = Value(3)
    y = x * x + 2 * x
    y.backward()
    print('y =', y.data, 'dy/dx =', x.grad)
    assert y.data == 15 and x.grad == 8
model.py手入力する完全なソース

shapesで寸法を確認。すべての重みが学習対象で、stateとloadは数値を扱います。

import random
from engine import Value, linear, norm, softmax


class GPT:
    def __init__(self, vocab_size, width, context, seed=7):
        self.width, self.context = width, context
        rng = random.Random(seed)
        shapes = {
            'token': (vocab_size, width), 'position': (context, width),
            'query': (width, width), 'key': (width, width),
            'value': (width, width), 'project': (width, width),
            'expand': (2 * width, width), 'contract': (width, 2 * width),
            'head': (vocab_size, width),
        }
        self.weights = {
            name: [[Value(rng.gauss(0, 0.1)) for _ in range(cols)]
                   for _ in range(rows)] for name, (rows, cols) in shapes.items()
        }
        self.parameters = [v for matrix in self.weights.values()
                           for row in matrix for v in row]

    def forward(self, ids):
        assert 0 < len(ids) <= self.context
        w, keys, values, output = self.weights, [], [], []
        for position, token in enumerate(ids):
            x = [a + b for a, b in zip(w['token'][token], w['position'][position])]
            h = norm(x)
            q = linear(h, w['query'])
            keys.append(linear(h, w['key']))
            values.append(linear(h, w['value']))
            scores = [sum(a * b for a, b in zip(q, k)) / self.width ** 0.5
                      for k in keys]
            attention = softmax(scores)
            mixed = [sum(p * v[i] for p, v in zip(attention, values))
                     for i in range(self.width)]
            x = [a + b for a, b in zip(x, linear(mixed, w['project']))]
            hidden = [v.relu() for v in linear(norm(x), w['expand'])]
            x = [a + b for a, b in zip(x, linear(hidden, w['contract']))]
            output.append(linear(norm(x), w['head']))
        return output

    def loss(self, inputs, targets):
        logits = self.forward(inputs)
        losses = [-softmax(row)[target].log()
                  for row, target in zip(logits, targets)]
        return sum(losses) / len(losses)

    def state(self):
        return {name: [[v.data for v in row] for row in matrix]
                for name, matrix in self.weights.items()}

    def load(self, state):
        assert set(state) == set(self.weights), 'checkpoint keys'
        for name, matrix in self.weights.items():
            assert len(state[name]) == len(matrix), 'checkpoint rows'
            for row, saved in zip(matrix, state[name]):
                assert len(saved) == len(row), 'checkpoint columns'
                for value, number in zip(row, saved):
                    value.data = float(number)

コードを1行ずつ読み解く

engine.py Value — 数字1つとその来歴

class Value:
    def __init__(self, data, parents=()):
        self.data = float(data)
        self.grad = 0.0
        self.parents = parents

Value は数字1つを包みます。data は値、grad は損失をこの値で微分した値(最初は0)、parents はこの値を作った入力と、その入力に対する局所微分のリストです。重みのように最初からある値は parents が空です。float(data) に変えておくので、整数を入れても実数で計算します。

engine.py 演算子 — 計算しながら微分を書き残す

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, ((self, 1), (other, 1)))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, power):
        return Value(self.data ** power,
                     ((self, power * self.data ** (power - 1)),))

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * (other ** -1)

    __radd__ = __add__
    __rmul__ = __mul__

Python は a + b を a.__add__(b) に置き換えて呼びます。この特殊メソッドを定義すると、Value 同士を普通の式のように計算できます。

  • __add__: 結果 = a + b。局所微分は a 側が1、b 側が1。
  • __mul__: 結果 = a × b。局所微分は a 側が b の値、b 側が a の値。
  • __pow__: a の n 乗。局所微分は n × a の (n − 1) 乗。
  • __neg__・__sub__・__truediv__: 引き算は −1 を掛けて足すことに、割り算は −1 乗を掛けることに置き換え、上の3つを使い回します。新しい微分の規則は要りません。
  • other が Value でなければ(2 のような普通の数)、Value で包みます。
  • __radd__・__rmul__: 2 * x のように左が普通の数のときに呼ばれます。sum() は 0 + 最初の値 から始めるので、__radd__ があってはじめて Value のリストを sum できます。

engine.py exp・log・relu — 関数の微分

    def exp(self):
        result = math.exp(self.data)
        return Value(result, ((self, result),))

    def log(self):
        return Value(math.log(self.data), ((self, 1 / self.data),))

    def relu(self):
        return Value(max(0, self.data), ((self, float(self.data > 0)),))

exp の微分は自分自身なので、結果の値 result をそのまま書きます。log x の微分は 1/x です。relu は正なら勾配1、0以下なら0を書きます。float(self.data > 0) は真なら 1.0、偽なら 0.0 です。この3つと四則演算さえあれば、モデル全体の微分を求められます。

engine.py backward — グラフを逆向きに歩いて勾配を集める

    def backward(self):
        order, seen, stack = [], set(), [(self, False)]
        while stack:
            node, expanded = stack.pop()
            if expanded:
                order.append(node)
            elif node not in seen:
                seen.add(node)
                stack.append((node, True))
                stack.extend((parent, False) for parent, _ in node.parents)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, derivative in node.parents:
                parent.grad += derivative * node.grad

2段階です。

  • 順序を作る(46〜54行): stack でグラフを深さ優先で探索し、order を作ります。ノードが (ノード, False) で最初に取り出されたら seen に印をつけ、(ノード, True) をもう一度積んでから親を積みます。親をすべて処理した後に (ノード, True) が取り出されたとき order に入れるので、order ではいつも入力が結果より先に来ます(トポロジカルソート)。
  • 勾配を広げる(55〜60行): グラフ内のすべてのノードの grad を0に消し、損失自身は 1.0(dL/dL = 1)にします。order を逆向きに(結果から入力へ)歩きながら、親ごとに「局所微分 × 自分の grad」を足します。逆向きに歩くので、あるノードの grad がすべての使われ方から集まりきってから、その親へ進みます。

engine.py softmax — あふれない確率

def softmax(values):
    peak = max(v.data for v in values)
    exps = [(v - peak).exp() for v in values]
    total = sum(exps)
    return [v / total for v in exps]

peak は点数の中で最も大きい値(.data で取り出した普通の数)です。すべての点数から peak を引いて exp するので、最も大きい値が1になり、あふれません。合計 total で割ると、合計1の確率になります。peak は普通の数として引くのでグラフには記録されませんが、すべての点数から同じ数を引いても確率は変わらないので、微分もそのまま正しくなります。

engine.py linear・norm — 行列の掛け算と正規化

def linear(x, weights):
    return [sum(a * b for a, b in zip(x, row)) for row in weights]


def norm(x):
    mean = sum(x) / len(x)
    variance = sum((v - mean) ** 2 for v in x) / len(x)
    return [(v - mean) * (variance + 1e-5) ** -0.5 for v in x]

linear は weights の行(row)ごとに、x と同じマス同士を掛けて足した値を出します。だから weights は(出力のマス数)行 ×(入力のマス数)マスで、結果は行数分のマスを持ちます。PyTorch の nn.Linear も、重みを(出力, 入力)の形で保存します。norm は平均 mean と分散 variance を求めてから、(値 − 平均) ÷ √(分散 + 0.00001) に変えます。-0.5 乗は平方根で割るのと同じです。

engine.py 自分で検査する

if __name__ == '__main__':
    x = Value(3)
    y = x * x + 2 * x
    y.backward()
    print('y =', y.data, 'dy/dx =', x.grad)
    assert y.data == 15 and x.grad == 8

engine.py を直接実行すると、y = x² + 2x を x = 3 で計算して微分します。y = 9 + 6 = 15、dy/dx = 2·3 + 2 = 8 になるはずで、assert がこれを確かめます。エンジンを直したら、まずこの検査を動かし直してみてください。

model.py 重み9種を作る

class GPT:
    def __init__(self, vocab_size, width, context, seed=7):
        self.width, self.context = width, context
        rng = random.Random(seed)
        shapes = {
            'token': (vocab_size, width), 'position': (context, width),
            'query': (width, width), 'key': (width, width),
            'value': (width, width), 'project': (width, width),
            'expand': (2 * width, width), 'contract': (width, 2 * width),
            'head': (vocab_size, width),
        }
        self.weights = {
            name: [[Value(rng.gauss(0, 0.1)) for _ in range(cols)]
                   for _ in range(rows)] for name, (rows, cols) in shapes.items()
        }
        self.parameters = [v for matrix in self.weights.values()
                           for row in matrix for v in row]

shapes が、行列ごとに(行数, マス数)を決めます。辞書内包表記が、名前ごとに 行数 × マス数 だけ Value(rng.gauss(0, 0.1)) を作ります。平均0、標準偏差 0.1 の正規分布から引いた小さな数です。すべて0で埋めると、すべてのマスが同じように動いて違う特徴を学べないので、ランダムに始めます。rng を seed で作るので、いつも同じ最初の値になります。parameters は9個の行列の Value を1つのリストに広げたもので、学習が直す数字のすべてです。

model.py forward の始まり — トークンと位置を足す

    def forward(self, ids):
        assert 0 < len(ids) <= self.context
        w, keys, values, output = self.weights, [], [], []
        for position, token in enumerate(ids):
            x = [a + b for a, b in zip(w['token'][token], w['position'][position])]
            h = norm(x)

assert が入力の長さを1以上 context 以下に制限します。位置の表の行数が context なので、それより長いと位置ベクトルがありません。keys・values は前の位置の key・value を積むリストです。ごとに position(0から)と token の番号を受け取り、トークンベクトルと位置ベクトルをマスごとに足して x を作ります。h = norm(x) は、アテンションに入れる正規化した値です(Pre-LN)。

model.py 因果アテンション 1ヘッド

            q = linear(h, w['query'])
            keys.append(linear(h, w['key']))
            values.append(linear(h, w['value']))
            scores = [sum(a * b for a, b in zip(q, k)) / self.width ** 0.5
                      for k in keys]
            attention = softmax(scores)
            mixed = [sum(p * v[i] for p, v in zip(attention, values))
                     for i in range(self.width)]
            x = [a + b for a, b in zip(x, linear(mixed, w['project']))]

q は今の位置の query で、今の位置の key・value はリストの最後に足します。リストにはこれまでの位置しかないので、未来は見えません。scores は q と各 key の内積 ÷ √width、attention はその softmax、mixed は value を attention の比重でマスごとに足したベクトルです。最後の行で mixed を project で変換し、x に足します(残差)。

model.py MLP の2層

            hidden = [v.relu() for v in linear(norm(x), w['expand'])]
            x = [a + b for a, b in zip(x, linear(hidden, w['contract']))]

x を正規化して expand で16マスに広げ、マスごとに relu をかけた hidden を作ります。contract でまた8マスに縮め、x に足します(残差)。アテンションと違い、他の位置を見ずに今の位置のベクトルだけを加工します。

model.py 出力層の点数

            output.append(linear(norm(x), w['head']))
        return output

x を最後に正規化して head と掛けると、語彙の数だけ点数が出て、output に位置ごとに積みます。繰り返しが終わると、output は(入力の長さ)×(語彙の数)の点数表です。

model.py loss — 位置ごとの −log 確率の平均

    def loss(self, inputs, targets):
        logits = self.forward(inputs)
        losses = [-softmax(row)[target].log()
                  for row, target in zip(logits, targets)]
        return sum(losses) / len(losses)

forward で位置ごとの点数を得て、その位置の softmax 確率のうち、正解 target の確率に log をかけて符号を反転します。すべての位置の損失を足し、位置の数で割った平均を返します。結果も Value なので、loss.backward() を呼べます。

model.py state・load — 重みの保存と読み込み

    def state(self):
        return {name: [[v.data for v in row] for row in matrix]
                for name, matrix in self.weights.items()}

    def load(self, state):
        assert set(state) == set(self.weights), 'checkpoint keys'
        for name, matrix in self.weights.items():
            assert len(state[name]) == len(matrix), 'checkpoint rows'
            for row, saved in zip(matrix, state[name]):
                assert len(saved) == len(row), 'checkpoint columns'
                for value, number in zip(row, saved):
                    value.data = float(number)

state は、Value の代わりに普通の数(.data)だけを取り出した同じ形の辞書を返します。Value にはのつながりがあり、JSON で保存できないからです。load は逆に、保存した数字を Value の data に入れます。その前に行列の名前・行数・マス数が今のモデルと同じかを assert で確かめ、違う大きさのチェックポイントを気づかずに間違って読まないようにします。

理解を確認

微分検査は15.0と8.0、形状検査は2 10です。未来トークンが過去の出力を変えない理由を説明します。

困ったとき

行列サイズとzipの長さを確認。全層の重みが逆伝播で学習します。

よくある質問

重みとベクトルは同じものですか?

役割と形の違いです。重み(パラメーター)は学習が直す数字という役割のことで、ベクトルは数字が1列に並んだ形のことです。埋め込みの表全体が重みで、その表から取り出した1行がトークンのベクトルです。計算中にできる q・k・v のようなベクトルは重みではなく、そのつどの中間値です。

知識はモデルのどこに保存されるのですか?

決まったマスに文が入っているわけではありません。写真が1つではなく、すべてのピクセルの配置で絵になるように、知識はすべての重みが一緒に作った計算の習慣に散らばっています。だから特定の事実1つだけを選んで消したり直したりするのは難しいのです。

なぜ √幅 で割るのですか?

幅が d のランダムなベクトル2つの内積は、d が大きいほど大きく揺れます(標準偏差が √d 倍)。点数が大きいと softmax がほぼ1つの位置にだけ1を与え、他の位置に勾配が流れません。√d で割ると、幅に関係なく点数の大きさが同じくらいに保たれます。

backward はなぜ再帰の代わりに stack を使うのですか?

計算グラフは学習1回で3万個を超えるノードが長くつながります。再帰関数でたどると、Python の再帰の上限(基本1,000段)を超えて RecursionError になることがあります。stack で順序を自分で管理すれば、グラフが深くても安全にトポロジカルソート(入力が先に来る順序)を作れます。

ガイドライン

人工知能

エージェントツール

バイブコーディング

AI活用

AI資格

AI授業

IT基礎

チャット

掲示板

制作

コミュニティ

その他

用語辞典

知らない言葉を選んでみましょう。やさしい説明と関連用語が見られます。

その用語はまだありません。

用語を選ぶと説明が表示されます。