TensorFlow/Kerasを使用したPythonでの5行GPTスタイルのテキスト生成

トランスフォーマーは、2017年にリリースされましたが、ここXNUMX、XNUMX年で大きな牽引力を獲得し始めました。 HuggingFace、NLP、 大規模言語モデル(LLM) これまで以上にアクセスしやすくなりました。

それでも–彼らの周りのすべての誇大宣伝と 多くの 理論指向のガイドでは、オンラインでのカスタム実装は多くなく、リソースは、以前から存在している他のネットワークタイプほど簡単には利用できません。 HuggingFace(別のガイドのトピック)から事前に構築されたTransformerを使用することで、ワークサイクルを簡素化できますが、 感じます ライブラリを介して抽象化する前に、自分でビルドすることでどのように機能するか。 ここでは、理論と最適化ではなく、構築に焦点を当てます。

このガイドでは、 自己回帰言語モデル 〜へ テキストを生成する。 データの読み込み、分割、ベクトル化、モデルの構築、カスタムコールバックの作成、トレーニング/推論の実用的でミニマル/簡潔な側面に焦点を当てます。 これらの各タスクは、より詳細なガイドに分割できるため、実装は一般的なものとして維持し、独自のデータセットに応じてカスタマイズと最適化の余地を残します。

LLMとGPTの種類-フョードル

分類ははるかに複雑になる可能性がありますが、 広く Transformerベースの言語モデルを次のXNUMXつのカテゴリに分類します。

  • エンコーダベースのモデル – ALBERT、BERT、DistilBERT、RoBERTa
  • デコーダーベース – GPT、GPT-2、GPT-3、TransformerXL
  • Seq2Seqモデル – BART、mBART、T5

エンコーダベース モデルは、アーキテクチャ(通常はスタック)でTransformerエンコーダーのみを使用し、文(分類、固有表現抽出、質問応答)を理解するのに最適です。

デコーダーベース モデルは、アーキテクチャでTransformerデコーダーのみを使用し(通常はスタックされます)、将来の予測に最適であるため、テキスト生成に適しています。

シーケンス 2 シーケンス モデルはエンコーダーとデコーダーの両方を組み合わせており、テキストの生成、要約、そして最も重要なのは翻訳に優れています。

過去数年で多くの注目を集めたGPTファミリーのモデルは、デコーダーベースのトランスフォーマーモデルであり、人間のようなテキストの作成に優れており、大量のデータでトレーニングされ、新しいものとしてプロンプトが表示されます。生成のためのシードを開始します。 例えば:

generate_text('the truth ultimately is')

ボンネットの下で、このプロンプトをGPTのようなモデルにフィードし、以下を生成します。

'the truth ultimately is really a joy in history, this state of life through which is almost invisible, superfluous  teleological...'

これは、実際には、ガイドの最後から小さなスポイラーです! もうXNUMXつの小さなネタバレは、そのテキストを生成したアーキテクチャです。

inputs = layers.Input(shape=(maxlen,))
embedding_layer = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
transformer_block = keras_nlp.layers.TransformerDecoder(embed_dim, num_heads)(embedding_layer)
outputs = layers.Dense(vocab_size, activation='softmax')(transformer_block)
    
model = keras.Model(inputs=inputs, outputs=outputs)

デコーダーのみのトランスフォーマーモデルを構築するために必要なのは5行だけで、小さなGPTをシミュレートします。 Fyodor Dostoyevskyの小説(ウィキペディアからRedditのコメントまで、他のものに置き換えることができます)でモデルをトレーニングするので、暫定的にモデルと呼びます。 GPT-フョードル.

ケラスNLP

5行GPTの秘訣-フョードルは ケラスNLPは、公式のKerasチームによって、Kerasの水平方向の拡張として開発されました。これは、真のKeras方式で、新しいレイヤー(エンコーダー、デコーダー、トークン埋め込み、位置埋め込み、メトリック、トークナイザーなど)。

KerasNLPはモデル動物園ではありません。 これはKerasの一部であり(個別のパッケージとして)、メインパッケージを使用した一般的な深層学習開発の参入障壁を下げるのと同じように、NLPモデル開発の参入障壁を下げます。

