banner
Nagi-ovo

Nagi-ovo

Breezing
github

LLMの進化の歴史(1):ビグラムの簡潔な道

本節のソースコードリポジトリのアドレス

前にmicrogradを実装することで、勾配の意味と最適化の方法を理解しました。今、私たちは言語モデルの学習段階に入ることができ、初級段階の言語モデルがどのように設計され、モデル化されるかを学びます。

言語モデルの発展#

この記事では、今のところ非常にシンプルなモデルアーキテクチャである Bigram に焦点を当てます。これはマルコフ連鎖の仮定に基づいており、次の単語の出現は前の単語のみに依存します。

データセットの紹介#

32033 個の英語の名前が含まれており、長さは 2 から 15 の範囲です。

Screenshot 2024-01-22 at 15.40.42

名前の中の情報#

データセットの 4 番目のデータ「isabella」を例にとると、この単語には多くの例が含まれています:

  • 文字 i は名前の最初の位置に出現する可能性が非常に高い文字です
  • s は i の後に続く可能性が非常に高いです;
  • a は is の後に続く可能性が非常に高いです...... このように続きます;
  • 「isabella」が出現した後、この単語は非常に高い確率で終了します。これは非常に重要な情報です。

Bigram#

このモデルでは、常に 2 文字ずつ処理し、特定の文字を見てシーケンス内の次の文字を予測します。このような局所構造のみに基づいてモデル化するため、非常にシンプルで弱いモデルです。

for w in words[:3]:
    chs = ['<S>'] + list(w) + ['<E>']
    for ch1, ch2 in zip(chs, chs[1:]):
        print(ch1, ch2)

Screenshot 2024-01-22 at 17.39.31

bigram の最もシンプルな実装方法は「カウント」であり、基本的にこれらの組み合わせがトレーニングセットに出現する頻度を計算するだけです。

各 bigram のカウントを維持するために辞書を使用できます:

Screenshot 2024-01-22 at 17.51.42

文字の組み合わせのマッピングを作成します:

N = torch.zeros((28,28), dtype=torch.int32)

