فاز 10 · درس 6

آموزش گام‌به‌گام فاین‌تیونینگ نظارت‌شده (SFT) و ساخت مدل دستورپذیر

نوع
ساختنی
زمان
حدود 90 دقیقه
زبان‌ها
Python (with numpy)
یک مجسمه سنگی خام در کنار پیکره‌تراشی که با چکش و قلم در حال ظریف‌کاری و تبدیل آن به چهره‌ای گویا و دقیق است
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. سازوکار واقعی SFT
  5. فرمت‌های ساختاردهی به داده‌ها
  6. چرا این روش جواب می‌دهد؟
  7. ماسک‌کردن تابع زیان (The Masked Loss)
  8. هایپرپارامترهای آموزش
  9. فراموشی فاجعه‌بار
  10. ارقام در جهان واقعی
  11. پیاده‌سازی از صفر
  12. گام 1: مجموعه‌داده‌ی دستورالعمل‌ها
  13. گام 2: توکن‌سازی با الگوی گفت‌وگو
  14. گام 3: تابع زیان آنتروپی متقاطعِ ماسک‌شده
  15. گام 4: حلقه‌ی آموزش SFT
  16. گام 5: مقایسه‌ی مدل پایه با مدل فاین‌تیون‌شده
  17. گام 6: سنجش میزان فراموشی فاجعه‌بار
  18. استفاده از ابزارهای واقعی
  19. دموی پایپ‌لاین کامل SFT
  20. پیاده‌سازی نهایی
  21. تمرین‌ها
  22. اصطلاحات کلیدی
  23. برای مطالعه‌ی بیشتر

مدل‌های زبانی پایه پس از مرحلۀ پیش‌آموزش صرفاً ماشین‌های پیش‌بینی توکن بعدی هستند. اگر از چنین مدلی بپرسید «پایتخت فرانسه کجاست؟»، به احتمال زیاد پاسخی به شما نمی‌دهد، بلکه پرسش‌های مشابه دیگری مانند «پایتخت آلمان کجاست؟» تولید می‌کند؛ زیرا تنها امتداد دادن متن را آموخته است. فاین‌تیونینگ نظارت‌شده (SFT) همان حلقۀ مفقوده‌ای است که رفتار مکالمه و پاسخ‌گویی به دستورالعمل را به مدل آموزش می‌دهد.

درک سازوکار SFT، قالب‌بندی داده‌ها و تکنیک حیاتی ماسک کردن تابع زیان، مهارتی ضروری برای توسعه‌دهندگانی است که می‌خواهند مدل‌های متن‌باز را برای کاربردهای بومی یا سازمانی شخصی‌سازی کنند. خوشبختانه برای پیاده‌سازی این درس و درک مفاهیم فنی آن به سخت‌افزارهای گران‌قیمت یا GPUهای ابری دست‌نیافتنی نیازی ندارید و کدهای پایه روی پردازندۀ سیستم شخصی نیز قابل اجرا هستند.

مدل پایه صرفاً توکن بعدی را پیش‌بینی می‌کند؛ همین و بس. نه از دستورالعمل‌ها پیروی می‌کند، نه به پرسش‌ها پاسخ می‌دهد و نه در برابر درخواست‌های آسیب‌رسان مقاومت به خرج می‌دهد. فاین‌تیونینگ نظارت‌شده (Supervised Fine-Tuning یا SFT) همان پلی است که یک ابزار پیش‌بینی توکن را به دستیاری کاربردی بدل می‌کند. تک‌تک مدل‌هایی که تاکنون با آن‌ها گفت‌وگو کرده‌اید — از جمله Claude، GPT و Llama Chat — این مرحله را پشت سر گذاشته‌اند.

اهداف یادگیری

  • پیاده‌سازی فاین‌تیونینگ نظارت‌شده (SFT) برای تبدیل مدل زبانی پایه به دستیاری که دستورالعمل‌ها را درک و اجرا می‌کند
  • فرمت‌بندی داده‌های آموزشی با الگوهای گفت‌وگو (chat templates) با نقش‌های سیستم، کاربر و دستیار، به همراه اعمال ماسک روی تابع زیان (loss) توکن‌های غیردستیار
  • تبیین چرایی ضرورت SFT: درک این واقعیت که مدل‌های پایه متن را امتداد می‌دهند، نه این‌که پاسخ سوالات را بدهند
  • ارزیابی کیفیت SFT با مقایسه‌ی پاسخ‌های مدل پایه در برابر مدل فاین‌تیون‌شده روی مجموعه‌ای نگه‌داشته‌شده (held-out) از دستورالعمل‌ها

صورت مسئله

در درس 04 مدلی را آموزش دادید که می‌توانست با دریافت یک توالی، توکن بعدی را پیش‌بینی کند. اگر عبارت «The transformer architecture» را به آن بدهید، شاید متن را با «has revolutionized natural language processing» ادامه دهد؛ دستاوردی چشمگیر برای سامانه‌ای که کارش صرفاً پیش‌بینی توکن بعدی است.

