فاز 2 · درس 17

مدیریت داده‌های نامتوازن: از SMOTE تا تنظیم آستانه

نوع
ساختنی
زمان
حدود 90 دقیقه
زبان‌ها
Python
پیش‌نیاز
یادگیری ماشین چیست؟ مفاهیم پایه، انواع و پیاده‌سازی از صفر، رگرسیون خطی از صفر تا پیاده‌سازی با scikit-learn، رگرسیون لجستیک و دسته‌بندی از صفر، درخت تصمیم و جنگل تصادفی: پیاده‌سازی از صفر و تئوری ریاضی، ماشین‌های بردار پشتیبان (SVM)؛ از فرمول‌بندی ریاضی تا پیاده‌سازی، الگوریتم K-نزدیک‌ترین همسایه (KNN) و معیارهای فاصله، یادگیری نظارت‌نشده و الگوریتم‌های خوشه‌بندی، مهندسی و انتخاب ویژگی در یادگیری ماشین، ارزیابی مدل در یادگیری ماشین: از صفر تا پیاده‌سازی و تحلیل
یک ترازوی سنجش با عدم تعادل شدید که یک تکه طلای کوچک اما بسیار باارزش با انبوهی از سنگ‌های بی‌ارزش مقایسه می‌شود
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مبانی نظری
  4. چرا معیار دقت شکست می‌خورد؟
  5. معیارهای بهتر
  6. پایپ‌لاین داده‌های نامتوازن
  7. تکنیک SMOTE: نمونه‌افزایی ساختگی کلاس اقلیت
  8. مقایسه‌ی استراتژی‌های نمونه‌گیری
  9. وزن‌دهی به کلاس‌ها (Class Weights)
  10. تنظیم آستانه (Threshold Tuning)
  11. یادگیری حساس به هزینه (Cost-Sensitive Learning)
  12. فلوچارت تصمیم‌گیری
  13. پیاده‌سازی
  14. گام 1: تولید یک مجموعه‌داده‌ی نامتوازن
  15. گام 2: پیاده‌سازی SMOTE از صفر
  16. گام 3: نمونه‌افزایی و نمونه‌کاهی تصادفی
  17. گام 4: رگرسیون لجستیک با وزن کلاس‌ها
  18. گام 5: تنظیم آستانه
  19. گام 6: توابع ارزیابی
  20. گام 7: مقایسه‌ی تمام روش‌ها
  21. استفاده در عمل
  22. تحویل پروژه
  23. تمرین‌ها
  24. اصطلاحات کلیدی
  25. برای مطالعه‌ی بیشتر

در پروژه‌های واقعی یادگیری ماشین، مانند تشخیص تقلب بانکی یا تشخیص بیماری‌های نادر، داده‌های متوازن یک رؤیا هستند. وقتی 99% داده‌ها متعلق به یک کلاس باشند، مدلی ساده‌لوح که همیشه کلاس اکثریت را حدس بزند به دقت 99% می‌رسد، اما در عمل کاملاً بی‌استفاده است. این مسئله یکی از شایع‌ترین چالش‌هایی است که مهندسان یادگیری ماشین با آن روبه‌رو می‌شوند.

این درس به شما نشان می‌دهد چطور فراتر از توهم معیار دقت (Accuracy) بروید. با معیارهای سنجش استاندارد مثل F1 و AUPRC آشنا می‌شوید، تفاوت روش‌های نمونه‌افزایی تصادفی و SMOTE را از پایه کدنویسی می‌کنید و یاد می‌گیرید با تکنیک‌های بدون هزینه مانند وزن‌دهی به کلاس‌ها و تنظیم آستانه تصمیم‌گیری، عملکرد مدل را به حداکثر برسانید.

تمامی کدهای این درس سبک هستند و برای پیاده‌سازی آن‌ها به پردازنده‌های گرافیکی یا منابع ابری گران‌قیمت نیازی ندارید. می‌توانید همه بخش‌ها را به‌صورت محلی روی سیستم شخصی اجرا کرده و برای نصب کتابخانه‌هایی مثل imbalanced-learn و scikit-learn در صورت ناپایداری ارتباط، از میرورهای داخلی PyPI کمک بگیرید.

وقتی 99% از داده‌های شما «عادی» هستند، دقت (accuracy) یک دروغ بزرگ است.

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

  • پیاده‌سازی SMOTE از صفر و درک تفاوت نمونه‌افزایی ساختگی با کپی‌برداری تصادفی
  • ارزیابی دسته‌بندهای داده‌های نامتوازن با معیارهای F1، AUPRC و ضریب همبستگی متیوز (Matthews Correlation Coefficient) به‌جای دقت
  • مقایسه‌ی وزن‌دهی به کلاس‌ها، تنظیم آستانه و راهبردهای بازنمونه‌گیری، همراه با انتخاب روش مناسب بر اساس نسبت عدم‌توازن
  • ساخت یک پایپ‌لاین کامل برای داده‌های نامتوازن با ترکیب SMOTE، وزن کلاس‌ها و بهینه‌سازی آستانه

