فصل ۱۶: پردازش زبان طبیعی با RNN و Attention
در سال ۱۹۵۰ آلن تورینگ برای ارزیابی توانایی ماشین در شبیهسازی هوش انسانی، یک Task زبانی را پیشنهاد کرد: گفتوگویی متنی که در آن ماشین تلاش میکند انسان را قانع کند که خودش انسان است. هرچند آزمون تورینگ همهٔ جنبههای هوش را پوشش نمیدهد، انتخاب زبان تصادفی نبود؛ تسلط بر زبان یکی از پیچیدهترین تواناییهای شناختی انسان است.
هدف کلی NLP ساخت سیستمهایی است که زبان نوشتاری و گفتاری را پردازش کنند، اما در عمل این هدف به Taskهای مشخصتر مانند Text Classification، Translation، Summarization و Question Answering شکسته میشود. فصل ابتدا RNNها را برای تولید متن، Sentiment Analysis و Neural Machine Translation بهکار میگیرد و سپس به Attention، Transformer، GPT، BERT و ابزارهای Hugging Face میرسد.
تولید متن شکسپیری با Character RNN
ایدهٔ Char-RNN این است که Model در هر Time Step Character بعدی را پیشبینی کند. با تکرار این Prediction میتوان متن جدید تولید کرد. Model نیازی به Ruleهای صریح Grammar ندارد؛ فقط از Task پیشبینی Character بعدی، Patternهایی دربارهٔ کلمه، فاصله، Punctuation و Syntax یاد میگیرد.
ساخت Dataset
متن کامل آثار شکسپیر Download میشود و با TextVectorization در سطح Character Encode میگردد:
import tensorflow as tf
shakespeare_url = "https://homl.info/shakespeare"
filepath = tf.keras.utils.get_file("shakespeare.txt", shakespeare_url)
with open(filepath) as f:
shakespeare_text = f.read()
text_vec_layer = tf.keras.layers.TextVectorization(
split="character", standardize="lower")
text_vec_layer.adapt([shakespeare_text])
encoded = text_vec_layer([shakespeare_text])[0]
encoded -= 2
n_tokens = text_vec_layer.vocabulary_size() - 2
dataset_size = len(encoded)
Tokenهای 0 و 1 برای Padding و Unknown رزرو شدهاند، بنابراین با کمکردن 2 حذف میشوند. در این Corpus تعداد Characterهای متمایز 39 و تعداد کل Characterها بیش از 1.1 میلیون است.
برای Train، Sequence بلند به Windowهای طول ثابت تبدیل میشود؛ Input یک Window است و Target همان Window است که یک Character به آینده Shift شده است:
def to_dataset(sequence, length, shuffle=False, seed=None, batch_size=32):
ds = tf.data.Dataset.from_tensor_slices(sequence)
ds = ds.window(length + 1, shift=1, drop_remainder=True)
ds = ds.flat_map(lambda window_ds: window_ds.batch(length + 1))
if shuffle:
ds = ds.shuffle(buffer_size=100_000, seed=seed)
ds = ds.batch(batch_size)
return ds.map(lambda window: (window[:, :-1], window[:, 1:])).prefetch(1)
شکل 16-1. ساخت Dataset
length = 100
train_set = to_dataset(encoded[:1_000_000], length=length,
shuffle=True, seed=42)
valid_set = to_dataset(encoded[1_000_000:1_060_000], length=length)
test_set = to_dataset(encoded[1_060_000:], length=length)
Window کوتاهتر Training را سریعتر میکند، اما Model Patternهای طولانیتر از Window را نمیبیند؛ بنابراین Length یک Hyperparameter مهم است.
ساخت و Train کردن Char-RNN
model = tf.keras.Sequential([
tf.keras.layers.Embedding(input_dim=n_tokens, output_dim=16),
tf.keras.layers.GRU(128, return_sequences=True),
tf.keras.layers.Dense(n_tokens, activation="softmax")
])
model.compile(loss="sparse_categorical_crossentropy",
optimizer="nadam", metrics=["accuracy"])
Embedding، ID هر Character را به Vector 16بعدی تبدیل میکند. GRU برای هر Time Step یک Representation میسازد و Dense Layer احتمال 39 Character ممکن را Output میدهد. چون Targetها ID عددی هستند، sparse_categorical_crossentropy مناسب است.
برای اینکه Model بتواند String خام دریافت کند، یک Preprocessing Model روی آن قرار میگیرد که TextVectorization را اعمال و IDها را با Offset مناسب به Model اصلی میدهد.
تولید متن و پارامتر Temperature
اگر همیشه Character با بیشترین Probability انتخاب شود، متن بهشدت Deterministic و تکراری میشود. برای کنترل Diversity، Logitها بر Temperature تقسیم میشوند و سپس Sampling انجام میشود:
def next_char(text, temperature=1):
y_proba = model.predict([text])[0, -1:]
rescaled_logits = tf.math.log(y_proba) / temperature
char_id = tf.random.categorical(rescaled_logits, num_samples=1)[0, 0]
return text_vec_layer.get_vocabulary()[char_id + 2]
def extend_text(text, n_chars=50, temperature=1):
for _ in range(n_chars):
text += next_char(text, temperature)
return text
Temperature کوچک، Distribution را Sharp و Output را محافظهکار میکند؛ Temperature بزرگ Diversity را بیشتر میکند اما احتمال متن بیمعنی نیز افزایش مییابد.
Stateful RNN
تا اینجا Model Stateless بود: Hidden State در پایان هر Batch دور ریخته میشد. برای یادگیری Patternهای طولانیتر میتوان State را بین Batchهای متوالی نگه داشت. شرط مهم این است که Sequence مربوط به هر Instance در Batch بعدی دقیقاً ادامهٔ همان Instance در Batch قبلی باشد.
برای ساخت Dataset چنین ترتیبی، متن به چند بخش پیوسته تقسیم میشود و از هر بخش Sequence Fragmentهای متوالی گرفته میشود؛ سپس Fragmentهای متناظر در Batch کنار هم قرار میگیرند.
شکل 16-2. Stateful RNN
در Model باید stateful=True تنظیم شود و Batch Size از ابتدا ثابت باشد؛ بنابراین batch_input_shape نیز مشخص میشود. در انتهای هر Epoch باید Stateها Reset شوند تا Epoch بعد از ابتدای متن شروع شود. این کار را میتوان با Callback انجام داد.
Stateful RNN میتواند Dependencyهای طولانیتر را یاد بگیرد، اما ساخت Dataset و مدیریت State آن پیچیدهتر است و Training را Sequence-sensitive میکند.
Sentiment Analysis روی IMDb
Task بعدی Classification Reviewهای فیلم به Positive و Negative است. Dataset IMDb با TensorFlow Datasets Load میشود؛ بخشی برای Train، بخشی برای Validation و بخش Test جدا نگه داشته میشود.
import tensorflow_datasets as tfds
raw_train_set, raw_valid_set, raw_test_set = tfds.load(
name="imdb_reviews",
split=["train[:90%]", "train[90%:]", "test"],
as_supervised=True)
train_set = raw_train_set.shuffle(5000, seed=42).batch(32).prefetch(1)
valid_set = raw_valid_set.batch(32).prefetch(1)
test_set = raw_test_set.batch(32).prefetch(1)
Tokenization و TextVectorization
این بار Input در سطح Word پردازش میشود. Tokenization بر اساس Space ساده است، اما برای زبان طبیعی همیشه ایدهآل نیست: عبارتهای چندکلمهای، Prefix/Suffix و Wordهای ناشناخته مشکل ایجاد میکنند. روشهایی مانند Subword Tokenization میتوانند Wordها را به قطعههای کوچکتر بشکنند و Vocabulary را بهتر پوشش دهند. TensorFlow Text و Tokenizerهای Hugging Face گزینههای پیشرفتهتری فراهم میکنند.
برای مثال IMDb، یک TextVectorization ساده با Vocabulary محدود استفاده میشود:
vocab_size = 1000
text_vec_layer = tf.keras.layers.TextVectorization(max_tokens=vocab_size)
text_vec_layer.adapt(train_set.map(lambda reviews, labels: reviews))
embed_size = 128
model = tf.keras.Sequential([
text_vec_layer,
tf.keras.layers.Embedding(vocab_size, embed_size),
tf.keras.layers.GRU(128),
tf.keras.layers.Dense(1, activation="sigmoid")
])
model.compile(loss="binary_crossentropy", optimizer="nadam",
metrics=["accuracy"])
مشکل Padding و Masking
Reviewها طولهای متفاوت دارند. وقتی TextVectorization آنها را Batch میکند، Sequenceهای کوتاه با Token صفر Pad میشوند. اگر RNN این Zeroها را مثل Token واقعی پردازش کند، State مفید ممکن است تا انتهای Sequence خراب شود.
راهحل این است که Padding Tokenها Mask شوند. کافی است در Embedding از mask_zero=True استفاده شود:
tf.keras.layers.Embedding(vocab_size, embed_size, mask_zero=True)
Embedding یک Boolean Mask تولید میکند؛ Layerهای سازگار مانند SimpleRNN، GRU، LSTM، Bidirectional، Dense و TimeDistributed آن را دریافت و به Layer بعدی Propagate میکنند. اگر Mask تا Output ادامه پیدا کند، Time Stepهای Maskشده در Loss نیز صفر در نظر گرفته میشوند.
Conv1D Masking را بهصورت مستقیم پشتیبانی نمیکند. در Modelهای پیچیدهتر باید Mask را با Functional API یا Subclassing دستی محاسبه و به Layer مناسب ارسال کرد. اگر Input از Embedding شروع نشود، tf.keras.layers.Masking میتواند Time Stepهایی را که تمام Featureهای آن صفر هستند Mask کند.
برای Custom Layer نیز میتوان Argument به نام mask در call() دریافت کرد و در صورت Propagate کردن Mask، supports_masking=True را تنظیم کرد؛ اگر Shape یا معنای Mask تغییر کند باید compute_mask() Override شود.