《A Neural Probabilistic Language Model》#
この記事は言語モデルのトレーニングに関する古典的な作品であり、Bengio は神経ネットワークを言語モデルのトレーニングに導入し、単語埋め込みという副産物を得ました。単語埋め込みは、後の深層学習における自然言語処理に大きな貢献をし、単語の意味的特徴を取得するための効果的な方法でもあります。
論文の提案は、元の単語ベクトル(one-hot 表現)が次元の呪いを引き起こす問題を解決することに由来しています。著者は、単語の分散表現を学習することでこの問題を解決することを提案しました。著者は n-gram モデルに基づき、コーパスを使用して神経ネットワークをトレーニングし、前の n 個の単語が現在の単語を予測する確率を最大化しました。このモデルは、各単語の分散表現と単語列の確率分布関数の両方を学習しました。
古典的な公式
このモデルが学習した語彙表現は、従来の one-hot 表現とは異なり、単語埋め込み間の距離(ユークリッド距離、コサイン距離など)を通じて語彙間の類似度を表すことができます。例えば、The cat is walking in the bedroom A dog was running in a room の中で、cat と dog は類似の意味を持ち、埋め込み空間を通じて知識を伝達し、新しいシーンに適用することができます。
共有ルックアップテーブル
隠れ層のサイズはハイパーパラメータです
出力層には 17000 個のニューロンがあり、隠れ層のニューロンと完全に接続されています。つまり、logits です。
神経ネットワークにおいて、「logits」という用語は通常、最後の線形層(すなわち、活性化関数が適用されていない)の出力を指します。分類タスクにおいて、この線形層の出力は softmax 関数に入力され、確率分布を生成します。logits は本質的に、モデルが各クラスに対して未正規化された予測スコアを反映しており、各クラスに対するモデルの信頼レベルを示すものと見なすことができます。
なぜ「logits」と呼ばれるのか?#
この用語はロジスティック回帰に由来し、「logit」関数はロジスティック関数の逆関数です。二項分類のロジスティック回帰において、出力確率と logitの関係は次のように表されます:
は、事象が発生する確率と発生しない確率の比を表し、odds、すなわちオッズと呼ばれます。確率に比べて、オッズの利点は、出力範囲が全実数範囲に拡張され、特徴と出力間に線形関係があり、尤度関数が簡潔であることです。
ここで、は logit です。神経ネットワークにおいて、logit 関数を直接使用していないにもかかわらず、「logits」という用語はネットワークの原始的な出力を説明するために使用されます。なぜなら、これらの原始的な出力は softmax 関数を通過する前にロジスティック回帰の logits に似ているからです。
多クラス問題において、ネットワークの logits は通常、各要素が 1 つのクラスの logit に対応するベクトルです。例えば、手書き数字認識(MNIST データセットなど)を処理するモデルの場合、出力 logits は 10 個の要素を持つベクトルであり、各要素は数字クラス(0 から 9)の予測スコアに対応します。
softmax 関数は logits を確率分布に変換します:
ここで、は第個の logit を指数化して正の数にし、すべての指数化された logits の合計で割ることで正規化し、有効な確率分布を得ます。
まとめ:神経ネットワークの文脈において、logits はモデルが各クラスに対して行う原始的な予測出力であり、通常は softmax 関数を適用する前に得られます。これらの原始的なスコアはモデルの予測信頼を反映しており、トレーニング中に損失を計算するために使用されます。特に交差エントロピー損失は、多くの分類タスクで一般的に使用される損失関数です。
NPLM の構築#
データセットの作成#
# データセットを構築する
block_size = 3 # コンテキストの長さ:次の文字を予測するためにどれだけの文字を取るか
X, Y = [], []
for w in words:
print(w)
context = [0] * block_size
for ch in w + '.':
ix = stoi[ch]
X.append(context)
Y.append(ix)
print(''.join(itos[i] for i in context), '--->', itos[ix])
context = context[1:] + [ix] # 切り取りと追加
X = torch.tensor(X)
Y = torch.tensor(Y)
今、神経ネットワークを構築し、X を使って Y を予測します。
ルックアップテーブル#
私たちは、出現する可能性のある 27 個の文字を低次元空間に埋め込む必要があります(元の論文では 17000 個の単語を 30 次元空間に埋め込んでいます)。
C = torch.randn((27,2))
F.one_hot(torch.tensor(5), num_classes=27).float() @ C
# (1, 27) @ (27, 2) = (1, 2)
これは C の 5 行目だけを保持していることに相当します。
つまり、計算量を減らすのは単語ベクトルの出現によるものではなく、one-hot の行列演算をルックアップ操作に簡素化したためです。
隠れ層#
W1 = torch.randn((6, 100)) # 入力数:3 x 2 = 6,100個のニューロン
b1 = torch.randn(100)
emb @ W1 + b1 # この形式を得たい
しかし、emb の形状が[228146, 3, 2]
であるため、3 と 2 を組み合わせて 6 にするにはどうすればよいでしょうか?
torch.cat(tensors, dim, )
:
block_size が変更可能であるため、ハードコーディング形式を避けるために、torch.unbind(tensors, dim, )
を使用してスライスのタプルを取得します:
上記の方法は新しいメモリを作成しますが、より効率的な方法はありますか?
tensor.view()
:
a = torch.arange(18)
a.storage() # 0 1 2 3 4 ... 17
この方法は効率的です。各テンソルには基底のストレージ形式があり、すなわちストレージされている数字自体は常に 1 次元のベクトルです。view()
を呼び出すとき、私たちはこのシーケンスの解釈方法の属性を変更するだけであり、この過程でメモリの変更、コピー、移動、または作成は行われず、両者のストレージは同じです。
したがって、最終的な形は次のようになります:
emb.view(emb.shape[0], 6) @ W1 + b1
# または
emb.view(-1, 6) @ W1 + b1
非線形変換を追加します:
h = torch.tanh(emb.view(-1, 6) @ W1 + b1)
出力層#
# 27個の可能な出力文字
W2 = torch.randn((100, 27))
b2 = torch.randn(27)
logits = h @ W2 + b2
前のセクションと同様に、counts と確率を得ます:
counts = logits.exp()
prob = counts / counts.sum(1, keepdim=True)
負の対数尤度損失:
loss = -prob[torch.arange(Y.shape[0]), Y].log().mean()
現在の損失は 19 以上であり、これは私たちのトレーニング最適化の出発点です。
神経ネットワークを再整理します:
g = torch.Generator().manual_seed(2147483647)
C = torch.randn((27,2), generator=g)
W1 = torch.randn((6,100), generator=g)
b1 = torch.randn(100, generator=g)
W2 = torch.randn((100,27), generator=g)
b2 = torch.randn(27, generator=g)
parameters = [C, W1, b1, W2, b2]
sum(p.nelement() for p in parameters) # 3481
# フォワードパス
emb = C[X] # (228146, 3, 2)
h = torch.tanh(emb.view(-1, 6) @ W1 + b1) # (228146, 100)
logits = h @ W2 + b2 # (228146, 27)
ここで、torch の交差エントロピー損失関数を使用して以前のコードを置き換えることができます:
# counts = logits.exp()
# prob = counts / counts.sum(1, keepdim=True)
# loss = -prob[torch.arange(Y.shape[0]), Y].log().mean()
loss = F.cross_entropy(logits, Y)
結果は完全に同じであることがわかります。
実際には、PyTorch の実装方法が使用されます。なぜなら、すべての操作が 1 つのfused kernel内で行われ、追加のストレージテンソルの中間メモリを作成せず、式がより簡潔で、フォワードおよびバックワードパスの効率が高くなるからです。さらに、教育的な実装方法では、count が非常に大きい場合、exp を通過すると nan にオーバーフローする可能性があります。
PyTorch の実装方法はこの問題をどのように解決するのでしょうか:
例:logits = torch.tensor([1,2,3,4])
とlogits = torch.tensor([1,2,3,4]) - 4
の場合、絶対値は異なりますが、相対的な差は変わりません。Softmax 関数は入力の相対的な差に敏感であり、絶対値には敏感ではありません。
logits から定数を引くと、指数関数の性質により、各 logit の指数が同じ倍数だけ減少します。しかし、この定数はすべての logit から引かれるため、分子と分母で相殺され、最終的な確率分布には影響を与えません。つまり、任意の logits ベクトルと定数 C に対して:
PyTorch は内部で logits の最大値を計算し、その値を引くことで、e^logits
を計算する際の数値的なオーバーフローを防ぎます。
トレーニング#
for p in parameters:
p.requires_grad_()
for _ in range(10): # データ量が多いため、最初に最適化が成功するかテストします
# フォワードパス
emb = C[X] # (228146, 3, 2)
h = torch.tanh(emb.view(-1, 6) @ W1 + b1) # (228146, 100)
logits = h @ W2 + b2 # (228146, 27)
loss = F.cross_entropy(logits, Y)
# バックワードパス
for p in parameters:
p.grad = None
loss.backward()
# 更新
for p in parameters:
p.data += -0.1 * p.grad
print(loss.item())
全データセットでトレーニングしているため、損失は相対的に小さな値にしか達しません。出力結果と正しい結果の間には一定の類似性が見られますが、バッチを 1 つだけ使用してトレーニングすると、過剰適合の効果が得られ、予測結果はほぼ正しい結果と一致します。根本的に言えば、損失は 0 に非常に近づくことはありません。なぜなら、
...
も予測する必要があり、多くの文字が可能性があるため、完全に過剰適合することは不可能だからです。
ミニバッチ#
毎回 22 万件のデータを逆伝播する必要があるため、各イテレーションの速度が遅く、計算量が非常に大きいです。実際には、前方および後方伝播の中で多くの小さなバッチのデータで更新し、パフォーマンスを測定することが一般的です。私たちがするべきことは、データセットの一部をランダムに選択すること、これがmini-batch
です。そして、これらの小さなバッチデータでイテレーションを行います。
for _ in range(1000):
# ミニバッチ
ix = torch.randint(0,X.shape[0],(32,))
# フォワードパス
emb = C[X[ix]] # (32, 3, 2)
h = torch.tanh(emb.view(-1, 6) @ W1 + b1) # (32, 100)
logits = h @ W2 + b2 # (32, 27)
loss = F.cross_entropy(logits, Y[ix])
現在、イテレーションの速度が飛躍的に向上しました。
このようにすることで、最適化されるのは本当の勾配と正しい方向ではなく、近似の勾配に基づいて数ステップ進むことになりますが、実際には非常に効果的です。
学習率#
lre = torch.linspace(-3, 0, 1000)
lrs = 10**lre # (0.001 - 1)
lri = []
lossi = []
for i in range(1000):
'''
ミニバッチ、フォワードおよびバックワードパスのコード
'''
# 更新
lr = lrs[i]
for p in parameters:
p.data += -lr * p.grad
# 統計を追跡
lri.append(lre[i])
lossi.append(loss.item())
plt.plot(lri, lossi)
図のように、lre が - 1.0 付近で損失が最小に達することがわかります。このときの学習率は $10^{-1}=0.1$ です。
これで、学習率の選択に自信が持てるようになりました。
lr = 0.1
for p in parameters:
p.data += -lr * p.grad
1 万ステップのイテレーションを数回実行した後、損失は 2.4 付近で安定し、この時点で学習率を下げることができます(learning rate decay)、例えば 10 分の 1 に下げて 0.01 で数エポックトレーニングすることで、ある程度トレーニングされたネットワークを得ることができます。
以前の bigram モデルに比べて損失が大幅に低下しました。これはこのモデルが以前のものよりも優れていると言えるのでしょうか?
実際、この言い方は正しくありません。パラメータ量を増やすと、このモデルの損失は非常に 0 に近づくことができますが、サンプリングするとトレーニングセットと完全に同じ例しか得られず、見たことのない単語に対しては損失が大きくなる可能性があるため、これは良いモデルではありません。
これがこの分野の標準的な手法を引き起こします:データセットを 3 つに分割すること、すなわちtraining split、validation(dev) split、test splitです。私たちがよく知っているトレーニングセット、検証セット、テストセットです。
-
トレーニングセット(Training Split):
- 用途:モデルのパラメータ、すなわちモデル内の重みとバイアスをトレーニングするために使用されます。
- プロセス:トレーニング中、モデルはデータの特徴とパターンを学習し、逆伝播や勾配降下などの最適化アルゴリズムを通じてそのパラメータを調整し、損失関数を最小化しようとします。
-
検証セット(Dev/Validation Split):
- 用途:モデルのハイパーパラメータ(学習率、ネットワークの層数、層のサイズなど)を調整するために使用されます。
- プロセス:モデルのトレーニング中、私たちは検証セットでモデルのパフォーマンスを評価し、最適なハイパーパラメータを調整および選択します。検証セットは、テストセットに触れることなくモデルの一般化能力を評価するのに役立ち、モデルの過剰適合を避けることができます。
-
テストセット(Test Split):
- 用途:モデルの最終的なパフォーマンスを評価するために使用されます。すなわち、実際のアプリケーションにおけるモデルの可能なパフォーマンスを評価します。
- プロセス:モデル開発段階では、テストセットは完全に触れられません。モデルがトレーニングされ、検証セットで全てのハイパーパラメータが調整された後にのみ、テストセットを使用してテストします。これにより、見たことのないデータに対する公正な評価が提供され、新しいデータを処理する際のモデルの真のパフォーマンスが示されます。
このような分割方法は、研究者や開発者がデータ漏洩(data leakage)や過剰適合(overfitting)を避けるのに役立ちます。これらの状況は、モデルがトレーニングセットで良好に機能する一方で、見えない新しいデータでのパフォーマンスが悪化することを引き起こします。この方法を通じて、私たちはモデルが実際の世界でどのように機能するかをより自信を持って予測できます。
データセットを構築する位置に戻り、それを関数としてカプセル化し、3 つの部分に分割します:
def build_dataset(words):
'''
以前のコード
'''
return X, Y
import random
random.seed(42)
random.shuffle(words)
n1 = int(0.8 * len(words))
n2 = int(0.9 * len(words))
Xtr, Ytr = build_dataset(words[:n1]) # 80% トレーニング
Xdev, Ydev = build_dataset(words[n1:n2]) # 10% 検証
Xte, Yte = build_dataset(words[n2:]) # 10% テスト
3 つの部分のデータ量
神経ネットワークのトレーニング部分を修正します:
ix = torch.randint(0,Xtr.shape[0],(32,))
loss = F.cross_entropy(logits, Ytr[ix])
トレーニングセットと検証セットの損失が近いことがわかります。したがって、私たちのモデルは過剰適合するほど強力ではなく、この状態は過少適合(underfitting)と呼ばれます。これは通常、ネットワークのパラメータ量が小さすぎることを意味します。
最も簡単な方法は、隠れ層のニューロンの数を増やすことです:
現在、1 万以上のパラメータがあり、元の 3000 以上のネットワークに比べて大幅に増加しました。
損失関数の最適化後のプロセスが「非常に厚い」ことがわかります。これは、ミニバッチでトレーニングするとノイズ(noise)が発生するためです。
現在のネットワークの効果は依然として不十分であり、パフォーマンスのボトルネックの原因は次のとおりです:
- ミニバッチサイズが小さすぎて、ノイズが大きすぎる
- 埋め込み方法に問題があり、過剰な文字を 2 次元空間に配置しているため、神経ネットワークがうまく利用できない
現在の埋め込みを可視化します:
plt.figure(figsize=(8, 8))
plt.scatter(C[:,0].data, C[:,1].data, s=200)
for i in range(C.shape[0]):
plt.text(C[i,0].item(), C[i,1].item(), itos[i], ha="center", va="center", color="white")
plt.grid('minor')
トレーニングの成果があることがわかります。
g
、q
、p
、および.
は特殊なベクトルとして扱われ、x
、h
、b
などは近いと見なされ、相互に置き換え可能なベクトルです。
埋め込みベクトルはネットワークのボトルネックに影響を与える可能性があります。
C = torch.randn((27,10), generator=g)
W1 = torch.randn((30,200), generator=g)
b1 = torch.randn(200, generator=g)
W2 = torch.randn((200,27), generator=g)
損失は以前よりも小さくなり、埋め込みベクトルが確かに大きな影響を与えていることがわかります。
さらなる最適化戦略:
- 埋め込みベクトルの次元
- コンテキストの長さ
- 隠れ層のニューロンの数
- 学習率
- トレーニングの実践
- ......
サンプリング#
最後に、モデルの現在の効果をサンプリングしてみましょう:
g = torch.Generator().manual_seed(2147483647 + 10)
for _ in range(20):
out = []
context = [0] * block_size # すべて...で初期化
while True:
emb = C[torch.tensor([context])] # (1, block_size, D)
h = torch.tanh(emb.view(1, -1) @ W1 + b1)
logits = h @ W2 + b2
probs = F.softmax(logits, dim=1)
ix = torch.multinomial(probs, num_samples=1, generator=g).item()
context = context[1:] + [ix]
out.append(ix)
if ix == 0:
break
print(''.join(itos[i] for i in out))
初歩的な形が見え、改善の兆しが見られます。
次に、現代のモデルの紹介に進みます。CNN、GRU、Transformers などです。