Feature map hasil convolution tetap berukuran besar, dan setiap lapisan baru membuat resolusinya makin memberatkan komputasi. Pooling layer menyelesaikannya dengan cara paling sederhana: meringkas tiap jendela kecil menjadi satu angka — nilai terbesar (max) atau rata-rata (average) — tanpa satu pun bobot yang perlu dipelajari. Operasi ini juga memberi CNN ketahanan terhadap pergeseran kecil: pola yang bergeser sedikit di dalam satu jendela tetap menghasilkan keluaran pooling yang sama. Pada chapter ini kita membangun pooling dari nol dengan NumPy, memverifikasinya dengan nn.MaxPool2d, dan menutup dengan global average pooling.

Mekanisme dan Rumus Ukuran #

Pooling bekerja seperti convolution tanpa bobot: jendela berukuran K digeser dengan stride S, lalu isi jendela diringkas satu angka. Ukuran keluarannya mengikuti rumus (H - K)/S + 1 per sumbu — bentuk yang sama dengan rumus convolution padding nol. Konfigurasi paling umum adalah jendela 2x2 dengan stride 2: input 6x6 menjadi 3x3, resolusi turun empat kali lebih sedikit pikselnya.

Berkas pooling_numpy.py mengimplementasikan kedua mode pooling dengan loop NumPy, lalu membandingkannya dengan layer PyTorch pada input yang sama:

python
"""Max pooling dan average pooling dari nol dengan NumPy, diverifikasi dengan torch."""

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

np.random.seed(42)
torch.manual_seed(42)


def pooling2d(x, kernel=2, stride=2, mode="max"):
    """Pooling 2D dengan loop eksplisit, tanpa parameter yang dipelajari."""
    H, W = x.shape
    H_out = (H - kernel) // stride + 1
    W_out = (W - kernel) // stride + 1
    out = np.zeros((H_out, W_out))
    for i in range(H_out):
        for j in range(W_out):
            jendela = x[i * stride:i * stride + kernel,
                        j * stride:j * stride + kernel]
            out[i, j] = jendela.max() if mode == "max" else jendela.mean()
    return out


x = np.array([
    [1, 3, 2, 4, 1, 2],
    [5, 8, 6, 7, 2, 1],
    [4, 2, 9, 6, 3, 5],
    [7, 6, 5, 8, 4, 3],
    [2, 4, 3, 5, 9, 6],
    [1, 5, 6, 2, 7, 8],
])

print("input (6x6):")
print(x)
print()

out_max = pooling2d(x, mode="max")
out_avg = pooling2d(x, mode="avg")
print("max pooling 2x2 stride 2 (3x3):")
print(out_max.astype(int))
print()
print("average pooling 2x2 stride 2 (3x3):")
print(np.round(out_avg, 2))
print()

# Verifikasi dengan torch
x_t = torch.tensor(x, dtype=torch.float32).reshape(1, 1, 6, 6)
max_t = nn.MaxPool2d(kernel_size=2, stride=2)(x_t)
avg_t = nn.AvgPool2d(kernel_size=2, stride=2)(x_t)
print("torch MaxPool2d == NumPy :", np.array_equal(max_t.reshape(3, 3).numpy(), out_max))
print("torch AvgPool2d == NumPy :", np.allclose(avg_t.reshape(3, 3).numpy(), out_avg))
print()

# Invariance kecil terhadap geser: pola berpindah di dalam satu jendela pooling
a = np.zeros((4, 4))
a[0, 0] = 9.0                      # pola di pojok kiri atas jendela
b = np.zeros((4, 4))
b[0, 1] = 9.0                      # pola digeser 1 ke kanan
c = np.zeros((4, 4))
c[1, 1] = 9.0                      # pola digeser 1 ke kanan dan 1 ke bawah
print("max pooling tiga versi geser (2x2 stride 2):")
print(pooling2d(a).astype(int))
print(pooling2d(b).astype(int))
print(pooling2d(c).astype(int))
print("ketiga keluaran identik: pola tetap terwakili setelah digeser kecil")
print()

# Global average pooling: tiap channel diringkas menjadi satu angka
fitur = torch.randn(2, 4, 4)       # 2 channel, masing-masing 4x4
gap = F.adaptive_avg_pool2d(fitur, output_size=1)
print("input  :", tuple(fitur.shape))
print("GAP    :", tuple(gap.shape))
print("nilai  :", [round(v, 3) for v in gap.flatten().tolist()])
print("padanan manual mean per channel:",
      [round(v, 3) for v in fitur.mean(dim=(1, 2)).tolist()])

Output yang Dihasilkan:

