Faza 04 · lecția 02

Convoluții de la zero

Scopul lecției: O convoluție este un strat dens minuscul pe care îl glisați peste o imagine, folosind aceleași ponderi în fiecare poziție.

Versiunea curentă AlexBred.com: primele 100 de lecții ale programului în limba română.

Curs
AI Engineering from Scratch
Fază
Viziune computerizată
Lectură
18 min.
Verificat
Cuprinsul lecției
  1. Obiective de învățare
  2. Problema
  3. Conceptul
  4. Un nucleu care glisează
  5. Formula dimensiunii ieșirii
  6. Completarea
  7. Pasul
  8. Mai multe canale de intrare
  9. Trucul im2col
  10. Câmpul receptiv
  11. Construiți
  12. Pasul 1: completați un array
  13. Pasul 2: convoluție 2D cu bucle imbricate
  14. Pasul 3: verificați cu un nucleu proiectat manual
  15. Pasul 4: im2col
  16. Pasul 5: convoluție rapidă prin im2col + înmulțire de matrice
  17. Pasul 6: o bancă de nuclee proiectate manual
  18. Utilizați
  19. Livrați
  20. Exerciții
  21. Termeni-cheie
  22. Lecturi suplimentare

O convoluție este un strat dens minuscul pe care îl glisați peste o imagine, folosind aceleași ponderi în fiecare poziție.

Tip: Construcție Limbaje: Python Cerințe preliminare: Faza 3 (Nucleul învățării profunde), faza 4, lecția 01 (Fundamentele imaginilor) Timp: ~75 de minute

Obiective de învățare

  • Implementați convoluția 2D de la zero folosind numai NumPy, inclusiv versiunea cu bucle imbricate și o versiune vectorizată im2col
  • Calculați dimensiunea spațială a ieșirii pentru orice combinație între dimensiunea intrării, dimensiunea nucleului, completare și pas și justificați formula (H - K + 2P) / S + 1
  • Proiectați manual nuclee (muchii, estompare, accentuare, Sobel) și explicați de ce fiecare produce tiparul de activări observat
  • Stivuiți convoluții într-un extractor de caracteristici și legați adâncimea stivei de dimensiunea câmpului receptiv

Problema

Un strat complet conectat aplicat unei imagini RGB de 224x224 ar necesita 224 * 224 * 3 = 150.528 de ponderi de intrare pentru fiecare neuron. Un singur strat ascuns cu 1.000 de unități are deja 150 de milioane de parametri — înainte de a fi învățat ceva util. Mai rău, acel strat nu are nicio noțiune că un câine din colțul stânga-sus și unul din colțul dreapta-jos reprezintă același tipar. Tratează fiecare poziție de pixel ca fiind independentă, ceea ce este exact abordarea greșită pentru imagini: deplasarea unei pisici cu trei pixeli nu ar trebui să oblige rețeaua să reînvețe conceptul.

Cele două proprietăți de care are nevoie un model pentru imagini sunt echivarianța la translație (ieșirea se deplasează când intrarea se deplasează) și partajarea parametrilor (același detector de caracteristici rulează pretutindeni). Straturile dense nu vă oferă niciuna. Convoluția vi le oferă pe amândouă fără cost suplimentar.

Notă tehnică a traducerii: Echivarianța exactă a unei convoluții discrete se referă la deplasări cu un număr întreg de pixeli și presupune tratarea compatibilă a marginilor. Sub-eșantionarea, inclusiv convoluțiile cu pas mai mare decât 1, poate încălca echivarianța la deplasări arbitrare.

Convoluția nu a fost inventată pentru învățarea profundă. Este aceeași operație care stă la baza compresiei JPEG, a estompării gaussiene din Photoshop, a detectării muchiilor în viziunea industrială și a fiecărui filtru audio livrat vreodată. Motivul pentru care CNN-urile au dominat ImageNet din 2012 până în 2020 este că convoluția oferă biasul inductiv potrivit pentru date în care valorile apropiate sunt corelate, iar același tipar poate apărea oriunde.

Notă tehnică a traducerii: Afirmația despre JPEG este o simplificare incorectă. Codarea JPEG cu pierderi este bazată în principal pe transformata cosinus discretă (DCT) aplicată blocurilor, nu pe aceeași operație de convoluție spațială descrisă în această lecție. Convoluția discretă descrie filtre liniare, invariante în timp (LTI), în special FIR; filtrele audio IIR, neliniare sau variabile în timp nu sunt toate o astfel de convoluție.

Conceptul

Un nucleu care glisează

