GPT をゼロから構築するには 60 行のコードが必要ですか? 最近、開発者が Numpy コードを使用して GPT をゼロから実装するための実用的なガイドを作成しました。 OpenAI によってリリースされた GPT-2 モデルの重みを構築した GPT にロードして、テキストを生成することもできます。 では、早速 GPT の構築を始めましょう。 GPTとは何ですか? GPT は Generative Pre-trained Transformer の略で、Transformer に基づいたニューラル ネットワーク構造です。 - 生成: GPT はテキストを生成します。 - 事前トレーニング済み: GPT は、書籍やインターネットなどからの大量のテキストに基づいてトレーニングされます。 - Transformer: GPT はデコーダー専用に使用される Transformer ニューラル ネットワークです。 OpenAI の GPT-3、Google の LaMDA、Cohere の Command XLarge などの大規模なモデルはすべて GPT に基づいています。これらが特別なのは、1) 非常に大規模である (数十億のパラメータを持つ)、2) 大量のデータ (数百 GB のテキスト) でトレーニングされていることです。 簡単に言えば、GPT はプロンプトでテキストを生成します。 非常にシンプルな API (入力 = テキスト、出力 = テキスト) でも、十分にトレーニングされた GPT は、電子メールの作成、本の要約、Instagram の投稿のアイデアの考案、5 歳児へのブラックホールの説明、SQL でのコードの作成、さらには遺言書の作成など、非常に驚くべきことを行うことができます。 GPT とその機能の概要は以上です。さらに詳しく見ていきましょう。 入力/出力 GPT 定義の入力と出力の形式は、おおよそ次のようになります。 def gpt(inputs: list[int]) -> list[list[float]]: # inputs has shape [n_seq] # output has shape [n_seq, n_vocab] output = # beep boop neural network magic return output 入力は、テキスト内のトークンにマップされた一連の整数によって表されるテキストです。 # integers represent tokens in our text, for example:# text = "not all heroes wear capes":# tokens = "not" "all" "heroes" "wear" "capes" inputs = [1, 0, 2, 4, 6] トークンは、トークナイザーを使用して生成されるテキストのサブセグメントです。語彙を使用してトークンを整数にマッピングできます。 # the index of a token in the vocab represents the integer id for that token# ie the integer id for "heroes" would be 2, since vocab[2] = "heroes" vocab = ["all", "not", "heroes", "the", "wear", ".", "capes"] # a pretend tokenizer that tokenizes on whitespace tokenizer = WhitespaceTokenizer(vocab) # the encode() method converts a str -> list[int] ids = tokenizer.encode("not all heroes wear") # ids = [1, 0, 2, 4]# we can see what the actual tokens are via our vocab mapping tokens = [tokenizer.vocab[i] for i in ids] # tokens = ["not", "all", "heroes", "wear"]# the decode() method converts back a list[int] -> str text = tokenizer.decode(ids) # text = "not all heroes wear" 要するに: - 紐があります。 - トークナイザーを使用して、トークンと呼ばれる小さなチャンクに分割します。 - 語彙を使用して、これらのトークンを整数にマッピングします。 実際には、単純に空白で分割するのではなく、バイトペアエンコーディング (BPE) や WordPiece などのより高度な単語分割方法を使用しますが、原理は同じです。 vocabは文字列トークンを整数インデックスにマッピングします encodeメソッドはstr -> list[int]を変換できる decodeメソッドはlist[int] -> strに変換できる([2]) 出力 出力は2次元配列で、output[i][j]はモデルによって予測された確率です。つまり、vocab[j]のトークンは、次のトークンinputs[i+1]です。例えば: vocab = ["all", "not", "heroes", "the", "wear", ".", "capes"] inputs = [1, 0, 2, 4] # "not" "all" "heroes" "wear" output = gpt(inputs) # ["all", "not", "heroes", "the", "wear", ".", "capes"] # output[0] = [0.75 0.1 0.0 0.15 0.0 0.0 0.0 ] # given just "not", the model predicts the word "all" with the highest probability # ["all", "not", "heroes", "the", "wear", ".", "capes"] # output[1] = [0.0 0.0 0.8 0.1 0.0 0.0 0.1 ] # given the sequence ["not", "all"], the model predicts the word "heroes" with the highest probability # ["all", "not", "heroes", "the", "wear", ".", "capes"] # output[-1] = [0.0 0.0 0.0 0.1 0.0 0.05 0.85 ] # given the whole sequence ["not", "all", "heroes", "wear"], the model predicts the word "capes" with the highest probability シーケンス全体の次のトークン予測を得るには、出力[-1]で最も確率の高いトークンを取得するだけです。 vocab = ["all", "not", "heroes", "the", "wear", ".", "capes"] inputs = [1, 0, 2, 4] # "not" "all" "heroes" "wear" output = gpt(inputs) next_token_id = np.argmax(output[-1]) # next_token_id = 6 next_token = vocab[next_token_id] # next_token = "capes" 最も高い確率を持つトークンを予測として取ることを、貪欲デコードまたは貪欲サンプリングと呼びます。 シーケンス内の次の論理単語を予測するタスクは、言語モデリングと呼ばれます。したがって、GPT は言語モデルと呼ぶことができます。 単語を 1 つ生成するのは便利ですが、文章全体や段落全体などを生成する場合はどうでしょうか? テキストの生成自己回帰 モデルから次のトークン予測を繰り返し取得することで、完全な文を生成できます。各反復で、予測されたトークンを入力に追加します。 def generate(inputs, n_tokens_to_generate): for _ in range(n_tokens_to_generate): # auto-regressive decode loop output = gpt(inputs) # model forward pass next_id = np.argmax(output[-1]) # greedy sampling inputs.append(int(next_id)) # append prediction to input return inputs[len(inputs) - n_tokens_to_generate :] # only return generated ids input_ids = [1, 0] # "not" "all" output_ids = generate(input_ids, 3) # output_ids = [2, 4, 6] output_tokens = [vocab[i] for i in output_ids] # "heroes" "wear" "capes" 将来の値を予測し (回帰)、それを入力に戻す (自動) というこのプロセスは、GPT が自己回帰として説明される理由です。 サンプリング 貪欲にサンプリングする代わりに、確率分布からサンプリングして、生成にランダム性を導入することができます。 inputs = [1, 0, 2, 4] # "not" "all" "heroes" "wear" output = gpt(inputs) np.random.choice(np.arange(vocab_size), p=output[-1]) # capes np.random.choice(np.arange(vocab_size), p=output[-1]) # hats np.random.choice(np.arange(vocab_size), p=output[-1]) # capes np.random.choice(np.arange(vocab_size), p=output[-1]) # capes np.random.choice(np.arange(vocab_size), p=output[-1]) # pants このようにして、同じ入力で異なる文を生成することができます。 サンプリング前に分布を変更する top-k、top-p、温度などの手法と組み合わせると、出力の品質が大幅に向上します。 これらの手法では、さまざまな生成動作を実現するために使用できるハイパーパラメータもいくつか導入されています (たとえば、温度を上げると、モデルのリスクが高まり、より「創造的」になります)。 電車 他のニューラル ネットワークのトレーニングと同様に、勾配降下法を使用して GPT をトレーニングし、損失関数を計算できます。 GPTでは、言語モデリングタスクのクロスエントロピー損失を採用します。 def lm_loss(inputs: list[int], params) -> float: # the labels y are just the input shifted 1 to the left # # inputs = [not, all, heros, wear, capes] # x = [not, all, heroes, wear] # y = [all, heroes, wear, capes] # # of course, we don't have a label for inputs[-1], so we exclude it from x # # as such, for N inputs, we have N - 1 langauge modeling example pairs x, y = inputs[:-1], inputs[1:] # forward pass # all the predicted next token probability distributions at each position output = gpt(x, params) # cross entropy loss # we take the average over all N-1 examples loss = np.mean(-np.log(output[y])) return loss def train(texts: list[list[str]], params) -> float: for text in texts: inputs = tokenizer.encode(text) loss = lm_loss(inputs, params) gradients = compute_gradients_via_backpropagation(loss, params) params = gradient_descent_update_step(gradients, params) return params これは非常に簡略化されたトレーニング設定ですが、要点を説明しています。 gpt 関数シグネチャにパラメータを追加したことに注意してください (簡潔にするために、前のセクションでは追加しませんでした)。トレーニング ループの各反復中に次の処理が行われます。 - 入力テキストの特定のインスタンスに対して、言語モデリング損失が計算されます - 損失はバックプロパゲーションで計算する勾配を決定する - 勾配を使用してモデルパラメータを更新し、損失を最小限に抑えます(勾配降下法) 明示的にラベル付けされたデータは使用しないことに注意してください。代わりに、生のテキスト自体からのみ入力/ラベルのペアを生成することができます。これは自己教師学習と呼ばれます。 自己監督により、できるだけ多くの生のテキストを取得してモデルに取り込むだけで、トレーニング データを大幅に拡張できます。たとえば、GPT-3 はインターネットと書籍からの 3,000 億のテキスト トークンでトレーニングされました。 もちろん、そのすべてのデータから学習できるほど大きなモデルが必要です。そのため、GPT-3 には 1,750 億のパラメーターがあり、トレーニングには 100 万ドルから 1,000 万ドルの計算コストがかかります。 この自己教師型トレーニングのステップは、事前トレーニングと呼ばれます。これは、「事前トレーニング済み」モデルの重みを再利用して、下流のタスクでモデルをさらにトレーニングできるためです。事前トレーニング済みモデルは、「ベースモデル」と呼ばれることもあります。 下流のタスクでモデルをトレーニングすることを微調整と呼びます。これは、モデルの重みが言語を理解するために事前にトレーニングされており、現在の特定のタスクに合わせて微調整されているからです。 「一般的なタスクのための事前トレーニング + 特定のタスクのための微調整」という戦略は転移学習と呼ばれます。 ヒント 原則として、元の GPT 論文は、転移学習のための Transformer モデルの事前トレーニングの利点についてのみ述べていました。 この論文では、ラベル付きデータセットで微調整すると、事前トレーニング済みの 117M GPT がさまざまな自然言語処理タスクで最先端のパフォーマンスを達成することが示されています。 GPT-2 および GPT-3 の論文が発表されて初めて、十分なデータとパラメータで事前トレーニングされた GPT モデルは、微調整なしであらゆるタスクを単独で実行できることが分かりました。 モデルに指示を与え、自己回帰言語モデリングを実行するだけで、モデルは魔法のように適切な応答を返します。モデルはタスクを完了するためにプロンプトのコンテキストのみを使用するため、これは「コンテキスト内学習」と呼ばれます。 コンテキスト内学習は 0 回、1 回、または複数回行うことができます。 プロンプトが与えられたテキストを生成することは、モデルが何らかの入力に基づいて何らかの出力を生成するため、条件付き生成とも呼ばれます。 GPT は NLP タスクに限定されません。 このモデルは、必要な条件に合わせて微調整できます。たとえば、会話履歴に基づいて GPT をチャットボット (ChatGPT など) に変換できます。 それでは最後に実際の実装を見てみましょう。 設定このチュートリアルのリポジトリをクローンします。 git clone https://github.com/jaymody/picoGPT cd picoGPT 次に依存関係をインストールします。 pip install -r requirements.txt 注: このコードは Python 3.9.10 でテストされました。 各ファイルの簡単な内訳: - encoding.py には、gpt-2 リポジトリから直接取得された OpenAI の BPE トークナイザーのコードが含まれています。 - utils.py には、GPT-2 モデルの重み、トークナイザー、ハイパーパラメータをダウンロードして読み込むためのコードが含まれています。 - gpt2.py には実際の GPT モデルと生成コードが含まれており、Python スクリプトとして実行できます。 - gpt2_pico.py は gpt2.py と同じですが、コード行数が少なくなっています。 gpt2.py を最初から再実装するので、これを削除して空のファイルとして再作成します。 rm gpt2.py touch gpt2.py まず、次のコードを gpt2.py に貼り付けます。 import numpy as np def gpt2(inputs, wte, wpe, blocks, ln_f, n_head): pass # TODO: implement this def generate(inputs, params, n_head, n_tokens_to_generate): from tqdm import tqdm for _ in tqdm(range(n_tokens_to_generate), "generating"): # auto-regressive decode loop logits = gpt2(inputs, **params, n_head=n_head) # model forward pass next_id = np.argmax(logits[-1]) # greedy sampling inputs.append(int(next_id)) # append prediction to input return inputs[len(inputs) - n_tokens_to_generate :] # only return generated ids def main(prompt: str, n_tokens_to_generate: int = 40, model_size: str = "124M", models_dir: str = "models"): from utils import load_encoder_hparams_and_params # load encoder, hparams, and params from the released open-ai gpt-2 files encoder, hparams, params = load_encoder_hparams_and_params(model_size, models_dir) # encode the input string using the BPE tokenizer input_ids = encoder.encode(prompt) # make sure we are not surpassing the max sequence length of our model assert len(input_ids) + n_tokens_to_generate < hparams["n_ctx"] # generate output ids output_ids = generate(input_ids, params, hparams["n_head"], n_tokens_to_generate) # decode the ids back into a string output_text = encoder.decode(output_ids) return output_text if __name__ == "__main__": import fire fire.Fire(main) 4 つのパートは次のように分かれています。 - gpt2 関数は、実際に実装する GPT コードです。入力に加えて、関数シグネチャにはいくつかの追加事項が含まれていることに気づくでしょう。 wte、wpe、blocks、ln_f はモデルのパラメータです。 n_head は、フォワードパス中に必要なハイパーパラメータです。 生成関数は、先ほど見た自己回帰デコード アルゴリズムです。簡単にするために、貪欲サンプリングを使用します。 tqdm は、一度に 1 つのトークンを生成する際のデコード プロセスを視覚化するのに役立つ進行状況バーです。 - 主な機能処理: トークナイザー(エンコーダー)、モデルの重み(パラメータ)、ハイパーパラメータ(hparams)をロードします。 トークナイザーを使用して入力ヒントをトークンIDにエンコードする ジェネレータ関数の呼び出し 出力IDを文字列にデコードする fire.Fire(main) はファイルを CLI アプリケーションに変換するだけなので、python gpt2.py "some prompt here" でコードを実行できます。 ノートブックまたは対話型 Python セッションで、encoder、hparams、params を詳しく見てみましょう。次のコマンドを実行します。 from utils import load_encoder_hparams_and_params encoder, hparams, params = load_encoder_hparams_and_params("124M", "models") これにより、必要なモデルとトークナイザー ファイルが models/124M にダウンロードされ、エンコーダー、hparams、および params がコードに読み込まれます。 エンコーダ エンコーダーは、GPT-2 で使用される BPE 単語セグメンターです。 ids = encoder.encode("Not all heroes wear capes.") ids [3673, 477, 10281, 5806, 1451, 274, 13] encoder.decode(ids) "Not all heroes wear capes." トークナイザーの語彙(encoder.decoder に保存されている)を使用すると、実際のトークンがどのように見えるかを確認できます。 [encoder.decoder[i] for i in ids] ['Not', 'Ġall', 'Ġheroes', 'Ġwear', 'Ġcap', 'es', '.'] トークンは、単語の場合 (例: Not)、前にスペースがある単語の場合 (例: Ġall、ここで Ġ はスペースを表します)、単語の一部の場合 (例: Capes は Ġcap と es に分割されます)、句読点の場合 (例: .) があることに注意してください。 BPE の利点の 1 つは、任意の文字列をエンコードできることです。語彙にないものに遭遇した場合は、それを理解できる部分文字列に分解するだけです。 [encoder.decoder[i] for i in encoder.encode("zjqfl")] ['z', 'j', 'q', 'fl'] 語彙のサイズも確認できます。 len(encoder.decoder) 50257 語彙と、文字列を分割する方法を決定するバイト ペアは、トークナイザーをトレーニングすることによって取得されます。 トークナイザーをロードすると、load_encoder_hparams_and_params を実行したときにモデル ファイルと一緒にダウンロードされたいくつかのファイルから、既にトレーニング済みの単語とバイトのペアのマージがロードされます。 ハイパーパラメータ hparams は、モデルのハイパーパラメータを含む辞書です。 >>> hparams { "n_vocab": 50257, # number of tokens in our vocabulary "n_ctx": 1024, # maximum possible sequence length of the input "n_embd": 768, # embedding dimension (determines the "width" of the network) "n_head": 12, # number of attention heads (n_embd must be divisible by n_head) "n_layer": 12 # number of layers (determines the "depth" of the network) } コード内のコメントでこれらの記号を使用して、物事の基本的な形状を示します。また、入力シーケンスの長さを表すために n_seq を使用します (つまり、n_seq = len(inputs))。 パラメータ params は、モデルのトレーニング済みの重みを保持するネストされた JSON 辞書です。 Json のリーフ ノードは NumPy 配列です。得られるもの: >>> import numpy as np >>> def shape_tree(d): >>> if isinstance(d, np.ndarray): >>> return list(d.shape) >>> elif isinstance(d, list): >>> return [shape_tree(v) for v in d] >>> elif isinstance(d, dict): >>> return {k: shape_tree(v) for k, v in d.items()} >>> else: >>> ValueError("uh oh") >>> >>> print(shape_tree(params)) { "wpe": [1024, 768], "wte": [50257, 768], "ln_f": {"b": [768], "g": [768]}, "blocks": [ { "attn": { "c_attn": {"b": [2304], "w": [768, 2304]}, "c_proj": {"b": [768], "w": [768, 768]}, }, "ln_1": {"b": [768], "g": [768]}, "ln_2": {"b": [768], "g": [768]}, "mlp": { "c_fc": {"b": [3072], "w": [768, 3072]}, "c_proj": {"b": [768], "w": [3072, 768]}, }, }, ... # repeat for n_layers ] } これらは、元の OpenAI TensorFlow チェックポイントから読み込まれます。 import tensorflow as tf tf_ckpt_path = tf.train.latest_checkpoint("models/124M") for name, _ in tf.train.list_variables(tf_ckpt_path): arr = tf.train.load_variable(tf_ckpt_path, name).squeeze() print(f"{name}: {arr.shape}") model/h0/attn/c_attn/b: (2304,) model/h0/attn/c_attn/w: (768, 2304) model/h0/attn/c_proj/b: (768,) model/h0/attn/c_proj/w: (768, 768) model/h0/ln_1/b: (768,) model/h0/ln_1/g: (768,) model/h0/ln_2/b: (768,) model/h0/ln_2/g: (768,) model/h0/mlp/c_fc/b: (3072,) model/h0/mlp/c_fc/w: (768, 3072) model/h0/mlp/c_proj/b: (768,) model/h0/mlp/c_proj/w: (3072, 768) model/h1/attn/c_attn/b: (2304,) model/h1/attn/c_attn/w: (768, 2304) ... model/h9/mlp/c_proj/b: (768,) model/h9/mlp/c_proj/w: (3072, 768) model/ln_f/b: (768,) model/ln_f/g: (768,) model/wpe: (1024, 768) model/wte: (50257, 768) 次のコードは、上記の TensorFlow 変数を params 辞書に変換します。 参考までに、params は次のようになりますが、数字はそれが表す hparams に置き換えられています。 >>> import tensorflow as tf >>> tf_ckpt_path = tf.train.latest_checkpoint("models/124M") >>> for name, _ in tf.train.list_variables(tf_ckpt_path): >>> arr = tf.train.load_variable(tf_ckpt_path, name).squeeze() >>> print(f"{name}: {arr.shape}") model/h0/attn/c_attn/b: (2304,) model/h0/attn/c_attn/w: (768, 2304) model/h0/attn/c_proj/b: (768,) model/h0/attn/c_proj/w: (768, 768) model/h0/ln_1/b: (768,) model/h0/ln_1/g: (768,) model/h0/ln_2/b: (768,) model/h0/ln_2/g: (768,) model/h0/mlp/c_fc/b: (3072,) model/h0/mlp/c_fc/w: (768, 3072) model/h0/mlp/c_proj/b: (768,) model/h0/mlp/c_proj/w: (3072, 768) model/h1/attn/c_attn/b: (2304,) model/h1/attn/c_attn/w: (768, 2304) ... model/h9/mlp/c_proj/b: (768,) model/h9/mlp/c_proj/w: (3072, 768) model/ln_f/b: (768,) model/ln_f/g: (768,) model/wpe: (1024, 768) model/wte: (50257, 768) 基本レイヤー最後に、実際の GPT アーキテクチャ自体に入る前に、GPT に固有ではない、より基本的なニューラル ネットワーク レイヤーをいくつか実装してみましょう。 ゲル GPT-2 が選択した非線形性 (活性化関数) は GELU (Gaussian Error Linear Unit) であり、これは REU の代替です。 これは次の関数で近似されます。 def gelu(x): return 0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x**3))) RELU と同様に、GeLU は入力に対して要素ごとに操作します。 gelu(np.array([[1, 2], [-2, 0.5]])) array([[ 0.84119, 1.9546 ], [-0.0454 , 0.34571]]) ソフトマックス 古き良きソフトマックス: def softmax(x): exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True)) return exp_x / np.sum(exp_x, axis=-1, keepdims=True) 数値安定性を確保するために、max(x) トリックを使用します。 SoftMax は、実数セット (-∞ から ∞ の間) を確率 (0 から 1 の間、すべての数値の合計は 1) に変換するために使用されます。入力の最後の軸にソフトマックスを適用します。 x = softmax(np.array([[2, 100], [-5, 0]])) x array([[0.00034, 0.99966], [0.26894, 0.73106]]) x.sum(axis=-1) array([1., 1.]) レイヤーの正規化 レイヤー正規化は、平均が 0、分散が 1 になるように値を正規化します。 def layer_norm(x, g, b, eps: float = 1e-5): mean = np.mean(x, axis=-1, keepdims=True) variance = np.var(x, axis=-1, keepdims=True) x = (x - mean) / np.sqrt(variance + eps) # normalize x to have mean=0 and var=1 over last axisreturn g * x + b # scale and offset with gamma/beta params レイヤーの正規化により、各レイヤーへの入力が常に一貫した範囲内に保たれ、トレーニング プロセスが高速化され、安定します。 バッチ正規化と同様に、正規化された出力は、2 つの学習可能なベクトル gamma と beta を使用してスケーリングおよびオフセットされます。分母の小さなイプシロン項は、ゼロ除算エラーを回避するために使用されます。 さまざまな理由により、Transformer はバッチ クォータではなく階層化されたクォータを使用します。 入力の最後の軸にレイヤー正規化を適用します。 >>> x = np.array([[2, 2, 3], [-5, 0, 1]]) >>> x = layer_norm(x, g=np.ones(x.shape[-1]), b=np.zeros(x.shape[-1])) >>> x array([[-0.70709, -0.70709, 1.41418], [-1.397 , 0.508 , 0.889 ]]) >>> x.var(axis=-1) array([0.99996, 1. ]) # floating point shenanigans >>> x.mean(axis=-1) array([-0., -0.]) Linear 標準的な行列乗算 + バイアス: def linear(x, w, b): # [m, in], [in, out], [out] -> [m, out] return x @ w + b 線形レイヤーは、多くの場合、マッピングと呼ばれます (あるベクトル空間から別のベクトル空間にマッピングするため)。 >>> x = np.random.normal(size=(64, 784)) # input dim = 784, batch/sequence dim = 64 >>> w = np.random.normal(size=(784, 10)) # output dim = 10 >>> b = np.random.normal(size=(10,)) >>> x.shape # shape before linear projection (64, 784) >>> linear(x, w, b).shape # shape after linear projection (64, 10) GPTアーキテクチャGPT アーキテクチャは Transformer アーキテクチャに従います。 大まかに言えば、GPT アーキテクチャには 3 つの部分があります。 テキスト + 位置の埋め込み トランスフォーマーデコーダースタック 単語ステップへのマッピング コードでは次のようになります。 def gpt2(inputs, wte, wpe, blocks, ln_f, n_head): # [n_seq] -> [n_seq, n_vocab] # token + positional embeddings x = wte[inputs] + wpe[range(len(inputs))] # [n_seq] -> [n_seq, n_embd] # forward pass through n_layer transformer blocks for block in blocks: x = transformer_block(x, **block, n_head=n_head) # [n_seq, n_embd] -> [n_seq, n_embd] # projection to vocab x = layer_norm(x, **ln_f) # [n_seq, n_embd] -> [n_seq, n_embd] return x @ wte.T # [n_seq, n_embd] -> [n_seq, n_vocab] すべてをまとめるとこれらすべてをまとめると、gpt2.py になります。これは、合計で 120 行のコードです (コメントと空白を除くと 60 行)。 実装をテストするには、次の操作を実行します。 python gpt2.py \"Alan Turing theorized that computers would one day become" \ --n_tokens_to_generate 8 出力は次のようになります: the most powerful machines on the planet. うまくいきました! 次の Dockerfile を使用して、実装が OpenAI の公式 GPT-2 リポジトリの結果と一致しているかどうかをテストできます。 docker build -t "openai-gpt-2" "https://gist.githubusercontent.com/jaymody/9054ca64eeea7fad1b58a185696bb518/raw/Dockerfile" docker run -dt "openai-gpt-2" --name "openai-gpt-2-app" docker exec -it "openai-gpt-2-app" /bin/bash -c 'python3 src/interactive_conditional_samples.py --length 8 --model_type 124M --top_k 1' # paste "Alan Turing theorized that computers would one day become" when prompted 同じ結果になるはずです: the most powerful machines on the planet. 次は何ですか? この実装はクールですが、多くの機能が欠けています。 GPU/TPU サポートNumPy を JAX に置き換えます。 import jax.numpy as np これで、GPU や TPU でもコードを使用できるようになりました。 JAX が正しくインストールされていることを確認してください。 バックプロパゲーション 同様に、NumPy を JAX に置き換えると次のようになります。 import jax.numpy as np 然后,计算梯度就像以下操作一样简单:
def lm_loss(params, inputs, n_head) -> float: x, y = inputs[:-1], inputs[1:] output = gpt2(x, **params, n_head=n_head) loss = np.mean(-np.log(output[y]))return loss grads = jax.grad(lm_loss)(params, inputs, n_head) Batching 再び、NumPy を JAX に置き換えると次のようになります。 import jax.numpy as np 然后,对gpt2函数进行批处理非常简单:
gpt2_batched = jax.vmap(gpt2, in_axes=[0, None, None, None, None, None]) gpt2_batched(batched_inputs) # [batch, seq_len] -> [batch, seq_len, vocab] 推論の最適化 私たちの実装は非常に非効率的です。最も高速かつ効果的な最適化 (GPU + バッチ サポート以外) は、KV キャッシュを実装することです。 電車 GPT のトレーニングは、ニューラル ネットワークでは標準的な方法です (損失関数は勾配降下法です)。 もちろん、GPT をトレーニングするときには、標準的な一連のトリックも使用する必要があります (ADAM オプティマイザーの使用、最適な学習率の検出、ドロップアウトや重みの減衰による正規化、学習率スケジューラの使用、適切な重みの初期化の使用、バッチ処理など)。 優れた GPT モデルをトレーニングするための本当の秘訣は、データとモデルを調整する能力であり、ここに本当の課題があります。 データをスケーリングするには、大規模で高品質かつ多様なテキストのコーパスが必要です。 - 大きいというのは、数十億のトークン(テラバイトのデータ)を意味します。 - 高品質とは、繰り返される例、フォーマットされていないテキスト、一貫性のないテキスト、ジャンク テキストなどを除外することを意味します。 - 多様性とは、シーケンスの長さが異なり、さまざまなトピックについて、さまざまなソースから、さまざまな視点からなどであることを意味します。 評価する LLM をどのように評価するかは難しい問題です。 生成を停止 現在の実装では、生成するトークンの正確な数を事前に指定する必要があります。これは良い方法ではありません。生成されるトークンが長すぎたり、短すぎたり、文の途中で途切れたりするからです。 この問題を解決するために、特別な文末 (EOS) マーカーを導入することができます。 事前トレーニング中に、入力の末尾に EOS トークンを追加します (つまり、tokens = ["not", "all", "heroes", "wear", "capes", ".", "<|EOS|>"])。 生成中、EOS トークンに遭遇するとすぐに(または最大シーケンス長に達すると)停止します。 def generate(inputs, eos_id, max_seq_len): prompt_len = len(inputs)while inputs[-1] != eos_id and len(inputs) < max_seq_len: output = gpt(inputs) next_id = np.argmax(output[-1]) inputs.append(int(next_id))return inputs[prompt_len:] GPT-2 には事前トレーニング済みの EOS トークンがないため、このアプローチをコードで使用することはできません。 無条件生成 モデルを使用してテキストを生成するには、プロンプトを使用してテキストを条件付ける必要があります。 ただし、モデルに無条件生成を実行させることもできます。この場合、モデルは入力プロンプトなしでテキストを生成します。 これは、事前トレーニング中に入力の先頭に特別な文頭 (BOS) トークンを追加することによって実現されます (つまり、トークン = ["<|BOS|>", "not", "all", "heroes", "wear", "capes", "."])。 次に、無条件にテキストを生成するために、BOS トークンのみを含むリストを入力します。 def generate_unconditioned(bos_id, n_tokens_to_generate): inputs = [bos_id]for _ in range(n_tokens_to_generate): output = gpt(inputs) next_id = np.argmax(output[-1]) inputs.append(int(next_id))return inputs[1:] GPT-2 は BOS トークン (<|endoftext|> という名前) で事前トレーニング済みなので、実装で無条件に生成するのは簡単です。次の行を次のように変更するだけです。 input_ids = encoder.encode(prompt) if prompt else [encoder.encoder["<|endoftext|>"]] 然后运行:
python gpt2.py "" 这将生成:
The first time I saw the new version of the game, I was so excited. I was so excited to see the new version of the game, I was so excited to see the new version 貪欲サンプリングを使用しているため、出力はあまり良くなく (繰り返し)、決定論的です (つまり、コードを実行するたびに同じ出力になります)。より高品質で非決定的な生成を得るには、分布から直接サンプリングする必要があります (理想的には top-p のようなものを適用した後)。 無条件生成は特に有用ではありませんが、GPT の威力を示す興味深い方法です。 微調整トレーニングセクションでは微調整について簡単に紹介しました。微調整とは、事前にトレーニングされた重みを再利用して、下流のタスクを実行するモデルをトレーニングすることであることを思い出してください。このプロセスを転移学習と呼びます。 理論的には、ゼロショットまたは少数ショットのプロンプトを使用して、モデルにタスクを完了させることができます。 ただし、トークンのデータセットにアクセスできる場合は、GPT を微調整するとより良い結果が得られます (また、より多くのデータとより高品質のデータがあれば、結果を拡張できます)。 微調整に関連するさまざまなトピックがありますが、以下にそれらを分類しました。 分類の微調整 分類の微調整では、モデルにテキストを与え、それがどのクラスに属するかを予測するように求めます。 たとえば、映画の良し悪しを評価する映画レビューを含む IMDB データセットを見てみましょう。 --- Example 1 --- Text: I wouldn't rent this one even on dollar rental night. Label: Bad --- Example 2 --- Text: I don't know why I like this movie so well, but I never get tired of watching it. Label: Good --- Example 3 --- ... モデルを微調整するために、言語モデリング ヘッドを分類ヘッドに置き換え、最後のトークン出力に適用します。 def gpt2(inputs, wte, wpe, blocks, ln_f, cls_head, n_head): x = wte[inputs] + wpe[range(len(inputs))] for block in blocks: x = transformer_block(x, **block, n_head=n_head) x = layer_norm(x, **ln_f) # project to n_classes # [n_embd] @ [n_embd, n_classes] -> [n_classes] return x[-1] @ cls_head 言語モデリングではn_seq分布ではなく、入力全体に対して単一の確率分布を生成するだけでよいため、最後のトークン出力x[-1]のみを使用します。 特に、最後のトークンを選択するのは、それがシーケンス全体に焦点を当てることができる唯一のトークンであり、したがって入力テキスト全体に関する情報を持っているためです。 いつものように、クロスエントロピー損失を最適化します。 def singe_example_loss_fn(inputs: list[int], label: int, params) -> float: logits = gpt(inputs, **params) probs = softmax(logits) loss = -np.log(probs[label]) # cross entropy loss return loss また、ソフトマックスの代わりにシグモイドを適用してマルチラベル分類を実行し、各クラスに関するバイナリクロスエントロピー損失を取得することもできます。 生成的な微調整 一部のタスクはきちんと分類できません。たとえば、このタスクを要約します。 入力とラベルに対して言語モデリングを実行するだけで、このようなタスクを微調整できます。たとえば、要約トレーニングの例を次に示します。 --- Article --- This is an article I would like to summarize. --- Summary --- This is the summary. 事前トレーニングと同様にモデルをトレーニングします(言語モデリング損失を最適化します)。 予測時に、モデルに --- Summary --- までのすべてを入力した後、自己回帰言語モデリングを実行して要約を生成します。 区切り文字 --- Article --- と --- Summary --- の選択は任意です。トレーニングと推論の間で一貫性が保たれている限り、テキストのフォーマット方法は自由に選択できます。 分類タスクを生成タスクとして定式化することもできることに注意してください (例: IMDB を使用)。 --- Text --- I wouldn't rent this one even on dollar rental night. --- Label --- Bad 指示の微調整 現在、最先端の大規模モデルのほとんどは、事前トレーニング後に追加の指示の微調整を受けます。 このステップでは、人間がラベル付けした何千もの指示プロンプト+補完ペアに基づいてモデルが微調整(生成)されます。指導的微調整は、データが人間によってラベル付けされるため、教師あり微調整とも呼ばれます。 では、指示の微調整にはどのような利点があるのでしょうか? Wikipedia の記事の次の単語を予測することで、モデルは文章を続けるのが得意になりますが、指示に従うこと、会話を続けること、ドキュメントを要約すること (これらはすべて GPT に期待すること) が特に得意になるわけではありません。 人間が注釈を付けた指示と完了のペアで微調整することは、モデルをより有用にし、操作しやすくする方法を教える方法です。 これは、モデルが目的の動作をするように調整するため、AI アライメントと呼ばれます。 効率的なパラメータの微調整 上記のセクションで微調整について説明したときは、すべてのモデル パラメータを更新することを前提としていました。 これにより最高のパフォーマンスが得られますが、計算コスト(モデル全体のバックプロパゲーションが必要)とストレージコスト(微調整されたモデルごとにパラメータのまったく新しいコピーを保存する必要がある)が高くなります。 この問題を解決する最も簡単な方法は、ヘッドを更新し、モデルの残りの部分をフリーズする(つまり、トレーニングできないようにする)ことです。 これによりトレーニングが高速化され、新しいパラメータの数が大幅に削減されますが、ディープラーニングの深さが失われるため、特に効果的ではありません。 代わりに、特定のレイヤーを選択的にフリーズすることで、深度を回復することができます。これを行うと、効果は大幅に向上しますが、パラメータ効率が大幅に低下し、トレーニング速度の向上がいくらか失われます。 パラメータ効率の高い微調整手法も活用できることは言及する価値があります。 アダプタの記事を例に挙げてみましょう。このアプローチでは、トランスフォーマー ブロックの FFN レイヤーと MHA レイヤーの後に「アダプター」レイヤーを追加します。 適応層は、入力次元と出力次元が n_embd で、暗黙の次元が n_embd 未満の単純な 2 層完全接続ニューラル ネットワークです。 潜在次元のサイズは、パラメータとパフォーマンスの間でトレードオフするために設定できるハイパーパラメータです。 この論文では、BERT モデルの場合、このアプローチを使用すると、完全な微調整と比較してパフォーマンスの低下がわずか (<1%) で、トレーニング済みパラメータの数を 2% に削減できることが示されています。 |