صورت مسئله

مدلی برای تشخیص تقلب می‌سازید. دقت مدل به 99.9% می‌رسد. جشن می‌گیرید؛ اما بعد متوجه می‌شوید مدل برای تک‌تک تراکنش‌ها برچسب «غیرتقلبی» پیش‌بینی کرده است.

این یک باگ نیست، بلکه وقتی فقط 0.1% از تراکنش‌ها واقعاً تقلب هستند، رفتاری کاملاً منطقی از سوی مدل به‌حساب می‌آید. مدل یاد می‌گیرد که با حدس زدن همیشگی کلاس اکثریت، خطای کلی به حداقل می‌رسد. نتیجه از نظر فنی درست است، اما در عمل کاملاً بی‌استفاده خواهد بود.

این اتفاق در تمام مسائل دسته‌بندی دنیای واقعی تکرار می‌شود: تشخیص بیماری با 1% نرخ مثبت، نفوذ به شبکه با 0.01% حملات، نقص‌های تولید صنعتی با 0.5% کالای معیوب، فیلتر هرزنامه با 20% هرزنامه، پیش‌بینی ریزش مشتریان با 5% ریزش. هرچه کلاس اقلیت پیامدهای حیاتی‌تری داشته باشد، احتمال وقوع آن کمتر است.

معیار دقت شکست می‌خورد، چون ارزش یکسانی برای همه‌ی پیش‌بینی‌های درست قائل است. برچسب‌گذاری درست یک تراکنش عادی و کشف درست یک تقلب، هر دو یک امتیاز به دقت اضافه می‌کنند. در حالی که کشف تقلب، اصلاً تنها دلیل ساخت این مدل بوده است. ما به معیارها، تکنیک‌ها و استراتژی‌های آموزشی نیاز داریم که مدل را وادار کنند به این کلاس نادر اما حیاتی اتنشن (attention) نشان دهد.

مبانی نظری

چرا معیار دقت شکست می‌خورد؟

مجموعه‌داده‌ای با 1000 نمونه را در نظر بگیرید: 990 نمونه منفی و 10 نمونه مثبت. مدلی که همیشه برچسب منفی پیش‌بینی می‌کند:

پیش‌بینی مثبت پیش‌بینی منفی
واقعاً مثبت 0 (TP) 10 (FN)
واقعاً منفی 0 (FP) 990 (TN)
Accuracy=(0+990)/1000=99.0%\text{Accuracy} = (0 + 990) / 1000 = 99.0\%

این مدل حتی یک مورد تقلب، بیماری یا نقص را پیدا نکرده است؛ اما دقت آن 99% گزارش می‌شود. به همین دلیل دقت در مسائل نامتوازن خطرناک است.

معیارهای بهتر

دقت دسته‌بندی (Precision) = TP/(TP+FP)\text{TP} / (\text{TP} + \text{FP}). از کل نمونه‌هایی که مثبت علامت خورده‌اند، چند تای آن‌ها واقعاً مثبت هستند؟ دقت بالا یعنی هشدارهای اشتباهِ اندک.

بازیابی یا پوشش (Recall) = TP/(TP+FN)\text{TP} / (\text{TP} + \text{FN}). از کل مواردی که واقعاً مثبت بودند، موفق به کشف چند مورد شدیم؟ پوشش بالا یعنی مثبت‌های ازدست‌رفته‌ی اندک.

امتیاز F1 (F1 Score) = 2⋅precision⋅recall/(precision+recall)2 \cdot \text{precision} \cdot \text{recall} / (\text{precision} + \text{recall}). میانگین همساز (harmonic mean). این معیار نسبت به میانگین حسابی، تفاوت شدید میان دقت و پوشش را بیشتر جریمه می‌کند.

امتیاز F-beta (F-beta Score) = (1+β2)⋅precision⋅recall/(β2⋅precision+recall)(1 + \beta^2) \cdot \text{precision} \cdot \text{recall} / (\beta^2 \cdot \text{precision} + \text{recall}). اگر β>1\beta > 1 باشد، پوشش اهمیت بیشتری دارد و اگر β<1\beta < 1 باشد، دقت دسته‌بندی مهم‌تر است. معیار F2 در سیستم‌های تشخیص تقلب بسیار رایج است (از دست دادن تقلب پیامد بدتری نسبت به یک هشدار اشتباه دارد).

