WhatsAppチャットで言語モデルを構築しましょう
WhatsAppチャットで言語モデルを作成する方法' (How to build a language model using WhatsApp chats)
GPTアーキテクチャを用いた応用による視覚的なガイド
チャットボットは、デジタルプラットフォームとのやり取りを変革しました。複雑なタスクを処理する基になる言語モデルの能力が向上しても、ユーザーエクスペリエンスはしばしば不足し、非個人的で切り離されたものに感じられます。
会話をより身近にするために、私はWhatsAppの友人とメッセージをやり取りするような、カジュアルなスタイルを模倣できるチャットボットを想像しました。
この記事では、WhatsAppのチャットメッセージを入力データとして使用し、合成会話を生成する(小さな)言語モデルを構築する過程をご紹介します。その過程で、視覚的かつ理解しやすい方法でGPTアーキテクチャの内部動作を解明し、実際のPythonの実装とともに補完します。プロジェクトの完全な内容は私のGitHubで見つけることができます。
注: モデルのクラスは、Andrzej Karpathyのビデオシリーズから大きな部分を借用し、私の必要に合わせて調整しました。彼のチュートリアルは非常におすすめです。
lad-gpt
WhatsAppグループチャットを元にして、一から言語モデルを作成します。
github.com
目次
- 選択したアプローチ
- データソース
- トークナイゼーション
- インデックス化
- モデルアーキテクチャ
- モデルトレーニング
- チャットモード
1. 選択したアプローチ
特定のデータコーパスに合わせて言語モデルをカスタマイズする場合、いくつかのアプローチがあります:
- モデルの構築: これは、モデルのアーキテクチャとトレーニングデータの選択において最大の柔軟性を提供する、ゼロからモデルを構築しトレーニングする方法です。
- ファインチューニング: このアプローチでは、既存の事前トレーニング済みモデルを活用し、その重みを特定のデータに合わせて微調整します。
- プロンプトエンジニアリング: これも既存の事前トレーニング済みモデルを活用しますが、ここでは独自のコーパスをプロンプトに直接組み込み、モデルの重みは変更しません。
私のこのプロジェクトへの動機は、主に自己教育を目的としているため、現代の言語モデルのアーキテクチャに興味がありますので、最初のアプローチを選びました。ただし、この選択には明らかな制約があります。データのサイズと利用可能な計算リソースを考慮すると、最先端の事前トレーニング済みモデルと同じレベルの結果は予想していませんでした。
それにもかかわらず、私は自分のモデルがいくつかの興味深い言語パターンを学び取ることを期待していました。第二のオプション(ファインチューニング)の探索は、将来の記事の焦点になるかもしれません。
2. データソース
WhatsAppは私の主なコミュニケーションチャネルであり、会話スタイルを捉える理想的なソースでした。6年以上にわたるグループチャットの履歴をエクスポートし、150万語以上のデータを簡単に取得しました。
データは正規表現パターンを使用して解析され、日付、連絡先名、チャットメッセージを含むタプルのリストにパースされました。
pattern = r'\[(.*?)\] (.*?): (.*)'matches = re.findall(pattern, text)text = [(x1, x2.lower()) for x0, x1, x2 in matches]
[ (2018-03-12 16:03:59, "Alice", "Hi, how are you guys?"), (2018-03-12 16:05:36, "Tom", "I am good thanks!"), ...]
現在、各要素は個別に処理されました。
- 送信日時:datetimeオブジェクトに変換する以外に、この情報は使用されていません。ただし、トピックディスカッションの開始と終了を区別するために、時間差を見ることができます。
- 連絡先名:テキストをトークン化する際に、各連絡先名は固有のトークンとして扱われます。これにより、名前と姓の組み合わせは1つのエンティティとして扱われます。
- チャットメッセージ:各メッセージの最後に特別な「<END>」トークンが追加されます。
3. トークン化
言語モデルを訓練するためには、言語を断片(いわゆるトークン)に分割し、それらをモデルに一つずつフィードする必要があります。トークン化は複数のレベルで実行することができます。
- 文字レベル:テキストは個々の文字(空白を含む)のシーケンスとして認識されます。この詳細なアプローチにより、あらゆる単語が文字のシーケンスから形成される可能性があります。ただし、単語間の意味的な関係を捉えることはより困難です。
- 単語レベル:テキストは単語のシーケンスとして表現されます。ただし、モデルの語彙は訓練データ内の既存の単語に制限されます。
- サブワードレベル:テキストは単語より小さなサブワード単位に分解されます。
最初は文字レベルのトークナイザーを使用していましたが、繰り返しの単語の文字列のシーケンスを学習することで訓練時間が無駄になり、文全体での単語間の意味的な関係に注力することができないと感じました。
概念的な単純さのために、より洗練されたトークン化戦略のための利用可能なライブラリを置いて、単語レベルのトークナイザーに切り替えることにしました。
from nltk.tokenize import RegexpTokenizerdef custom_tokenizer(txt: str, spec_tokens: List[str], pattern: str="|\d|\\w+|[^\\s]") -> List[str]: """ NLTKのRegexpTokenizerを使用して、指定された特殊な組み合わせを単一のトークンとして考慮して、テキストを単語または文字にトークン化します。 :param txt: 文集合を単一の文字列要素として指定します。 :param spec_tokens: 特殊なトークン(終了、未知語など)のリスト。 :param pattern: デフォルトでは文集合は単語レベルでトークン化されます(スペースで分割されます)。 数字は単一のトークンとして考えられます。 >> 注:文字レベルのトークン化のパターンは '|.' です。 """ pattern = "|".join(spec_tokens) + pattern tokenizer = RegexpTokenizer(pattern) tokens = tokenizer.tokenize(txt) return tokens
["Alice:", "こんにちは", "お元気ですか", "みなさん", "?", "<END>", "Tom:", ... ]
訓練データは約70,000のユニークな単語を持っていることがわかりました。ただし、多くの単語が1回または2回しか現れないため、このような珍しい単語を「<UNK>」という特殊なトークンで置き換えることにしました。これにより、語彙が約25,000の単語に減少し、後で訓練する必要のあるモデルのサイズも小さくなりました。
from collections import Counterdef get_infrequent_tokens(tokens: Union[List[str], str], min_count: int) -> List[str]: """ 最小カウント以下の頻度があるトークンを特定します。 :param tokens: 文字レベルで生のテキストである場合、頻度は文字レベルでカウントされます。 リストのトークン化されたコーパスである場合、頻度はトークンレベルでカウントされます。 :min_count: トークンをフラグ付けするための発生の閾値。 :return: 珍しいトークンのリスト。 """ counts = Counter(tokens) infreq_tokens = set([k for k,v in counts.items() if v<=min_count]) return infreq_tokensdef mask_tokens(tokens: List[str], mask: Set[str]) -> List[str]: """ すべてのトークンを反復処理します。セットに含まれるトークンは未知語のトークンに置き換えられます。 :param tokens: トークン化されたコーパス。 :param mask: コーパスでマスクするトークンのセット。 :return: マスキング操作後のトークン化されたコーパスのリスト。 """ return [t.replace(t, unknown_token) if t in mask else t for t in tokens]infreq_tokens = get_infrequent_tokens(tokens, min_count=2)tokens = mask_tokens(tokens, infreq_tokens)
["Alice:", "こんにちは", "お元気ですか", "みなさん", "<UNK>", "?", "<END>", "Tom:", ... ]
4. インデックス付け
トークン化の後、次のステップは単語と特殊トークンを数値表現に変換することです。固定されたボキャブラリーリストを使用して、各単語はその位置でインデックス化されました。エンコードされた単語はPyTorchのテンソルとして準備されました。
import torchdef encode(s: list, vocab: list) -> torch.tensor: """ トークンのリストを整数のテンソルにエンコードします。固定ボキャブラリーが与えられ、 ボキャブラリーにトークンが見つからない場合、特殊な不明なトークンが割り当てられます。 トレーニングセットでその特殊なトークンを使用しなかった場合、ランダムなトークンが割り当てられます。 """ rand_token = random.randint(0, len(vocab)) map = {s:i for i,s in enumerate(vocab)} enc = [map.get(c, map.get(unknown_token, rand_token)) for c in s] enc = torch.tensor(enc, dtype=torch.long) return enc
torch.tensor([8127, 115, 2363, 3, ..., 14028])
見えないデータに対してモデルの品質を評価する必要があるため、テンソルを2つの部分に分割します。そうすることで、トレーニングセットとバリデーションセットが用意され、言語モデルに供給する準備が整います。
5. モデルアーキテクチャ
私は影響力のある論文「Attention is All you Need」によって推奨されているGPTアーキテクチャを適用することにしました。質問応答ボットではなく言語生成モデルを構築しようとしたため、デコーダーのみ(右側)のアーキテクチャがこの目的には十分でした。
以下のセクションでは、GPTアーキテクチャの各コンポーネントを分解し、その役割と基礎となる行列演算を説明します。準備されたトレーニングテストから始めて、モデルを通じて3単語の例文をトレースし、次のトークンの予測に至るまで説明します。
5.1. モデルの目的
技術の詳細に突入する前に、モデルの主な目的を理解することが重要です。デコーダーのみのセットアップでは、シーケンスの前のトークンの文脈を考慮して、次のトークンを正確に予測するために言語の構造をデコードすることが目的です。
インデックス化されたトークンのシーケンスをモデルに入力すると、さまざまな重み行列との行列積が行われます。出力は、入力コンテキストに基づいて、各トークンが次のシーケンスである確率を表すベクトルです。
モデルの評価:
モデルのパフォーマンスはトレーニングデータとの比較で評価されます。目的は、次のトークンを正しく予測する確率を最大化することです。
ただし、機械学習では、間違った予測のエラーや確率を定量化する「損失」の概念に焦点を当てることがよくあります。これを計算するために、モデルの出力確率と実際の次のトークン(エントロピー正則化強化学習を使用)を比較します。
最適化:
現在の損失を理解した上で、パフォーマンスを向上させるために重み行列を調整しながら、反復的にトークンのシーケンスをモデルに入力します。
各図では、その手順中に最適化される重み行列を黄色で強調表示します。
5.2. 出力埋め込み
ここまでのところで、シーケンス内の各トークンは整数インデックスによって表されてきました。しかし、この単純な形式では単語間の関係や類似性を反映していません。この問題を解決するために、我々は埋め込みを通じて一次元のインデックスを高次元の空間に昇格させることがあります。
- 単語埋め込み: 単語の本質は、浮動小数点のn次元ベクトルによって捉えられます。
- 位置埋め込み: これらの埋め込みは、文内での単語の位置の重要性を強調するもので、浮動小数点のn次元ベクトルによって表されます。
各トークンに対して、その単語埋め込みと位置埋め込みを参照し、要素ごとにそれらを合計します。これにより、コンテキスト内の各トークンの出力埋め込みが得られます。
以下の例では、コンテキストは3つのトークンで構成されています。埋め込みプロセスの終わりに、各トークンはn次元ベクトル (nは埋め込みサイズで、調整可能なハイパーパラメータです) で表されます。
PyTorchでは、このような埋め込みに対する専用のクラスが提供されています。モデルクラス内では、単語埋め込みと位置埋め込みを以下のように定義します(行列の次元を引数として渡す)。
self.word_embedding = nn.Embedding(vocab_size, embed_size)self.pos_embedding = nn.Embedding(block_size, embed_size)
5.3. 自己注意ヘッド
単語の埋め込みは、単語の類似性の一般的な感覚を提供しますが、単語の真の意味はしばしば前後の文脈に依存します。例えば、「bat」は文によって動物やスポーツ用具を指すことがあります。これはGPTアーキテクチャの主要な要素である自己注意機構が関与する領域です。
自己注意機構は、Query (Q)、Key (K)、およびValue (V) という3つの主要な概念に焦点を当てています。
- Query (Q): クエリは、注目が必要な現在のトークンの表現です。これは、「現在のトークンとして、文脈の残りの部分で何に注目すべきか?」というようなものです。
- Keys (K): キーは、入力シーケンスの各トークンの表現です。キーはクエリとペアになり、注意のスコアを決定します。この比較によって、クエリトークンが文脈内の他のトークンにどれだけ焦点を当てるべきかを測定します。スコアが高いほど、より多くの注意を払う必要があります。
- Value (V): バリューも入力シーケンスの各トークンの表現ですが、彼らの役割は異なり、注意スコアに最終的な重み付けを行います。
例:
この例では、コンテキストの各トークンは既に埋め込み形式である、n次元ベクトル (e1, e2, e3) としています。自己注意ヘッドは、それらを入力として受け取り、それぞれのトークンに対して1つずつ文脈化されたバージョンを出力します。
- トークン「name」を評価するとき、埋め込みベクトル v2 を学習可能な行列 M_Q で掛け算することで、クエリベクトル q が得られます。
- 同時に、各トークンに対して入力シーケンス内の埋め込みベクトル (e1, e2, e3) をそれぞれ学習可能な行列 M_K で掛け算することで、キーベクトル (k1, k2, k3) が計算されます。
- バリューベクトル (v1, v2, v3) も同様の方法で得られますが、異なる学習可能な行列 M_V を掛け算します。
- 注意スコア w は、クエリベクトルと各キーベクトルを個別にドット積で計算します。
- 最後に、すべての値ベクトルを行列に積み重ね、注意スコアでそれを乗算することで、「name」トークンの文脈化ベクトルを得ます。
class Head(nn.Module): """ このモジュールは、入力テンソルに対して自己注意操作を行い、同じ時間ステップですが異なるチャネルを持つ出力テンソルを生成します。 :param head_size: マルチヘッド注意機構内のヘッドのサイズです。 """ def __init__(self, head_size): super().__init__() self.key = nn.Linear(embed_size, head_size, bias=False) self.query = nn.Linear(embed_size, head_size, bias=False) self.value = nn.Linear(embed_size, head_size, bias=False) def forward(self, x): """ # input of size (batch, time-step, channels) # output of size (batch, time-step, head size) """ B,T,C = x.shape k = self.key(x) q = self.query(x) # attentionのスコアを計算 wei = q @ k.transpose(-2,-1) wei /= math.sqrt(k.shape[-1]) # look-aheadを避ける tril = torch.tril(torch.ones(T, T)) wei = wei.masked_fill(tril == 0, float("-inf")) wei = F.softmax(wei, dim=-1) # valuesの重み付け集約 v = self.value(x) out = wei @ v return out
5.4. マスクされたマルチヘッド注意
言語は複雑であり、そのすべてのニュアンスを捉えることは容易ではありません。単一の注意計算では、単語がどのように協力して働くかの微妙さを捉えることはできません。そこで、GPTモデルにおけるマルチヘッド注意のアイデアが役に立ちます。
マルチヘッド注意を、データを異なる方法で見るいくつかの目の組み合わせと考えてください。各目はユニークな詳細に注意を払い、これらの別々の観察結果を1つの大きな画像にまとめます。この大きな画像を管理可能なサイズに保ち、モデルの残りの部分と互換性を持たせるために、線形層(トレーニング可能な重み)を使用して元の埋め込みサイズに圧縮します。
最後に、モデルがトレーニングデータを単に記憶するだけでなく、新しいテキストに対しても良い予測ができるようにするために、ドロップアウト層を使用します。これにより、トレーニング中にデータの一部をランダムにオフにすることで、モデルがより適応性を持つようになります。
class MultiHeadAttention(nn.Module): """ このクラスには、平行して自己注意操作を実行する複数の `Head` オブジェクトが含まれています。 """ def __init__(self): super().__init__() head_size = embed_size // n_heads heads_list = [Head(head_size) for _ in range(n_heads)] self.heads = nn.ModuleList(heads_list) self.linear = nn.Linear(n_heads * head_size, embed_size) self.dropout = nn.Dropout(dropout) def forward(self, x): heads_list = [h(x) for h in self.heads] out = torch.cat(heads_list, dim=-1) out = self.linear(out) out = self.dropout(out) return out
5.5. フィードフォワード
マルチヘッド注意層は、シーケンス内の文脈関係を最初に捉えます。2つの連続する線形層を介してネットワークに深さを追加し、これはフィードフォワードニューラルネットワークを構成します。
最初の線形層では、次元数を増やします(私たちの場合は4倍)が、これによりネットワークはより複雑な特徴を学習し表現する能力が増します。結果の行列の各要素にReLU関数が適用され、非線形パターンが認識されます。
その後、2番目の線形層はコンプレッサーとして機能し、展開された次元を元の形状(ブロックサイズx埋め込みサイズ)に縮小します。モデルの一般化のために、ドロップアウト層がそのプロセスを終了します。
class FeedFoward(nn.Module): """ このモジュールは、入力テンソルを一連の線形変換と非線形アクティベーションを介して渡します。 """ def __init__(self): super().__init__() self.net = nn.Sequential( nn.Linear(embed_size, 4 * embed_size), nn.ReLU(), nn.Linear(4 * embed_size, embed_size), nn.Dropout(dropout), ) def forward(self, x): return self.net(x)
5.6. Add & Norm
今度は、マルチヘッドアテンションとフィードフォワードコンポーネントを結びつけるために、さらに2つの重要な要素を導入します:
- 残差接続(Add):これらの接続は、レイヤーの出力を変えずにその入力と要素ごとの加算を行います。トレーニング中、モデルは層の変換の重要性に基づいて強調を調整します。変換が必要ないと判断された場合、その重みおよびその結果としての出力はゼロに近づきます。この場合、少なくとも変更されていない入力が残差接続を通過します。このテクニックは、勾配消失問題を緩和するのに役立ちます。
- レイヤーノーマライゼーション(Norm):この方法は、コンテキスト内の各埋め込みベクトルをその平均値で引いて標準偏差で割ることによって、正規化します。このプロセスにより、バックプロパゲーション中の勾配が爆発または消失することがないようにもなります。
「Add & Norm」でリンクされたマルチヘッドアテンションとフィードフォワードレイヤーの連鎖は、ブロックに統合されます。このモジュール式の設計により、ブロックのシーケンスを形成することができます。これらのブロックの数は、モデルのアーキテクチャの深さを決定するハイパーパラメータです。
class Block(nn.Module): """ このモジュールは、マルチヘッドのセルフアテンションに続くフィードフォワードニューラルネットワークで構成される、1 つのトランスフォーマーブロックを含みます。 """ def __init__(self): super().__init__() self.sa = MultiHeadAttention() self.ffwd = FeedFoward() self.ln1 = nn.LayerNorm(embed_size) self.ln2 = nn.LayerNorm(embed_size) def forward(self, x): x = x + self.sa(self.ln1(x)) x = x + self.ffwd(self.ln2(x)) return x
5.7. ソフトマックス
複数のブロックコンポーネントを通過すると、次元が(ブロックサイズ x 埋め込みサイズ)の行列が得られます。この行列を必要な次元(ブロックサイズ x 語彙サイズ)に変形するために、最後の線形層を通過させます。この形状は、コンテキスト内の各位置の語彙内の各単語に対するエントリを表します。
最後に、これらの値にソフトマックス変換を適用し、確率に変換します。これにより、コンテキスト内の各位置で次のトークンの確率分布を正常に取得します。
6. モデルのトレーニング
言語モデルのトレーニングには、トレーニングデータ内のランダムな位置からトークンシーケンスを選択しました。WhatsAppの会話のペースが速いため、32語のコンテキスト長が十分であると判断しました。したがって、ランダムな32語のチャンクをコンテキストの入力として選択し、一語ずつシフトした対応するベクトルを比較のためのターゲットとして使用しました。
トレーニングプロセスは、以下のステップを繰り返しました:
- 複数のコンテキストのバッチをサンプルします。
- これらのサンプルをモデルに供給して現在の損失を計算します。
- 現在の損失とモデルの重みに基づいてバックプロパゲーションを適用します。
- 500回目のイテレーションごとに損失をより包括的に評価します。
入力データサイズと計算リソースの制約があるため、他のすべてのモデルハイパーパラメータ(埋め込みサイズ、セルフアテンションヘッド数など)が固定された状態で、私は2.5百万のパラメータを持つモデルを最終的に決定しました。トレーニングプロセスは、約12時間かかりました。損失の散らばりがバリデーションセットとトレーニングセットの間で広がったため、トレーニングを早期終了することもできたことが分かります。
“`html
import json import torch from config import eval_interval, learn_rate, max_iters from src.model import GPTLanguageModel from src.utils import current_time, estimate_loss, get_batch def model_training(update: bool) -> None: """ Trains or updates a GPTLanguageModel using pre-loaded data. This function either initializes a new model or loads an existing model based on the `update` parameter. It then trains the model using the AdamW optimizer on the training and validation data sets. Finally the trained model is saved. :param update: Boolean flag to indicate whether to update an existing model. """ # LOAD DATA ----------------------------------------------------------------- train_data = torch.load("assets/output/train.pt") valid_data = torch.load("assets/output/valid.pt") with open("assets/output/vocab.txt", "r", encoding="utf-8") as f: vocab = json.loads(f.read()) # INITIALIZE / LOAD MODEL --------------------------------------------------- if update: try: model = torch.load("assets/models/model.pt") print("Loaded existing model to continue training.") except FileNotFoundError: print("No existing model found. Initializing a new model.") model = GPTLanguageModel(vocab_size=len(vocab)) else: print("Initializing a new model.") model = GPTLanguageModel(vocab_size=len(vocab)) # initialize optimizer optimizer = torch.optim.AdamW(model.parameters(), lr=learn_rate) # number of model parameters n_params = sum(p.numel() for p in model.parameters()) print(f"Parameters to be optimized: {n_params}\n", ) # MODEL TRAINING ------------------------------------------------------------ for i in range(max_iters): # evaluate the loss on train and valid sets every 'eval_interval' steps if i % eval_interval == 0 or i == max_iters - 1: train_loss = estimate_loss(model, train_data) valid_loss = estimate_loss(model, valid_data) time = current_time() print(f"{time} | step {i}: train loss {train_loss:.4f}, valid loss {valid_loss:.4f}") # sample batch of data x_batch, y_batch = get_batch(train_data) # evaluate the loss logits, loss = model(x_batch, y_batch) optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step() torch.save(model, "assets/models/model.pt") print("Model saved")
7. Chat-Mode
To interact with the trained model, I created a function that allows selecting a contact name via a dropdown menu and inputting a message for the model to respond to. The parameter “n_chats” determines the number of responses the model generates at once. The model concludes a generated message when it predicts the <END> token as the next token.
import json import random import torch from prompt_toolkit import prompt from prompt_toolkit.completion import WordCompleter from config import end_token, n_chats from src.utils import custom_tokenizer, decode, encode, print_delayed def conversation() -> None: """ Emulates chat conversations by sampling from a pre-trained GPTLanguageModel. This function loads a trained GPTLanguageModel along with vocabulary and the list of special tokens. It then enters into a loop where the user specifies a contact. Given this input, the model generates a sample response. The conversation continues until the user inputs the end token. """ with open("assets/output/vocab.txt", "r", encoding="utf-8") as f: vocab = json.loads(f.read()) with open("assets/output/contacts.txt", "r", encoding="utf-8") as f: contacts = json.loads(f.read()) spec_tokens = contacts + [end_token] model = torch.load("assets/models/model.pt") completer = WordCompleter(spec_tokens, ignore_case=True) input = prompt("message >> ", completer=completer, default="") output = torch.tensor([], dtype=torch.long) print() while input != end_token: for _ in range(n_chats): add_tokens = custom_tokenizer(input, spec_tokens) add_context = encode(add_tokens, vocab) context = torch.cat((output, add_context)).unsqueeze(1).T n0 = len(output) output = model.generate(context, vocab) n1 = len(output) print_delayed(decode(output[n0-n1:], vocab)) input = random.choice(contacts) input = prompt("\nresponse >> ", completer=completer, default="") print()
```
結論:
個人的なチャットのプライバシーのため、ここでは具体的なプロンプトや会話の例を示すことができません。
それでも、このスケールのモデルでは、文の一般的な構造をうまく学習し、単語の順序によって意味のある出力を生成することが期待されます。私の場合、訓練データで顕著なトピックがある場合には、特定のトピックの文脈も把握しました。たとえば、私の個人的なチャットはしばしばテニスを中心に展開しているため、テニス選手の名前やテニスに関連する単語が一緒に出力されることが一般的でした。
ただし、生成された文の結束性を評価すると、結果は私の控えめな期待には少し達していないと認めざるを得ません。もちろん、友人たちが無駄話をあまりにも多くしてしまったことも、モデルが有益な情報を学ぶ能力を制限した要因として考えられます...
最後に、少なくともいくつかの例の出力を示すために、訓練されたダミーメッセージの200件でダミーモデルがどのように機能するかご覧いただけます 😉
We will continue to update VoAGI; if you have any questions or suggestions, please contact us!
Was this article helpful?
93 out of 132 found this helpful
Related articles