شاید برای شما هم پیش آمده باشد که بعد از چند ساعت آموزش شبکه عصبی، ناگهان مقدار زیان به NaN تبدیل شود یا خروجی لایهها بدون هیچ پیام خطای روشنی روی inf قفل کند. در نگاه اول ممکن است تصور کنید معماری یا نرخ یادگیری ایراد دارد، اما در بسیاری از پروژهها مقصر اصلی ساختار محاسبات ممیز شناور (floating point) در سختافزار است.
درک رفتار اعداد ممیز شناور طبق استاندارد IEEE 754 و خطاهایی مانند زیرریز، سرریز و حذف فاجعهبار (catastrophic cancellation)، تفاوت میان یک اجرای پایدار و روزها اتلاف وقت برای خطایابی است. وقتی بدانید توابعی مثل Softmax یا Cross-Entropy در عمل چطور پیاده میشوند، جلوی خطاهای خاموشی را که به افت نامحسوس دقت مدل منجر میشوند، خواهید گرفت.
علاوه بر این، با رایج شدن آموزش با دقت ترکیبی (Mixed Precision) و استفاده از فرمتهایی مثل float16 و bfloat16 روی پردازندههای گرافیکی جدید، شناخت محدودیتهای بازه و دقت عددی به یک مهارت پایه برای هر مهندس یادگیری ماشین تبدیل شده است. در این درس یاد میگیرید که چگونه با چند ترفند استاندارد و بررسی تحلیلی گرادیان، پایداری عددی مدلهای خود را تضمین کنید.
ممیز شناور (Floating point) یک انتزاع رخنهپذیر است. درست وسط آموزش غافلگیرتان میکند و ضربهاش را میزند.
اهداف یادگیری
- پیادهسازی سافتمکس (softmax) و log-sum-exp پایدار از نظر عددی با ترفند تفریق بیشینه
- شناسایی سرریز (overflow)، زیرریز (underflow) و حذف فاجعهبار (catastrophic cancellation) در محاسبات ممیز شناور
- اعتبارسنجی گرادیانهای تحلیلی در برابر گرادیانهای عددی با تفاضل متناهی مرکزی
- توضیح علت برتری bfloat16 نسبت به float16 در آموزش و چگونگی پیشگیری از زیرریز گرادیان با مقیاسبندی تابع زیان (loss scaling)
صورت مسئله
مدلتان سه ساعت آموزش میبیند و ناگهان زیان NaN میشود. یک دستور print اضافه میکنید. در گام 9,000 لاجیتها (logits) مشکلی ندارند. در گام 9,001 تبدیل به inf میشوند. تا گام 9,002 تکتک گرادیانها nan شدهاند و فرایند آموزش میمیرد.
یا حالتی دیگر: آموزش مدل تا انتها پیش میرود اما دقت آن 2% کمتر از ادعای مقاله است. همهچیز را بررسی میکنید. معماری مطابقت دارد. هایپرپارامترها یکی هستند. دادهها هم یکی هستند. مشکل اینجاست: نویسندگان مقاله از float32 استفاده کردهاند و شما از float16 بدون مقیاسبندی مناسب. خطای گردکردن انباشته در 32 بیت، آرام و بیصدا دقت مدل را بلعیده است.
یا مثلاً تابع زیان انتروپی متقاطع (cross-entropy loss) را از صفر پیاده میکنید. روی لاجیتهای کوچک کار میکند، اما همین که لاجیتها از 100 بیشتر میشوند، inf برمیگرداند. سافتمکس دچار سرریز شده چون مقدار exp(100) از بازهی قابلنمایش در float32 فراتر میرود. همهی فریمورکهای یادگیری ماشین این مسئله را با ترفندی دوخطی حل میکنند؛ ترفندی که حتی از وجودش خبر نداشتید.
پایداری عددی بحثی صرفاً نظری نیست؛ مرز میان اجرایی موفق و شکستی خاموش در آموزش مدل است. هر باگ جدی و عمیقی که در یادگیری ماشین دیباگ کنید، در نهایت به ممیز شناور میرسد.
مفاهیم پایه
استاندارد IEEE 754: کامپیوترها چطور اعداد حقیقی را ذخیره میکنند؟
کامپیوترها اعداد حقیقی را در قالب مقادیر ممیز شناور و طبق استاندارد IEEE 754 ذخیره میکنند. هر عدد ممیز شناور سه بخش دارد: بیت علامت (sign bit)، توان (exponent) و مانتیس یا بخش اعشاری (mantissa/significand).
Float32 layout (32 bits total):
[1 sign] [8 exponent] [23 mantissa]
Value = (-1)^sign * 2^(exponent - 127) * 1.mantissa
مانتیس دقت عدد را مشخص میکند (تعداد ارقام بامعنا). توان بازهی عددی را معین میکند (اینکه عدد چقدر میتواند بزرگ یا کوچک باشد).
Format Bits Exponent Mantissa Decimal digits Range (approx)
float64 64 11 52 ~15-16 +/- 1.8e308
float32 32 8 23 ~7-8 +/- 3.4e38
float16 16 5 10 ~3-4 +/- 65,504
bfloat16 16 8 7 ~2-3 +/- 3.4e38
فرمت float32 حدود 7 رقم اعشاری بامعنا در اختیارتان میگذارد؛ یعنی تفاوت 1.0000001 و 1.0000002 را تشخیص میدهد، اما قادر به تفکیک 1.00000001 از 1.00000002 نیست. بعد از 7 رقم، هر چه میبینید نویز گردکردن است.
فرمت float16 حدود 3 رقم بامعنا در اختیارتان میگذارد. بزرگترین عددی که میتواند نمایش دهد 65,504 است. برای یادگیری ماشین، جایی که لاجیتها، گرادیانها و اکتیویشنها مدام از این عدد بالاتر میروند، این سقف بهشدت پایین و نگرانکننده است.
فرمت bfloat16 پاسخ گوگل به مشکل محدودیت بازهی float16 است. این فرمت مثل float32 توان 8 بیتی دارد (همان بازه، تا 3.4e38)، اما مانتیس آن تنها 7 بیت است (دقت کمتر نسبت به float16). در آموزش شبکههای عصبی (neural networks)، بازه اهمیت بسیار بیشتری نسبت به دقت دارد؛ به همین خاطر معمولاً bfloat16 برنده میشود.
چرا 0.1 + 0.2 != 0.3 است؟
عدد 0.1 را نمیتوان دقیقاً در قالب دودوییِ ممیز شناور نمایش داد. این عدد در مبنای 2 کسر متناوب است:
0.1 in binary = 0.0001100110011001100110011... (repeating forever)
فرمت float32 این دنباله را در 23 بیت مانتیس کوتاه میکند. مقدار ذخیرهشده تقریباً 0.100000001490116 خواهد بود. بهطور مشابه، 0.2 بهصورت تقریبی 0.200000002980232 ذخیره میشود. حاصلجمع آنها 0.300000004470348 است، نه 0.3.
In Python:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
این مسئله به سه دلیل در یادگیری ماشین اهمیت دارد:
- مقایسههای زیان مانند
if loss < thresholdممکن است پاسخهای غلطی بدهند. - انباشت مقادیر خرد فراوان (بهروزرسانیهای گرادیان در طول هزاران گام) از جمع واقعی فاصله میگیرد.
- چکسامها (checksums) و آزمونهای بازتولیدپذیری (reproducibility) در صورت مقایسهی مقادیر اعشاری با
==شکست میخورند.
راهحل: هرگز اعداد ممیز شناور را با == مقایسه نکنید. از abs(a - b) < epsilon یا math.isclose() استفاده کنید.
حذف فاجعهبار
وقتی دو عدد اعشاری تقریباً برابر را از یکدیگر کم میکنید، ارقام بامعنا یکدیگر را خنثی میکنند و نویز ناشی از گردکردن جای ارقام اصلی را میگیرد.
a = 1.0000001 (stored as 1.00000011920929 in float32)
b = 1.0000000 (stored as 1.00000000000000 in float32)
True difference: 0.0000001
Computed: 0.00000011920929
Relative error: 19.2%
همین یک تفریق ساده 19% خطای نسبی ایجاد کرد. در یادگیری ماشین، این اتفاق در چنین موقعیتهایی رخ میدهد:
- محاسبهی واریانس روی دادههایی با میانگین بزرگ: زمانی که بزرگ باشد
- تفریق لگاریتم احتمالهای تقریباً برابر
- محاسبهی گرادیان به روش تفاضل متناهی با اپسیلون بیش از حد کوچک
راهحل: فرمولها را طوری بازآرایی کنید که مجبور به تفریق اعداد بزرگ و تقریباً برابر نباشید. برای واریانس، از الگوریتم ولفورد (Welford algorithm) استفاده کنید یا ابتدا دادهها را میانگینزدایی (center) کنید. برای لگاریتم احتمالات هم محاسبات را تماماً در فضای لگاریتمی انجام دهید.
سرریز و زیرریز
سرریز زمانی رخ میدهد که حاصل محاسبه بزرگتر از آن باشد که بتوان ذخیرهاش کرد. زیرریز زمانی اتفاق میافتد که نتیجه بیش از حد کوچک شود (نزدیکتر به صفر از کوچکترین عدد مثبت قابلنمایش).
Float32 boundaries:
Maximum: 3.4028235e+38
Minimum positive (normal): 1.175e-38
Minimum positive (denorm): 1.401e-45
Overflow: anything > 3.4e38 becomes inf
Underflow: anything < 1.4e-45 becomes 0.0
تابع exp() منبع اصلی سرریز در یادگیری ماشین است:
exp(88.7) = 3.40e+38 (barely fits in float32)
exp(89.0) = inf (overflow)
exp(-87.3) = 1.18e-38 (barely above underflow)
exp(-104) = 0.0 (underflow to zero)
تابع log() در جهت معکوس دچار مشکل میشود:
log(0.0) = -inf
log(-1.0) = nan
log(1e-45) = -103.3 (fine)
log(1e-46) = -inf (input underflowed to 0, then log(0) = -inf)
در یادگیری ماشین، exp() در سافتمکس، سیگموئید و محاسبات احتمالات ظاهر میشود. تابع log() نیز در انتروپی متقاطع، درستنمایی لگاریتمی (log-likelihoods) و واگرایی KL به کار میرود. ترکیب log(exp(x)) بدون ترفندهای مناسب دقیقاً مثل یک میدان مین است.
ترفند Log-Sum-Exp
محاسبهی مستقیم log(sum(exp(x_i))) از نظر عددی بسیار خطرناک است. اگر حتی یکی از x_iها بزرگ باشد، exp(x_i) سرریز میکند. اگر همهی x_iها مقادیری شدیداً منفی باشند، تکتک exp(x_i)ها دچار زیرریز شده و به صفر میرسند؛ در نتیجه log(0) برابر با -inf خواهد شد.
ترفند ماجرا: قبل از به توان رساندن، مقدار بیشینه را کم کنید.
چرا این روش جواب میدهد؟ چون پس از کم کردن max(x)، بزرگترین مقدار توان exp(0) = 1 خواهد بود و سرریز عملاً غیرممکن میشود. از طرفی حداقل یکی از جملات مجموع 1 است، پس کل جمع حداقل 1 خواهد بود و log(1) = 0 میشود؛ بنابراین زیرریز به سمت -inf هم غیرممکن است.
اثبات:
مقدار c = max(x) را قرار دهید تا خطر سرریز کاملاً برطرف شود.
این ترفند در همهجای یادگیری ماشین دیده میشود:
- نرمالسازی سافتمکس
- محاسبهی تابع زیان انتروپی متقاطع
- جمعبندی لگاریتم احتمالات در مدلهای توالی
- مدلهای آمیختهی گوسی (Mixture of Gaussians)
- استنتاج واریانسی (Variational inference)
چرا سافتمکس به ترفند تفریق بیشینه نیاز دارد؟
سافتمکس لاجیتها را به احتمال تبدیل میکند:
بدون این ترفند، لاجیتهای [100, 101, 102] سرریز ایجاد میکنند:
exp(100) = 2.69e43
exp(101) = 7.31e43
exp(102) = 1.99e44
sum = 2.99e44
2.69e43 > 3.4e38, the float32 maximum, so every term overflows.
exp(88.7) is already at the float32 limit.
exp(100) = inf in float32.
با این ترفند، مقدار max(x) = 102 را کم میکنیم:
exp(100 - 102) = exp(-2) = 0.135
exp(101 - 102) = exp(-1) = 0.368
exp(102 - 102) = exp(0) = 1.000
sum = 1.503
softmax = [0.090, 0.245, 0.665]
احتمالات حاصل کاملاً یکسان هستند و محاسبه هم ایمن انجام میشود. این یک بهینهسازی دلخواه نیست، پیششرطی برای صحت محاسبات است.
NaN و Inf: شناسایی و پیشگیری
مقادیر nan (نامشخص یا Not a Number) و inf (بینهایت) مثل ویروس در کل محاسبات تکثیر میشوند. یک nan در آپدیت گرادیان، وزن را nan میکند و در نتیجه تمام خروجیهای بعدی nan میشوند. آموزش ظرف تنها یک گام از دست میرود.
چطور inf ایجاد میشود:
- گرفتن
exp()از یک عدد مثبت بزرگ - تقسیم بر صفر:
1.0 / 0.0 - سرریز
float32در انباشت محاسبات
چطور nan ایجاد میشود:
0.0 / 0.0inf - infinf * 0- گرفتن
sqrt()از یک عدد منفی - گرفتن
log()از یک عدد منفی - هر عملیات ریاضی با مقداری که از قبل
nanبوده است
شناسایی:
import math
math.isnan(x) # True if x is nan
math.isinf(x) # True if x is +inf or -inf
math.isfinite(x) # True if x is neither nan nor inf
راهبردهای پیشگیری:
- ورودیهای
exp()را کلمپ (محدود) کنید:exp(clamp(x, -80, 80)) - به مخرجها اپسیلون اضافه کنید:
x / (y + 1e-8) - داخل
log()اپسیلون اضافه کنید:log(x + 1e-8) - از پیادهسازیهای پایدار بهره بگیرید (مثل log-sum-exp و سافتمکس پایدار)
- از برش گرادیان (gradient clipping) برای جلوگیری از انفجار وزنها استفاده کنید
- هنگام دیباگ، بعد از هر گذر رفت (forward pass) حضور
nan/infرا بررسی کنید
بررسی عددی گرادیان
گرادیانهای تحلیلی (حاصل از پسانتشار) ممکن است باگ داشته باشند. بررسی عددی گرادیان (numerical gradient checking) این مقادیر را با محاسبهی گرادیانها از طریق تفاضلهای متناهی ارزیابی و تأیید میکند.
فرمول تفاضل مرکزی:
دقت این فرمول از مرتبهی است که عملکرد بسیار بهتری نسبت به تفاضل پیشرو با دقت دارد.
انتخاب : اگر مقدارش بیش از حد بزرگ باشد، تقریب غلط از آب درمیآید؛ اگر بیش از حد کوچک باشد، حذف فاجعهبار پاسخ را خراب میکند. بازهی معمولی بین h = 1e-5 تا 1e-7 است.
بررسی خطا: خطای نسبی بین گرادیان تحلیلی و عددی را حساب کنید.
قواعد سرانگشتی:
- خطای نسبی کمتر از 1e-7: بینقص، گرادیان درست است
- خطای نسبی کمتر از 1e-5: قابل قبول، به احتمال زیاد درست است
- خطای نسبی بیشتر از 1e-3: مشکلی وجود دارد
- خطای نسبی بیشتر از 1: گرادیان کاملاً غلط است
هر بار که لایه یا تابع زیان جدیدی پیاده میکنید، همیشه گرادیانها را بررسی کنید. کتابخانهی PyTorch ابزار torch.autograd.gradcheck() را برای همین کار در اختیارتان میگذارد.
آموزش با دقت ترکیبی
پردازندههای گرافیکی مدرن سختافزار اختصاصی دارند (هستههای تنسور یا Tensor Cores) که ضرب ماتریسی در فرمت float16 را 2 تا 8 برابر سریعتر از float32 انجام میدهند. آموزش با دقت ترکیبی (mixed precision training) از همین قابلیت بهره میبرد:
1. Maintain float32 master copy of weights
2. Forward pass in float16 (fast)
3. Compute loss in float32 (prevents overflow)
4. Backward pass in float16 (fast)
5. Scale gradients to float32
6. Update float32 master weights
مشکل آموزش تماماً مبتنی بر float16 این است که گرادیانها معمولاً بسیار کوچک هستند (1e-8 یا کمتر). فرمت float16 مقادیر کمتر از حدود 6e-8 را دچار زیرریز کرده و صفر میکند. مدل دیگر چیزی یاد نمیگیرد چون تمام آپدیتهای گرادیان صفر شدهاند.
راهحل، مقیاسبندی تابع زیان است:
1. Multiply loss by a large scale factor (e.g., 1024)
2. Backward pass computes gradients of (loss * 1024)
3. All gradients are 1024x larger (pushed above float16 underflow)
4. Divide gradients by 1024 before updating weights
5. Net effect: same update, but no underflow
مقیاسبندی پویای زیان ضریب مقیاس را بهطور خودکار تنظیم میکند. کار را با مقداری بزرگ (65536) شروع کنید. اگر گرادیانها دچار سرریز شده و به inf تبدیل شوند، مقدار ضریب را نصف کنید. اگر گام بدون هیچ سرریزی سپری شد، ضریب را دو برابر کنید.
مقایسهی bfloat16 با float16: چرا bfloat16 در آموزش برنده میشود؟
float16: [1 sign] [5 exponent] [10 mantissa]
bfloat16: [1 sign] [8 exponent] [7 mantissa]
فرمت float16 دقت بیشتری دارد (10 بیت مانتیس در برابر 7 بیت)، اما بازهاش محدود است (حداکثر حدود 65,504). در مقابل، bfloat16 دقت کمتری دارد اما بازهاش دقیقاً معادل float32 است (حداکثر حدود 3.4e38).
در فاز آموزش شبکههای عصبی:
- اکتیویشنها و لاجیتها هنگام جهشهای آموزش مدام از 65,504 فراتر میروند. فرمت float16 سرریز میکند، درحالیکه bfloat16 آن را مدیریت میکند.
- استفاده از مقیاسبندی تابع زیان برای float16 الزامی است، اما در bfloat16 معمولاً به آن نیازی نیست؛ چون بازهی آن دامنهی بزرگی گرادیانها را پوشش میدهد.
- فرمت bfloat16 یک کوتاهسازی ساده از float32 است: 16 بیت پایینی مانتیس حذف میشود. تبدیل به آن ساده است و در بخش توان هیچ دادهای هدر نمیرود.
فرمت float16 برای استنتاج (inference) ترجیح داده میشود که در آن مقادیر کراندار هستند و دقت اهمیت بیشتری دارد. فرمت bfloat16 هم گزینهی اول آموزش است چون بازهی مقادیر اولویت دارد. به همین دلیل TPUها و پردازندههای مدرن NVIDIA (مانند A100 و H100) از bfloat16 پشتیبانی سختافزاریِ نیتیو دارند.
برش گرادیان
انفجار گرادیان زمانی پیش میآید که گرادیانها حین عبور از لایههای پرتعداد رشدی توانی پیدا کنند (مشکلی متداول در RNNها، شبکههای عمیق و مدلهای ترنسفورمر). یک گرادیان بزرگ بهتنهایی میتواند در یک گام تمام وزنها را به باد دهد.
دو روش برای برش گرادیان وجود دارد:
برش بر اساس مقدار (Clip by value): محدود کردن مستقل هر یک از مؤلفههای گرادیان.
grad = clamp(grad, -max_val, max_val)
ساده است، اما ممکن است جهت بردار گرادیان را تغییر دهد.
برش بر اساس نُرم (Clip by norm): تغییر مقیاس کل بردار گرادیان طوری که نُرم آن از آستانهای مشخص بیشتر نشود.
if ||grad|| > max_norm:
grad = grad * (max_norm / ||grad||)
جهت بردار گرادیان را حفظ میکند. تابع torch.nn.utils.clip_grad_norm_() از همین روش استفاده میکند و رویکرد استاندارد محسوب میشود.
مقادیر رایج: max_norm=1.0 برای ترنسفورمرها، max_norm=0.5 برای یادگیری تقویتی (RL) و max_norm=5.0 برای شبکههای سادهتر.
برش گرادیان یک وصلهپینهی موقت نیست؛ یک سازوکار ایمنی ضروری است. بدون آن، حتی یک بچ دادهی پرت میتواند چنان گرادیان مهیبی بسازد که حاصل چندین هفته آموزش را بسوزاند.
لایههای نرمالسازی به مثابه تثبیتکنندههای عددی
نرمالسازی دستهای (Batch normalization)، نرمالسازی لایهای (Layer normalization) و RMS normalization را معمولاً به چشم روشهای تنظیمکننده (regularizers) برای همگرایی بهتر معرفی میکنند؛ درحالیکه نقش مهم دیگر آنها، تثبیتکنندگی عددی است.
بدون نرمالسازی، اکتیویشنها ممکن است در طول لایهها رشدی سرطانی یا افتی توانی داشته باشند:
Layer 1: values in [0, 1]
Layer 5: values in [0, 100]
Layer 10: values in [0, 10,000]
Layer 50: values in [0, inf]
نرمالسازی در هر لایه، اکتیویشنها را مجدداً میانگینزدایی کرده و مقیاس آنها را تنظیم میکند:
مقدار epsilon (معمولاً 1e-5) مانع از تقسیم بر صفر در زمان برابری اکتیویشنها میشود. پارامترهای یادگیریپذیر gamma و beta هم به شبکه اجازه میدهند هر مقیاسی را که لازم دارد دوباره بازسازی کند.
این کار مقادیر را در سراسر شبکه درون بازهای ایمن از نظر عددی نگه میدارد؛ در نتیجه نه در گذر رفت دچار سرریز میشوید و نه در گذر برگشت با انفجار گرادیان دستبهگریبان خواهید شد.
باگهای عددی رایج در یادگیری ماشین
باگ: مقدار زیان بعد از چند دوره (epoch) برابر با NaN میشود. علت: لاجیتها بیش از اندازه بزرگ شدهاند و سافتمکس سرریز کرده است؛ یا اینکه نرخ یادگیری بالا بوده و وزنها واگرا شدهاند. راهحل: استفاده از سافتمکس پایدار (تفریق بیشینه)، کاهش نرخ یادگیری و افزودن برش گرادیان.
باگ: مقدار زیان روی log(num_classes) قفل میکند. علت: خروجیهای مدل توزیع احتمالاتی تقریباً یکنواخت دارند. معمولاً نشانهی محو شدن گرادیانهاست یا اینکه مدل اصلاً چیزی یاد نمیگیرد. راهحل: بررسی درستی برچسب دادهها، چک کردن تابع زیان و بررسی وجود ReLUهای مرده.
باگ: دقت اعتبارسنجی 1 تا 3 درصد کمتر از حد انتظار است. علت: استفاده از دقت ترکیبی بدون مقیاسبندی درست زیان؛ زیرریز گرادیان بیصدا آپدیتهای کوچک را صفر میکند. راهحل: فعالسازی مقیاسبندی پویای زیان یا سوئیچ کردن به bfloat16.
باگ: نُرم گرادیان در بعضی لایهها برابر با 0.0 است. علت: خاموش شدن نورونهای ReLU (تمام ورودیها منفی شدهاند) یا زیرریز در float16. راهحل: استفاده از LeakyReLU یا GELU، اعمال مقیاسبندی گرادیان و بازبینی مقداردهی اولیهی وزنها.
باگ: مدل روی یک GPU کار میکند اما روی دیگری نتایج متفاوتی میدهد.
علت: تفاوت ترتیب در جمع ممیز شناور؛ عملیات کاهش موازی (parallel reduction) روی سختافزارهای مختلف ترتیب متفاوتی در جمع زدن دارد و جمع اعداد اعشاری هم خاصیت شرکتپذیری ندارد.
راهحل: تفاوتهای کوچک (در حد 1e-6) را بپذیرید، یا torch.use_deterministic_algorithms(True) را فعال کنید و کاهش سرعت را به جان بخرید.
باگ: تابع exp() در محاسبهی زیان مقدار inf برمیگرداند.
علت: پاس دادن لاجیتهای خام به exp() بدون اجرای ترفند تفریق بیشینه.
راهحل: استفاده از torch.nn.functional.log_softmax() که سازوکار log-sum-exp را در دل خود پیادهسازی کرده است.
باگ: آموزش بعد از تغییر از float32 به float16 واگرا میشود. علت: فرمت float16 نمیتواند مقادیر گرادیان کمتر از 6e-8 یا اکتیویشنهای بالاتر از 65,504 را نمایش دهد. راهحل: استفاده از دقت ترکیبی به همراه مقیاسبندی زیان (AMP)، یا جابهجایی به فرمت bfloat16.
پیادهسازی
گام 1: نمایش محدودیتهای دقت در ممیز شناور
print("=== Floating Point Precision ===")
print(f"0.1 + 0.2 = {0.1 + 0.2}")
print(f"0.1 + 0.2 == 0.3? {0.1 + 0.2 == 0.3}")
print(f"Difference: {(0.1 + 0.2) - 0.3:.2e}")
گام 2: پیادهسازی سافتمکس ساده در برابر پایدار
import math
def softmax_naive(logits):
exps = [math.exp(z) for z in logits]
total = sum(exps)
return [e / total for e in exps]
def softmax_stable(logits):
max_logit = max(logits)
exps = [math.exp(z - max_logit) for z in logits]
total = sum(exps)
return [e / total for e in exps]
safe_logits = [2.0, 1.0, 0.1]
print(f"Naive: {softmax_naive(safe_logits)}")
print(f"Stable: {softmax_stable(safe_logits)}")
dangerous_logits = [100.0, 101.0, 102.0]
print(f"Stable: {softmax_stable(dangerous_logits)}")
# softmax_naive(dangerous_logits) would return [nan, nan, nan]
گام 3: پیادهسازی log-sum-exp پایدار
def logsumexp_naive(values):
return math.log(sum(math.exp(v) for v in values))
def logsumexp_stable(values):
c = max(values)
return c + math.log(sum(math.exp(v - c) for v in values))
safe = [1.0, 2.0, 3.0]
print(f"Naive: {logsumexp_naive(safe):.6f}")
print(f"Stable: {logsumexp_stable(safe):.6f}")
large = [500.0, 501.0, 502.0]
print(f"Stable: {logsumexp_stable(large):.6f}")
# logsumexp_naive(large) returns inf
گام 4: پیادهسازی انتروپی متقاطع پایدار
def cross_entropy_naive(true_class, logits):
probs = softmax_naive(logits)
return -math.log(probs[true_class])
def cross_entropy_stable(true_class, logits):
max_logit = max(logits)
shifted = [z - max_logit for z in logits]
log_sum_exp = math.log(sum(math.exp(s) for s in shifted))
log_prob = shifted[true_class] - log_sum_exp
return -log_prob
logits = [2.0, 5.0, 1.0]
true_class = 1
print(f"Naive: {cross_entropy_naive(true_class, logits):.6f}")
print(f"Stable: {cross_entropy_stable(true_class, logits):.6f}")
گام 5: بررسی عددی گرادیان
def numerical_gradient(f, x, h=1e-5):
grad = []
for i in range(len(x)):
x_plus = x[:]
x_minus = x[:]
x_plus[i] += h
x_minus[i] -= h
grad.append((f(x_plus) - f(x_minus)) / (2 * h))
return grad
def check_gradient(analytical, numerical, tolerance=1e-5):
for i, (a, n) in enumerate(zip(analytical, numerical)):
denom = max(abs(a), abs(n), 1e-8)
rel_error = abs(a - n) / denom
status = "OK" if rel_error < tolerance else "FAIL"
print(f" param {i}: analytical={a:.8f} numerical={n:.8f} "
f"rel_error={rel_error:.2e} [{status}]")
def f(params):
x, y = params
return x**2 + 3*x*y + y**3
def f_grad(params):
x, y = params
return [2*x + 3*y, 3*x + 3*y**2]
point = [2.0, 1.0]
analytical = f_grad(point)
numerical = numerical_gradient(f, point)
check_gradient(analytical, numerical)
استفاده در عمل
شبیهسازی دقت ترکیبی
import struct
def float32_to_float16_round(x):
packed = struct.pack('f', x)
f32 = struct.unpack('f', packed)[0]
packed16 = struct.pack('e', f32)
return struct.unpack('e', packed16)[0]
def simulate_bfloat16(x):
packed = struct.pack('f', x)
as_int = int.from_bytes(packed, 'little')
truncated = as_int & 0xFFFF0000
repacked = truncated.to_bytes(4, 'little')
return struct.unpack('f', repacked)[0]
برش گرادیان
def clip_by_norm(gradients, max_norm):
total_norm = math.sqrt(sum(g**2 for g in gradients))
if total_norm > max_norm:
scale = max_norm / total_norm
return [g * scale for g in gradients]
return gradients
grads = [10.0, 20.0, 30.0]
clipped = clip_by_norm(grads, max_norm=5.0)
print(f"Original norm: {math.sqrt(sum(g**2 for g in grads)):.2f}")
print(f"Clipped norm: {math.sqrt(sum(g**2 for g in clipped)):.2f}")
print(f"Direction preserved: {[c/clipped[0] for c in clipped]} == {[g/grads[0] for g in grads]}")
شناسایی NaN/Inf
def check_tensor(name, values):
has_nan = any(math.isnan(v) for v in values)
has_inf = any(math.isinf(v) for v in values)
if has_nan or has_inf:
print(f"WARNING {name}: nan={has_nan} inf={has_inf}")
return False
return True
check_tensor("good", [1.0, 2.0, 3.0])
check_tensor("bad", [1.0, float('nan'), 3.0])
check_tensor("ugly", [1.0, float('inf'), 3.0])
برای پیادهسازی کامل همراه با مدیریت تمام حالتهای مرزی (edge cases)، فایل code/numerical.py را ببینید.
تحویل نهایی
خروجیهای این درس:
- فایل
code/numerical.pyبا پیادهسازی پایدار سافتمکس، log-sum-exp، انتروپی متقاطع، بررسی گرادیان و شبیهسازی دقت ترکیبی - فایل
outputs/prompt-numerical-debugger.mdبرای خطایابی NaN/Inf و مشکلات عددی در فرایند آموزش
این پیادهسازیهای پایدار دوباره در فاز 3 هنگام ساخت حلقهی آموزش و در فاز 4 موقع پیادهسازی سازوکارهای اتنشن (attention) به کارتان میآیند.
تمرینها
-
حذف فاجعهبار. واریانس آرایهی [1000000.0, 1000001.0, 1000002.0] را یک بار با فرمول سادهی در فرمت float32 حساب کنید. سپس آن را با الگوریتم آنلاین ولفورد به دست آورید. خطای هر دو روش را نسبت به واریانس واقعی (0.6667) مقایسه کنید.
-
شکار دقت. کوچکترین عدد مثبت
xرا در فرمت float32 پیدا کنید که رابطهی1.0 + x == 1.0در پایتون برایش صادق باشد. این عدد همان اپسیلون ماشین (machine epsilon) است. مطمئن شوید با خروجیnumpy.finfo(numpy.float32).epsهمخوانی دارد. -
حالتهای مرزی Log-sum-exp. تابع
logsumexp_stableخود را با این ورودیها ارزیابی کنید: (الف) همهی مقادیر برابر، (ب) یک مقدار بسیار بزرگتر از بقیه، (ج) همهی مقادیر اعدادی شدیداً منفی (-1000). درستی عملکرد آن را در شرایطی که نسخهی ساده از کار میافتد بسنجید. -
بررسی عددی گرادیان روی یک لایهی شبکه عصبی. یک لایهی خطی
y = Wx + bو گذر برگشت تحلیلی آن را پیاده کنید. ازnumerical_gradientبرای تأیید صحت محاسبات روی یک ماتریس وزن 3x2 استفاده کنید. -
آزمایش مقیاسبندی تابع زیان. آموزش را در float16 شبیهسازی کنید: گرادیانهایی تصادفی در بازهی [1e-9, 1e-3] بسازید، آنها را به float16 ببرید و ببینید چه کسری از آنها صفر میشوند. سپس مقیاسبندی زیان را اعمال کنید (ضرب در 1024)، مقادیر را به float16 تبدیل کنید، مقیاس را برگردانید و دوباره درصد مقادیر صفرشده را حساب کنید.
راهنماییها
تمرین 1. قبل از اینکه اعداد را به توان دو برسانید، با np.float32 تبدیلشان کنید؛ سپس توابع variance_naive و welford_variance را فراخوانی کنید. فاصلهی اعداد نزدیک به در float32 خیلی بیشتر از 1 است؛ پس انتظار داشته باشید نتیجهی روش ساده فاصلهی زیادی با 0.6667 داشته باشد اما خروجی ولفورد به آن کاملاً نزدیک بماند.
تمرین 2. از x = np.float32(1.0) شروع کنید و تا زمانی که شرط np.float32(1.0) + x != np.float32(1.0) برقرار است آن را نصف کنید. همهی مقادیر را در float32 نگه دارید و آخرین مقداری را که جمع را تغییر داده، با مقدار numpy.finfo(numpy.float32).eps (یعنی ) بسنجید.
تمرین 3. اول پاسخ هر سناریو را حدس بزنید. برای مقدار برابرِ ، خروجی معادل میشود. برای یک مقدار خیلی بزرگ، پاسخ به همان مقدار نزدیک است. برای مقادیر حول -1000، نتیجه باید حدود باشد؛ درحالیکه تابع logsumexp_naive خروجی -inf میدهد، چون هر جملهی نمایی دچار زیرریز شده و صفر میشود.
تمرین 4. در رابطهی با تابع زیان ، گرادیان نسبت به برابر است با حاصلضرب خارجی گرادیان بازگشتی و . تابع زیان را برحسب شکل تختشدهی بنویسید، تابع numerical_gradient را صدا بزنید و هر دو را به check_gradient تحویل دهید.
تمرین 5. بزرگی مقادیر را در مقیاس لگاریتمی با 10 ** np.random.uniform(-9, -3, n) بسازید و با np.float16 فرمتشان را تغییر دهید. از آنجا که float16 نمیتواند مقادیر کمتر از حدود را نگه دارد، حدود یکچهارم مقادیر قبل از مقیاسبندی صفر خواهند شد؛ اما بعد از ضرب در 1024، این رقم تقریباً به صفر میرسد.
اصطلاحات کلیدی
| اصطلاح | در گفتار عامیانه | معنای دقیق فنی |
|---|---|---|
| IEEE 754 | «استاندارد ممیز شناور» | استاندارد بینالمللی که قالبهای باینری ممیز شناور، قواعد گردکردن و مقادیر خاص (مثل inf و nan) را تعریف میکند. تمام پردازندهها و کارتهای گرافیک مدرن از آن استفاده میکنند. |
| اپسیلون ماشین (Machine epsilon) | «سقف دقت» | کوچکترین مقدار بهطوریکه در یک قالب ممیز شناور معین، برقرار باشد. برای float32 این مقدار حدود 1.19e-7 است. |
| حذف فاجعهبار (Catastrophic cancellation) | «افت دقت در تفریق» | وقتی دو عدد ممیز شناورِ تقریباً مساوی تفریق میشوند، ارقام بامعنا یکدیگر را خنثی میکنند و نویز گردکردن نتیجه را خراب میکند. |
| سرریز (Overflow) | «عدد خیلی بزرگ» | نتیجه از سقف مقادیر قابلنمایش بیشتر شده و تبدیل به inf میشود. مثلاً exp(89) در float32 سرریز میکند. |
| زیرریز (Underflow) | «عدد خیلی کوچک» | حاصل محاسبه چنان به صفر نزدیک میشود (کوچکتر از کمترین عدد مثبت قابلنمایش) که تبدیل به 0.0 میگردد. مثلاً exp(-104) در float32 زیرریز میکند. |
| ترفند Log-sum-exp | «اول بیشینه را کم کن» | محاسبهی با فاکتور گرفتن از برای جلوگیری از سرریز و زیرریز. در سافتمکس، انتروپی متقاطع و محاسبات لگاریتم احتمال به کار میرود. |
| سافتمکس پایدار (Stable softmax) | «سافتمکس ضد انفجار» | تفریق پیش از عمل نمایی. خروجی از لحاظ ریاضی دقیقاً همان است، اما سرریز را غیرممکن میکند. |
| بررسی عددی گرادیان (Gradient checking) | «بررسی درستی پسانتشار» | مقایسهی گرادیانهای تحلیلی حاصل از پسانتشار با گرادیانهای عددی به دست آمده از تفاضلهای متناهی، برای شناسایی باگهای پیادهسازی. |
| دقت ترکیبی (Mixed precision) | «رفت با float16، برگشت با float32» | استفاده از ممیزهای شناور با دقت پایینتر برای کارهای نیازمند سرعت، و دقتهای بالاتر برای بخشهای حساس از نظر عددی. معمولاً 2 تا 3 برابر سرعت را بالا میبرد. |
| مقیاسبندی تابع زیان (Loss scaling) | «جلوگیری از زیرریز گرادیان» | ضرب تابع زیان در عددی بزرگ پیش از پسانتشار تا گرادیانها در بازهی float16 حفظ شوند، و سپس تقسیم بر همان عدد قبل از آپدیت وزنها. |
| bfloat16 | «ممیز شناور مغز (Brain)» | قالب 16 بیتی گوگل با 8 بیت توان (همان بازهی float32) و 7 بیت مانتیس (دقت کمتر از float16). گزینهی ارجح برای آموزش. |
| برش گرادیان (Gradient clipping) | «سقف گذاشتن روی نُرم گرادیان» | تغییر مقیاس بردار گرادیان بهگونهای که نُرم آن از آستانهای مشخص فراتر نرود. جلوی تخریب وزنها بر اثر انفجار گرادیان را میگیرد. |
| NaN | «غیر عدد (Not a Number)» | مقدار خاص ممیز شناور حاصل از عملیات تعریفنشده (0/0، inf-inf یا رادیکال عدد منفی) که به تمام محاسبات بعدی هم سرایت میکند. |
| Inf | «بینهایت (Infinity)» | مقدار خاص ممیز شناور ناشی از سرریز یا تقسیم بر صفر. میتواند ترکیب شود و NaN تولید کند (مانند inf - inf یا inf * 0). |
| گرادیان عددی (Numerical gradient) | «مشتقگیری مکانیکی» | تقریب زدن مشتق با ارزیابی و و تقسیم حاصل بر . کند است اما معیاری مطمئن برای بررسی درستی پیادهسازی به شمار میرود. |
برای مطالعهی بیشتر
- آنچه هر دانشمند کامپیوتر باید دربارهی محاسبات ممیز شناور بداند (Goldberg 1991) -- مرجع اصلی و کلاسیک مبحث؛ فشرده اما کامل
- آموزش با دقت ترکیبی (Micikevicius et al., 2018) -- مقالهی NVIDIA که روش مقیاسبندی زیان را برای آموزش با float16 معرفی کرد
- مستندات دقت ترکیبی خودکار در PyTorch (AMP) -- راهنمای عملی پیادهسازی دقت ترکیبی در PyTorch
- قالب bfloat16 (مستندات Cloud TPU گوگل) -- دلایل انتخاب این قالب توسط گوگل برای سختافزارهای TPU
- الگوریتم جمع کاهان (Kahan Summation در ویکیپدیا) -- الگوریتمی هوشمندانه برای کاهش خطای گردکردن در جمعبندیهای ممیز شناور