سطح زیر منحنی دقت-پوشش (AUPRC). مشابه AUC-ROC، اما برای داده‌های نامتوازن بسیار کارآمدتر است. برای یک دسته‌بند تصادفی، مقدار AUPRC با نسبت کلاس مثبت برابر است (نه مقدار 0.5 که در ROC می‌بینیم). این ویژگی باعث می‌شود روند بهبود مدل بسیار واضح‌تر دیده شود.

ضریب همبستگی متیوز (Matthews Correlation Coefficient) = (TP⋅TN−FP⋅FN)/(TP+FP)(TP+FN)(TN+FP)(TN+FN)(\text{TP} \cdot \text{TN} - \text{FP} \cdot \text{FN}) / \sqrt{(\text{TP}+\text{FP})(\text{TP}+\text{FN})(\text{TN}+\text{FP})(\text{TN}+\text{FN})}. مقداری بین 1- تا 1+ دارد. این معیار فقط وقتی امتیاز بالایی می‌دهد که مدل در هر دو کلاس عملکرد خوبی داشته باشد. حتی در تفاوت شدید اندازه‌ی کلاس‌ها نیز مقداری متوازن ارائه می‌دهد.

برای مدلی که بالاتر دیدیم و «همیشه منفی پیش‌بینی می‌کرد»: precision=0/0\text{precision} = 0/0 (تعریف‌نشده، معمولاً 0 در نظر گرفته می‌شود)، recall=0/10=0\text{recall} = 0/10 = 0، F1=0\text{F1} = 0، MCC=0\text{MCC} = 0. این معیارها به‌درستی نشان می‌دهند که مدل عملاً بی‌ارزش است.

پایپ‌لاین داده‌های نامتوازن

خفیف: 80/20متوسط: 95/5شدید: 99/1خیربلهمجموعه‌داده نامتوازننسبت عدم‌توازن؟وزن‌دهی به کلاس‌هاSMOTE + تنظیم آستانهSMOTE + وزن‌دهی به کلاس‌ها + آستانهآموزش مدلارزیابی با F1 / AUPRC / MCCبه اندازه کافی خوب است؟امتحان استراتژی دیگراستقرار همراه با مانیتورینگ

تکنیک SMOTE: نمونه‌افزایی ساختگی کلاس اقلیت

در روش نمونه‌افزایی تصادفی (Random oversampling)، نمونه‌های موجود از کلاس اقلیت عیناً تکثیر می‌شوند. این کار جواب می‌دهد، اما ریسک بیش‌برازش (overfitting) را بالا می‌برد؛ چون مدل نقاط داده‌ی یکسانی را بارها می‌بیند.

الگوریتم SMOTE نمونه‌های ساختگی جدیدی می‌سازد که باورپذیرند، اما کپی داده‌های قبلی نیستند. مراحل الگوریتم:

  1. به‌ازای هر نمونه از کلاس اقلیت مثل xx، تعداد kk همسایه‌ی نزدیک را از بین سایر نمونه‌های همان کلاس پیدا کن
  2. یکی از همسایه‌ها را به‌تصادف انتخاب کن
  3. یک نمونه‌ی جدید روی پاره‌خط واصل بین xx و آن همسایه بساز

فرمول: new_sample = x + random(0, 1) * (neighbor - x)

این کار با درون‌یابی (interpolation) بین نقاط واقعی کلاس اقلیت، نمونه‌های جدیدی در همان بخش از فضای ویژگی‌ها می‌سازد، بدون این‌که داده‌های قبلی را صرفاً کپی کند.

مجموعه تقویت‌شدهتولید داده با SMOTEنقاط اولیه کلاس اقلیتx1 (1.0, 2.0)x2 (1.5, 2.5)x3 (2.0, 1.5)انتخاب x1، همسایه x2تصادفی t = 0.4new = x1 + 0.4*(x2-x1)جدید = (1.2, 2.2)x1 (1.0, 2.0)x2 (1.5, 2.5)x3 (2.0, 1.5)مصنوعی (1.2, 2.2)

مقایسه‌ی استراتژی‌های نمونه‌گیری

نمونه‌افزایی تصادفی (Random Oversampling): تکثیر نمونه‌های اقلیت برای برابر شدن با تعداد کلاس اکثریت.

  • مزایا: ساده، بدون از دست رفتن اطلاعات
  • معایب: کپی‌های دقیق باعث بیش‌برازش می‌شوند، زمان آموزش را بالا می‌برد

نمونه‌کاهی تصادفی (Random Undersampling): حذف نمونه‌های اکثریت برای رسیدن به تعداد کلاس اقلیت.

  • مزایا: آموزش سریع، ساده
  • معایب: دور ریختن داده‌های احتمالاً مفید کلاس اکثریت، واریانس بالاتر