chars = sorted(list(set(''.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)}
stoi['.'] = 0 # .を置き換えシンボルとして使用し、<S>が2番目の文字になるなどの不可能な状況を避けます
itos = {i:s for s,i in stoi.items()}
itos

前のカウント統計ループをコピーして修正します:

for w in words:
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1 = stoi[ch1]
        ix2 = stoi[ch2]
        N[ix1, ix2] += 1

出力をより美しくするために、matplotlibライブラリを使用します:

import matplotlib.pyplot as plt
%matplotlib inline

plt.figure(figsize=(16,16))
plt.imshow(N, cmap='Blues')
for i in range(27):
    for j in range(27):
        chstr = itos[i] + itos[j]
        plt.text(j, i, chstr, ha='center', va='bottom', color='gray')
        plt.text(j, i, N[i, j].item(), ha='center', va='top', color='gray') # ここでN[i,j]はtorch.Tensorであり、.item()を使用してその値を取得します
plt.axis('off')

Pasted image 20240122183831

現在、Bigram モデルに必要なサンプリングはほぼ完了しました。次に行うことは、これらのカウント(確率)に基づいてモデルからサンプリングすることです:

  • 名前の最初の文字をサンプリングします:最初の行を見てください

Screenshot 2024-01-22 at 18.41.31

最初の行のカウントは、すべての文字が最初の文字として出現する頻度を教えてくれます。

今、私たちはtorch.multinomial(与えられた多項分布からランダムにサンプリングするための関数)を使用して、この分布からサンプリングできます。具体的には、入力テンソル(Tensor)内の重みに基づいてサンプルをランダムに抽出し、これらの重みは各要素が選ばれる確率を定義します。

実験の再現性を確保するために、torch.Generatorを使用します:

Screenshot 2024-01-22 at 19.03.47

意味は:半分以上が 0 で、小部分が 1、2 は非常に少数です

p = N[0].float()
p = p / p.sum()

Screenshot 2024-01-22 at 19.11.23

最初の文字が c と予測されると、次は c で始まる行を見ます...... この論理に従ってループを構築します:

Screenshot 2024-01-22 at 19.19.14

20 回ループします:

for i in range(20):
    out = []
    ix = 0
    while True:
        p = N[ix].float()
        p = p / p.sum()
        ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
        out.append(itos[ix])
        if ix == 0:
            break
    print(''.join(out))

Screenshot 2024-01-22 at 20.56.09

結果は少し奇妙ですが、実際にはコードに問題はありません。なぜなら、現在のモデル設計が得られる結果はこのようなものだからです。

現在のモデルは単一文字の名前も生成することがあります。例えば「h」です。この理由は、次の文字の可能性を知っているだけで、それが最初の文字であるかどうかはわからず、非常に局所的な関係しか捉えられないからです。しかし、完全に訓練されていないモデルよりは良い結果が得られ、少なくとも部分的には名前のように見える結果が得られます。

現在、各ループで確率を一度計算しています。効率を向上させ、torch API の Tensor 操作を練習するために、次のようにします:

Screenshot 2024-01-22 at 22.10.53

dimkeepdim=Trueにより、次元インデックスを指定できます。ここでは、27 行それぞれの合計を計算したいので、2 次元テンソルではdim=1です。

さらに重要な点は、PyTorch のbroadcasting semantics(ブロードキャストセマンティクス)です。

簡単に言えば、PyTorchの操作がブロードキャストをサポートしている場合、そのテンソルパラメータは自動的に同じサイズに拡張されます(データをコピーする必要はありません)。

テンソルが次の条件を満たす場合、ブロードキャストが可能です:

  • 各テンソルは少なくとも 1 つの次元を持つ必要があります。
  • 次元サイズを走査する際、尾部次元から始めて、両者の次元は等しくなければならず、どちらか一方は1であるか、存在しない必要があります。

Screenshot 2024-01-22 at 22.19.51

現在の状況は条件を満たしており、ブロードキャスト可能です。すなわち (27x27) / (27x1) であり、(27x1) が 27 回コピーされ、各行に対して商を計算し、正規化が行われます。

P = N.float()
P /= P.sum(dim=1, keepdim=True)
# “インプレース操作”(in-place operation)を使用して、新しいテンソルを作成しないようにします

for i in range(50):
    out = []
    ix = 0
    while True:
        p = P[ix] # 再計算を避けます
        ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
        out.append(itos[ix])
        if ix == 0:
            break
    print(''.join(out))

最終的な形式

さて、問題が発生しました。このモデルがトレーニングセットを予測する効果を判断するために、損失関数をどのように設定すればよいのでしょうか?

Screenshot 2024-01-23 at 18.38.45

各組み合わせの確率を印刷すると、ランダム予測(1274%\frac{1}{27}\approx4\%)と比較して、一部の組み合わせは最大39%39\%に達することがわかります。これは明らかに良いことです。特に、モデルの性能が非常に優れている場合、これらの期待値は 1 に近づくはずです。これは、モデルがトレーニングセットで次に何が起こるかを正しく予測したことを意味します。

負の対数尤度損失#

では、これらの確率をどのようにまとめてモデルの質を測る数字にするのでしょうか?

最大尤度推定や統計モデルなどの文献を研究すると、通常「尤度(likelihood)」と呼ばれるものが使用されていることがわかります。このモデル内のすべての確率の積が尤度であり、この積は高い方が良いとされています。現在のモデルの確率はすべて 0-1 の間の比較的小さな数字であり、全体の積も非常に小さな値になります。

便宜上、人々は通常「対数尤度(log likelihood)」を使用します。ここでは、確率の対数を取るだけで対数尤度を得ることができます:

Screenshot 2024-01-23 at 19.31.22

ここで prob の型を Tensor に戻して、torch.log()を呼び出せるようにします。確率が大きくなるほど、対数尤度は0に近づきます。

Screenshot 2024-01-23 at 19.35.26

log(x),x(0,1)log(x) , x\in(0,1)のプロット

基本的な数学の知識に基づいて:log(abc)=log(a)+log(b)+log(c)log(a*b*c)=log(a)+log(b)+log(c)
対数尤度は各確率の対数の和であることがわかります。

Screenshot 2024-01-23 at 23.35.41

現在の定義の下では、モデルが良いほど対数尤度値は 0 に近づき、誤差は小さくなります。しかし、「損失関数」の意味は「低いほど良い」、つまり「損失を減少させる」ことです。したがって、現在の式の逆数が必要であり、これにより負の対数尤度negative log likelihood)が得られます:

log_likelihood = 0.0
n = 0 # 平均を取るためのカウント
for w in words[:3]:
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1 = stoi[ch1]
        ix2 = stoi[ch2]
        prob = P[ix1, ix2]
        logprob = torch.log(prob)
        log_likelihood += logprob
        n += 1
        print(f'{ch1}{ch2}:{prob:.4f} {logprob:.4f}')

nll = -log_likelihood 
print(f'{nll / n}') # 正規化

まとめると、現在の損失関数は非常に良好です。なぜなら、その最小値は 0 であり、値が高いほど予測効果が悪いことを示します。私たちのトレーニングタスクは、負の対数尤度損失を最小化するパラメータを見つけることです。最大化尤度は対数尤度を最大化することと同等であり、log は単調関数であるため、元の尤度関数にスケーリングの効果を与えます。対数尤度を最大化することは、負の対数尤度を最小化することと同等であり、実践では平均負の対数尤度を最小化することになります。