O convoluție 2D ia o matrice mică de ponderi, numită nucleu (sau filtru), o glisează peste intrare și, în fiecare poziție, calculează suma produselor element cu element. Acea sumă devine un pixel al ieșirii.

Диаграмма к уроку «Convoluții de la zero»

Un exemplu concret 3x3 aplicat unei intrări 5x5 (fără completare, pas 1):

Intrarea X (5 x 5):            Nucleul 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

Nucleul glisează peste fiecare fereastră validă de 3 x 3. Ieșirea Y este 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] )
 ... și așa mai departe

Acea singură formulă — ponderi partajate, localitate, fereastră glisantă — reprezintă întreaga idee. Tot restul ține de gestionarea detaliilor.

Formula dimensiunii ieșirii

Pentru dimensiunea spațială a intrării H, dimensiunea nucleului K, completarea P și pasul S:

H_out = floor( (H - K + 2P) / S ) + 1

Memorați formula. O veți calcula de zeci de ori pentru fiecare arhitectură.

Scenariu H K P S H_out
Convoluție validă, fără completare 32 3 0 1 30
Convoluție same (păstrează dimensiunea) 32 3 1 1 32
Sub-eșantionare cu factorul 2 32 3 1 2 16
Pooling 2x2 32 2 0 2 16
Câmp receptiv mare 32 7 3 2 16

„Completarea same” înseamnă să alegeți P astfel încât H_out == H când S == 1. Pentru K impar, aceasta este P = (K - 1) / 2. De aceea domină nucleele 3x3 — sunt cele mai mici nuclee impare care au totuși un centru.

Completarea

Fără completare, fiecare convoluție micșorează harta de caracteristici. Stivuiți 20 de astfel de convoluții, iar imaginea de 224x224 devine 184x184, ceea ce irosește calculul efectuat la margine și complică acele conexiuni reziduale care necesită forme identice.

Completare cu zerouri (P = 1) pentru o intrare 5 x 5:

  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       Acum nucleul poate fi centrat pe pixelul
  0  1  0  2  1  3  0       (0, 0) și poate avea în continuare trei rânduri și
  0  2  1  1  0  1  0       trei coloane de valori pentru înmulțire.
  0  0  0  0  0  0  0

Moduri pe care le întâlniți în practică: zero (cel mai frecvent), reflect (reflectă marginea și evită contururile dure în modelele generative), replicate (copiază marginea), circular (revine la capătul opus, folosit în probleme toroidale).

Pasul

Pasul (stride) este dimensiunea deplasării la fiecare glisare. stride=1 este valoarea implicită. stride=2 înjumătățește dimensiunile spațiale și reprezintă metoda clasică de sub-eșantionare într-un CNN fără un strat separat de pooling — fiecare arhitectură modernă (ResNet, ConvNeXt, MobileNet) folosește undeva convoluții cu pas în locul max-poolingului.

Pas 1 pentru o intrare 5 x 5 și un nucleu 3 x 3:

  începuturi: (0,0) (0,1) (0,2)        -> rândul 0 al ieșirii
             (1,0) (1,1) (1,2)        -> rândul 1 al ieșirii
             (2,0) (2,1) (2,2)        -> rândul 2 al ieșirii

  Ieșire: 3 x 3

Pas 2 pentru aceeași intrare:

  începuturi: (0,0) (0,2)              -> rândul 0 al ieșirii
             (2,0) (2,2)              -> rândul 1 al ieșirii

  Ieșire: 2 x 2

Mai multe canale de intrare

Imaginile reale au trei canale. O convoluție 3x3 aplicată unei intrări RGB este, de fapt, un volum 3x3x3: câte o secțiune 3x3 pentru fiecare canal de intrare. În fiecare poziție spațială, înmulțiți și însumați valorile din toate cele trei secțiuni, apoi adăugați un bias.

Intrare:  (C_in,  H,  W)        3 x 5 x 5
Nucleu:   (C_in,  K,  K)        3 x 3 x 3 (un nucleu)
Ieșire:   (1,     H', W')       hartă 2D

Pentru un strat care produce C_out canale de ieșire, stivuiți C_out nuclee:

Pondere:  (C_out, C_in, K, K)   de exemplu, 64 x 3 x 3 x 3
Ieșire:   (C_out, H', W')       64 x 3 x 3

Număr de parametri: C_out * C_in * K * K + C_out   (+ C_out reprezintă biasurile)