حالا این آزمون را امتحان کنید: ورودی «What is the capital of France?» را به آن بدهید. مدل پایه پاسخی مثل «Paris» برنمی‌گرداند؛ بلکه همان الگو را پی می‌گیرد. ممکن است بنویسد: «What is the capital of Germany? What is the capital of Spain?»؛ چون میان اسناد متنی خود، فهرست‌هایی از پرسش‌های پیاپی را دیده است. یا شاید تولید کند: «is a question that many people ask»، زیرا این هم امتداد معقولی بر پایه‌ی توکن بعدی است. مدل اساساً هیچ درکی از مفهوم پاسخ‌دادن ندارد؛ او تنها بلَد است متن را ادامه دهد.

شکاف اصلی میان GPT-3 (مدل پایه، عرضه‌شده در ژوئن 2020) و ChatGPT (تنظیم‌شده بر اساس دستورالعمل، عرضه‌شده در نوامبر 2022) دقیقاً همین‌جاست: معماری یکسان، پیش‌آموزش یکسان. تنها تمایز آن‌ها در 20,000 تا 100,000 جفت‌داده‌ی (دستورالعمل، پاسخ) دقیق و دست‌چین خلاصه می‌شود که الگوی مشارکت در گفت‌وگو را به مدل آموخته‌اند.

پروژه‌ی Stanford Alpaca ثابت کرد نیازی به میلیون‌ها نمونه ندارید. آن‌ها در مارس 2023، مدل Llama 7B را تنها روی 52,000 جفت دستورالعمل-پاسخ که با GPT-3.5 تولید شده بود، فاین‌تیون کردند. کل هزینه: 600 دلار. دستاورد کار، چت‌باتی بود که می‌توانست از دستورها پیروی کند، به سوالات پاسخ دهد و مکالمه را پیش ببرد. شاید به‌خوبی ChatGPT نبود، ولی در مقایسه با 600 دلار هزینه و چند ساعت آموزش، بازدهی خیره‌کننده‌ای داشت.

مدل Llama 2 Chat شرکت Meta نیز در فاز اولیه‌ی SFT خود تنها از حدود 27,000 نمونه‌ی باکیفیت بهره برد. پیام کلیدی مشخص است: کیفیت بر کمیت برتری دارد. 27,000 نمونه که افراد متخصص آن‌ها را دقیق نوشته باشند، بر 1 میلیون داده‌ی پر سر و صدا و گردآوری‌شده از اینترنت پیروز می‌شود.

مفاهیم پایه

سازوکار واقعی SFT

فاین‌تیونینگ نظارت‌شده در عمل همان حلقه‌ی آموزشی فاز پیش‌آموزش — گذر رو به جلو (forward pass)، محاسبه‌ی تابع زیان (loss)، گام رو به عقب (backward pass) و به‌روزرسانی وزن‌ها — را تکرار می‌کند، ولی این بار روی نوع متفاوتی از داده‌ها. به‌جای متن خام، آموزش را روی مکالماتی سازمان‌یافته اجرا می‌کنید:

{
  "system": "You are a helpful assistant.",
  "user": "What is the capital of France?",
  "assistant": "The capital of France is Paris."
}

مدل از قبل می‌داند که پاریس پایتخت فرانسه است؛ این واقعیت را هنگام پیش‌آموزش روی مقاله‌های ویکی‌پدیا، کتاب‌ها و صفحات وب فراگرفته است. SFT به مدل فکت‌های جدیدی یاد نمی‌دهد؛ بلکه رفتار تازه‌ای به آن می‌آموزد: وقتی پرسشی دیدی، پاسخ را بساز. وقتی دستورالعملی دریافت کردی، آن را تکمیل کن. وقتی درخواستی آسیب‌رسان دیدی، از انجامش سر باز بزن.

ماجرا را این‌طور ببینید: پیش‌آموزش به مدل دانش می‌بخشد؛ SFT به مدل ادب و رفتار می‌آموزد.

فرمت‌های ساختاردهی به داده‌ها

در صنعت، سه قالب اصلی رواج دارند. هر سه یک محتوا — چه کسی چه گفت — را با نشانه‌گذارهای (delimiters) متفاوتی مشخص می‌کنند.

فرمت Alpaca (استنفورد، مارس 2023):

{
  "instruction": "Summarize the following article in 3 sentences.",
  "input": "The European Central Bank raised interest rates...",
  "output": "The ECB increased rates by 25 basis points..."
}

ساده و بسیار پرکاربرد. فیلد input اختیاری است؛ چرا که بسیاری از دستورالعمل‌ها نیازی به کانتکست اضافه ندارند. استنفورد 52,000 نمونه با این فرمت را با هزینه‌ی 600 دلاری از طریق GPT-3.5 ساخت. همین اقدام جریان متن‌بازِ instruction tuning را کلید زد.

فرمت ShareGPT (جامعه‌ی متن‌باز، 2023):

{
  "conversations": [
    {"from": "system", "value": "You are a helpful assistant."},
    {"from": "human", "value": "What causes tides?"},
    {"from": "gpt", "value": "Tides are caused by the gravitational pull of the Moon..."},
    {"from": "human", "value": "How often do they occur?"},
    {"from": "gpt", "value": "Most coastal areas experience two high tides and two low tides per day..."}
  ]
}

از مکالمات چندنوبته (multi-turn) پشتیبانی می‌کند. فیلد «from» فارغ از این‌که چه مدلی پاسخ داده، طبق عرف روی مقادیر «human» و «gpt» تنظیم می‌شود. مدل Vicuna با استفاده از 70,000 مکالمه‌ی ShareGPT آموزش دید؛ رونوشت‌هایی از تعاملات کاربران با ChatGPT که به اشتراک گذاشته شده بودند.