注: 執筆時点で、KerasNLPはまだ作成中であり、初期段階です。 将来のバージョンでは、微妙な違いが存在する可能性があります。 記事はバージョンを利用しています 0.3.0.

KerasNLPを使用できるようにするには、次の方法でインストールする必要があります。 pip:

$ pip install keras_nlp

そして、次の方法でバージョンを確認できます。

keras_nlp.__version__

Kerasを使用したGPTスタイルモデルの実装

使用するライブラリ(TensorFlow、Keras、KerasNLP、NumPy)をインポートすることから始めましょう。

import tensorflow as tf
from tensorflow import keras
import keras_nlp
import numpy as np

データのロード

ドストエフスキーの小説のいくつかをロードしてみましょう。初期段階からかなりの過剰適合がなければ、モデルが収まるには短すぎるでしょう。 からの生のテキストファイルを優雅に使用します プロジェクト·グーテンベルク、そのようなデータでの作業が簡単なため、次のようになります。

crime_and_punishment_url = 'https://www.gutenberg.org/files/2554/2554-0.txt'
brothers_of_karamazov_url = 'https://www.gutenberg.org/files/28054/28054-0.txt'
the_idiot_url = 'https://www.gutenberg.org/files/2638/2638-0.txt'
the_possessed_url = 'https://www.gutenberg.org/files/8117/8117-0.txt'

paths = [crime_and_punishment_url, brothers_of_karamazov_url, the_idiot_url, the_possessed_url]
names = ['Crime and Punishment', 'Brothers of Karamazov', 'The Idiot', 'The Possessed']
texts = ''
for index, path in enumerate(paths):
    filepath = keras.utils.get_file(f'{names[index]}.txt', origin=path)
    text = ''
    with open(filepath, encoding='utf-8') as f:
        text = f.read()
        
        
        
        texts += text[10000:]

すべてのファイルをダウンロードし、それらを調べて、それらを上下に連結しただけです。 これには、使用する言語の多様性が含まれますが、それでも明確にフョードルを維持します! 各ファイルについて、最初の10k文字をスキップしました。これは、序文とグーテンベルクのイントロの平均的な長さであるため、反復ごとに本の本文はほとんどそのまま残ります。 中のランダムな500文字を見てみましょう texts 今すぐ文字列:


texts[25000:25500]
'nd that was whynI addressed you at once. For in unfolding to you the story of my life, Indo not wish to make myself a laughing-stock before these idle listeners,nwho indeed know all about it already, but I am looking for a mannof feeling and education. Know then that my wife was educated in anhigh-class school for the daughters of noblemen, and on leaving shendanced the shawl dance before the governor and other personages fornwhich she was presented with a gold medal and a certificate of merit.n'

他の処理を行う前に、文字列を文に分けましょう。

text_list = texts.split('.')
len(text_list) 

69kの文があります。 交換する場合 n 空白を含む文字と単語を数えます:

len(texts.replace('n', ' ').split(' ')) 

注: 通常、データセットには少なくとも5万語、理想的にはそれよりはるかに多くの単語が必要です。 言語モデルは数十ギガバイトのテキストでトレーニングされるのが一般的ですが、私たちは数メガバイトのデータ(〜XNUMXMB)で作業しています。 これにより、当然、テキスト入力の過剰適合が非常に簡単になり、一般化が困難になります(過剰適合のない高パープレキシティ、または過剰適合の多い低パープレキシティ)。 一粒の塩で結果を取ります。

それにもかかわらず、これらをに分割しましょう トレーニング, test & 設定。 まず、空の文字列を削除して、文をシャッフルしましょう。


text_list = list(filter(None, text_list))

import random
random.shuffle(text_list)

次に、70/15/15の分割を行います。

length = len(text_list)
text_train = text_list[:int(0.7*length)]
text_test = text_list[int(0.7*length):int(0.85*length)]
text_valid = text_list[int(0.85*length):]

これは、トレインテスト検証分割を実行するためのシンプルでありながら効果的な方法です。 覗いてみましょう text_train:

