اگر تا به حال در PyTorch یا TensorFlow مدلی پیاده کرده باشید، احتمالاً با خطاهای ناهماهنگی ابعاد (Shape Mismatch) روبهرو شدهاید؛ خطاهایی مثل ضربناپذیر بودن دو ماتریس یا ناسازگاری ابعاد ورودی لایهها. درک نادرست از ساختار تنسورها نه تنها باعث شکست کد هنگام اجرا میشود، بلکه گاهی با ایجاد خطاهای پنهان در برادکستینگ، محاسبات مدل را بیسروصدا منحرف میکند.
تنسورها سنگبنای محاسبات یادگیری عمیق هستند؛ از تصاویر ورودی چندکاناله گرفته تا وزنها و مقادیر QKV در سازوکار اتنشن ترنسفورمرها، همهچیز در قالب تنسور پردازش میشود. در این درس با پیادهسازی گامبهگام یک کلاس تنسور از صفر، مفهوم گامهای حافظه (Strides)، تغییر شکل، قواعد برادکستینگ و عملگر قدرتمند einsum را فرامیگیرید تا هرگز در خطاهای ابعادی سردرگم نشوید.
تنسورها زبان مشترک بین دادهها و یادگیری عمیق هستند. تکتک تصاویر، جملهها و گرادیانها از دل آنها میگذرند.
اهداف یادگیری
- پیادهسازی یک کلاس تنسور از صفر؛ شامل ابعاد (shape)، گامها (strides)، تغییر شکل (reshape)، ترانهاده (transpose) و عملیاتهای عنصربهعنصر (element-wise)
- بهکارگیری قواعد برادکستینگ (broadcasting) برای اجرای عملیات روی تنسورهایی با ابعاد متفاوت، بدون کپی کردن دادهها
- نوشتن عبارتهای جمع اینشتین (einsum) برای ضرب داخلی، ضرب ماتریسی، ضرب خارجی و عملیاتهای دستهای (batched)
- ردیابی دقیق ابعاد تنسورها در تکتک مراحل اتنشن چندسر (multi-head attention)
صورت مسئله
یک ترنسفورمر میسازید. مرحلهی رفت (forward pass) کاملاً تمیز و بینقص به نظر میرسد. کد را اجرا میکنید و ناگهان با این خطا روبهرو میشوید: RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x768 and 512x768). به ابعاد خیره میشوید. یک ترانهاده امتحان میکنید. حالا میگوید: Expected 4D input (got 3D input). یک تابع unsqueeze اضافه میکنید؛ اما جای دیگری از کار میافتد.
خطاهای ابعاد، رایجترین باگها در کدهای یادگیری عمیق هستند. این خطاها از نظر مفهومی سخت نیستند؛ هر عملیاتی قرارداد مشخصی برای ابعاد ورودی دارد؛ اما خیلی سریع چندبرابر میشوند. در یک ترنسفورمر، دهها عملیات reshape، ترانهاده و برادکستینگ زنجیروار به هم وصل شدهاند. یک محور را اشتباه انتخاب کنید تا خطا دومینووار کل فرایند را خراب کند. بدتر از آن، برخی اشتباههای ابعادی اصلاً خطایی ایجاد نمیکنند، بلکه بیسروصدا با برادکستینگ روی بعد اشتباه یا جمع بستن روی محوری نادرست، خروجیهای بیمعنی تحویل میدهند.
ماتریسها فقط از پس روابط دوتایی بین دو مجموعه برمیآیند؛ اما دادههای دنیای واقعی در دو بعد جا نمیشوند. یک دستهی 32تایی از تصاویر RGB با ابعاد 224x224، یک تنسور 4بعدی است: (32, 3, 224, 224). سلفاتنشن با 12 سر نیز 4بعدی است: (batch, heads, seq_len, head_dim). شما به ساختار دادهای نیاز دارید که برای هر تعداد بعد تعمیمپذیر باشد و عملیاتهایش روی تمام این ابعاد بهشکل تمیزی با هم ترکیب شوند. این ساختار داده، همان «تنسور» (tensor) است. اگر روی عملیاتهای آن مسلط شوید، اشکالزدایی خطاهای ابعادی برایتان مثل آب خوردن میشود.
مفاهیم پایه
تنسور چیست؟
تنسور یک آرایهی چندبعدی از اعداد با نوع دادهی یکسان است. تعداد ابعاد را مرتبه (rank یا order) مینامند. به هر بعد یک محور (axis) میگوییم. ابعاد یا شکل (shape) تاپلی است که اندازهی تنسور را در راستای هر محور مشخص میکند.
تعداد کل عناصر = حاصلضرب تمام اندازهها. تنسوری با ابعاد (2, 3, 4) شامل عنصر است.
ابعاد تنسور در یادگیری عمیق
طبق قرارداد، انواع مختلف دادهها در ابعاد مشخصی از تنسورها قرار میگیرند.
فریمورک PyTorch از فرمت NCHW (کانال اول) استفاده میکند؛ در حالی که پیشفرض TensorFlow فرمت NHWC (کانال آخر) است. ناهماهنگی در این چیدمانها باعث افت شدید و نامحسوس سرعت یا بروز خطا میشود.
چیدمان حافظه چگونه کار میکند؟
یک آرایهی 2بعدی در حافظه بهصورت دنبالهای 1بعدی از بایتها ذخیره میشود. گامها (strides) مشخص میکنند که برای یک گام حرکت در امتداد هر محور، باید چند عنصر را در حافظه رد کنید.
عملیات ترانهاده دادهها را جابهجا نمیکند؛ بلکه گامها را با هم تعویض میکند. این کار باعث میشود تنسور غیرپیوسته (non-contiguous) شود؛ یعنی عناصر یک سطر دیگر پشت سر هم در حافظه قرار نخواهند داشت.
قواعد برادکستینگ
برادکستینگ (broadcasting) این امکان را میدهد که بدون کپی کردن دادهها، روی تنسورهایی با ابعاد متفاوت عملیات انجام دهید. برای این کار، ابعاد را از سمت راست تراز کنید. دو بعد در صورتی سازگارند که برابر باشند یا اندازهی یکی از آنها 1 باشد. ابعادی که کمتر هستند، از سمت چپ با 1 پر میشوند.
Tensor A: (8, 1, 6, 1)
Tensor B: (7, 1, 5)
Padded B: (1, 7, 1, 5)
Result: (8, 7, 6, 5)
عملگر einsum: همهفنحریف عملیاتهای تنسوری
قرارداد جمعبندی اینشتین (einsum) هر محور را با یک حرف نامگذاری میکند. محورهایی که در ورودی آمدهاند اما در خروجی نیستند، با هم جمع میشوند. محورهایی که در هر دو مشترکند، حفظ میشوند.
الگوهای کلیدی: i,i-> (ضرب داخلی)، i,j->ij (ضرب خارجی)، ii-> (رد ماتریس یا trace)، ij->ji (ترانهاده)، bij,bjk->bik (ضرب ماتریسی دستهای)، bhtd,bhsd->bhts (امتیازهای اتنشن).
پیادهسازی از صفر
کد کامل این بخش در فایل code/tensors.py قرار دارد. تمام مراحل به پیادهسازی همان فایل ارجاع میدهند.
گام 1: فضای ذخیرهسازی و گامهای تنسور
یک تنسور از یک لیست خطی از اعداد بههمراه متادیتای ابعاد تشکیل شده است. گامها به منطق نمایهسازی (indexing) میگویند که چگونه اندیسهای چندبعدی را به موقعیتهای خطی در حافظه مپ کند.
class Tensor:
def __init__(self, data, shape=None):
if isinstance(data, (list, tuple)):
self._data, self._shape = self._flatten_nested(data)
elif isinstance(data, np.ndarray):
self._data = data.flatten().tolist()
self._shape = tuple(data.shape)
else:
self._data = [data]
self._shape = ()
if shape is not None:
total = reduce(lambda a, b: a * b, shape, 1)
if total != len(self._data):
raise ValueError(
f"Cannot reshape {len(self._data)} elements into shape {shape}"
)
self._shape = tuple(shape)
self._strides = self._compute_strides(self._shape)
@staticmethod
def _compute_strides(shape):
if len(shape) == 0:
return ()
strides = [1] * len(shape)
for i in range(len(shape) - 2, -1, -1):
strides[i] = strides[i + 1] * shape[i + 1]
return tuple(strides)
برای ابعاد (3, 4)، گامها برابر با (4, 1) خواهند بود؛ یعنی برای رفتن به سطر بعدی باید 4 عنصر، و برای رفتن به ستون بعدی 1 عنصر را رد کنید.
گام 2: تغییر شکل با reshape، squeeze و unsqueeze
عملیات reshape ابعاد را بدون تغییر دادن ترتیب عناصر عوض میکند. تعداد کل عناصر باید همیشه ثابت بماند. میتوانید برای یکی از ابعاد مقدار -1 بگذارید تا اندازهی آن بهطور خودکار استخراج شود.
t = Tensor(list(range(12)), shape=(2, 6))
r = t.reshape((3, 4))
r = t.reshape((-1, 3))
متد squeeze محورهایی با اندازهی 1 را حذف میکند و unsqueeze یک محور جدید میافزاید. استفاده از unsqueeze برای برادکستینگ حیاتی است؛ مثلاً اگر بخواهید بردار بایاس با ابعاد (D,) را به یک دستهی داده با ابعاد (B, T, D) اضافه کنید، باید با unsqueeze ابعاد آن را به (1, 1, D) برسانید.
t = Tensor(list(range(6)), shape=(1, 3, 1, 2))
s = t.squeeze()
v = Tensor([1, 2, 3])
u = v.unsqueeze(0)
گام 3: ترانهاده و جابهجایی محورها (permute)
عملیات transpose جای دو محور را با هم عوض میکند. متد permute همهی محورها را دوباره مرتب میسازد. تبدیل بین دو فرمت NCHW و NHWC دقیقاً با همین کار انجام میشود.
mat = Tensor(list(range(6)), shape=(2, 3))
tr = mat.transpose(0, 1)
t4d = Tensor(list(range(24)), shape=(1, 2, 3, 4))
perm = t4d.permute((0, 2, 3, 1))
پس از اجرای transpose یا permute، تنسور در حافظه دیگر پیوسته نخواهد بود. در PyTorch متد view روی تنسورهای غیرپیوسته کار نمیکند؛ بنابراین باید از reshape استفاده کنید یا اول متد .contiguous() را صدا بزنید.
گام 4: عملیاتهای عنصربهعنصر و تجمیعی (reductions)
عملیاتهای عنصربهعنصر (مانند جمع، ضرب و تفریق) مستقل از یکدیگر روی تکتک عناصر اعمال میشوند و ابعاد تنسور را حفظ میکنند. عملیاتهای تجمیعی (مانند sum، mean و max) یک یا چند محور را فرو میریزند و جمع میکنند.
a = Tensor([[1, 2], [3, 4]])
b = Tensor([[10, 20], [30, 40]])
c = a + b
d = a * 2
s = a.sum(axis=0)
میانگینگیری سراسری (Global average pooling) در یک شبکهی CNN: عبارت (B, C, H, W).mean(axis=[2, 3]) ابعاد (B, C) را تولید میکند. میانگینگیری روی دنباله در پردازش زبان طبیعی: عبارت (B, T, D).mean(axis=1) خروجی (B, D) میدهد.
گام 5: برادکستینگ با NumPy
تابع demo_broadcasting_numpy() در فایل tensors.py الگوهای اصلی را نشان میدهد.
activations = np.random.randn(4, 3)
bias = np.array([0.1, 0.2, 0.3])
result = activations + bias
images = np.random.randn(2, 3, 4, 4)
scale = np.array([0.5, 1.0, 1.5]).reshape(1, 3, 1, 1)
result = images * scale
a = np.array([1, 2, 3]).reshape(-1, 1)
b = np.array([10, 20, 30, 40]).reshape(1, -1)
outer = a * b
محاسبهی فاصلهی دوبهدو (pairwise distance) با برادکستینگ: ابتدا ابعاد (M, 2) را به (M, 1, 2) و ابعاد (N, 2) را به (1, N, 2) تغییر دهید، آنها را از هم کم کنید، به توان دو برسانید، روی محور آخر جمع بزنید و جذر بگیرید. نتیجه تنسوری با ابعاد (M, N) خواهد بود.
گام 6: عملیات با einsum
توابع demo_einsum() و demo_einsum_gallery() تکتک الگوهای رایج را مرور میکنند.
a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0, 6.0])
dot = np.einsum("i,i->", a, b)
A = np.array([[1, 2], [3, 4], [5, 6]], dtype=float)
B = np.array([[7, 8, 9], [10, 11, 12]], dtype=float)
matmul = np.einsum("ik,kj->ij", A, B)
batch_A = np.random.randn(4, 3, 5)
batch_B = np.random.randn(4, 5, 2)
batch_mm = np.einsum("bij,bjk->bik", batch_A, batch_B)
هزینهی محاسباتی یک انقباض تنسوری (contraction)، حاصلضرب اندازهی تمام اندیسهاست (چه حفظشدهها و چه جمعزدهها). برای رابطهی bij,bjk->bik با مقادیر ، ، و ، تعداد عملیاتهای ضرب و جمع برابر با خواهد بود.
گام 7: سازوکار اتنشن با einsum
تابع demo_attention_einsum() سازوکار اتنشن چندسر را از ابتدا تا انتها پیادهسازی میکند.
B, H, T, D = 2, 4, 8, 16
E = H * D
X = np.random.randn(B, T, E)
W_q = np.random.randn(E, E) * 0.02
Q = np.einsum("bte,ek->btk", X, W_q)
Q = Q.reshape(B, T, H, D).transpose(0, 2, 1, 3)
scores = np.einsum("bhtd,bhsd->bhts", Q, K) / np.sqrt(D)
weights = softmax(scores, axis=-1)
attn_output = np.einsum("bhts,bhsd->bhtd", weights, V)
concat = attn_output.transpose(0, 2, 1, 3).reshape(B, T, E)
output = np.einsum("bte,ek->btk", concat, W_o)
تکتک مراحل در واقع یک عملیات تنسوری است: تصویر خطی (ضرب ماتریسی با einsum)، تفکیک سرها (reshape + ترانهاده)، امتیازهای اتنشن (ضرب ماتریسی دستهای با einsum)، مجموع وزندار (ضرب ماتریسی دستهای با einsum)، ادغام سرها (ترانهاده + reshape)، و تصویر خروجی (ضرب ماتریسی با einsum).
استفاده از ابزارهای واقعی
مقایسهی پیادهسازی از صفر با NumPy
| عملیات | پیادهسازی از صفر (کلاس Tensor) | کتابخانهی NumPy |
|---|---|---|
| ساخت | Tensor([[1,2],[3,4]]) |
np.array([[1,2],[3,4]]) |
| تغییر شکل (Reshape) | t.reshape((3,4)) |
a.reshape(3,4) |
| ترانهاده (Transpose) | t.transpose(0,1) |
a.T یا a.transpose(0,1) |
| حذف ابعاد واحد (Squeeze) | t.squeeze(0) |
np.squeeze(a, 0) |
| مجموع (Sum) | t.sum(axis=0) |
a.sum(axis=0) |
| جمع اینشتین (Einsum) | N/A | np.einsum("ij,jk->ik", a, b) |
مقایسهی پیادهسازی از صفر با PyTorch
import torch
t = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.float32)
t.shape
t.stride()
t.is_contiguous()
t.reshape(3, 2)
t.unsqueeze(0)
t.transpose(0, 1)
t.transpose(0, 1).contiguous()
torch.einsum("ik,kj->ij", A, B)
کتابخانهی PyTorch قابلیتهای مشتقگیری خودکار (autograd)، پشتیبانی از GPU و کرنلهای بهینهشدهی BLAS را اضافه میکند؛ اما معنای ابعاد در هر دو کاملاً یکسان است. اگر نسخهی صفر تا صدی را متوجه شوید، خواندن و رفع خطاهای ابعادی PyTorch هم برایتان ساده خواهد بود.
تمام لایههای شبکهی عصبی در قالب عملیات تنسوری
| عملیات | فرم تنسوری | فرم با Einsum |
|---|---|---|
| لایهی خطی (Linear layer) | Y = X @ W.T + b |
"bd,od->bo" + بایاس |
| ماتریسهای QKV در اتنشن | Q = X @ W_q |
"btd,dh->bth" |
| امتیازهای اتنشن | Q @ K.T / sqrt(d) |
"bhtd,bhsd->bhts" |
| خروجی اتنشن | softmax(scores) @ V |
"bhts,bhsd->bhtd" |
| نرمالسازی دستهای (Batch norm) | (X - mu) / sigma * gamma |
عنصربهعنصر + برادکستینگ (broadcasting) |
| تابع Softmax | exp(x) / sum(exp(x)) |
عنصربهعنصر + تجمیع |
خروجی این درس
این درس دو پرامپت آماده و کاربردی به شما میدهد:
-
outputs/prompt-tensor-shapes.md-- یک پرامپت ساختاریافته برای اشکالزدایی سیستماتیک خطاهای عدم تطابق ابعاد (Shape Mismatch). این پرامپت شامل جدولهای تصمیمگیری برای همهی عملیاتهای متداول (matmul، برادکستینگ، cat، لایههای Linear، Conv2d، BatchNorm و softmax) به همراه جدول راهنمای سریع رفع خطا است. -
outputs/prompt-tensor-debugger.md-- یک پرامپت اشکالزدایی گامبهگام که در زمان گیر افتادن در خطاهای ابعادی، میتوانید آن را در هر دستیار هوش مصنوعی پیست کنید؛ پیام خطا و ابعاد تنسورهایتان را به آن بدهید تا راهحل دقیق را پس بگیرید.
تمرینها
-
ساده -- رفت و برگشت با Reshape. یک تنسور با ابعاد
(2, 3, 4)بسازید. شکل آن را به(6, 4)، سپس به(24,)و در نهایت دوباره به(2, 3, 4)تغییر دهید. با چاپ کردن دادههای مسطح (flat)، بررسی کنید که ترتیب عناصر در هر مرحله حفظ شده باشد. -
متوسط -- پیادهسازی برادکستینگ. کلاس
Tensorرا با افزودن متدbroadcast_to(shape)ارتقا دهید؛ بهطوری که بعدهایی با اندازهی 1 را متناسب با ابعاد هدف گسترش دهد. سپس متد_elementwise_opرا طوری بازنویسی کنید که قبل از محاسبات، خودبهخود برادکستینگ را انجام دهد. عملکرد آن را با دو تنسور به ابعاد(3, 1)و(1, 4)برای ساخت خروجی(3, 4)آزمایش کنید. -
سخت -- ساخت einsum از صفر. یک تابع اولیهی
einsum(subscripts, *tensors)بنویسید که دستکم این حالتها را پوشش دهد: ضرب داخلی (i,i->)، ضرب ماتریسی (ij,jk->ik)، ضرب خارجی (i,j->ij) و ترانهاده (ij->ji). رشتهی اندیسها را پردازش کنید، اندیسهای منقبضشده (حذفشده در خروجی) را بشناسید و با حلقه زدن روی تمام ترکیبهای اندیس، خروجی را بسازید. نتایج خود را با خروجیnp.einsumمقایسه کنید. -
سخت -- ردیاب ابعاد در مکانیسم اتنشن. تابعی بنویسید که مقادیر
batch_size،seq_len،embed_dimوnum_headsرا به عنوان ورودی بگیرد و ابعاد دقیق تنسورها را در تکتک مراحل اتنشن چندسر چاپ کند: ورودی، تصاویر خطی Q/K/V، تفکیک سرها، امتیازهای اتنشن، وزنهای سافتمکس، مجموع وزندار، merge سرها و تصویر خروجی. درستی نتایج را با خروجی تابعdemo_attention_einsum()بررسی کنید.
راهنماییها
تمرین 1. تنسور اولیه را با list(range(24)) بسازید تا هر عنصر مقدار موقعیت خودش را نشان دهد. بعد از هر فراخوانی reshape، خروجی flatten().to_list() را چاپ کنید و مطمئن شوید اعداد همچنان بهترتیب از 0 تا 23 هستند.
تمرین 2. عملیات برادکستینگ روی یک بعد تنها زمانی ممکن است که اندازهی آن در هر دو تنسور برابر باشد یا اندازهی یکی از آنها 1 باشد؛ اول این شرط را بررسی کنید و اگر برقرار نبود، خطا پرتاب کنید. برای ابعاد (3, 1) و (1, 4)، بررسی کنید که عنصر [i][j] در نتیجه دقیقاً برابر با a[i][0] + b[0][j] باشد و کل خروجی را با NumPy بسنجید.
تمرین 3. رشتهی اندیسها را بر اساس -> جدا کنید. حروفی که در بخش ورودی هستند اما در بخش خروجی وجود ندارند، اندیسهای جمعبندی هستند؛ بنابراین روی مقادیر تمام این حروف حلقه بزنید و حاصل را در موقعیت خروجی جمع کنید. هر یک از چهار حالت را با آرایههای تصادفی کوچک بسازید و با دستور np.allclose(mine, np.einsum(...)) درستی آن را بسنجید.
تمرین 4. کار را با اندازهی هر سر یعنی embed_dim // num_heads شروع کنید و با دستور assert مطمئن شوید که embed_dim % num_heads == 0 است. امتیازهای اتنشن ابعادی برابر با (batch_size, num_heads, seq_len, seq_len) دارند؛ شکلهای چاپشده را با این ابعاد و همچنین با خروجیهای تابع demo_attention_einsum() تطبیق دهید.
اصطلاحات کلیدی
| اصطلاح | باور اشتباه | مفهوم واقعی |
|---|---|---|
| تنسور (Tensor) | «همان ماتریس با ابعاد بیشتر» | آرایهای چندبعدی با نوع دادهی یکسان، ابعاد مشخص، گامهای معین و عملیاتهای تعریفشده |
| مرتبه (Rank) | «همان تعداد بعد» | تعداد محورها؛ یک ماتریس مرتبهی 2 دارد، نه اینکه مرتبهاش برابر با رتبهی ماتریسی در جبر خطی باشد |
| ابعاد (Shape) | «اندازهی تنسور» | تاپلی که اندازهی هر محور را نشان میدهد؛ مثلاً (2, 3) یعنی 2 سطر و 3 ستون |
| گام (Stride) | «نحوهی چیدمان حافظه» | تعداد عناصری که برای یک خانه جلو رفتن در امتداد هر محور، باید در حافظه رد شوند |
| برادکستینگ (Broadcasting) | «هماهنگ شدن خودکار ابعاد متفاوت» | مجموعهای دقیق از قواعد: تراز از راست، و الزام به برابر بودن یا 1 بودن اندازهها در هر بعد |
| پیوسته (Contiguous) | «تنسور مرتب و معمولی» | عناصری که پشت سر هم و بدون فاصله در حافظه قرار گرفتهاند، بدون هیچ انحرافی نسبت به چیدمان منطقی |
| جمع اینشتین (Einsum) | «روشی شیک برای ضرب ماتریسی» | نمادگذاری کلی برای بیان هر نوع انقباض تنسوری، ضرب خارجی، رد ماتریس یا ترانهاده در یک خط |
| دید (View) | «همان عملیات reshape» | تنسوری با بافر حافظهی مشترک اما با متادیتای متفاوت ابعاد و گامها، که روی دادههای غیرپیوسته کار نمیکند |
| انقباض (Contraction) | «جمع زدن روی یک اندیس» | عملیات کلی ضرب و جمع روی اندیسهای مشترک بین دو تنسور که خروجیای با مرتبهی پایینتر تولید میکند |
| فرمت NCHW / NHWC | «تفاوت سلیقهی PyTorch و TensorFlow» | استانداردهای چیدمان حافظه برای تنسورهای تصویر؛ NCHW کانالها را قبل از ابعاد فضایی و NHWC کانالها را بعد از آنها قرار میدهد |
برای مطالعهی بیشتر
- مستندات برادکستینگ در NumPy -- آموزش قواعد استاندارد همراه با مثالهای تصویری
- مستندات متدهای View در PyTorch -- بررسی دقیق مواردی که view دادهها را اشتراک میگذارد یا آنها را کپی میکند
- کتابخانهی einops -- کتابخانهای برای خوانا کردن و ایمنسازی عملیات تغییر شکل تنسورها
- ترنسفورمر به روایت تصویر (The Illustrated Transformer) -- نمایشی دیداری از ابعاد تنسورها در مراحل مختلف اتنشن
- قرارداد جمع اینشتین در NumPy -- مستندات کامل تابع einsum همراه با مثالهای کاربردی