text
input (6x6):
[[1 3 2 4 1 2]
 [5 8 6 7 2 1]
 [4 2 9 6 3 5]
 [7 6 5 8 4 3]
 [2 4 3 5 9 6]
 [1 5 6 2 7 8]]

max pooling 2x2 stride 2 (3x3):
[[8 7 2]
 [7 9 5]
 [5 6 9]]

average pooling 2x2 stride 2 (3x3):
[[4.25 4.75 1.5 ]
 [4.75 7.   3.75]
 [3.   4.   7.5 ]]

torch MaxPool2d == NumPy : True
torch AvgPool2d == NumPy : True

max pooling tiga versi geser (2x2 stride 2):
[[9 0]
 [0 0]]
[[9 0]
 [0 0]]
[[9 0]
 [0 0]]
ketiga keluaran identik: pola tetap terwakili setelah digeser kecil

input  : (2, 4, 4)
GAP    : (2, 1, 1)
nilai  : [-0.137, 0.573]
padanan manual mean per channel: [-0.137, 0.573]

Mari kita telaah kode di atas baris per baris:

  1. Baris 12-23 (pooling2d): strukturnya kemiripan konvolusi2d dari chapter Convolution Layer , tetapi baris 22 tidak lagi mengalikan bobot — jendela langsung diringkas lewat max() atau mean() sesuai mode.
  2. Baris 26-33: input 6x6 berisi angka beragam; jendela pertama [1, 3, 5, 8] menghasilkan max 8 dan average 4.25 — keduanya terlihat pada sel kiri atas keluaran.
  3. Baris 39-45: kedua mode dijalankan pada input yang sama. Max pooling mempertahankan nilai terbesar tiap blok ([[8,7,2],[7,9,5],[5,6,9]]), average pooling menghasilkan angka pecahan yang lebih halus.
  4. Baris 49-53: nn.MaxPool2d(kernel_size=2, stride=2) dan nn.AvgPool2d menghasilkan keluaran identik dengan implementasi NumPy — dua baris True adalah buktinya.
  5. Baris 57-67: satu pola bernilai 9 diletakkan di tiga posisi berbeda yang masih berada di dalam jendela pooling kiri atas. Ketiga keluaran max pooling bernilai sama persis — inilah invariance terhadap geser kecil yang dijanjikan pooling.
  6. Baris 71-77: F.adaptive_avg_pool2d(fitur, output_size=1) merata-ratakan seluruh channel menjadi satu angka per channel; hasilnya sama dengan fitur.mean(dim=(1, 2)) manual — (2, 4, 4) menjadi (2, 1, 1).

Max, Average, dan Global #

Jenis poolingOperasi per jendelaEfek utama
Max 2x2 stride 2ambil nilai terbesarfitur terkuat bertahan, geser kecil diabaikan
Average 2x2 stride 2rata-rataringkasan halus seluruh area
Global averagerata-rata satu channel utuhsatu angka per channel, pengganti flatten + FC besar

Max pooling mendominasi CNN klasik karena citra alami lebih peduli pada keberadaan pola daripada posisi persisnya: sudut mata atau garis tepi tetap terdeteksi selama muncul di suatu tempat dalam jendela. Average pooling lebih jarang dipakai di tengah jaringan, namun idenya bangkit kembali dalam bentuk global average pooling — arsitektur ResNet dan pendahulunya mengganti layer fully connected raksasa dengan GAP: seluruh channel diringkas satu angka, sehingga parameter klasifikasi menyusut tajam dan model lebih tahan terhadap overfitting.

Dalam susunan khas, pooling diletakkan setelah blok Conv + ReLU, membentuk pola Conv → ReLU → Pool yang diulang beberapa kali seperti diagram arsitektur pada chapter Pengenalan CNN . Jendela 2x2 stride 2 adalah pilihan paling aman karena tidak saling menindih; varian jendela 3x3 stride 2 yang saling menindih sedikit lebih jarang dipakai dan mewarisi sebagian informasi dari tumpang tindihnya. Bila resolusi cukup besar, sebagian arsitektur bahkan membuang pooling sama sekali dan menyerahkan downsampling pada convolution berstride 2 dari chapter sebelumnya.

Pooling menjelaskan bagaimana resolusi mengecil, tetapi pertanyaan besar CNN masih tersisa: apa sebenarnya isi puluhan channel di antara layer-layer tersebut, dan mengapa menumpuknya membangun pengenalan objek? Jawabannya adalah pokok bahasan chapter Feature Map . Dokumentasi resmi kedua layer pooling tersedia di docs.pytorch.org/docs/stable/generated/torch.nn.MaxPool2d.html .