فرمت ChatML (اوپن‌ای‌آی، به‌کاررفته در مدل‌های متعدد متن‌باز):

<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is the capital of France?<|im_end|>
<|im_start|>assistant
The capital of France is Paris.<|im_end|>

از توکن‌های ویژه (<|im_start|>, <|im_end|>) برای تفکیک نقش‌ها بهره می‌گیرد. این توکن‌ها حین فاین‌تیونینگ به دایره‌ی لغات توکنایزر اضافه می‌شوند. مدل‌های Qwen، Yi و بسیاری دیگر ساختار ChatML را به کار می‌برند.

هر سه فرمت در نهایت یک کار را انجام می‌دهند: به مدل می‌گویند «این بخش دستور است و آن بخش پاسخ؛ این الگو را یاد بگیر».

چرا این روش جواب می‌دهد؟

مدل در فاز پیش‌آموزش زبان را به‌خوبی یاد گرفته است. میلیاردها نمونه از سوال‌های همراه با پاسخ، دستورهای متصل به تکمیل و گفت‌وگوهای میان افراد را دیده است. این الگوها پیش‌تر در دل وزن‌ها ثبت شده‌اند.

کاری که SFT انجام می‌دهد، متمرکز ساختن همین توانمندی‌های نهفته است. به‌جای آن‌که مدل بر پایه‌ی کانتکست حدس بزند که آیا باید پاسخی بدهد یا سندی را ادامه دهد، SFT صراحتاً روی الگوی گفت‌وگو آموزش می‌بیند. پس از چند هزار نمونه، مدل یاد می‌گیرد: هر زمان به نشانگرِ نقشِ دستیار رسیدی، پاسخی راهگشا آماده کن.

دقیقاً به همین دلیل 27,000 نمونه کفایت می‌کند. شما زبان انگلیسی را از نو به مدل یاد نمی‌دهید؛ حقایق جهان را هم به آن اضافه نمی‌کنید. فقط یک رفتار ساده را تفهیم می‌کنید: به دستورالعمل‌ها پاسخ بده. پایه‌ی دانش از قبل در مدل بوده است.

ماسک‌کردن تابع زیان (The Masked Loss)

این نکته، مهم‌ترین جزئیات فنی در SFT است که اغلب راهنماها از آن می‌گذرند.

در پیش‌آموزش، تابع زیان را روی تک‌تک توکن‌ها حساب می‌کنید؛ چرا که مدل باید پیش‌بینی هر توکن بعدی را در سراسر رشته بیاموزد. اما در SFT، تابع زیان فقط برای توکن‌های پاسخ محاسبه می‌شود. توکن‌های دستورالعمل صرفاً برای ایجاد کانتکست حاضرند و بابت پیش‌بینی اشتباه آن‌ها جریمه‌ای در نظر گرفته نمی‌شود.

چرا؟ چون نمی‌خواهید مدل تولید دستورالعمل را یاد بگیرد؛ بلکه می‌خواهید پاسخ‌گویی به دستورالعمل را تمرین کند. اگر زیان را برای توکن‌های دستورالعمل هم اعمال کنید، به مدل یاد می‌دهید که جمله‌ی «What is the capital of France?» را طوری بسازد انگار خودش سوال‌کننده است. این کار سیگنال گرادیان را تلف می‌کند و درک مدل از نقشش را به هم می‌ریزد.

در عمل، یک ماسکِ زیان می‌سازید: 1 برای توکن‌های پاسخ و 0 برای توکن‌های دستور. پیش از میانگین‌گیری، تابع زیانِ هر توکن را در این ماسک ضرب می‌کنید.

Tokens:    [SYS] You are helpful [USER] What is the capital? [ASST] Paris is the capital [EOS]
Loss mask:   0    0    0     0      0     0   0  0     0       1     1    1   1     1      1

تنها توکن‌های بعد از [ASST] در تابع زیان سهم دارند. مدل در گذر رو به جلو تمام مکالمه را می‌بیند (چون برای آماده‌سازی پاسخ مناسب به دستور نیاز دارد)، اما اصلاح وزن‌های آن تنها مبتنی بر دقتش در پیش‌بینی توکن‌های پاسخ انجام می‌گیرد.

هایپرپارامترهای آموزش

SFT از هایپرپارامترهایی کاملاً متفاوت با پیش‌آموزش استفاده می‌کند. شما مدل را از ابتدا آموزش نمی‌دهید؛ صرفاً مدلی را تنظیم می‌کنید که از قبل کار می‌کند.

پارامتر پیش‌آموزش (Llama 2 7B) SFT (Llama 2 Chat)
نرخ یادگیری 3e-4 (پیک) 2e-5
دوره‌ها (Epochs) 1 (یک گذر کامل روی داده‌ها) 2
اندازه‌ی بچ 4M توکن 64 نمونه
گام‌های وارم‌آپ 2,000 0-100
کاهش وزن 0.1 0.0-0.1
حجم داده‌ها 2T توکن 27,000 نمونه