الگوریتم SMOTE: تولید نمونه‌های ساختگی اقلیت از طریق درون‌یابی.

  • مزایا: تولید نقاط داده‌ی جدید، کاهش بیش‌برازش در مقایسه با نمونه‌افزایی تصادفی
  • معایب: احتمال ایجاد نمونه‌های پرنویز در نزدیکی مرز تصمیم‌گیری، نادیده گرفتن توزیع کلاس اکثریت
استراتژی تغییرات داده ریسک زمان استفاده
نمونه‌افزایی تکرار داده‌های اقلیت بیش‌برازش مجموعه‌داده‌های کوچک، عدم‌توازن متوسط
نمونه‌کاهی حذف داده‌های اکثریت از دست رفتن اطلاعات مجموعه‌داده‌های حجیم، نیاز به آموزش سریع
SMOTE افزودن داده‌های ساختگی اقلیت نویز در مرزها عدم‌توازن متوسط، وجود نمونه‌های اقلیت کافی برای k-NN

وزن‌دهی به کلاس‌ها (Class Weights)

به‌جای دستکاری داده‌ها، نحوه‌ی برخورد مدل با خطاها را تغییر دهید. به‌اشتباه در دسته‌بندی کلاس اقلیت وزن بیشتری اختصاص دهید.

برای یک مسئله‌ی دودویی با 950 نمونه‌ی منفی و 50 نمونه‌ی مثبت:

  • وزن کلاس منفی = nsamples/(2⋅nnegative)=1000/(2⋅950)=0.526n_{\text{samples}} / (2 \cdot n_{\text{negative}}) = 1000 / (2 \cdot 950) = 0.526
  • وزن کلاس مثبت = nsamples/(2⋅npositive)=1000/(2⋅50)=10.0n_{\text{samples}} / (2 \cdot n_{\text{positive}}) = 1000 / (2 \cdot 50) = 10.0

کلاس مثبت وزنی 19 برابر دریافت می‌کند. جریمه‌ی خطای دسته‌بندی روی یک نمونه‌ی مثبت به‌اندازه‌ی خطای 19 نمونه‌ی منفی هزینه دارد. در نتیجه مدل ناچار می‌شود به کلاس اقلیت توجه کند.

در رگرسیون لجستیک، این کار تابع زیان (loss) را تغییر می‌دهد:

weighted_loss=−∑(wi⋅[yilog⁡(pi)+(1−yi)log⁡(1−pi)])\text{weighted\_loss} = -\sum \bigl( w_i \cdot [y_i \log(p_i) + (1-y_i) \log(1-p_i)] \bigr)

که در آن wiw_i به کلاس نمونه‌ی ii بستگی دارد.

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

تنظیم آستانه (Threshold Tuning)

بیشتر دسته‌بندها احتمال خروجی می‌دهند. آستانه‌ی پیش‌فرض 0.5 است: اگر P(positive)≥0.5P(\text{positive}) \ge 0.5 باشد، مثبت پیش‌بینی می‌کنند. اما 0.5 انتخابی قراردادی است. وقتی کلاس‌ها نامتوازن هستند، آستانه‌ی بهینه معمولاً مقداری بسیار کمتر است.

مراحل کار:

  1. مدل را آموزش دهید
  2. احتمالات پیش‌بینی‌شده را روی مجموعه‌ی اعتبارسنجی (validation set) به دست آورید
  3. آستانه را از 0.0 تا 1.0 تغییر دهید
  4. معیار F1 (یا معیار انتخابی خود) را در هر آستانه محاسبه کنید
  5. آستانه‌ای را که معیار شما را بیشینه می‌کند انتخاب کنید
مدلپیش‌بینی احتمالاتپیمایش آستانه‌ها از 0.0 تا 1.0محاسبه F1 در هر آستانهانتخاب بهترین آستانهاستفاده در محیط پروداکشن

ممکن است مدل برای یک تراکنش تقلبی احتمال P(fraud)=0.15P(\text{fraud}) = 0.15 را خروجی دهد. در آستانه‌ی 0.5، این مورد غیرتقلبی دسته‌بندی می‌شود؛ اما در آستانه‌ی 0.10 به‌درستی کشف خواهد شد. کالیبره بودن احتمالات اهمیت کمتری نسبت به رتبه‌بندی (ranking) آن‌ها دارد؛ تا زمانی که تقلب احتمالات بالاتری نسبت به تراکنش‌های عادی دریافت کند، همواره آستانه‌ای برای جداسازی آن‌ها وجود خواهد داشت.

یادگیری حساس به هزینه (Cost-Sensitive Learning)

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

پیش‌بینی مثبت پیش‌بینی منفی
واقعاً مثبت 0 (درست) CFN=100C_{\text{FN}} = 100
واقعاً منفی CFP=1C_{\text{FP}} = 1 0 (درست)

