اگر برای پردازش یک تصویر 224x224 سراغ لایههای تماممتصل (Fully Connected) بروید، هر نورون فقط در ورودی به بیش از 150 هزار وزن نیاز دارد؛ ساختاری که میلیونها پارامتر میبلعد و حتی درکی از موقعیت اشیا و جابهجایی آنها در تصویر ندارد. کانولوشن با دو ایدهی بنیادینِ اشتراک وزنها و موضعی دیدن پیکسلها، حجم پارامترها را مهار میکند و پایه و اساس بینایی ماشین مدرن را شکل میدهد.
در این درس بهجای تکیهی کورکورانه بر کتابخانهها، لایهی کانولوشن دوبعدی را از صفر و صرفاً با NumPy پیادهسازی میکنید. ابتدا آن را با حلقههای تودرتو میسازید تا ریاضیات پدینگ (Padding) و استراید (Stride) را لمس کنید، سپس با ترفند im2col عملیات را به یک ضرب ماتریسی پرسرعت تبدیل میکنید؛ دقیقاً همان روشی که فریمورکهای صنعتی و موتورهای محاسباتی برای اجرای بهینه روی سختافزار به کار میبرند.
تمام کدهای این درس کاملاً سبک هستند و بدون نیاز به منابع پردازشی ابری یا کارت گرافیک اختصاصی، بهراحتی روی CPU سیستم شخصی شما اجرا میشوند.
کانولوشن در واقع یک لایهی متراکم کوچک است که آن را روی تصویر میلغزانید و در تمام نقاط از وزنهای یکسانی استفاده میکنید.
اهداف یادگیری
- پیادهسازی کانولوشن دوبعدی از صفر صرفاً با NumPy؛ شامل نسخهی مبتنی بر حلقههای تودرتو و نسخهی برداریشده با
im2col - محاسبهی ابعاد مکانی خروجی برای هر ترکیبی از ابعاد ورودی، ابعاد کرنل، پدینگ (padding) و استراید (stride)، بههمراه درک و اثبات فرمول
(H - K + 2P) / S + 1 - طراحی دستی کرنلها (لبه، تاری، وضوحبخشی و سوبل) و تحلیل چرایی شکلگیری الگوهای اکتیویشن ناشی از هرکدام
- چیدن لایههای کانولوشن روی هم برای ساخت یک استخراجکنندهی ویژگی و پیوند دادن عمق این پشته با ابعاد میدان دید (receptive field)
صورت مسئله
یک لایهی تماممتصل (fully connected) روی یک تصویر RGB با ابعاد 224x224، بهازای هر نورون به 224 * 224 * 3 = 150,528 وزن ورودی نیاز دارد. یک لایهی پنهان با 1,000 واحد، پیش از آنکه اصلاً الگوی مفیدی یاد بگیرد، 150 میلیون پارامتر مصرف میکند. بدتر از آن، چنین لایهای نمیفهمد که تصویر سگ در بالا سمت چپ و همان سگ در پایین سمت راست، یک الگوی یکسان هستند. این لایه با هر موقعیت پیکسلی کاملاً مستقل برخورد میکند؛ رویکردی که برای دادههای تصویری کاملاً غلط است: جابهجا شدن یک گربه بهاندازهی سه پیکسل نباید شبکه را وادار کند که این مفهوم را دوباره از صفر یاد بگیرد.
دو ویژگی بنیادین برای هر مدل تصویری عبارتاند از: همواریانس با انتقال (translation equivariance بهمعنای اینکه با جابهجایی ورودی، خروجی نیز جابهجا شود) و اشتراک پارامترها (parameter sharing بهمعنای اینکه یک آشکارساز ویژگیِ ثابت در تمام تصویر اجرا شود). لایههای متراکم هیچیک از این دو را ندارند؛ اما کانولوشن هر دو را رایگان در اختیارتان میگذارد.
کانولوشن برای یادگیری عمیق اختراع نشد؛ این عملیات همان موتور محرک فشردهسازی JPEG، تاری گاوسی در Photoshop، لبهیابی در بینایی ماشین صنعتی و تکتک فیلترهای صوتی است. دلیل یکهتازیِ شبکههای عصبی کانولوشنی (CNN) در مسابقات ImageNet بین سالهای 2012 تا 2020 این بود که کانولوشن دقیقاً همان پیشینِ (prior) درست برای دادههایی است که در آنها مقادیر مجاور به هم وابستهاند و یک الگوی مشخص میتواند در هر نقطهای ظاهر شود.
مفاهیم پایه
لغزش یک کرنل منفرد
کانولوشن دوبعدی یک ماتریس وزن کوچک بهنام کرنل (یا فیلتر) را برمیدارد، آن را روی ورودی میلغزاند و در هر نقطه، مجموع ضربهای نظیربهنظیر را محاسبه میکند. این حاصلجمع، یک پیکسل از خروجی را تشکیل میدهد.
یک مثال ملموس از کرنل 3x3 روی ورودی 5x5 (بدون پدینگ، با استراید 1):
Input X (5 x 5): Kernel W (3 x 3):
1 2 0 1 2 1 0 -1
0 1 3 1 0 2 0 -2
2 1 0 2 1 1 0 -1
1 0 2 1 3
2 1 1 0 1
The kernel slides across every valid 3 x 3 window. Output Y is 3 x 3:
Y[0,0] = sum( W * X[0:3, 0:3] )
Y[0,1] = sum( W * X[0:3, 1:4] )
Y[0,2] = sum( W * X[0:3, 2:5] )
Y[1,0] = sum( W * X[1:4, 0:3] )
... and so on
کل ایده در همین خلاصه میشود: وزنهای مشترک، موضعی بودن، و پنجرهی لغزان. بقیهی مسائل صرفاً حسابوکتاب است.
فرمول ابعاد خروجی
با فرض بعد مکانی ورودی H، بعد کرنل K، پدینگ P و استراید S:
این رابطه را به خاطر بسپارید؛ در طراحی هر معماری بارها آن را محاسبه خواهید کرد.
| سناریو | H | K | P | S | H_out |
|---|---|---|---|---|---|
| کانولوشن معتبر، بدون پدینگ | 32 | 3 | 0 | 1 | 30 |
| کانولوشن یکسان (حفظ ابعاد) | 32 | 3 | 1 | 1 | 32 |
| کاهش ابعاد با ضریب 2 | 32 | 3 | 1 | 2 | 16 |
| پولینگ 2x2 | 32 | 2 | 0 | 2 | 16 |
| میدان دید بزرگ | 32 | 7 | 3 | 2 | 16 |
منظور از «Same padding» انتخاب بهگونهای است که در حالت ، رابطهی برقرار بماند. برای های فرد، این مقدار برابر با است. به همین دلیل کرنلهای 3x3 همهگیر شدهاند؛ آنها کوچکترین کرنلهای فردی هستند که مرکز متقارن دارند.
پدینگ
بدون پدینگ، هر لایهی کانولوشن نقشهی ویژگی (feature map) را کوچکتر میکند. اگر 20 لایه را روی هم بچینید، تصویر 224x224 شما به 184x184 آب میرود؛ اتفاقی که توان پردازشی لبهها را هدر میدهد و اتصالهای باقیمانده (residual connections) را که به ابعاد یکسان وابستهاند، مختل میکند.
Zero padding (P = 1) on a 5 x 5 input:
0 0 0 0 0 0 0
0 1 2 0 1 2 0
0 0 1 3 1 0 0
0 2 1 0 2 1 0 Now the kernel can centre on pixel
0 1 0 2 1 3 0 (0, 0) and still have three rows and
0 2 1 1 0 1 0 three columns of values to multiply.
0 0 0 0 0 0 0
حالتهای پرکاربرد در عمل: zero (رایجترین حالت)، reflect (بازتاب لبه، برای جلوگیری از مرزهای تند در مدلهای زایشی)، replicate (کپی کردن مقادیر لبه)، و circular (حلقهای، در مسائلی با ساختار چرخشی یا توریدال).
استراید
استراید همان طول گام حرکت کرنل است. مقدار پیشفرض آن stride=1 است. مقدار stride=2 ابعاد مکانی را نصف میکند و روش استاندارد کاهش ابعاد در دل یک CNN بدون نیاز به لایهی جداگانهی پولینگ به شمار میرود؛ تمام معماریهای مدرن (مثل ResNet ،ConvNeXt و MobileNet) در بخشهایی از مدل، کانولوشنهای دارای استراید را جایگزین max-pool میکنند.
Stride 1 on a 5 x 5 input, 3 x 3 kernel:
starts: (0,0) (0,1) (0,2) -> output row 0
(1,0) (1,1) (1,2) -> output row 1
(2,0) (2,1) (2,2) -> output row 2
Output: 3 x 3
Stride 2 on the same input:
starts: (0,0) (0,2) -> output row 0
(2,0) (2,2) -> output row 1
Output: 2 x 2
کانالهای ورودی چندگانه
تصاویر واقعی سه کانال دارند. بنابراین یک کانولوشن 3x3 روی ورودی RGB در واقع یک مکعب 3x3x3 است: یک برش 3x3 بهازای هر کانال ورودی. در هر موقعیت مکانی، ضرب و جمع را روی هر سه برش انجام میدهید و مقدار بایاس (bias) را اضافه میکنید.
Input: (C_in, H, W) 3 x 5 x 5
Kernel: (C_in, K, K) 3 x 3 x 3 (one kernel)
Output: (1, H', W') 2D map
For a layer that produces C_out output channels, you stack C_out kernels:
Weight: (C_out, C_in, K, K) e.g. 64 x 3 x 3 x 3
Output: (C_out, H', W') 64 x 3 x 3
Parameter count: C_out * C_in * K * K + C_out (the + C_out is biases)
خط آخر همان فرمولی است که هنگام برنامهریزی یک مدل محاسبه خواهید کرد. یک کانولوشن 3x3 با 64 کانال روی ورودی 3 کاناله، دقیقاً 64 * 3 * 3 * 3 + 64 = 1,792 پارامتر دارد؛ بسیار سبک و کمهزینه.
ترفند im2col
خواندن حلقههای تودرتو آسان است، اما سرعت بسیار پایینی دارند. پردازندههای گرافیکی (GPU) به ضرب ماتریسهای بزرگ نیاز دارند. ترفند کار این است: هر پنجره از میدان دید ورودی را باز کرده و در قالب یک ستون از یک ماتریس بزرگ بچینید (flatten)، کرنل را هم بهصورت یک سطر درآورید؛ حالا کل عملیات کانولوشن به یک ضرب ماتریسی (matmul) تکی تبدیل میشود.
تمام پیادهسازیهای صنعتی کانولوشن، نوعی از همین روش را بههمراه تکنیکهای مدیریت حافظهی کَش (مانند direct conv ،Winograd و FFT conv برای کرنلهای بزرگ) به کار میبرند. مفهوم im2col را که درک کنید، هستهی اصلی را یاد گرفتهاید.
میدان دید (Receptive Field)
یک کانولوشن 3x3 منفرد تنها به 9 پیکسل ورودی دید دارد. اگر دو کانولوشن 3x3 را روی هم بگذارید، یک نورون در لایهی دوم پنجرهای 5x5 از پیکسلهای ورودی را پوشش میدهد. سه لایهی 3x3 به یک محدودهی 7x7 میرسند. در حالت کلی:
RF after L stacked K x K convs (stride 1) = 1 + L * (K - 1)
With strides: RF grows multiplicatively with stride along each layer.
دلیل اینکه معماریهای برتر بر رویکرد «همهجا 3x3» تکیه میکنند (مثل VGG ،ResNet و ConvNeXt) این است که دو لایهی کانولوشن 3x3 دقیقاً همان محدودهی ورودیِ یک لایهی 5x5 را میبینند، اما با پارامترهای کمتر و یک تابع غیرخطی اضافی در بین لایهها.
پیادهسازی
گام 1: پد کردن آرایه
از پایهایترین بخش شروع کنید: تابعی که دور یک آرایهی H x W را با صفر پد میکند.
import numpy as np
def pad2d(x, p):
if p == 0:
return x
h, w = x.shape[-2:]
out = np.zeros(x.shape[:-2] + (h + 2 * p, w + 2 * p), dtype=x.dtype)
out[..., p:p + h, p:p + w] = x
return out
x = np.arange(9).reshape(3, 3)
print(x)
print()
print(pad2d(x, 1))
ترفند استخراج محورهای انتهایی با x.shape[:-2] باعث میشود تا همین تابع برای (H, W) ،(C, H, W) یا (N, C, H, W) بدون هیچ تغییری کار کند.
گام 2: کانولوشن دوبعدی با حلقههای تودرتو
پیادهسازی مرجع؛ کند است، ولی جای هیچ ابهامی باقی نمیگذارد. این کد دقیقاً همان کاری است که torch.nn.functional.conv2d از نظر منطقی انجام میدهد.
def conv2d_naive(x, w, b=None, stride=1, padding=0):
c_in, h, w_in = x.shape
c_out, c_in_w, kh, kw = w.shape
assert c_in == c_in_w
x_pad = pad2d(x, padding)
h_out = (h + 2 * padding - kh) // stride + 1
w_out = (w_in + 2 * padding - kw) // stride + 1
out = np.zeros((c_out, h_out, w_out), dtype=np.float32)
for oc in range(c_out):
for i in range(h_out):
for j in range(w_out):
hs = i * stride
ws = j * stride
patch = x_pad[:, hs:hs + kh, ws:ws + kw]
out[oc, i, j] = np.sum(patch * w[oc])
if b is not None:
out[oc] += b[oc]
return out
چهار حلقهی تودرتو (کانال خروجی، سطر، ستون، بههمراه جمع ضمنی روی C_in ،kh و kw). این پیادهسازی، معیار سنجش و اعتبارسنجی تمام نسخههای سریعتر بعدی خواهد بود.
گام 3: بررسی صحت با یک کرنل دستساز
یک کرنل سوبل عمودی بسازید، آن را روی یک تصویر پلهای مصنوعی اعمال کنید و روشن شدن لبهی عمودی را ببینید.
def synthetic_step_image():
img = np.zeros((1, 16, 16), dtype=np.float32)
img[:, :, 8:] = 1.0
return img
sobel_x = np.array([
[[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]]
], dtype=np.float32)[None]
x = synthetic_step_image()
y = conv2d_naive(x, sobel_x, padding=1)
print(y[0].round(1))
انتظار میرود روی ستون 7 مقادیر مثبت بزرگی ظاهر شود (ناشی از افزایش روشنایی از چپ به راست) و بقیهی نقاط صفر باشند. همین یک خروجی ساده نشان میدهد محاسبات ریاضی را درست پیاده کردهاید.
گام 4: تکنیک im2col
هر پنجره بهاندازهی ابعاد کرنل در ورودی را به یک ستون از ماتریس تبدیل کنید. برای C_in=3, K=3، هر ستون شامل 27 عدد خواهد بود.
def im2col(x, kh, kw, stride=1, padding=0):
c_in, h, w = x.shape
x_pad = pad2d(x, padding)
h_out = (h + 2 * padding - kh) // stride + 1
w_out = (w + 2 * padding - kw) // stride + 1
cols = np.zeros((c_in * kh * kw, h_out * w_out), dtype=x.dtype)
col = 0
for i in range(h_out):
for j in range(w_out):
hs = i * stride
ws = j * stride
patch = x_pad[:, hs:hs + kh, ws:ws + kw]
cols[:, col] = patch.reshape(-1)
col += 1
return cols, h_out, w_out
این بخش همچنان از یک حلقهی پایتونی استفاده میکند، اما بار سنگین محاسبات را به یک ضرب ماتریسی برداری واگذار خواهیم کرد.
گام 5: کانولوشن سریع با ترکیب im2col و matmul
چهار حلقهی تودرتو را با یک ضرب ماتریسی جایگزین کنید.
def conv2d_im2col(x, w, b=None, stride=1, padding=0):
c_out, c_in, kh, kw = w.shape
cols, h_out, w_out = im2col(x, kh, kw, stride, padding)
w_flat = w.reshape(c_out, -1)
out = w_flat @ cols
if b is not None:
out += b[:, None]
return out.reshape(c_out, h_out, w_out)
بررسی درستی عملکرد: هر دو پیادهسازی را اجرا کرده و مقایسه کنید.
rng = np.random.default_rng(0)
x = rng.normal(0, 1, (3, 16, 16)).astype(np.float32)
w = rng.normal(0, 1, (8, 3, 3, 3)).astype(np.float32)
b = rng.normal(0, 1, (8,)).astype(np.float32)
y_naive = conv2d_naive(x, w, b, padding=1)
y_im2col = conv2d_im2col(x, w, b, padding=1)
print(f"max abs diff: {np.max(np.abs(y_naive - y_im2col)):.2e}")
مقدار max abs diff باید در حدود 1e-5 باشد؛ این اختلاف جزئی بهخاطر تغییر ترتیب جمع در محاسبات ممیز شناور رخ میدهد، نه یک باگ.
گام 6: مجموعهای از کرنلهای دستساز
پنج فیلتر که نشان میدهند یک لایهی کانولوشن منفرد، حتی پیش از هرگونه آموزش، توانایی بازنمایی چه الگوهایی را دارد.
KERNELS = {
"identity": np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.float32),
"blur_3x3": np.ones((3, 3), dtype=np.float32) / 9.0,
"sharpen": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32),
"sobel_x": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32),
"sobel_y": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32),
}
def apply_kernel(img2d, kernel):
x = img2d[None].astype(np.float32)
w = kernel[None, None]
return conv2d_im2col(x, w, padding=1)[0]
وقتی این فیلترها را روی یک تصویر خاکستری اعمال کنید: فیلتر تاری تصویر را نرم میکند، فیلتر شارپکننده لبهها را شفاف و تیز میکند، سوبل افقی (Sobel-x) لبههای عمودی را مشخص میسازد و سوبل عمودی (Sobel-y) لبههای افقی را بیرون میکشد. اینها دقیقاً الگوهایی هستند که اولین لایهی کانولوشنِ آموزشدیده در مدلهایی مثل AlexNet و VGG یاد گرفتند؛ زیرا یک مدل تصویری مناسب، فارغ از وظیفهی نهاییاش، در ابتدا به آشکارسازهای لبه و لکه نیاز دارد.
استفاده در عمل
کلاس nn.Conv2d در PyTorch همین عملیات را همراه با مشتقگیری خودکار (autograd)، کرنلهای CUDA و بهینهسازیهای cuDNN کپسوله کرده است. ساختار و معناشناسی ابعاد در آن دقیقاً یکسان است.
import torch
import torch.nn as nn
conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1)
print(conv)
print(f"weight shape: {tuple(conv.weight.shape)} # (C_out, C_in, K, K)")
print(f"bias shape: {tuple(conv.bias.shape)}")
print(f"param count: {sum(p.numel() for p in conv.parameters())}")
x = torch.randn(8, 3, 224, 224)
y = conv(x)
print(f"\ninput shape: {tuple(x.shape)}")
print(f"output shape: {tuple(y.shape)}")
اگر padding=1 را به padding=0 تغییر دهید، خروجی به 222x222 افت میکند. تغییر stride=1 به stride=2 نیز ابعاد را به 112x112 میرساند؛ دقیقاً همان فرمولی که در بالا دیدید.
خروجی این درس
این درس موارد زیر را به همراه دارد:
outputs/prompt-cnn-architect.md— پرامپتی که بر اساس ابعاد ورودی، سقف پارامترها و میدان دید هدف، پشتهای از لایههایConv2dبا مقادیر درست K/S/P در هر مرحله طراحی میکند.outputs/skill-conv-shape-calculator.md— مهارتی که ساختار لایههای شبکه را تحلیل کرده و ابعاد خروجی، میدان دید و تعداد پارامترهای هر بلوک را برمیگرداند.
تمرینها
- (ساده) برای یک ورودی تککاناله (خاکستری) با ابعاد 128x128 و پشتهای از لایههای
[Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)]، ابعاد مکانی خروجی و میدان دید را در هر لایه دستی حساب کنید. صحت نتیجه را با یک شیءnn.Sequentialاز کانولوشنهای تصادفی در PyTorch بررسی کنید. - (متوسط) توابع
conv2d_naiveوconv2d_im2colرا طوری توسعه دهید که آرگومانgroupsرا نیز بپذیرند. نشان دهید که شرطgroups=C_in=C_outساختار کانولوشن تفکیکپذیر عمقی (depthwise convolution) را بازتولید میکند و تعداد پارامترهای آن بهجایC * C * K * KبهC * K * Kکاهش مییابد. - (سخت) مسیر پسانتشار (backward pass) الگوریتم
conv2d_im2colرا دستی پیادهسازی کنید: با داشتن گرادیان خروجی، گرادیانهایxوwرا محاسبه کنید. خروجی را باtorch.autograd.gradروی همان ورودیها و وزنها مقایسه کنید. نکتهی اصلی: گرادیان تابع im2col همان عملگرcol2imاست و باید مقادیر پنجرههای همپوشان را جمع کند.
واژگان کلیدی
| اصطلاح | در گفتگوی عامیانه | معنای دقیق فنی |
|---|---|---|
| کانولوشن (Convolution) | «لغزاندن یک فیلتر» | ضرب داخلیِ قابلیادگیری که با وزنهای مشترک در تمام موقعیتهای مکانی اعمال میشود؛ از نظر ریاضی همبستگی متقابل (cross-correlation) است، اما در یادگیری عمیق همه آن را کانولوشن مینامند |
| کرنل / فیلتر (Kernel / filter) | «آشکارساز ویژگی» | تنسور وزن کوچکی با ابعاد (C_in, K, K) که ضرب داخلی آن با پنجرهای از ورودی، یک پیکسل خروجی را میسازد |
| استراید (Stride) | «طول پرش» | طول گام جابهجایی بین موقعیتهای متوالیِ کرنل؛ استراید 2 هر بعد مکانی را نصف میکند |
| پدینگ (Padding) | «صفرهای حاشیه» | مقادیر اضافهای که دور ورودی قرار میگیرند تا کرنل بتواند روی پیکسلهای مرزی متمرکز شود؛ حالت same ابعاد خروجی را برابر با ورودی نگه میدارد |
| میدان دید (Receptive field) | «وسعت دید نورون» | محدودهای از ورودی اصلی که یک خروجی مشخص به آن وابسته است؛ این مقدار با افزایش عمق و استراید گسترش مییابد |
| متد im2col | «ترفند GEMM» | بازآرایی پنجرههای ورودی در قالب ستونهای یک ماتریس تا کانولوشن به یک ضرب ماتریسی بزرگ تبدیل شود؛ اساس کار کرنلهای محاسباتی سریع |
| کانولوشن عمقی (Depthwise conv) | «یک کرنل بهازای هر کانال» | نوعی کانولوشن با شرط groups == C_in که هر کانال خروجی را صرفاً از روی کانال متناظر خودش در ورودی محاسبه میکند؛ ستون فقرات معماریهای MobileNet و ConvNeXt |
| همواریانس با انتقال (Translation equivariance) | «جابهجایی ورودی، جابهجایی خروجی» | این ویژگی که جابهجا شدن ورودی بهاندازهی k پیکسل، خروجی را هم دقیقاً بهاندازهی k پیکسل جابهجا میکند؛ این ویژگی بهطور مستقیم ناشی از اشتراک پارامترهاست |
منابع پیشنهادی برای مطالعهی بیشتر
- A guide to convolution arithmetic for deep learning (Dumoulin & Visin, 2016) — منبع مرجعِ دیاگرامهای پدینگ، استراید و dilation که پایهی آموزشی بیشتر دورههاست
- CS231n: Convolutional Neural Networks for Visual Recognition — یادداشتهای استاندارد دانشگاه استنفورد، شامل توضیحات اصلی تکنیک im2col
- The Annotated ConvNet (fast.ai) — نوتبوکی که از پیادهسازی دستی کانولوشن تا آموزش یک مدل طبقهبندی ارقام را گامبهگام پیاده میکند
- Receptive Field Arithmetic for CNNs (Dang Ha The Hien) — راهنمای تعاملی و دقیق نحوهی محاسبهی میدان دید در سطح مقالات معتبر Distill