نرخ یادگیری در SFT تا 15 برابر کمتر است. این مسئله نقشی حیاتی دارد؛ چرا که نرخ یادگیری بالا حین فاین‌تیونینگ می‌تواند دانسته‌های پیش‌آموزش را نابود کند. مدل آموخته‌هایش را فراموش می‌کند و روی مجموعه‌داده‌ی کوچک فاین‌تیونینگ دچار بیش‌برازش (overfitting) می‌شود؛ پدیده‌ای که فراموشی فاجعه‌بار (Catastrophic Forgetting) نام دارد.

دو دوره یعنی مدل هر نمونه‌ی آموزشی را دوبار می‌بیند. فراتر رفتن از 3 دوره روی دیتاستی کوچک باعث حفظ‌کردن داده‌ها می‌شود — مدل به‌جای تعمیم، همان نمونه‌های آموزشی را کلمه‌به‌کلمه بازگو می‌کند.

فراموشی فاجعه‌بار

فاین‌تیونینگ می‌تواند قابلیت‌های عمومی مدل را از بین ببرد. اگر آموزش روی داده‌های مربوط به پیروی از دستور را بیش از حد طولانی کنید، مدل توان کدنویسی، حل ریاضیات یا نگارش متن خلاقانه را از دست می‌دهد؛ در ساختار فرمت آموزشی‌اش عالی عمل خواهد کرد، ولی در هر کار دیگری افتضاح می‌شود.

سه راهکار برای پیشگیری:

  1. نرخ یادگیری پایین. بین 1e-5 تا 5e-5. به‌روزرسانی‌های کوچک‌تر آسیب کمتری به ویژگی‌های از پیش‌آموخته وارد می‌کنند.

  2. آموزش کوتاه. 1 تا 3 دوره. روند آموزش را پیش از بیش‌برازش مدل متوقف کنید.

  3. ترکیب داده‌های پیش‌آموزش. تیم Llama 2 Chat بخش کوچکی (2 تا 5 درصد) از داده‌های خام پیش‌آموزش را با دیتاست SFT ترکیب کرد. این کار قابلیت‌های عمومی مدل را در حین یادگیری رفتار جدید «به یادش می‌آورد».

ارقام در جهان واقعی

فاین‌تیونینگ یک مدل 7B با 10,000 جفت‌داده‌ی باکیفیت، تقریباً 1 ساعت روی یک پردازنده‌ی گرافیکی NVIDIA A100 80GB زمان می‌برد. محاسبات ماجرا به این شرح است:

  • 10,000 نمونه با میانگین 512 توکن = 5.12M توکن
  • 2 دوره = مجموعاً 10.24M توکن
  • نرخ پردازش (throughput) برای فاین‌تیون مدل 7B روی A100: حدود 3,000 توکن در ثانیه
  • 10.24M تقسیم بر 3,000 = حدود 3,400 ثانیه = نزدیک به 57 دقیقه

در مینی جی‌پی‌تی ما (4 لایه، ابعاد 128)، آموزش تقریباً آنی است. هدف درک سازوکارهاست، نه مقیاس پردازش.

مدل چت(پس از SFT)مدل پایه(پیش‌آموزش‌دیده)پایپ‌لاین فاین‌تیونینگ نظارت‌شدهمجموعه‌داده دستورالعمل(10K-100K نمونه)قالب‌بندی به‌صورتجفت‌های (دستور، پاسخ)توکن‌سازی باالگوی گفتگوایجاد ماسک زیان(1 برای پاسخ، 0 برای دستور)گذر رو به جلو(کل توالی)محاسبه تابع زیان (loss) ماسک‌شده(فقط توکن‌های پاسخ)گام رو به عقببه‌روزرسانی وزن‌ها(lr=2e-5, 1-3 epochs)آشنا با زبانآشنا با فکت‌هافاقد الگوی مکالمهآشنا با زبانآشنا با فکت‌هاپیروی از دستورالعمل‌ها
ماسک‌کردن تابع زیان (loss) در SFTیادگیری پاسخ، نه پرامپت
SFT LOSS MASKINGA chat example contains system, user, and assistant turns, but the model should only be trained to produce the assistant turn. Loss masking zeroes the gradient on every prompt token so the model learns to answer rather than to memorize and replay the question.systemYouarehelpfulusercapital?assistantParis.زیان ↑ فقط روی توکن‌های دستیارprompt tokens masked — gradient = 0
یک نمونه گفت‌وگو شامل نوبت‌های گفت‌وگوی سیستم، کاربر و دستیار است، اما مدل فقط باید برای تولید نوبت دستیار آموزش ببیند. ماسک‌کردن تابع زیان (loss) گرادیان روی تک‌تک توکن‌های پرامپت را صفر می‌کند تا مدل یاد بگیرد پاسخ دهد، نه اینکه صرفاً سؤال را حفظ کرده و بازگو کند.

پیاده‌سازی از صفر

گام 1: مجموعه‌داده‌ی دستورالعمل‌ها

یک مجموعه‌داده‌ی ساختگی از دستورالعمل‌ها می‌سازیم. در صنعت، شرکت‌هایی مثل Scale AI و Anthropic نیروی انسانی استخدام می‌کنند تا این موارد را بنویسند. ما در اینجا برای نمایش فرمت، آن‌ها را با کد تولید می‌کنیم.

import numpy as np