از دست دادن یک تراکنش تقلبی (FN) معادل 100 برابر بیشتر از یک هشدار اشتباه (FP) هزینه دارد. مدل تلاش می‌کند کل هزینه را بهینه‌سازی کند، نه تعداد کل خطاها را.

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

فلوچارت تصمیم‌گیری

< 70/3070/30 to 95/595/5< 1000 نمونه1000-1000010000خیربلهخیربلهشروع: مجموعه‌داده نامتوازنچقدر نامتوازن است؟خفیف: ابتدا وزن‌دهی به کلاس‌ها را امتحانکنیدمتوسط: SMOTE + وزن‌دهی به کلاس‌هاشدید: ترکیب چندین استراتژیداده کافی است؟بیش‌نمونه‌گیری یا SMOTE، اجتناب ازکم‌نمونه‌گیریSMOTE + تنظیم آستانهکم‌نمونه‌گیری مناسب است، یا وزن‌دهی بهکلاس‌هاآموزش + ارزیابی با F1/AUPRCآیا Recall به اندازه کافی بالاست؟کاهش آستانهآیا Precision قابل‌قبول است؟افزایش آستانه یا افزودن ویژگی‌هاانتشار نهایی
عدم تعادل کلاس‌هانسبت نمونه‌های مثبت را بکشید
CLASS IMBALANCEWhen one class is rare, a classifier that always predicts the majority scores high accuracy while catching none of the cases you care about. The gold slice, the positives it never finds, is invisible to accuracy. This is why recall and F1 matter on imbalanced data.
وقتی یک کلاس نادر است، طبقه‌بندی‌کننده‌ای که همیشه کلاس اکثریت را پیش‌بینی می‌کند به دقت (accuracy) بالایی دست می‌یابد، در حالی که هیچ‌یک از مواردی را که برایتان مهم است شناسایی نمی‌کند. بخش طلایی، یعنی همان نمونه‌های مثبتی که هرگز پیدا نمی‌شوند، از دید معیار accuracy پنهان می‌ماند. به همین دلیل است که معیارهای recall و F1 در داده‌های نامتعادل اهمیت دارند.

پیاده‌سازی

گام 1: تولید یک مجموعه‌داده‌ی نامتوازن

import numpy as np


def make_imbalanced_data(n_majority=950, n_minority=50, seed=42):
    rng = np.random.RandomState(seed)

    X_maj = rng.randn(n_majority, 2) * 1.0 + np.array([0.0, 0.0])
    X_min = rng.randn(n_minority, 2) * 0.8 + np.array([2.5, 2.5])

    X = np.vstack([X_maj, X_min])
    y = np.concatenate([np.zeros(n_majority), np.ones(n_minority)])

    shuffle_idx = rng.permutation(len(y))
    return X[shuffle_idx], y[shuffle_idx]

گام 2: پیاده‌سازی SMOTE از صفر

def euclidean_distance(a, b):
    return np.sqrt(np.sum((a - b) ** 2))


def find_k_neighbors(X, idx, k):
    distances = []
    for i in range(len(X)):
        if i == idx:
            continue
        d = euclidean_distance(X[idx], X[i])
        distances.append((i, d))
    distances.sort(key=lambda x: x[1])
    return [d[0] for d in distances[:k]]


def smote(X_minority, k=5, n_synthetic=100, seed=42):
    rng = np.random.RandomState(seed)
    n_samples = len(X_minority)
    k = min(k, n_samples - 1)
    synthetic = []

    for _ in range(n_synthetic):
        idx = rng.randint(0, n_samples)
        neighbors = find_k_neighbors(X_minority, idx, k)
        neighbor_idx = neighbors[rng.randint(0, len(neighbors))]
        t = rng.random()
        new_point = X_minority[idx] + t * (X_minority[neighbor_idx] - X_minority[idx])
        synthetic.append(new_point)

    return np.array(synthetic)

گام 3: نمونه‌افزایی و نمونه‌کاهی تصادفی

def random_oversample(X, y, seed=42):
    rng = np.random.RandomState(seed)
    classes, counts = np.unique(y, return_counts=True)
    max_count = counts.max()

    X_resampled = list(X)
    y_resampled = list(y)

    for cls, count in zip(classes, counts):
        if count < max_count:
            cls_indices = np.where(y == cls)[0]
            n_needed = max_count - count
            chosen = rng.choice(cls_indices, size=n_needed, replace=True)
            X_resampled.extend(X[chosen])
            y_resampled.extend(y[chosen])

    X_out = np.array(X_resampled)
    y_out = np.array(y_resampled)
    shuffle = rng.permutation(len(y_out))
    return X_out[shuffle], y_out[shuffle]


