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
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
例えば "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
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] ...
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]
学習するには、重みを1つずつ少し変えたときに損失がどれだけ変わるか(勾配、gradient)を知る必要があります。1,248個を1つずつ揺らしてみるのは遅すぎるので、微分ので一度に求めます。これを(backpropagation)といい、その仕事を自動でしてくれる仕組みが(autograd)です。engine.py の Value がまさにその仕組みです。
Value は計算するたびに、結果と一緒に parents に(入力、その入力に対する局所微分)を書き残します。足し算は1、掛け算は相手の値、exp は結果自身、log は 1/x、ReLU は正なら1・そうでなければ0です。
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
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)
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]
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']))]
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 で確かめ、違う大きさのチェックポイントを気づかずに間違って読まないようにします。