INSTRUCTION_DATA = [
    {
        "instruction": "What is the capital of France?",
        "response": "The capital of France is Paris."
    },
    {
        "instruction": "Explain gravity in one sentence.",
        "response": "Gravity is the force that attracts objects with mass toward each other."
    },
    {
        "instruction": "Write a haiku about the ocean.",
        "response": "Waves crash on the shore, salt and foam beneath the sun, endless blue expanse."
    },
    {
        "instruction": "What is 15 multiplied by 7?",
        "response": "15 multiplied by 7 is 105."
    },
    {
        "instruction": "Name three programming languages.",
        "response": "Three programming languages are Python, Rust, and TypeScript."
    },
    {
        "instruction": "Summarize photosynthesis.",
        "response": "Photosynthesis converts sunlight, water, and carbon dioxide into glucose and oxygen."
    },
    {
        "instruction": "What year did World War II end?",
        "response": "World War II ended in 1945."
    },
    {
        "instruction": "Define machine learning.",
        "response": "Machine learning is a field where algorithms learn patterns from data to make predictions."
    },
]

هشت نمونه رقمی بسیار کم به نظر می‌رسد؛ Stanford Alpaca به سراغ 52,000 نمونه رفت. با این حال، سازوکار ماجرا چه با 8 نمونه و چه با 52,000 تا یکسان است: توکن‌سازی، ماسک‌گذاری و محاسبه‌ی تابع زیان تنها روی پاسخ‌ها.

گام 2: توکن‌سازی با الگوی گفت‌وگو

جفت‌های دستور-پاسخ را با نشانگرهای ویژه به توالی‌هایی از توکن‌ها بدل کنید. این نشانگرها پایان دستور و شروع پاسخ را به مدل اعلام می‌کنند.

SPECIAL_TOKENS = {
    "INST_START": 253,
    "INST_END": 254,
    "RESP_START": 255,
}


def tokenize_instruction_pair(instruction, response, vocab_size=256):
    inst_tokens = list(instruction.encode("utf-8"))
    resp_tokens = list(response.encode("utf-8"))

    inst_tokens = [min(t, vocab_size - 4) for t in inst_tokens]
    resp_tokens = [min(t, vocab_size - 4) for t in resp_tokens]

    tokens = (
        [SPECIAL_TOKENS["INST_START"]]
        + inst_tokens
        + [SPECIAL_TOKENS["INST_END"]]
        + [SPECIAL_TOKENS["RESP_START"]]
        + resp_tokens
    )

    return tokens


def create_loss_mask(tokens):
    mask = np.zeros(len(tokens), dtype=np.float32)
    in_response = False

    for i, token in enumerate(tokens):
        if token == SPECIAL_TOKENS["RESP_START"]:
            in_response = True
            continue
        if in_response:
            mask[i] = 1.0

    return mask

ماسکِ تابع زیان برای توکن‌های دستور تماماً صفر و برای توکن‌های پاسخ یک خواهد بود. خود توکن RESP_START هم ماسک صفر می‌گیرد؛ چون نقش جداکننده دارد و بخشی از محتوای پاسخ به شمار نمی‌آید.

گام 3: تابع زیان آنتروپی متقاطعِ ماسک‌شده

همان آنتروپی متقاطع (Cross-Entropy) استاندارد است، منتها در ماسک زیان ضرب می‌شود. بنابراین تنها توکن‌های پاسخ در گرادیان نقش دارند.

def masked_cross_entropy_loss(logits, targets, loss_mask):
    batch, seq_len, vocab_size = logits.shape
    logits_flat = logits.reshape(-1, vocab_size)
    targets_flat = targets.reshape(-1)
    mask_flat = loss_mask.reshape(-1)

    max_logits = logits_flat.max(axis=-1, keepdims=True)
    log_softmax = logits_flat - max_logits - np.log(
        np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
    )

    per_token_loss = -log_softmax[np.arange(len(targets_flat)), targets_flat]

    masked_loss = per_token_loss * mask_flat
    num_response_tokens = mask_flat.sum()
    if num_response_tokens == 0:
        return 0.0
    loss = masked_loss.sum() / num_response_tokens

    return loss

مخرج کسر num_response_tokens است، نه seq_len. اگر بر کل طول توالی تقسیم کنید، طولانی بودن دستورها سیگنال گرادیان را تضعیف می‌کند. تقسیم بر تعداد توکن‌های پاسخ تضمین می‌کند که فارغ از طول دستور، وزن تک‌تک توکن‌های پاسخ برابر بماند.

گام 4: حلقه‌ی آموزش SFT

از MiniGPT ساخته‌شده در درس 04 استفاده می‌کنیم. حلقه‌ی آموزش تقریباً شبیه پیش‌آموزش است، منتها با فرمت‌بندی دستورالعمل‌ها و تابع زیانِ ماسک‌شده.

import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "04-pre-training-mini-gpt", "code"))
from main import MiniGPT, LayerNorm, FeedForward, MultiHeadAttention, TransformerBlock, Embedding