def random_undersample(X, y, seed=42):
    rng = np.random.RandomState(seed)
    classes, counts = np.unique(y, return_counts=True)
    min_count = counts.min()

    X_resampled = []
    y_resampled = []

    for cls in classes:
        cls_indices = np.where(y == cls)[0]
        chosen = rng.choice(cls_indices, size=min_count, replace=False)
        X_resampled.extend(X[chosen])
        y_resampled.extend(y[chosen])

    X_out = np.array(X_resampled)
    y_out = np.array(y_resampled)
    shuffle = rng.permutation(len(y_out))
    return X_out[shuffle], y_out[shuffle]

گام 4: رگرسیون لجستیک با وزن کلاس‌ها

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))


def logistic_regression_weighted(X, y, weights, lr=0.01, epochs=200):
    n_samples, n_features = X.shape
    w = np.zeros(n_features)
    b = 0.0

    for _ in range(epochs):
        z = X @ w + b
        pred = sigmoid(z)
        error = pred - y
        weighted_error = error * weights

        gradient_w = (X.T @ weighted_error) / n_samples
        gradient_b = np.mean(weighted_error)

        w -= lr * gradient_w
        b -= lr * gradient_b

    return w, b


def compute_class_weights(y):
    classes, counts = np.unique(y, return_counts=True)
    n_samples = len(y)
    n_classes = len(classes)
    weight_map = {}
    for cls, count in zip(classes, counts):
        weight_map[cls] = n_samples / (n_classes * count)
    return np.array([weight_map[yi] for yi in y])

گام 5: تنظیم آستانه

def find_optimal_threshold(y_true, y_probs, metric="f1"):
    best_threshold = 0.5
    best_score = -1.0

    for threshold in np.arange(0.05, 0.96, 0.01):
        y_pred = (y_probs >= threshold).astype(int)
        tp = np.sum((y_pred == 1) & (y_true == 1))
        fp = np.sum((y_pred == 1) & (y_true == 0))
        fn = np.sum((y_pred == 0) & (y_true == 1))

        if metric == "f1":
            precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
            recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
            score = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
        elif metric == "recall":
            score = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        elif metric == "precision":
            score = tp / (tp + fp) if (tp + fp) > 0 else 0.0

        if score > best_score:
            best_score = score
            best_threshold = threshold

    return best_threshold, best_score

گام 6: توابع ارزیابی

def confusion_matrix_values(y_true, y_pred):
    tp = np.sum((y_pred == 1) & (y_true == 1))
    tn = np.sum((y_pred == 0) & (y_true == 0))
    fp = np.sum((y_pred == 1) & (y_true == 0))
    fn = np.sum((y_pred == 0) & (y_true == 1))
    return tp, tn, fp, fn


def compute_metrics(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix_values(y_true, y_pred)
    accuracy = (tp + tn) / (tp + tn + fp + fn)
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0

    denom = np.sqrt(float((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)))
    mcc = (tp * tn - fp * fn) / denom if denom > 0 else 0.0

    return {
        "accuracy": accuracy,
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "mcc": mcc,
    }

گام 7: مقایسه‌ی تمام روش‌ها

X, y = make_imbalanced_data(950, 50, seed=42)
split = int(0.8 * len(y))
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

# مدل پایه: بدون تغییر یا اعمال روش خاص
w_base, b_base = logistic_regression_weighted(
    X_train, y_train, np.ones(len(y_train)), lr=0.1, epochs=300
)
probs_base = sigmoid(X_test @ w_base + b_base)
preds_base = (probs_base >= 0.5).astype(int)

# Oversampled
X_over, y_over = random_oversample(X_train, y_train)
w_over, b_over = logistic_regression_weighted(
    X_over, y_over, np.ones(len(y_over)), lr=0.1, epochs=300
)
preds_over = (sigmoid(X_test @ w_over + b_over) >= 0.5).astype(int)

# SMOTE
minority_mask = y_train == 1
X_minority = X_train[minority_mask]
synthetic = smote(X_minority, k=5, n_synthetic=len(y_train) - 2 * int(minority_mask.sum()))
X_smote = np.vstack([X_train, synthetic])
y_smote = np.concatenate([y_train, np.ones(len(synthetic))])
w_sm, b_sm = logistic_regression_weighted(
    X_smote, y_smote, np.ones(len(y_smote)), lr=0.1, epochs=300
)
preds_smote = (sigmoid(X_test @ w_sm + b_sm) >= 0.5).astype(int)

# وزن‌دهی به کلاس‌ها (Class weights)
sample_weights = compute_class_weights(y_train)
w_cw, b_cw = logistic_regression_weighted(
    X_train, y_train, sample_weights, lr=0.1, epochs=300
)
probs_cw = sigmoid(X_test @ w_cw + b_cw)
preds_cw = (probs_cw >= 0.5).astype(int)

# تنظیم آستانه (روی بخشی از داده‌های اعتبارسنجی از مجموعه‌ی آموزش تنظیم شود، نه مجموعه‌ی آزمون)
val_split = int(0.75 * len(y_train))
X_tr, X_val = X_train[:val_split], X_train[val_split:]
y_tr, y_val = y_train[:val_split], y_train[val_split:]
w_val, b_val = logistic_regression_weighted(
    X_tr, y_tr, compute_class_weights(y_tr), lr=0.1, epochs=300
)
probs_val = sigmoid(X_val @ w_val + b_val)
best_thresh, best_f1 = find_optimal_threshold(y_val, probs_val, metric="f1")
preds_thresh = (probs_cw >= best_thresh).astype(int)

فایل کد، تمام این مراحل را در یک اسکریپت واحد اجرا کرده و نتایج را چاپ می‌کند.

استفاده در عمل

با scikit-learn و imbalanced-learn، اجرای این تکنیک‌ها در چند خط خلاصه می‌شود:

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, f1_score
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline

X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y)