モデルスムージング#

「jq」を含む名前の例を挙げると、損失が無限大になります。理由は、トレーニングセットに「jq」の組み合わせが全く存在しないため、発生確率が 0 になり、このような状況を引き起こすからです。

Screenshot 2024-01-23 at 23.54.40

この問題を解決するための非常にシンプルな方法はモデルスムージング(model smoothing)であり、各組み合わせの数に 1 を加えることができます:

P = (N + 1).float()

Screenshot 2024-01-23 at 23.59.42

ここでスムージングを加えるほど、得られるモデルはより平坦になります。加える量が少ないほど、モデルはよりピークになります。1 は非常に良い値であり、確率行列 P に 0 が出現しないことを保証し、無限損失の問題を解決します。

ニューラルネットワークフレームワークの使用#

ニューラルネットワークフレームワークでは、問題を処理する方法がわずかに異なりますが、非常に似た場所で終わります。このネットワークのパラメータを調整するために勾配に基づく最適化を使用し、重みを調整してニューラルネットワークが次の文字を正しく予測できるようにします。

最初のステップは、このニューラルネットワークのトレーニングセットをコンパイルすることです:

Screenshot 2024-01-24 at 00.20.51

ここでの意味は、入力文字列のインデックスが 0 のとき、正しい予測ラベルは 5 であるべきだということです。

tensor と Tensor の違い#

Screenshot 2024-01-24 at 00.16.49

違いは、torch.Tensorのデフォルトタイプがfloat32であるのに対し、torch.tensorは提供されたデータに基づいて自動的にデータ型を推測することです。

したがって、小文字のtensorを使用することをお勧めします。ここで必要なのはint型の整数であるため、小文字を使用します。

ワンホットエンコーディング#

ここでは直接単純に代入することはできません。なぜなら、現在のサンプルはすべて整数であり、提供されるのは文字のインデックスであり、入力ニューロンが入力された整数のインデックスを取得して重みを掛けることは意味がないからです。

一般的な整数エンコーディング方法の 1 つはワンホットエンコーディングone-hot encoding)です:

13 の例を挙げると、ワンホットエンコーディングでは、この整数を取得し、13 次元目が 1 で他はすべて 0 のベクトルを作成します。このベクトルをニューラルネットワークに入力できます。

import torch.nn.functional as F

xenc = F.one_hot(xs, num_classes=27)

Screenshot 2024-01-24 at 18.09.56

次元を指定する必要があります。さもなければ、合計で 13 次元しかないと推測される可能性があります。

しかし、現在のxenc.dtypeはその型がint64であることを示しており、torch はont_hotの出力型を指定することをサポートしていないため、強制的に変換する必要があります:

xenc = F.one_hot(xs, num_classes=27).float()

Screenshot 2024-01-24 at 18.13.24

現在の結果はfloat32浮動小数点数であり、ニューラルネットワークに入力できます。

ニューロンの構築#

micrograd で紹介されたように、ニューロンが実行する機能は基本的に入力値xxに対してwx+bw\cdot x+bを行うことです。この演算はドット積です:

W = torch.randn((27,27))
# (5,27) @ (27,27) = (5,27)
xenc @ W

Screenshot 2024-01-24 at 21.37.22

現在、27 次元の入力を 27 個のニューロンを持つニューラルネットワークの第一層に入力しています。

では、ニューラルネットワークの出力をどのように解釈するのでしょうか?

現在の出力には正と負があり、前のカウントと確率はすべて正数です。したがって、ニューラルネットワークが出力するのは対数カウント(log counts、記号は logits)であり、正のカウントを得るためには対数カウントを指数化する必要があります:

Screenshot 2024-01-24 at 21.51.47

ソフトマックス#

# 27ニューロンの重みをランダムに初期化し、各ニューロンは27の入力を受け取ります
g = torch.Generator().manual_seed(2147483647)
W = torch.randn((27,27), generator=g)

# フォワードパス

xenc = F.one_hot(xs, num_classes=27).float()
logits =  xenc @ W # 対数カウントを予測
counts = logits.exp() # カウント、Nに相当
probs = counts / counts.sum(dim=1, keepdim=True) # 次の文字の確率

ちなみに、ここでの最後の 2 行はソフトマックスと呼ばれます。

ソフトマックスはニューラルネットワークでよく使用される層であり、対数logitsを取り、指数化して正規化します。これは、ニューラルネットワーク層の出力を取得する方法であり、出力結果は常に 1 に等しく、すべて正数であり、確率分布のようになります。これは、ニューラルネットワークの任意の線形層の上に置くことができます。