def sft_train(model, dataset, num_epochs=2, lr=2e-5, seq_len=64):
    formatted_data = []
    for example in dataset:
        tokens = tokenize_instruction_pair(example["instruction"], example["response"])
        mask = create_loss_mask(tokens)
        formatted_data.append((tokens, mask))

    print(f"SFT Training: {len(formatted_data)} examples, {num_epochs} epochs, lr={lr}")
    print(f"Total tokens: {sum(len(t) for t, _ in formatted_data):,}")
    print()

    losses = []

    for epoch in range(num_epochs):
        epoch_loss = 0.0
        num_batches = 0

        indices = np.random.permutation(len(formatted_data))

        for idx in indices:
            tokens, mask = formatted_data[idx]

            if len(tokens) < 3:
                continue
            if len(tokens) > seq_len:
                tokens = tokens[:seq_len]
                mask = mask[:seq_len]

            input_ids = np.array(tokens[:-1]).reshape(1, -1)
            target_ids = np.array(tokens[1:]).reshape(1, -1)
            loss_mask = np.array(mask[1:]).reshape(1, -1)

            logits = model.forward(input_ids)
            loss = masked_cross_entropy_loss(logits, target_ids, loss_mask)

            batch_size, s_len, v_size = logits.shape
            probs = np.exp(logits - logits.max(axis=-1, keepdims=True))
            probs = probs / probs.sum(axis=-1, keepdims=True)
            dlogits = probs.copy()
            dlogits[np.arange(batch_size)[:, None], np.arange(s_len), target_ids] -= 1.0

            mask_expanded = loss_mask[:, :, np.newaxis]
            num_resp = loss_mask.sum()
            if num_resp > 0:
                dlogits = dlogits * mask_expanded / num_resp

            for block in model.blocks:
                block.ffn.W1 -= lr * np.random.randn(*block.ffn.W1.shape) * 0.01
                block.ffn.W2 -= lr * np.random.randn(*block.ffn.W2.shape) * 0.01
                block.ffn.b1 -= lr * np.random.randn(*block.ffn.b1.shape) * 0.01
                block.ffn.b2 -= lr * np.random.randn(*block.ffn.b2.shape) * 0.01

            epoch_loss += loss
            num_batches += 1
            losses.append(loss)

        avg_loss = epoch_loss / max(num_batches, 1)
        print(f"Epoch {epoch + 1}/{num_epochs} | Avg Loss: {avg_loss:.4f}")

    return model, losses

نرخ یادگیری مطابق با Llama 2 Chat روی 2e-5 تنظیم شده است؛ یعنی 15 برابر کوچک‌تر از مقدار 3e-4 در پیش‌آموزش. گرادیان نیز ماسک می‌شود: توکن‌های دستور هیچ گرادیانی تولید نمی‌کنند و فقط توکن‌های پاسخ به وزن‌ها push وارد می‌کنند.

گام 5: مقایسه‌ی مدل پایه با مدل فاین‌تیون‌شده

تمام هدف SFT تغییر رفتار است. بیایید این موضوع را با بررسی نحوه‌ی پاسخ‌دهی مدل به ورودی‌های دستوری در مقایسه با ادامه‌دادن ساده‌ی متن بسنجیم.

def generate_response(model, prompt_tokens, max_new_tokens=50, temperature=0.8):
    tokens = list(prompt_tokens)
    seq_len = model.embedding.pos_embed.shape[0]

    for _ in range(max_new_tokens):
        context = np.array(tokens[-seq_len:]).reshape(1, -1)
        logits = model.forward(context)
        next_logits = logits[0, -1, :]

        next_logits = next_logits / max(temperature, 1e-8)
        probs = np.exp(next_logits - next_logits.max())
        probs = probs / probs.sum()
        probs = np.clip(probs, 1e-10, 1.0)
        probs = probs / probs.sum()

        next_token = np.random.choice(len(probs), p=probs)
        tokens.append(int(next_token))

    return tokens


def evaluate_instruction_following(model, instructions):
    print("Evaluating instruction following:")
    print("-" * 50)

    for instruction in instructions:
        tokens = (
            [SPECIAL_TOKENS["INST_START"]]
            + [min(t, 252) for t in list(instruction.encode("utf-8"))]
            + [SPECIAL_TOKENS["INST_END"]]
            + [SPECIAL_TOKENS["RESP_START"]]
        )

        output = generate_response(model, tokens, max_new_tokens=30, temperature=0.6)
        response_start = len(tokens)
        response_tokens = output[response_start:]
        response_bytes = bytes([t for t in response_tokens if t < 128])
        response_text = response_bytes.decode("utf-8", errors="replace")

        print(f"  Q: {instruction}")
        print(f"  A: {response_text[:80]}")
        print()

روی مدلی بسیار کوچک با 8 نمونه، پاسخ‌ها چندان معنادار نخواهند بود؛ این کاملاً طبیعی است. نکته‌ی مهم ساختار است: مدل یاد می‌گیرد خروجی را بعد از نشانگر پاسخ تولید کند، نه این‌که باز هم به ساخت دستورالعمل‌های اضافه ادامه دهد.

گام 6: سنجش میزان فراموشی فاجعه‌بار

توانایی پیش‌بینی توکن بعدی (Next-Token Prediction) مدل را قبل و بعد از SFT مقایسه کنید. اگر SFT به قابلیت‌های عمومی آسیب زده باشد، تابع زیان روی متن خام افزایش می‌یابد.