Ultima linie este cea pe care o veți calcula când planificați un model. O convoluție 3x3 cu 64 de canale, aplicată unei intrări cu 3 canale, are 64 * 3 * 3 * 3 + 64 = 1.792 de parametri. Puțini.

Trucul im2col

Buclele imbricate sunt ușor de citit, dar lente. GPU-urile preferă înmulțiri de matrice mari. Iată trucul: aplatizați fiecare fereastră a câmpului receptiv din intrare într-o coloană a unei matrice mari, aplatizați nucleul într-un rând, iar întreaga convoluție devine o singură înmulțire de matrice.

Диаграмма к уроку «Convoluții de la zero»

Fiecare implementare de producție a convoluției este o variantă a acestei metode, la care se adaugă trucuri de împărțire în dale pentru cache (convoluție directă, Winograd, convoluție FFT pentru nuclee mari). Dacă înțelegeți im2col, înțelegeți mecanismul de bază.

Notă tehnică a traducerii: im2col urmat de GEMM este o implementare importantă, dar nu una universală. Bibliotecile de producție folosesc și algoritmi direct/implicit GEMM, FFT și Winograd; abordarea implicit GEMM nici măcar nu materializează neapărat matricea de coloane.

Câmpul receptiv

O singură convoluție 3x3 examinează 9 pixeli de intrare. Stivuiți două convoluții 3x3, iar un neuron din al doilea strat examinează 5x5 pixeli de intrare. Trei convoluții 3x3 oferă 7x7. În general:

CR după L convoluții K x K stivuite (pas 1) = 1 + L * (K - 1)

Cu pași:   CR crește multiplicativ cu pasul de-a lungul fiecărui strat.

Notă tehnică a traducerii: Pentru pași diferiți, relația exactă este recurentă: dacă j_l reprezintă saltul dintre pozițiile receptive ale stratului l, atunci j_l = j_(l-1) * S_l, iar RF_l = RF_(l-1) + (K_l - 1) * j_(l-1). Prin urmare, câmpul receptiv nu se obține doar înmulțind dimensiunea sa cu pasul fiecărui strat.

Întregul motiv pentru care funcționează abordarea „3x3 până la capăt” (VGG, ResNet, ConvNeXt) este că două convoluții 3x3 văd aceeași zonă de intrare ca o convoluție 5x5, dar au mai puțini parametri și o neliniaritate suplimentară între ele.

convolution-kernel

Construiți

Pasul 1: completați un array

Începeți cu cea mai mică primitivă: o funcție care adaugă zerouri în jurul unui array 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))

Trucul axelor finale x.shape[:-2] înseamnă că aceeași funcție lucrează cu (H, W), (C, H, W) sau (N, C, H, W) fără modificări.

Pasul 2: convoluție 2D cu bucle imbricate

Implementarea de referință — lentă, dar lipsită de ambiguități. Aceasta este, în principiu, operația efectuată de 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

Patru bucle imbricate (canalul de ieșire, rândul, coloana, plus suma implicită peste C_in, kh, kw). Acesta este adevărul de referință (ground truth) cu care veți compara fiecare implementare mai rapidă.

Pasul 3: verificați cu un nucleu proiectat manual

Construiți un nucleu Sobel vertical, aplicați-l unei imagini sintetice cu salt și observați cum se evidențiază muchia verticală.

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))

Așteptați-vă la valori pozitive mari pe coloana 7 (creșterea luminozității de la stânga la dreapta) și la zerouri peste tot în rest. Această singură afișare este verificarea de bun-simț care confirmă corectitudinea calculelor.

Notă tehnică a traducerii: Cu indexare de la zero, codul afișează răspunsuri pozitive pe coloanele 7 și 8, deoarece nucleul 3x3 se suprapune peste salt în ambele poziții. Completarea cu zerouri produce și un răspuns negativ pe coloana 15, la marginea dreaptă a imaginii; valorile de pe primul și ultimul rând au magnitudine mai mică din cauza aceleiași completări.

Pasul 4: im2col

Transformați fiecare fereastră din intrare, cu dimensiunea nucleului, într-o coloană a unei matrice. Pentru C_in=3, K=3, fiecare coloană conține 27 de numere.

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

Există încă o buclă Python, dar acum calculul dificil va fi efectuat printr-o singură înmulțire de matrice vectorizată.

Pasul 5: convoluție rapidă prin im2col + înmulțire de matrice

Înlocuiți bucla cvadruplă cu o singură înmulțire de matrice.

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)

Verificarea corectitudinii: rulați ambele implementări și comparați-le.

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 ar trebui să fie aproximativ 1e-5 — diferența provine din ordinea acumulării în virgulă mobilă, nu dintr-o eroare.