nlls = torch.zeros(5)
for i in range(5):
    # i番目のbigram
    x = xs[i].item()
    y = ys[i].item()
    print('---------')
    print(f'bigram example {i+1}: {itos[x]}{itos[y]} (indexes {x},{y})') 
    print('inputs to neural net:', x)
    print('outputs probabilities from the neural net:', probs[i])
    print('label (actual next character):', y)
    p = probs[i, y]
    print('probability assigned by the net to the correct character:', p.item())
    logp = torch.log(p)
    print(f'logp=')
    nll = -logp
    print('negative log-likelihood:', nll.item())
    nlls[i] = nll

print('===========')
print('average negative log-likelihood, i.e. loss:', nlls.mean().item())

現在の損失はすべて微分可能(differentiable)な操作で構成されており、これらのWWの重み行列の損失勾配に基づいてWWを調整して損失を最小化できます。

最初の例で興味のある確率は次の通りです:

Screenshot 2024-01-24 at 22.36.15

これらの確率にアクセスする効率を向上させるために、タプルに保存するのではなく、torch で次のようにできます:

Screenshot 2024-01-24 at 22.37.39

逆伝播を実行し、各重みの勾配情報を取得しました:

Screenshot 2024-01-24 at 23.00.34

例えば [0,0]=0.0121 は、この重みが増加するとその勾配が正であり、したがって損失も増加することを示しています。

これらの情報に基づいてニューラルネットワークの重みを更新できます。micrograd の実装と類似しています:

W.data += -0.1 * W.grad

再度フォワードパスを計算し、より低い損失が得られることを期待します:

Screenshot 2024-01-24 at 23.18.14

確かに以前の 3.76 よりも小さくなっています。

# 勾配降下法
for k in range(1000):

    # フォワードパス
    xenc = F.one_hot(xs, num_classes=27).float()
    logits = xenc @ W # 対数カウントを予測
    counts = logits.exp() # カウント、Nに相当
    probs = counts / counts.sum(dim=1, keepdim=True) # 次の文字の確率
    loss = -probs[torch.arange(5), ys].log().mean() + 0.01 * (W**2).mean() # L2正則化
    print(loss.item())

    # バックワードパス
    W.grad = None
    loss.backward()

    # 更新
    W.data += -50 * W.grad

その後、ニューラルネットワークを複雑化し、最終的に Transformer を使用するまで、ニューラルネットワークに渡す構造は根本的に変わることはありません。唯一の違いは、フォワードパスを行う方法です。

bigram をより多くの入力文字の状況に拡張することは明らかに現実的ではありません。なぜなら、文字の組み合わせが非常に多くなるからです。この方法はスケーラブルではありません。一方、ニューラルネットワークの方法は明らかにスケーラビリティが高く、継続的に改善できます。これがこのシリーズの研究方向です。

正則化#

実際、勾配に基づくフレームワークとスムージングは等価です:
もしWWのすべての要素が等しい、または特に 0 である場合、logitsは 0 になり、これらの対数を取って指数を計算すると 1 になり、確率は完全に一致します。

したがって、W を 0 に近づけるように促すことは、基本的にラベルスムージングに相当します。損失関数内で促すほど、得られる分布はよりスムーズになります。これが **「正則化」** を引き起こします。実際、損失関数に小さな成分を追加することができます。これを「正則化損失」と呼びます。

loss = -probs[torch.arange(5), ys].log().mean() + 0.01 * (W**2).mean() # L2正則化

現在、すべての確率が正常に機能するように促すだけでなく、すべてのWWを 0 に近づけるようにも促しています。重みが 0 に近づくことで、モデルの出力が入力の変化に対してそれほど敏感でなくなり、過剰適合を防ぐのに役立ちます。分類タスクでは、これによりモデルの各クラスに対する予測確率分布がより均衡になり、特定のクラスに対する過度の自信が減少する可能性があります。

効果が同じモデル#

Screenshot 2024-01-25 at 01.36.30

2 つのモデルの損失が近く、サンプリング結果が確率行列を使用したモデルと同じであることがわかります。これは、実際には同じモデルであることを示していますが、後者の方法はニューラルネットワークという非常に異なる解釈で同じ結果を得ています。

次に、これらのようなキャラクターをニューラルネットワークに入力し、同じ結果を得ることになります。ニューラルネットワークは logits を出力し、これらは依然として同じ方法で正規化されます。すべての損失や勾配に基づくフレームワーク内の他のものは同じですが、ニューラルネットワークは常に複雑になり、最終的には Transformer に至ります。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。