تولید شعر رپ فارسی با استفاده از LSTM‌ها در PyTorch

تولید متن توسط مدل‌های یادگیری ماشین یکی از روندهای مهم فعلی صنعت پردازش زبان طبیعی است. تلاش برای تولید متن توسط ماشین‌، سابقه‌ی طولانی دارد. کارپاتی (مدیر هوش‌مصنوعی تسلا) بود که در سال ۲۰۱۵ و در یک پست وبلاگی معروف، نشان داد می‌توان با استفاده از LSTM و مدل‌های نسبتا! ساده و در سطح کاراکتر، نتایج قابل قبولی در تولید متن کسب کرد. در این روش، مدل؛ صرفا به ارتباط بین کاراکترها نگاه می‌کند و احتمال وقوع کاراکتری بعد از کاراکتر دیگر را محاسبه می‌کند. در زبان فارسی نیز آقای افشین خاشعی همین شیوه را روی اشعار شاهنامه پیاده‌سازی کرده و نتایج خوبی گرفته است.با این حال در سال‌های اخیر و با توسعه مدل‌های غول‌پیکری مانند GPT-3 شاهد تولید نوشته‌هایی هستیم که به لحاظ معنایی قادر هستند با متون تولید شده توسط انسان رقابت کنند. این مدل‌ها بسیار پیچیده‌تر هستند، بر روی حجم عظیمی از متن تعلیم داده شده‌اند و در سطح واژه‌ها (توکن‌ها!) عمل می‌کنند و نه کاراکترها.تولید شعر رپ فارسی با LSTMدر این آموزش مرحله به مرحله، یک مدل یادگیری ماشین بسیار ساده می‌سازیم که می‌تواند شعر رپ فارسی تولید کند. برای ساخت این مدل از فریم‌ورک شناخته شده‌ی PyTorch و معماری LSTM‌ بهره می‌بریم. اساس کدهایی که در این آموزش استفاده شده، برگرفته از این مخزن است ولی تغییراتی نیز در آن داده شده است. برای پیاده‌سازی این مدل صرفا به کتابخانه torch نیاز داریم و چیز دیگری لازم نداریم.توجه:‌ برای درک کامل این آموزش شما می‌بایست شناخت اولیه نسبت به یادگیری ماشین داشته باشید و تئوری‌های مربوط به شبکه‌های بازگشتی و مشخصا LSTM را مطالعه کنید. با این حال حتی اگر اولین بار شما است که با این مفاهیم آشنا می‌شوید، توصیه می‌کنم کد را روی گوگل کولب اجرا کنید تا درک بهتری از مراحل کار بدست آورید.۱- دیتاست رپ فارسیاشعار رپ فارسی، برخلاف شاهنامه از انسجام خاصی برخوردار نیستند. هر رپری شیوه خاص خود را دارد و هیچ دیتاست مشخصی هم از این اشعار موجود نیست. مجبور بودم، خودم دست به کار شده و چیزی حدود ۹۰۰۰ مصرع! فارسی از اشعار رپ را کنار هم جمع کنم. هر مصرع طول متفاوتی دارد و کلمات بسیاری دیده می‌شود که کلمات متداول فارسی نیستند و گاهی ابداع خود رپر هستند و قبلا استفاده نشده‌اند. نمونه‌ای از بخش‌های دیتاست: یه روز خوب میاد که ما هم رو نکشیم، به هم نگاه بد نکنیمبا هم دوست باشیم و دست بندازیم رو شونه های هم، آها… مثل بچگیا تو دبستانهیجکدوممون هم نیستیم بی‌کار، در حال ساخت و ساز ایران....چشا رو به ابراس توی عمقم خود غواصنوک پا سر بالا دست باز سلامتی هر کسی تنهاستدنبال اسم در کردن لا دخیاس یاروکار خفنش اینه دیشب توی پاسگاه بود...مطمئنا مدل، برای یادگیری کار سختی در پیش دارد ولی ما سعیمان را می‌کنیم! ۲- طبق معمول تمیزکاری!قصد ندارم تمیزکاری عجیب و غریبی روی این دیتاست اعمال کنم. امیدوارم که مدل نهایی قادر باشد از تولید کاراکترهایی که متداول نیستند خودداری کند. دیتاست خود را خط به خط می‌خوانم، مصرع‌هایی که بیشتر از پنج کاراکتر دارند را نگه می‌دارم (چرا پنج؟) و صرفا با هضم آن‌ها را نورمالایز می‌کنم.۳- آماده کردن دیتا برای ورود به مدلدیتالودر بخش مهمی از کار ما را تشکیل می‌دهد. وظیفه‌ی این کلاس آماده کردن دیتا برای ورود به مدل است. اینکه ما چگونه دیتا را آماده می‌کنیم نقش کلیدی در خروجی کار ما خواهد داشت.هر مصرع را به کاراکترهای جدا از هم تبدیل می‌کنم.در واقع مدل من باید بیاموزد که بعد از هر کاراکتری کدام کاراکتر را پیش‌بینی کند.مدل با دیدن هر کاراکتر می‌بایست کاراکتر بعدی را پیش‌بینی کند.یک نمای کلی از شیوه‌ی ورود کاراکترها به مدل و خروجی مدنظر (منبع)طبق تصویر بالا یک آرایه‌ی دو بعدی می‌سازیم. تعداد ستون‌های این آرایه پارامتری است که خودمان از پیش آن را انتخاب کردیم. برای مثال اگر جمله‌ی یه روز خوب میاد را در نظر بگیرید. این جمله به آرایه‌ی زیر تبدیل می‌شود: سپس تمام کاراکترها را به عدد تبدیل می‌کنم. از صفر شروع می‌کنم و به تعداد کاراکترهای منحصر بفردی که داریم عدد اختصاص می‌دهیم. مثلا 4 را برای و اختصاص می‌دهم و هرجا که و ببینم 4 جایگزین می‌کنم. این آرایه اصلی من است که برای مدل ارسال می‌کنم و مدل از آن ترتیب رخ دادن کاراکترها را یاد می‌گیرد.ولی X و Y مدل من دقیقا کدام آرایه‌ها هستند؟ آرایه‌ی ورودی و آرایه‌ی مورد هدف مدل چیست؟ همانطور که بالاتر نوشتم انتظار دارم که مدل با دیدن هر کاراکتر، کاراکتر بعدی را حدس بزند. برای مثال ردیف اول را در نظر بگیرید. ورودی و خروجی مدل من باید یک ایندکس تفاوت داشته باشند. آرایه‌ی INPUT همیشه یک ایندکس عقب‌تر از آرایه TARGET است. یعنی مدل با دیدن کاراکترهای قبلی یاد می‌گیرد که کاراکتر بعدی چیست.این قطعه کد همه این کارها را برای ما انجام می‌دهد. توجه کنید که من CharDataset را از کلاس Dataset به ارث می‌برم. در واقع همه متغیرها و توابع کلاس Dataset را دارم ولی سه تابع __init__ و __len__ و __getitem__ را بازنویسی (overwrite) می‌کنم. برای آشنایی با شیوه‌ی کار این کلاس، مستندات پایتورچ را مطالعه کنید.۴- آماده کردن مدلتنظیم ابعاد آرایه‌ها، شاید سخت‌ترین بخش درک نحوه‌ی کار این آموزش باشد. دوباره پیشنهاد می‌کنم برای درک بهتر، کد را خودتان اجرا کنید و ابعاد تک تک آرایه‌ها را به دقت بررسی کنید. جزییات زیادی در این بخش وجود دارد که من از ورود به تک تک آن‌ها خودداری می‌کنم.از مدل ساده‌ای استفاده می‌کنم که سه لایه اصلی دارد. لایه‌ی اول یک One-Hot انکودینگ است. لایه‌ی دوم LSTM ما است و لایه‌ی سوم یک لایه‌ی خطی ساده است.مدل CharRNN تمام متغیرها و توابع nn.Module از پایتورچ را به ارث می‌برد. با این تفاوت که توابع __init__ و forward در آن بازنویسی می‌شوند.۵- تابع یاد دهنده حال که هم معماری مدل را ساختیم و هم داده‌هایمان را سروسامان دادیم، باید تابعی داشته باشیم که وظیفه‌ی یاد دادن به مدل را بر عهده‌ دارد. در هر epoch و برای هر batch این تابع را فراخوانی خواهیم کرد تا مدل را آموزش دهد.۶- تابع تولید کنندهتابع تولید کننده نقشی در یادگیری مدل ندارد. صرفا به ارزیابی عملکرد مدل کمک می‌کند. این تابع با گرفتن یک رشته‌ی متنی، ادامه‌ی آن را تولید می‌کند. این رشته‌ی متنی می‌تواند یک حرف، کلمه و یا صرفا یک کاراکتر باشد. ابتدا رشته‌ی متنی اولیه را به مدل می‌دهیم و سپس به تعداد دلخواه (predict_len) کاراکتر پیش‌بینی می‌کنیم. متغیر output_dist در این تابع، یک آرایه، باندازه‌ی تمام حروف استفاده شده در دیتاست است. در این آرایه احتمالا وقوع هر کاراکتر بعد از دیدن کاراکترهای قبلی را می‌بینیم و با استفاده از torch.multinomial ایندکس کاراکتری را انتخاب می‌کنیم که احتمال وقوع آن از بقیه بیشتر است. دیکشنری itos هم با دریافت ایندکس هر کاراکتر آن را تبدیل به کاراکتر واقعی کرده و به ما برمی‌گرداند.۷- شروع یادگیریتقریبا همه ابزارهای لازم برای شروع یادگیری را در اختیار داریم. پارامترهای اولیه مدل را تعریف می‌کنیم. مقدار سِل‌های داخلی LSTM (و یا همان hidden_size) و تعداد لایه‌های LSTM که روی هم سوار می‌کنیم (n_layers) در خروجی کار اهمیت بیشتری دارند. با این حال تنظیم بقیه هایپرپارامترها هم می‌تواند به یک خروجی خوب (بالاخص پرهیز ار overfitting) کمک کند.در این مرحله با فراخوانی کلاس CharRNN، مدل را با پارامترهای مدنظر می‌سازیم. n_characters تعداد منحصربفرد کاراکترهای موجود در دیتاست ما است. خروجی مدل برای هر کاراکتر یک آرایه به اندازه تمام کاراکترها است. برای بهینه کردن گرادینت‌ها از Adam استفاده می‌کنیم که اغلب می‌توان از آن نتیجه‌ی بهتری گرفت. CrossEntropy را هم بعنوان نوع Loss انتخاب می‌کنیم.در نهایت یک حلقه از تعداد epochها می‌سازیم و داده‌مان را از طریق دیتالودر برای مدل ارسال می‌کنیم. بعد از هر ۲۰۰ batch که برای مدل فرستادیم، مقدار Loss را پرینت می‌کنیم. همچنین تابع generate را با جمله آغازین "یه روز خوب" فراخوانی می‌کنیم تا یک ایده‌ی کلی نسبت به روند یادگیری مدلمان داشته باشیم و در نهایت بعد از هر ۵ دور کامل یادگیری، وزن‌های مدل را ذخیره می‌کنیم.روند یادگیری ممکن است طولانی باشد. بهترین راه‌حل برای سریع‌تر کردن این روند استفاده از GPU روی Google Colab است. همانطور که در بین خط‌های کد هم دیدید، در بخش‌های از cuda استفاده کردیم. با فراخوانی کودا، آرایه‌ها را به GPU منتقل می‌کنیم و سرعت روند یادگیری افزایش می‌یابد.۸- بررسی نتیجهخوب! نتیجه خیلی فوق‌العاده نیست ولی از نظر من بد هم نیست. اگر سطح انتظار را کمی پایین بیاوریم می‌توان گفت که مدل درست کار می‌کند و می‌تواند ساختار کلمات را بفهمد و آن‌ها را کنار هم قرار دهد. ولی اگر بخواهیم سخت‌گیری کنیم باید بپرسیم آیا انسجام کل متن حفظ شده؟ و یا اصلا خروجی قابل فهم است؟‌ چند نمونه از خروجی‌ها را باهم ببینم:یه روز خوب میاد، این درد شیرتو فرض کنواسه اینکه خسته مثل سختهاگه با هم دیگه زندگی به مادری به من باش از خونه باشیممن خنده می‌خوابم مثل اسبی که با ما چندان از آسمون مثل سربازن همه چی هست در میان، هیچ مغزینمی‌خواد که من مثل تو می‌دونمشمارم باشی که تو از نبود و من باشیمکه می‌گه با هم تو این سرابه می‌خوابمیه روز خوب میاد، یه روز خوب میاد، این دلو ببینمفکر کن بالاترین شاده بی تو می‌دونموقتی که زیرزنی هم نیستیم دیدم یه روز خوب میاد، این آخرین بالاسرههمه چی فکر می‌شه بالا بهتره، می‌خندم در نهایت به نظر من جالب است که LSTM می‌تواند صرفا با دیدن کاراکترها، آن‌ هم روی یک دیتاست کوچک و محدود، ارتباط بین کلمه‌ها و حتی در برخی مواقع Context را متوجه شود. (این را هم در نظر بگیرید که بسیاری از شعرهای رپ فارسی که ما آدم‌ها نوشته‌ایم هم، قابل درک نیستند!)۹- بهتر کردن خروجیولی برای بهتر کردن این نتایج چه کارهای دیگری می‌توانیم انجام دهیم؟ دیتاست بهتر و بزرگ‌تری جمع‌آوری کنیم.برای بررسی روند یادگیری validation_loss و دیگر معیارها مانند BLEU را اضافه کنیم.پارامترهای مختلف را تست کنیم.از Learning Rate Schedulerها استفاده کنیم تا LR با گذشت epochها ثابت نماند.مدل پیچیده‌تری بسازیم. (از LSTMهای bidirectional استفاده کنیم، به لایه‌های بین LSTM، نرخ Dropout اضافه کنیم و .... )۱۰- مقایسه با دیگر مدل‌هاهرچند LSTMها از قدرتمندترین مدل‌های موجود هستند ولی پس از سال ۲۰۱۸ و معرفی ترنسفورمرها، از محبوبیت کمتری در تسک‌های تولید متن برخوردارند. یکی از قدرتمندترین مدل‌ها بر اساس معماری ترنسفورمر، GPT است. معماری GPT با پارامترهای مختلف و توسط شرکت‌های مختلف در دیتاست‌هایی به ابعاد متنوع آموزش دیده است. برخی از این مدل‌ها بصورت متن باز و برخی در انحصار شرکت‌های مختلف در دسترس قرار دارند. دیتاست‌ اغلب این مدل‌ها، دیتاست‌های چندزبانه است. با این حال بدیهی است که بیشتر دیتای موجود در آن‌ها به زبان انگلیسی باشد.می‌توانیم مدل GPT-3 با ۱۷۰ میلیارد پارامتر که توانایی تشخیص زبان‌های مختلف دارد را روی پلتفرم OpenAI برای تولید شعر رپ فارسی آزمایش کنیم. نتیجه کار فوق‌العاده است! با وجود اینکه دیتاست GPT-3 شناخت چندانی از محتوای فارسی ندارد ولی می‌تواند یک شعر رپ خوب درباره پیتزا و اسپاگتی بسراید که با یه روز خوب میاد شروع می‌‌شود!همچنین مدل GPT-3 با تعداد ۲۰ میلیارد پارامتر که توسط eleuther.ai توسعه داده شده است را می‌توانیم روی goose.ai آزمایش کنیم. انتظار نداریم این مدل به خوبی مدل openAI باشد با این حال به خوبی می‌تواند ساختار شعر را متوجه شود. هرچند احتمالا به دلیل دیتاست کوچکتری که روی آن آموزش دیده، خلاقیت کمتری دارد!لینک‌ها و منابعکدهای منتشر شده در متن روی Google Colabویدیو آموزشی این پست روی یوتوب (بهمراه جزییات بیشتر و دقیق‌تر!)پست وبلاگی کارپاتی: موثر بودن غیرمنطقی شبکه‌های بازگشتی