[' It was a dull morning, but the snow had ceased',
 'nn"Pierre, you who know so much of what goes on here, can you really havenknown nothing of this business and have heard nothing about it?"nn"What? What a set! So it's not enough to be a child in your old age,nyou must be a spiteful child too! Varvara Petrovna, did you hear what hensaid?"nnThere was a general outcry; but then suddenly an incident took placenwhich no one could have anticipated', ...

標準化とベクトル化の時間です!

テキストのベクトル化

ネットワークは単語を理解しません–彼らは数字を理解します。 単語をトークン化する必要があります。

...
sequence = ['I', 'am', 'Wall-E']
sequence = tokenize(sequence)
print(sequence) # [4, 26, 472]
...

また、文の長さが異なるため、通常、パディングは左または右に追加され、入力される文全体で同じ形状になるようにします。最長の文は5ワード(トークン)の長さであるとします。 その場合、Wall-E文にはXNUMXつのゼロが埋め込まれるため、同じ入力形状が保証されます。

sequence = pad_sequence(sequence)
print(sequence) # [4, 26, 472, 0, 0]

従来、これはTensorFlowを使用して行われました Tokenizer とKeras ' pad_sequences() メソッド–ただし、はるかに便利なレイヤー、 TextVectorization、トークン化するを使用できます & 入力を埋めて、語彙を事前に知らなくても、語彙とそのサイズを抽出できるようにします。

ベストプラクティス、業界で認められた標準、および含まれているチートシートを含む、Gitを学習するための実践的で実用的なガイドを確認してください。 グーグルGitコマンドを停止し、実際に 学ぶ それ!

適応してフィットしましょう TextVectorization 層:

from tensorflow.keras.layers import TextVectorization

def custom_standardization(input_string):
    sentence = tf.strings.lower(input_string)
    sentence = tf.strings.regex_replace(sentence, "n", " ")
    return sentence

maxlen = 50



vectorize_layer = TextVectorization(
    standardize = custom_standardization,
    output_mode="int",
    output_sequence_length=maxlen + 1,
)

vectorize_layer.adapt(text_list)
vocab = vectorize_layer.get_vocabulary()

  custom_standardization() メソッドはこれよりもはるかに長くなる可能性があります。 すべての入力を小文字にして置き換えました n   " "。 これは、テキストの前処理のほとんどを実際に行うことができる場所です–そしてオプションを介してベクトル化レイヤーにそれを提供します standardize 口論。 一度あなた adapt() テキストへのレイヤー(NumPy配列またはテキストのリスト)–語彙とそのサイズをそこから取得できます。

vocab_size = len(vocab)
vocab_size 

最後に、単語のトークン化を解除するために、 index_lookup 辞書:

index_lookup = dict(zip(range(len(vocab)), vocab))    
index_lookup[5] 

すべてのトークンをマップします([1, 2, 3, 4, ...])語彙の単語に(['a', 'the', 'i', ...])。 キー(トークンインデックス)を渡すことで、簡単に単語を取り戻すことができます。 これで、 vectorize_layer() 任意の入力で、ベクトル化された文を観察します。

vectorize_layer(['hello world!'])

結果は次のとおりです。

<tf.Tensor: shape=(1, 51), dtype=int64, numpy=
array([[   1, 7509,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0]], dtype=int64)>

こんにちはのインデックスがあります 1 世界にはのインデックスがありますが 7509! 残りはへのパディングです maxlen 計算しました。

テキストをベクトル化する手段があります–では、からデータセットを作成しましょう text_train, text_test & text_valid、GPT-Fyodorに入力できる単語とベクトル間の変換媒体としてベクトル化レイヤーを使用します。

データセットの作成

作成します tf.data.Dataset セットごとに、 from_tensor_slices() テンソルスライス(文)のリストを提供します。

batch_size = 64

train_dataset = tf.data.Dataset.from_tensor_slices(text_train)
train_dataset = train_dataset.shuffle(buffer_size=256)
train_dataset = train_dataset.batch(batch_size)

test_dataset = tf.data.Dataset.from_tensor_slices(text_test)
test_dataset = test_dataset.shuffle(buffer_size=256)
test_dataset = test_dataset.batch(batch_size)

valid_dataset = tf.data.Dataset.from_tensor_slices(text_valid)
valid_dataset = valid_dataset.shuffle(buffer_size=256)
valid_dataset = valid_dataset.batch(batch_size)

作成してシャッフルしたら(これも適切な方法です)–前処理(ベクトル化とシーケンス分割)関数を適用できます。

def preprocess_text(text):
    text = tf.expand_dims(text, -1)
    tokenized_sentences = vectorize_layer(text)
    x = tokenized_sentences[:, :-1]
    y = tokenized_sentences[:, 1:]
    return x, y


train_dataset = train_dataset.map(preprocess_text)
train_dataset = train_dataset.prefetch(tf.data.AUTOTUNE)

test_dataset = test_dataset.map(preprocess_text)
test_dataset = test_dataset.prefetch(tf.data.AUTOTUNE)

valid_dataset = valid_dataset.map(preprocess_text)
valid_dataset = valid_dataset.prefetch(tf.data.AUTOTUNE)

  preprocess_text() 関数は単に最後の次元で展開し、私たちを使用してテキストをベクトル化します vectorize_layer 単一のトークンでオフセットされた入力とターゲットを作成します。 モデルは使用します [0..n] 推測する n+1、各単語の予測を生成し、その前のすべての単語を考慮します。 いずれかのデータセットの単一のエントリを見てみましょう。

for entry in train_dataset.take(1):
    print(entry)

返された入力とターゲットを64のバッチ(それぞれ30の長さ)で調査すると、それらがXNUMXつオフセットされていることがはっきりとわかります。

(<tf.Tensor: shape=(64, 50), dtype=int64, numpy=
array([[17018,   851,     2, ...,     0,     0,     0],
       [  330,    74,     4, ...,     0,     0,     0],
       [   68,   752, 30273, ...,     0,     0,     0],
       ...,
       [    7,    73,  2004, ...,     0,     0,     0],
       [   44,    42,    67, ...,     0,     0,     0],
       [  195,   252,   102, ...,     0,     0,     0]], dtype=int64)>, <tf.Tensor: shape=(64, 50), dtype=int64, numpy=
array([[  851,     2,  8289, ...,     0,     0,     0],
       [   74,     4,    34, ...,     0,     0,     0],
       [  752, 30273,  7514, ...,     0,     0,     0],
       ...,
       [   73,  2004,    31, ...,     0,     0,     0],
       [   42,    67,    76, ...,     0,     0,     0],
       [  252,   102,  8596, ...,     0,     0,     0]], dtype=int64)>)

最後に、モデルを作成します。

モデル定義

ここでは、KerasNLPレイヤーを利用します。 後 Input、入力をエンコードします TokenAndPositionEmbedding レイヤー、私たちを渡す vocab_size, maxlen & embed_dim。 同じ embed_dim このレイヤーが出力および入力すること TransformerDecoder なります デコーダーに保持。 執筆時点では、デコーダーは入力の次元を自動的に維持し、それを別の出力に投影することはできませんが、 intermediate_dim 引数。

潜在的な表現のために埋め込みの寸法をXNUMX倍しますが、同じままにするか、埋め込みの薄暗い値から切り離された数値を使用できます。

embed_dim = 128
num_heads = 4

def create_model():
    inputs = keras.layers.Input(shape=(maxlen,), dtype=tf.int32)
    embedding_layer = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
    decoder = keras_nlp.layers.TransformerDecoder(intermediate_dim=embed_dim, 
                                                            num_heads=num_heads, 
                                                            dropout=0.5)(embedding_layer)
    
    outputs = keras.layers.Dense(vocab_size, activation='softmax')(decoder)
    
    model = keras.Model(inputs=inputs, outputs=outputs)
    
    model.compile(
        optimizer="adam", 
        loss='sparse_categorical_crossentropy',
        metrics=[keras_nlp.metrics.Perplexity(), 'accuracy']
    )
    return model

model = create_model()
model.summary()

デコーダーの上に、 Dense シーケンス内の次の単語を選択するレイヤー。 softmax アクティベーション(次のトークンごとに確率分布を生成します)。 モデルの概要を見てみましょう。

Model: "model_5"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 input_6 (InputLayer)        [(None, 30)]              0         
                                                                 
 token_and_position_embeddin  (None, 30, 128)          6365824   
 g_5 (TokenAndPositionEmbedd                                     
 ing)                                                            
                                                                 
 transformer_decoder_5 (Tran  (None, 30, 128)          132480    
 sformerDecoder)                                                 
                                                                 
 dense_5 (Dense)             (None, 30, 49703)         6411687   
                                                                 
=================================================================
Total params: 13,234,315
Trainable params: 13,234,315
Non-trainable params: 0
_________________________________________________________________

GPT-2は多くのデコーダーをスタックします– GPT-2 Smallには12個のスタックデコーダー(117Mパラメーター)があり、GPT-2 Extra Largeには48個のスタックデコーダー(1.5Bパラメーター)があります。 控えめな13Mパラメータを使用したシングルデコーダモデルは、教育目的で十分に機能するはずです。 LLMの場合–スケールアップは非常に優れた戦略であることが証明されており、Transformersは優れたスケーリングを可能にし、非常に大きなモデルのトレーニングを可能にします。

GPT-3には 「貧弱」 175Bパラメータ。 Google Brainのチームは、1.6Tパラメータモデルをトレーニングして、はるかに小さいモデルと同じレベルで計算を維持しながら、スパース性の調査を実行しました。

実際のところ、デコーダーの数を1から3に増やした場合。

def create_model():
    inputs = keras.layers.Input(shape=(maxlen,), dtype=tf.int32)
    x = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
    for i in range(4):
        x = keras_nlp.layers.TransformerDecoder(intermediate_dim=embed_dim*2, num_heads=num_heads,                                                             dropout=0.5)(x)
    do = keras.layers.Dropout(0.4)(x)
    outputs = keras.layers.Dense(vocab_size, activation='softmax')(do)
    
    model = keras.Model(inputs=inputs, outputs=outputs)

パラメータ数は400k増加します。

Total params: 13,631,755
Trainable params: 13,631,755
Non-trainable params: 0

私たちのネットワークのパラメータのほとんどは、 TokenAndPositionEmbedding & Dense レイヤー!

デコーダーのさまざまな深さを試してみてください– 1から、マシンが処理して結果を記録できるすべての方法まで。 いずれにせよ、モデルをトレーニングする準備がほぼ整いました。 各エポックでテキストのサンプルを生成するカスタムコールバックを作成して、モデルがトレーニングを通じて文を形成する方法を学習する方法を確認しましょう。

カスタムコールバック

class TextSampler(keras.callbacks.Callback):
    def __init__(self, start_prompt, max_tokens):
        self.start_prompt = start_prompt
        self.max_tokens = max_tokens
        
    
    
    def sample_token(self, logits):
        logits, indices = tf.math.top_k(logits, k=5, sorted=True)
        indices = np.asarray(indices).astype("int32")
        preds = keras.activations.softmax(tf.expand_dims(logits, 0))[0]
        preds = np.asarray(preds).astype("float32")
        return np.random.choice(indices, p=preds)

    def on_epoch_end(self, epoch, logs=None):
        decoded_sample = self.start_prompt
        
        for i in range(self.max_tokens-1):
            tokenized_prompt = vectorize_layer([decoded_sample])[:, :-1]
            predictions = self.model.predict([tokenized_prompt], verbose=0)
            
            
            
            
            sample_index = len(decoded_sample.strip().split())-1
            
            sampled_token = self.sample_token(predictions[0][sample_index])
            sampled_token = index_lookup[sampled_token]
            decoded_sample += " " + sampled_token
            
        print(f"nSample text:n{decoded_sample}...n")


random_sentence = ' '.join(random.choice(text_valid).replace('n', ' ').split(' ')[:4])
sampler = TextSampler(random_sentence, 30)
reducelr = keras.callbacks.ReduceLROnPlateau(patience=10, monitor='val_loss')

モデルのトレーニング

最後に、トレーニングの時間です! 私たちの中にチャックしましょう train_dataset & validation_dataset コールバックが設定されている場合:

model = create_model()
history = model.fit(train_dataset, 
                    validation_data=valid_dataset,
                    epochs=10, 
                    callbacks=[sampler, reducelr])

サンプラーは、終了引用符と開始引用符で始まる不幸な文を選択しましたが、トレーニング中に興味深い結果が得られます。

# Epoch training
Epoch 1/10
658/658 [==============================] - ETA: 0s - loss: 2.7480 - perplexity: 15.6119 - accuracy: 0.6711
# on_epoch_end() sample generation
Sample text:
”  “What do you had not been i had been the same man was not be the same eyes to been a whole man and he did a whole man to the own...
# Validation
658/658 [==============================] - 158s 236ms/step - loss: 2.7480 - perplexity: 15.6119 - accuracy: 0.6711 - val_loss: 2.2130 - val_perplexity: 9.1434 - val_accuracy: 0.6864 - lr: 0.0010
...
Sample text:
”  “What do you know it is it all this very much as i should not have a great impression  in the room to be  able of it in my heart...

658/658 [==============================] - 149s 227ms/step - loss: 1.7753 - perplexity: 5.9019 - accuracy: 0.7183 - val_loss: 2.0039 - val_perplexity: 7.4178 - val_accuracy: 0.7057 - lr: 0.0010

それでは始まります:

「あなたは何をしていなかったのか私は同じだった」…

これはあまり意味がありません。 XNUMXの短いエポックの終わりまでに、それは次の線に沿って何かを生成します。

「もちろん、男性の中で最も普通の男性とはどういう意味ですか」…

10番目の文はまだあまり意味がありませんが、最初の文よりもはるかに官能的です。 より多くのデータ(より複雑な前処理ステップを含む)でのより長いトレーニングは、より良い結果をもたらします。 小さなデータセットサイズと戦うために、ドロップアウトの高いXNUMXエポックでのみトレーニングしました。 それがずっと長い間訓練されたままであるならば、それはそれの大きな塊を記憶していたであろうので、それは非常にフョードルのようなテキストを生み出すでしょう。

注: 出力はかなり冗長なので、微調整することができます verbose 画面上のテキストの量を減らすためにモデルをフィッティングする際の引数。

モデル推論

推論を実行するには、のインターフェイスを複製する必要があります TextSampler –シードとを受け入れるメソッド response_length (max_tokens)。 サンプラー内と同じ方法を使用します。

def sample_token(logits):
        logits, indices = tf.math.top_k(logits, k=5, sorted=True)
        indices = np.asarray(indices).astype("int32")
        preds = keras.activations.softmax(tf.expand_dims(logits, 0))[0]
        preds = np.asarray(preds).astype("float32")
        return np.random.choice(indices, p=preds)

def generate_text(prompt, response_length=20):
    decoded_sample = prompt
    for i in range(response_length-1):
        tokenized_prompt = vectorize_layer([decoded_sample])[:, :-1]
        predictions = model.predict([tokenized_prompt], verbose=0)
        sample_index = len(decoded_sample.strip().split())-1

        sampled_token = sample_token(predictions[0][sample_index])
        sampled_token = index_lookup[sampled_token]
        decoded_sample += " " + sampled_token
    return decoded_sample

これで、新しいサンプルでメソッドを実行できます。

generate_text('the truth ultimately is')


generate_text('the truth ultimately is')

結果を改善しますか?

では、どうすれば結果を改善できますか? あなたができるいくつかのかなり実用的なことがあります:

  • データクレンジング(入力データをより細心の注意を払ってクレンジングします。最初からおおよその数値をトリミングし、改行文字を削除しました)
  • より多くのデータを取得します(数メガバイトのテキストデータのみを処理しました)
  • データと一緒にモデルをスケーリングします(デコーダーのスタックは難しくありません!)

まとめ

前処理パイプラインは最小限で改善できますが、このガイドで概説されているパイプラインは、Kerasを使用してカスタムデコーダーのみのトランスフォーマーを構築するために必要なコードがわずか5行で、まともなGPTスタイルのモデルを生成しました。

トランスフォーマーは人気があり、一般的なシーケンスモデリングに広く適用できます(そして多くのものをシーケンスとして表現できます)。 これまでのところ、参入障壁は面倒な実装でしたが、KerasNLPを使用すると、ディープラーニングの実践者は実装を活用してモデルをすばやく簡単に構築できます。

タイムスタンプ:

より多くの スタックアバス