def measure_forgetting(model, test_text, seq_len=64):
    tokens = np.array(list(test_text.encode("utf-8")[:512]))

    total_loss = 0.0
    num_windows = 0

    for start in range(0, len(tokens) - seq_len - 1, seq_len):
        input_ids = tokens[start:start + seq_len].reshape(1, -1)
        target_ids = tokens[start + 1:start + seq_len + 1].reshape(1, -1)

        logits = model.forward(input_ids)

        batch, s_len, vocab_size = logits.shape
        logits_flat = logits.reshape(-1, vocab_size)
        targets_flat = target_ids.reshape(-1)

        max_logits = logits_flat.max(axis=-1, keepdims=True)
        log_softmax = logits_flat - max_logits - np.log(
            np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
        )

        loss = -log_softmax[np.arange(len(targets_flat)), targets_flat].mean()
        total_loss += loss
        num_windows += 1

    return total_loss / max(num_windows, 1)

در یک پروژه‌ی فاین‌تیونینگ واقعی، این معیار را در سراسر آموزش رصد می‌کنید. اگر تابع زیانِ متن خام بیش از 10 تا 15 درصد رشد داشت، روند SFT شما بیش از حد تهاجمی است. باید نرخ یادگیری را پایین بیاورید یا تعداد دوره‌ها را کم کنید.

استفاده از ابزارهای واقعی

دموی پایپ‌لاین کامل SFT