model_weighted = LogisticRegression(class_weight="balanced")
model_weighted.fit(X_train, y_train)
print(classification_report(y_test, model_weighted.predict(X_test)))

smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
model_smote = LogisticRegression()
model_smote.fit(X_resampled, y_resampled)
print(classification_report(y_test, model_smote.predict(X_test)))

pipeline = Pipeline([
    ("smote", SMOTE()),
    ("model", LogisticRegression(class_weight="balanced")),
])
pipeline.fit(X_train, y_train)
print(classification_report(y_test, pipeline.predict(X_test)))

پیاده‌سازی از صفر نشان داد هر تکنیک دقیقاً چه کار می‌کند: SMOTE صرفاً یک درون‌یابی k-NN روی کلاس اقلیت است. وزن‌دهی به کلاس‌ها یعنی ضرب کردن یک ضریب در تابع زیان (loss). تنظیم آستانه هم یک حلقه‌ی for ساده روی مقادیر مرزی است؛ هیچ جادویی در کار نیست.

تحویل پروژه

از این درس خروجی زیر به دست می‌آید:

  • outputs/skill-imbalanced-data.md -- یک چک‌لیست تصمیم‌گیری برای مواجهه با مسائل دسته‌بندی نامتوازن

تمرین‌ها

  1. الگوریتم Borderline-SMOTE: پیاده‌سازی SMOTE را به شکلی تغییر دهید که داده‌های ساختگی فقط برای نمونه‌هایی از کلاس اقلیت تولید شوند که نزدیک به مرز تصمیم‌گیری هستند (نمونه‌هایی که k همسایه‌ی نزدیک آن‌ها شامل نمونه‌هایی از کلاس اکثریت نیز هست). روی مجموعه‌داده‌ای با کلاس‌های هم‌پوشان، نتایج را با SMOTE استاندارد مقایسه کنید.

  2. بهینه‌سازی ماتریس هزینه: یادگیری حساس به هزینه را طوری پیاده‌سازی کنید که ماتریس هزینه یک پارامتر باشد. تابعی بنویسید که یک ماتریس هزینه بگیرد و پیش‌بینی‌های بهینه‌ای برگرداند که هزینه‌ی مورد انتظار را به حداقل برسانند. مدل را با نسبت‌های مختلف هزینه (1:10، 1:100، 1:1000) آزمایش کنید و نمودار تغییرات توازن بین دقت و پوشش را رسم کنید.

  3. کالیبراسیون آستانه: روش Platt scaling را پیاده‌سازی کنید (یک رگرسیون لجستیک روی خروجی‌های خام مدل برازش دهید تا احتمالات کالیبره‌شده به دست آیند). منحنی دقت-پوشش را قبل و بعد از کالیبراسیون مقایسه کنید. نشان دهید کالیبراسیون رتبه‌بندی را تغییر نمی‌دهد (AUC ثابت می‌ماند) اما مقادیر احتمالات را معنادارتر می‌کند.

  4. مدل‌های تجمیعی با بگینگ متوازن (Balanced Bagging): چندین مدل آموزش دهید که هر کدام روی یک نمونه‌ی بوت‌استرپ متوازن (تمام داده‌های اقلیت + زیرمجموعه‌ای تصادفی از اکثریت) آموزش دیده‌اند. میانگین پیش‌بینی آن‌ها را بگیرید. این رویکرد را با یک مدل تکی مجهز به SMOTE مقایسه کنید. هم کارایی و هم واریانس مدل‌ها را در چند بار اجرا بسنجید.

  5. آزمایش نسبت عدم‌توازن: یک مجموعه‌داده‌ی متوازن را بردارید و نسبت عدم‌توازن را مرحله به مرحله افزایش دهید (50/50، 70/30، 90/10، 95/5، 99/1). در هر نسبت، مدل را با و بدون SMOTE آموزش دهید. نمودار تغییرات F1 بر حسب نسبت عدم‌توازن را برای هر دو حالت رسم کنید. در چه نسبتی SMOTE تأثیر چشمگیر خود را نشان می‌دهد؟

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