Pasul 6: o bancă de nuclee proiectate manual

Cinci filtre care arată ce poate exprima un singur strat convoluțional înaintea oricărei antrenări.

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]

Aplicate oricărei imagini în tonuri de gri, estomparea netezește, accentuarea face muchiile mai clare, Sobel-x evidențiază muchiile verticale, iar Sobel-y pe cele orizontale. Acestea sunt exact tiparele pe care primul strat convoluțional antrenat din AlexNet și VGG a ajuns să le învețe — deoarece un model de imagini bun are nevoie de detectoare de muchii și pete indiferent de sarcina care urmează.

Utilizați

nn.Conv2d din PyTorch încapsulează aceeași operație și adaugă autograd, nuclee CUDA și optimizare cu cuDNN. Semantica formelor este identică.

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)}")

Înlocuiți padding=1 cu padding=0, iar ieșirea scade la 222x222. Înlocuiți stride=1 cu stride=2, iar aceasta scade la 112x112. Este aceeași formulă pe care ați memorat-o mai sus.

Livrați

Această lecție produce:

  • outputs/prompt-cnn-architect.md — un prompt care, primind dimensiunea intrării, bugetul de parametri și câmpul receptiv țintă, proiectează o stivă de straturi Conv2d cu valorile K/S/P corecte la fiecare pas.
  • outputs/skill-conv-shape-calculator.md — o abilitate care parcurge specificația unei rețele strat cu strat și întoarce forma ieșirii, câmpul receptiv și numărul de parametri pentru fiecare bloc.

Exerciții

  1. (Ușor) Pentru o intrare în tonuri de gri de 128x128 și o stivă [Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)], calculați manual dimensiunea spațială a ieșirii și câmpul receptiv la fiecare strat. Verificați folosind un nn.Sequential PyTorch cu convoluții fictive.
  2. (Mediu) Extindeți conv2d_naive și conv2d_im2col pentru a accepta un argument groups. Arătați că groups=C_in=C_out reproduce o convoluție depthwise și că numărul său de parametri este C * K * K în loc de C * C * K * K.
  3. (Dificil) Implementați manual propagarea înapoi pentru conv2d_im2col: primind gradientul ieșirii, calculați gradientul lui x și al lui w. Verificați față de torch.autograd.grad pe aceleași intrări și ponderi. Trucul: gradientul lui im2col este col2im, iar acesta trebuie să acumuleze ferestrele suprapuse.

Termeni-cheie

Termen Cum i se spune Ce înseamnă de fapt
Convoluție „Glisarea unui filtru” Un produs scalar antrenabil aplicat în fiecare poziție spațială cu ponderi partajate; din punct de vedere matematic, este o corelație încrucișată, dar toată lumea îi spune convoluție
Nucleu / filtru „Detectorul de caracteristici” Un tensor mic de ponderi, cu forma (C_in, K, K), al cărui produs scalar cu o fereastră a intrării produce un pixel al ieșirii
Pas „Cât de departe săriți” Dimensiunea deplasării între poziționări consecutive ale nucleului; pasul 2 înjumătățește fiecare dimensiune spațială
Completare „Zerouri pe margini” Valori suplimentare adăugate în jurul intrării pentru ca nucleul să poată fi centrat pe pixelii de margine; completarea same păstrează dimensiunea ieșirii egală cu cea a intrării
Câmp receptiv „Cât vede neuronul” Patch-ul intrării originale de care depinde o anumită activare de ieșire și care crește odată cu adâncimea și pasul
im2col „Trucul GEMM” Rearanjarea fiecărei ferestre receptive în coloane, astfel încât convoluția să devină o singură înmulțire mare de matrice — mecanismul central al multor nuclee convoluționale rapide
Convoluție depthwise „Un nucleu per canal” O convoluție cu groups == C_in, care calculează fiecare canal de ieșire numai din canalul de intrare corespunzător; coloana vertebrală a MobileNet și ConvNeXt
Echivarianță la translație „Deplasați intrarea, se deplasează ieșirea” Proprietatea prin care deplasarea intrării cu k pixeli deplasează ieșirea cu k pixeli; rezultă din ponderile partajate, în condițiile precizate mai sus

Lecturi suplimentare

Sursă: Originalul în limba engleză

Navigare: ← Lecția 04.01 — Fundamentele imaginilor — pixeli, canale și spații de culoare · Faza 4 — Viziune computerizată · Catalog complet · Lecția 04.03 — CNN-uri — de la LeNet la ResNet →