پردازش داده
در این بخش در ادامه مثال فصل قبل، نحوه تعلیم مدلهای دستهبندی کننده رشتهها را در یک بَتچ با استفاده از پایتورچ شرح میدهیم:
import torch
from transformers import AdamW, AutoTokenizer, AutoModelForSequenceClassification
# Same as before
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"This course is amazing!",
]
batch = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")
# This is new
batch["labels"] = torch.tensor([1, 1])
optimizer = AdamW(model.parameters())
loss = model(**batch).loss
loss.backward()
optimizer.step()
البته تعلیم با استفاده از دو جمله به نتایج چشمگیری منتهی نخواهد شد. برای به دست آوردن نتایج بهتر نیاز به آمادهسازی دیتاسِت بزرگتری خواهید داشت.
در این بخش ما از دیتاسِت MRPC[^1] که در یک مقاله، نوشتهی ویلیام بی دالن و کریس براکت، معرفی شده به عنوان یک مثال استفاده خواهیم کرد. این دیتاسِت شامل ۵۸۰۱ جفت جمله و یک برچسب میباشد که برچسب نشان دهنده متناظر بودن جملات میباشد (به عنوان مثال اینکه آیا دو جمله معنی یکسانی دارند یا خیر). علت انتخاب این دیتاسِت این است که دیتاسِت کوچکی است و تجربه تعلیم روی آن آسان است.
بارگذاری دیتاسِتها از هاب
هاب تنها شامل مدلها نمیباشد؛ بلکه شامل دیتاسِتهای متعدد در بسیاری از زبانهای مختلف میباشد. شما میتوانید دیتاسِتها را در این لینک جستجو کنید و پیشنهاد میکنیم پس از اتمام این بخش یک دیتاسِت جدید را دریافت و پردازش کنید (بخش مستندات عمومی را در اینجا مشاهده کنید). اما اجازه بدهید اکنون روی دیتاسِت MRPC تمرکز کنیم! این یکی از ۱۰ دیتاسِت GLUE benchmark است که یک محک تهیه شده در محیط دانشگاهی جهت اندازه گیری کارکرد مدلهای یادگیری ماشینی در ۱۰ مسئله دستهبندی متن مختلف میباشد.
کتابخانه دیتاسِت هاگینگفِیس یک دستور بسیار ساده جهت دانلود و انبار کردن یک دیتاسِت در هاب ارائه میکند. ما میتوانیم دیتاسِت MRPC را به روش زیر دانلود کنیم:
from datasets import load_dataset
raw_datasets = load_dataset("glue", "mrpc")
raw_datasets
DatasetDict({
train: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 3668
})
validation: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 408
})
test: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 1725
})
})
همانطور که میبینید یک شیء DatasetDict
بدست میآوریم که شامل مجموعه training
، مجموعه validation
و مجموعه test
میباشد. هر یک از اینها شامل چندین ستون (label
، sentence2
، sentence1
و idx
) و تعداد متغیری سطر که عناصر هر مجموعه را تشکیل میدهند میباشد. (بنابراین، ۳۶۶۸ جفت جمله در مجموعه training
وجود دارد، ۴۰۸ تا در مجموعه validation
و ۱۷۲۵ تا در مجموعه test
).
این دستور دیتاسِت را دانلود و به صورت پیشفرض در پوشه ~/.cache/huggingface/dataset انبار میکند. از فصل ۲ به یاد داشته باشید که میتوانید پوشه انبار کردنتان را با تنظیم متغیر محیطی HF_HOME
به دلخواه تغییر دهید.
ما میتوانیم به هر جفت از جملات در شئ raw_datasets
با استفاده از اندیس, مانند یک دیکشنری دسترسی پیدا کنیم:
raw_train_dataset = raw_datasets["train"]
raw_train_dataset[0]
{'idx': 0,
'label': 1,
'sentence1': 'Amrozi accused his brother , whom he called " the witness " , of deliberately distorting his evidence .',
'sentence2': 'Referring to him as only " the witness " , Amrozi accused his brother of deliberately distorting his evidence .'}
میبینیم که برچسبها از پیش اعداد صحیح هستند، بنابراین لازم نیست هیچ پیشپردازشی روی آنها انجام دهیم. برای این که بدانیم کدام مقدار عددی صحیح به کدام برچسب مربوط میشود، میتوانیم features
از raw_train_dataset
مان را بررسی کنیم. این کار نوع هر ستون را به ما خواهد گفت.
raw_train_dataset.features
{'sentence1': Value(dtype='string', id=None),
'sentence2': Value(dtype='string', id=None),
'label': ClassLabel(num_classes=2, names=['not_equivalent', 'equivalent'], names_file=None, id=None),
'idx': Value(dtype='int32', id=None)}
در پشت صحنه، label
از نوع ClassLabel
میباشد، و نگاشت اعداد صحیح به نام برچسب در پوشه names ذخیره شده است. 0
مربوط به not_equivalent
و 1
مربوط به equivalent
میباشد.
پیشپردازش دیتاسِتها
به منظور پیشپردازش دیتاسِت، لازم است متن را به اعدادی قابل پردازش برای مدل تبدیل کنیم. همانطور که درفصل قبل مشاهده کردید، این کار با استفاده از یک توکِنایزر انجام میشود. ما میتوانیم یک یا چند جمله را به توکِنایزر بدهیم، در نتیجه میتوانیم به طور مستقیم تمام جملات اول و دوم هر جفت جمله را به صورت زیر توکِن کنیم:
from transformers import AutoTokenizer
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
tokenized_sentences_1 = tokenizer(raw_datasets["train"]["sentence1"])
tokenized_sentences_2 = tokenizer(raw_datasets["train"]["sentence2"])
با این حال، نمیتوانیم دو جمله را به مدل ارسال کنیم تا پیشبینی کند که متناظر هستند یا خیر. ما نیاز داریم با دو رشته به صورت یک جفت برخورد کنیم و پیشپردازش مناسب را به آن اعمال کنیم. خوشبختانه، توکِنایزر میتواند یک جفت رشته را دریافت کند و آنرا به گونهای که مدل BERT ما انتظار دارد آمادهسازی کند:
inputs = tokenizer("This is the first sentence.", "This is the second one.")
inputs
{
'input_ids': [101, 2023, 2003, 1996, 2034, 6251, 1012, 102, 2023, 2003, 1996, 2117, 2028, 1012, 102],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
}
در فصل ۲ در مورد کلیدهای input_ids
و attention_mask
بحث کردیم، اما از گفتگو در مورد token_type_ids
اجتناب کردیم. در این مثال این همان چیزی است که به مدل میگوید کدام بخش از ورودی جمله اول و کدام بخش جمله دوم است.
✏️ امتحان کنید! عنصر شماره ۱۵ از مجموعه training
را بردارید و دو جمله را به صورت جداگانه و جفت توکِن کنید. تفاوت دو نتیجه چیست؟
اگر شناسههای داخل input_ids
را به کلمات کدگشایی کنیم:
tokenizer.convert_ids_to_tokens(inputs["input_ids"])
خواهیم داشت:
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
بنابراین میبینیم که مدل انتظار دارد وقتی که دو جمله داریم ورودیها به صورت [CLS] sentence1 [SEP] sentence2 [SEP]
باشند.
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
[ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
همانطور که میبینید، بخشهایی از ورودی که مربوط به [CLS] sentence1 [SEP]
هستند اندیس نشان دهنده نوع توکِن آنها 0
و بخشهایی که مربوط به sentence2 [SEP]
هستند اندیس نشان دهنده نوع توکِنشان 1
میباشد.
توجه داشته باشید که اگر نقطه تعلیم متفاوتی را انتخاب کنید، در ورودیها لزوما token_type_ids
نخواهید داشت (به عنوان مثال، اگر از یک DistilBERT استفاده کنید آنها بازگردانده نخواهند شد). آنها فقط زمانی بازگردانده میشوند که مدل میداند با آنها چکار کند، به این خاطر که آنها را در زمان پیشتعلیم دیده است.
در اینجا، مدل BERT با شناسههایی که نشان دهنده نوع توکِن هستند از پیش تعلیم دیده و علاوه بر هدف تکمیل جاهای خالی متن که در فصل ۱ در مورد آن صحبت کردیم وظیفه دیگری تحت عنوان پیشبینی جمله بعدی بر عهده دارد. هدف از این وظیفه مدل کردن رابطه بین جملات جفتی میباشد.
در پیشبینی جمله بعدی، لیستی از جملههای جفت شده (با کلماتی که به طور تصادفی پنهان شدهاند) به مدل داده میشوند و از مدل خواسته میشود پیشبینی کند که آیا جمله دوم در ادامه جمله اول قرار دارد یا خیر. برای سختتر کردن مسئله، در نیمی از حالتها دو جمله در متن اصلی به دنبال هم آمده، و در نیمی دیگر از دو متن متفاوت میآیند.
در مجموع، نیازی نیست نگران وجود یا عدم وجود token_type_ids
در ورودیهای توکِن شده خود باشید: مادامی که از نقطه تعلیم یکسان برای توکِنایزر و مدل استفاده کنید، همه چیز خوب پیش خواهد رفت چرا که توکِنایزر میداند چه چیزی برای مدل فراهم کند.
اکنون که مشاهده کردیم چگونه توکِن کننده ما میتواند با دو جمله برخورد کند، میتوانیم آن را برای توکِن کردن کل دیتاسِتمان به کار ببریم: مانند فصل قبل، ما میتوانیم توکِنایزر را با لیستی از جفت جملهها، با دادن لیست جملات اول و سپس لیست جملات دوم، تغذیه کنیم. این روش همچنین با گزینههای padding
و truncation
که در فصل ۲ مشاهده کردیم سازگاری دارد. بنابراین، یک روش برای پیشپردازش دیتاسِت training
اینگونه میباشد:
tokenized_dataset = tokenizer(
raw_datasets["train"]["sentence1"],
raw_datasets["train"]["sentence2"],
padding=True,
truncation=True,
)
این روش به خوبی کار میکند، اما مشکلاش این است که دیکشنری (از کلیدهای ما شامل، input_ids
, attention_mask
و token_type_ids
و مقادیر آنها که لیستهایی از لیستها هستند) برمیگرداند. همچنین این روش فقط زمانی کار میکند که حافظه موقت کافی جهت ذخیرهسازی کل دیتاسِت در حین توکِن کردن داشته باشید (در حالی که دیتاسِتهای موجود در کتابخانه Datatasets
از هاگینگفِیس فایلهایی از نوع Apache Arrow هستند که روی دیسک ذخیره شدهاند، بنابراین شما فقط نمونههایی را که جهت ذخیره در حافظه درخواست کردهاید نگه میدارید).
به منظور نگه داشتن داده به صورت یک دیتاسِت، از تابع Dataset.map()
استفاده میکنیم. چنانچه به پیشپردازشهای بیشتری علاوه بر توکِن کردن نیاز داشته باشیم این روش انعطافپذیری لازم را به ما میدهد. تابع map()
با اعمال کردن یک عملیات روی هر عنصر دیتاسِت عمل میکند، بنابراین اجازه دهید تابعی تعریف کنیم که ورودیها را توکِن کند:
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
این تابع دیکشنری (مثل اقلام داخل دیتاسِت) دریافت میکند و دیکشنری دیگری با کلیدهای input_ids
، attention_mask
و token_type_ids
برمیگرداند. توجه داشته باشید از آنجایی که توکِنایزر روی لیستهایی از دو جملهها کار میکند، همانطور که قبلا مشاهده کردیم، این تابع نیز در صورتی که دیکشنری example
شامل چندین نمونه (هر کلید به عنوان لیستی از جملهها) باشد کار میکند. این به ما این امکان را خواهد داد که از گزینه batched=True
در فراخوانی تابع map()
استفاده کنیم که توکِنایزر را به میزان زیادی سریعتر خواهد کرد. این tokenizer
با توکِنایزری در کتابخانه Tokenizers از هاگینگفِیس که به زبان برنامهنویسی Rust نوشته شده پشتیبانی میشود. این توکِنایزر میتواند بسیار سریع باشد، اما فقط به شرطی که ورودیهای زیادی را به صورت یک جا به آن بدهیم.
توجه داشته باشید که ما آرگومان همطولسازی را در تابع توکِن کنندهمان نادیده گرفتهایم. این به این خاطر است که همطولسازی روی همه نمونهها برای بیشترین طول به صرفه نیست: بهتر است که نمونهها را زمانی که در حال ساختن بَتچ هستیم همطول کنیم، در این صورت فقط نیاز داریم نمونهها را به اندازه بزرگترین طول همان بَتچ و نه بیشترین طول در سرتاسر دیتاسِت همطول کنیم. این روش زمانی که ورودیها دارای طولهای بسیار متغیری هستند وقت و انرژی زیادی را صرفهجویی خواهد کرد.
در اینجا نشان میدهیم چگونه تابع تولید توکِن را روی کل دیتاسِت به یکباره اعمال میکنیم. ما از batched=True
در فراخوانی تابع map
استفاده میکنیم بنابر این تابع ما به جای اینکه روی هر عنصر به صورت جداگانه عمل کند روی چندین عنصر از دیتاسِت به یکباره عمل میکند. این کار اجازه میدهد که پیشپردازش سریعتر انجام گیرد:
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
tokenized_datasets
کتابخانه Datasets
از هاگینگفِیس این پیشپردازش را با افزودن -فیلدهای- جدید به دیتاسِتها، یکی به اِزای هر کلید در -دیکشنری- که توسط تابع پیشپردازش بازگردانده میشوند، اعمال میکند:
DatasetDict({
train: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 3668
})
validation: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 408
})
test: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 1725
})
})
شما حتی میتوانید زمانی که تابع پیشپردازش خود را اعمال میکنید، با ارسال آرگومان num_proc
در تابع map()
از چندپردازشی استفاده کنید. در اینجا ما این کار را انجام ندادیم چرا که کتابخانه Tokenizers
هاگینگفِیس از پیش، از چندین رشته پردازشی برای توکِن کردن سریعتر نمونههای ما استفاده میکند، اما اگر شما از یک توکِنایزر سریع که با این کتابخانه پشتیبانی شود استفاده نمیکنید، این روش میتواند پیشپردازش شما را سریعتر کند.
تابع tokenize_function
ما یک دیکشنری شامل کلیدهای input_ids
، attention_mask
و token_type_ids
برمیگرداند به گونهای که این کلیدها به صورت سه فیلد جدید به همه بخشهای دیتاسِت افزوده گردند. توجه داشته باشید اگر تابع پیشپردازش ما برای یک کلید موجود در دیتاسِت مقدار جدیدی بازمیگرداند ما میتوانستیم فیلدهای موجود در دیتاسِتی که تابع map()
به آن اعمال میشود را نیز تغییر دهیم.
آخرین کاری که باید انجام دهیم این است که هنگامی که عناصر را با هم در یک بَتچ قرار میدهیم، طول همه عناصر را به اندازه بلندترین عنصر برسانیم - تکنیکی که ما به آن همطولسازی پویا میگوییم.
همطولسازی پویا
تابعی که مسئول کنار هم گذاشتن نمونهها در یک بَتچ میباشد تابع ترکیب کننده خوانده میشود. شما میتوانید این تابع را که در حالت پیش فرض نمونههای شما را به تِنسور پایتورچ تبدیل کرده و به هم الحاق میکند (اگر عناصر شما لیست، تاپِل یا دیکشنری باشند این کار به صورت بازگشتی انجام میگیرد) هنگام ساختن DataLoader
به داخل آن ارسال کنید. از آنجایی که ورودیهای ما همطول نخواهند بود استفاده از این تابع برای ما امکانپذیر نیست. ناهمطولی ورودیها به این خاطر است که ما فرایند همطولسازی را عمدا به تعویق انداختیم تا فقط در زمان نیاز آن را روی هر بَتچ اجرا کنیم و از داشتن ورودیهای بیش از اندازه طولانی با مقدار زیادی همطولسازی پیشگیری کنیم. این روش، فرایند تعلیم را تا اندازهای سرعت میبخشد، اما توجه داشته باشید که اگر شما در حال تعلیم روی TPU هستید این کار میتواند مشکل ساز باشد چرا که TPU اشکال معین را ترجیح میدهد، حتی اگر نیاز به همطولسازی اضافه داشته باشد.
برای انجام این کار در عمل، ما باید یک تابع ترکیب کننده تعریف کنیم که میزان درستی از همطولسازی را به آیتمهای دیتاسِتهایی که ما میخواهیم باهم در یک بَتچ قرار دهیم اعمال کند. خوشبختانه، کتابخانه ترنسفورمرهای هاگینگفِیس چنین قابلیتی را توسط کلاس DataCollatorWithPadding
به ما میدهد. به محض این که شیءای از این کلاس را تعریف کنیم (یعنی تعیین کنیم چه توکِنی برای همطولسازی استفاده کند و مدل انتظار همطولسازی از سمت چپ یا راست ورودیها را داشته باشد) یک توکِنایزر را برداشته و هر کاری را که لازم دارید انجام میدهد:
from transformers import DataCollatorWithPadding
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
اجازه دهید چند نمونه از مجموعه training
را که میخواهیم باهم در یک بَتچ قرار دهیم برداریم تا این ابزار جدید را امتحان کنیم. در اینجا ستونهای idx
، sentence1
و sentence2
را حذف میکنیم چرا که احتیاج نخواهند شد و شامل رشتههای متنی میشوند (که ما نمیتوانیم تنسورهایی از رشتههای متنی ایجاد کنیم) و سپس نگاهی میاندازیم به طول هر ورودی در هر بَتچ:
samples = tokenized_datasets["train"][:8]
samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}
[len(x) for x in samples["input_ids"]]
[50, 59, 47, 67, 59, 50, 62, 32]
تعجبی ندارد که نمونههایی با طولهای متغییر، از ۳۲ تا ۶۷ بدست میآوریم. همطولسازی پویا به این معنی است که نمونههای موجود در این بَتچ باید همگی با طول ۶۷، که بزرگترین طول داخل بَتچ میباشد، همطول شده باشند. بدون همطولسازی پویا، همه نمونهها در کل دیتاسِت باید به اندازه بزرگترین طول یا بزرگترین طول قابل پذیرش برای مدل، همطول شوند. اجازه دهید بررسی کنیم آیا data_collator
ما بَتچ را به درستی همطول میکند:
batch = data_collator(samples)
{k: v.shape for k, v in batch.items()}
{'attention_mask': torch.Size([8, 67]),
'input_ids': torch.Size([8, 67]),
'token_type_ids': torch.Size([8, 67]),
'labels': torch.Size([8])}
به نظر خوب میآید! اکنون که از متن خالص به بَتچهایی رسیدهایم که مدلمان میتواند با آنها کار کند، آماده کوک کردن مدل هستیم:
✏️ امتحان کنید! پروسه پیشپردازش را روی دیتاسِت GLUE SST-2 باز تکرار کنید. از آنجایی که این مجموعه به جای دو جملهها شامل تک جملهها میباشد این کار کمی متفاوت است، اما بقیه کارهایی که انجام دادهایم باید یکسان به نظر برسند. برای یک چالش مشکلتر، سعی کنید تابع پیشپردازشی بنویسید که برای همه مسئلههای GLUE کار کند.
[^1]: Microsoft Research Paraphrase Corpus