اصطلاح در گفتار عامیانه معنای دقیق فنی
عدم‌توازن کلاس‌ها (Class imbalance) «یکی از کلاس‌ها نمونه‌های خیلی بیشتری دارد» چولگی شدید توزیع کلاس‌ها در مجموعه‌داده، به‌طوری که مدل به نفع کلاس اکثریت سوگیری پیدا می‌کند
الگوریتم SMOTE «نمونه‌افزایی ساختگی» ساخت نمونه‌های جدید برای کلاس اقلیت از طریق درون‌یابی بین نمونه‌های اقلیت موجود و k همسایه‌ی اقلیت نزدیک به آن‌ها
وزن‌دهی به کلاس‌ها (Class weights) «گران‌تر کردن خطا روی کلاس‌های نادر» ضرب کردن وزن‌های خاص هر کلاس در تابع زیان (loss) تا مدل جریمه‌ی سنگین‌تری برای خطای دسته‌بندی کلاس اقلیت در نظر بگیرد
تنظیم آستانه (Threshold tuning) «جابه‌جا کردن مرز تصمیم‌گیری» تغییر حد آستانه‌ی احتمال دسته‌بندی از مقدار پیش‌فرض 0.5 به مقداری که معیار مورد نظر را بهینه کند
توازن دقت و پوشش (Precision-recall tradeoff) «نمی‌شود هر دو را با هم داشت» پایین آوردن آستانه باعث کشف مثبت‌های بیشتر می‌شود (پوشش بالاتر)، اما هشدارهای اشتباه بیشتری هم تولید می‌کند (دقت پایین‌تر) و برعکس
سطح زیر منحنی دقت-پوشش (AUPRC) «سطح زیر منحنی PR» خلاصه‌سازی منحنی دقت-پوشش در قالب یک عدد؛ برای داده‌های با عدم‌توازن شدید بسیار آموزنده‌تر از AUC-ROC است
ضریب همبستگی متیوز (Matthews Correlation Coefficient) «معیار متوازن» ضریب همبستگی بین برچسب‌های پیش‌بینی‌شده و واقعی که فقط در صورت عملکرد مناسب مدل روی هر دو کلاس امتیاز بالایی می‌دهد
یادگیری حساس به هزینه (Cost-sensitive learning) «هزینه‌ی اشتباهات مختلف یکسان نیست» وارد کردن هزینه‌های واقعی اشتباهات دسته‌بندی در هدف آموزش، تا مدل به‌جای تعداد خطاها، کل هزینه را بهینه‌سازی کند
نمونه‌افزایی تصادفی (Random oversampling) «کپی کردن نمونه‌های اقلیت» تکرار نمونه‌های کلاس اقلیت برای برابر شدن تعداد کلاس‌ها؛ روشی ساده اما همراه با ریسک بیش‌برازش روی نمونه‌های تکراری

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

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

چرا معیار دقت (Accuracy) برای داده‌های نامتوازن گمراه‌کننده است؟

چون دقت برای همه‌ی پیش‌بینی‌ها وزن یکسان قائل است. مدلی که همیشه کلاس اکثریت را پیش‌بینی کند، دقت کلی بالایی نشان می‌دهد اما تمام نمونه‌های کلاس نادر و حیاتی را از دست می‌دهد.

الگوریتم SMOTE چه تفاوتی با نمونه‌افزایی تصادفی (Random Oversampling) دارد؟

نمونه‌افزایی تصادفی داده‌های کلاس اقلیت را عیناً کپی می‌کند و باعث بیش‌برازش می‌شود، اما SMOTE با درون‌یابی بین نمونه‌های اقلیت و k همسایه‌ی نزدیک آن‌ها، نقاط ساختگی و باورپذیر جدید می‌سازد.

تنظیم آستانه (Threshold Tuning) چیست و چه کاربردی دارد؟

به‌جای استفاده از حد مرزی پیش‌فرض 0.5 برای خروجی احتمال مدل، مقادیر مختلف آستانه روی داده‌های اعتبارسنجی بررسی می‌شوند تا عددی که بهترین F1 یا معیار مورد نظر را می‌دهد انتخاب شود.

بین وزن‌دهی به کلاس‌ها و SMOTE کدام گزینه بهتر است؟

وزن‌دهی به کلاس‌ها با تغییر تابع زیان عمل می‌کند، نیاز به تولید داده‌ی اضافه ندارد و سریع‌تر است؛ اما SMOTE زمانی که تعداد نمونه‌های اقلیت کم است و فضای ویژگی‌ها نیاز به بازسازی دارد، گزینه‌ی بهتری به شمار می‌رود.

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

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