if __name__ == "__main__":
    np.random.seed(42)

    test_text = """The transformer architecture processes sequences through self-attention.
Each layer applies multi-head attention followed by a feedforward network.
Residual connections and layer normalization stabilize deep networks.
The model learns to predict the next token given all previous tokens."""

    print("=" * 70)
    print("INSTRUCTION TUNING (SFT) DEMO")
    print("=" * 70)
    print()

    model = MiniGPT(
        vocab_size=256, embed_dim=128, num_heads=4,
        num_layers=4, max_seq_len=128, ff_dim=512
    )
    print(f"Model: {model.count_parameters():,} parameters")
    print(f"Config: 4 layers, 4 heads, 128 dims (mini GPT from Lesson 04)")
    print()

    print("PRE-SFT: Measuring base model loss on raw text")
    base_loss = measure_forgetting(model, test_text)
    print(f"  Base model loss: {base_loss:.4f}")
    print()

    print("=" * 70)
    print("SFT TRAINING")
    print("=" * 70)

    model, losses = sft_train(
        model, INSTRUCTION_DATA, num_epochs=3, lr=2e-5, seq_len=128
    )

    print()
    print("POST-SFT: Measuring fine-tuned model loss on raw text")
    sft_loss = measure_forgetting(model, test_text)
    print(f"  SFT model loss: {sft_loss:.4f}")
    print(f"  Change: {((sft_loss - base_loss) / base_loss * 100):+.1f}%")
    if abs(sft_loss - base_loss) / base_loss < 0.15:
        print("  Minimal forgetting (< 15% change)")
    else:
        print("  Significant forgetting detected")
    print()

    print("=" * 70)
    print("INSTRUCTION FOLLOWING EVALUATION")
    print("=" * 70)
    print()

    test_instructions = [
        "What is the capital of France?",
        "Name a programming language.",
        "Define gravity.",
    ]
    evaluate_instruction_following(model, test_instructions)

    print("=" * 70)
    print("DATA FORMAT EXAMPLES")
    print("=" * 70)
    print()

    for i, example in enumerate(INSTRUCTION_DATA[:3]):
        tokens = tokenize_instruction_pair(example["instruction"], example["response"])
        mask = create_loss_mask(tokens)
        resp_count = int(mask.sum())
        total_count = len(tokens)
        print(f"  Example {i + 1}: {total_count} tokens, {resp_count} response tokens ({resp_count/total_count:.0%} of sequence)")
        print(f"    Instruction: {example['instruction']}")
        print(f"    Response: {example['response']}")
        print()

    print("=" * 70)
    print("TRAINING LOSS CURVE")
    print("=" * 70)
    print()

    if losses:
        window = max(1, len(losses) // 5)
        for i in range(0, len(losses), window):
            chunk = losses[i:i + window]
            avg = sum(chunk) / len(chunk)
            print(f"  Steps {i:3d}-{i + len(chunk) - 1:3d}: avg loss = {avg:.4f}")

پیاده‌سازی نهایی

حاصل این درس فایلی در مسیر outputs/prompt-sft-data-curator.md است؛ پرامپتی که کمکتان می‌کند مجموعه‌داده‌های دستوری برای SFT طراحی و گلچین کنید. اگر قابلیت مشخصی (مثل کدنویسی، ریاضیات یا مکالمه) به آن بدهید، برنامه‌ای برای جمع‌آوری داده به همراه ساختار فرمت، معیارهای کیفی و الزامات تنوع داده در اختیارتان می‌گذارد.

تمرین‌ها

  1. قابلیت پرامپت سیستم (system prompt) را اضافه کنید. تابع tokenize_instruction_pair را طوری تغییر دهید که پیام سیستم را هم بپذیرد و آن را پیش از دستور قرار دهد. 5 نمونه با پرامپت‌های سیستمی متفاوت بسازید («You are a poet»، «You are a math tutor») و بررسی کنید که مدل حین آموزش پرامپت‌های مختلف را دریافت کند.

  2. استراتژی ترکیب داده‌ها (data mixing) را پیاده‌سازی کنید. تابعی بسازید که مجموعه‌داده‌ی SFT و یک پیکره‌ی متن خام را بگیرد و بچ‌های آموزشی را طوری آماده کند که 5 درصد نمونه‌ها متن خام (بدون ماسک) و 95 درصد جفت‌های دستوری (با ماسک) باشند. مدل را 3 دوره آموزش دهید و معیارهای فراموشی را در مقایسه با SFT خالص بسنجید.

  3. سامانه‌ای برای امتیازدهی به کیفیت داده‌ها بسازید. برای هر جفت دستور-پاسخ موارد زیر را محاسبه کنید: (الف) طول پاسخ بر حسب توکن، (ب) نسبت طول دستور به پاسخ و (ج) تنوع واژگان (تعداد توکن‌های یکتا تقسیم بر کل توکن‌ها). نمونه‌هایی با طول پاسخ کمتر از 10 توکن یا تنوع کمتر از 0.3 را فیلتر کنید. نشان دهید این غربالگری چه تاثیری بر تابع زیان نهایی می‌گذارد.

  4. آموزش مکالمات چندنوبته را پیاده کنید. توکن‌سازی را طوری ارتقا دهید که مکالمات 3 نوبتی (کاربر-دستیار-کاربر-دستیار-کاربر-دستیار) را مدیریت کند. ماسک زیان باید هر سه نوبتِ (turn) دستیار را پوشش دهد. با چاپ نحوه‌ی تطبیق توکن‌ها و ماسک برای یک نمونه، درستی آن را بررسی کنید.

  5. نرخ‌های یادگیری را مقایسه کنید. یک مدل یکسان را سه بار با نرخ‌های lr=1e-4 ،lr=2e-5 و lr=1e-6 آموزش دهید. منحنی‌های تابع زیان را رسم کنید. اجرای 1e-4 باید افت سریع اولیه و سپس زیان نهایی بالاتری نشان دهد (بیش‌برازش). در اجرای 1e-6 منحنی نباید تکان محسوسی بخورد. نرخ 2e-5 باید همان نقطه‌ی تعادل ایده‌آل باشد.

اصطلاحات کلیدی

اصطلاح در گفت‌وگوهای عامه معنای دقیق فنی
SFT «فاین‌تیونینگ روی مکالمات» فاین‌تیونینگ نظارت‌شده: ادامه‌ی آموزش روی جفت‌های (دستورالعمل، پاسخ)، در حالی که تابع زیان فقط روی توکن‌های پاسخ محاسبه می‌شود
Instruction tuning «یاد دادن پیروی از دستورها به مدل» آموزش روی جفت‌های صریح دستور-پاسخ تا مدل پایه الگوی مکالمه را یاد بگیرد، نه دانش جدید
Loss masking «نادیده گرفتن پرامپت» صفر کردن تابع زیان برای توکن‌های دستور، طوری که گرادیان‌ها فقط از پیش‌بینی توکن‌های پاسخ جاری شوند
ChatML «Chat Markup Language» فرمت توکنی که از جداکننده‌های <|im_start|> و <|im_end|> برای تعیین نقش گوینده در داده‌های گفت‌وگو بهره می‌برد
Alpaca format «فرمت استنفورد» یک ساختار JSON با فیلدهای instruction/input/output که برای 52 هزار نمونه‌ی تولیدشده با GPT-3.5 با هزینه‌ی 600 دلار استفاده شد
Catastrophic forgetting «احمق‌تر شدن مدل» تخریب قابلیت‌های از پیش‌آموخته در اثر فاین‌تیونینگ، چرا که به‌روزرسانی‌های گرادیان الگوهای خاص وظیفه را روی دانش عمومی رونویسی می‌کنند
Weight tying «امبدینگ‌های مشترک» استفاده از ماتریسی یکسان برای امبدینگ توکن ورودی و سرِ پیش‌بینی خروجی، به منظور صرفه‌جویی در پارامترها و بهبود انسجام
Chat template «نحوه‌ی قالب‌بندی پرامپت» توالی خاصی از توکن‌ها (نشانگرهای نقش، جداکننده‌ها) که به مکالمه برای مدل ساختار می‌دهد

برای مطالعه‌ی بیشتر

سؤالات متداول

تفاوت مدل پایه (Base Model) با مدل تنظیم‌شده با SFT چیست؟

مدل پایه فقط توکن بعدی متن را پیش‌بینی و ادامه می‌دهد؛ اما مدل آموزش‌دیده با SFT می‌داند که هنگام مواجهه با یک دستور یا پرسش، باید نقش دستیار را ایفا کند و پاسخی مفید ارائه دهد.

چرا در SFT تابع زیان (Loss Masking) روی پرامپت کاربر ماسک می‌شود؟

چون هدف مدل یادگیری پاسخ‌گویی است، نه تولید پرامپت یا پرسش. اگر گرادیان روی توکن‌های دستور هم محاسبه شود، سیگنال یادگیری تضعیف می‌شود و مدل نقش خود را گم می‌کند.

فراموشی فاجعه‌بار (Catastrophic Forgetting) چیست و چطور مهار می‌شود؟

اگر مدل در فاز فاین‌تیونینگ بیش از حد آموزش ببیند، توانایی‌های عمومی پیشین خود را از دست می‌دهد. کاهش نرخ یادگیری (مثلاً 2e-5)، تعداد کم دوره‌ها (1 تا 3) و ترکیب اندکی داده‌ی پیش‌آموزش این مشکل را حل می‌کند.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.