수민 '-'

플오그래밍

제가 작성하는 모든 글은 절대 상업적인 이용이 아니며, 그저 개인적인 공부 용도로만 사용하는 것임을 밝힙니다.

오토인코더 - 개념, MNIST·CIFAR10 구현, Denoising과 유사 이미지 탐색

입력을 압축했다가 다시 복원하는 비지도 학습

오토인코더(Autoencoder) 는 입력 데이터를 효율적으로 압축하고 다시 복원하는 것을 목표로 하는 인공신경망 기반의 비지도 학습 모델이다. 인코더가 입력을 저차원 잠재 공간(latent space) 으로 압축하고, 디코더가 그 요약을 원래 크기로 복원한다. 학습은 원본 입력과 복원 출력 간 재구성 오류(reconstruction error) 를 최소화하는 방식으로 이루어지며, 차원 축소·특징 추출·노이즈 제거·이상 탐지 등에 쓰인다. VAE, GAN 같은 생성 모델의 기초가 되는 구조이기도 하다.

(오토인코더 - 류지 프로젝트를 바탕으로 재구성했다.)


1. 오토인코더 구조

1-1. 인코더 (Encoder): 데이터를 요약하는 압축기

인코더는 고차원 데이터를 받아, 핵심 특징만 담은 저차원 잠재 벡터(latent vector) 로 바꾼다.

  • 예: 28×28 이미지는 784개 픽셀 → 인코더가 중요한 특징만 뽑아 32차원 벡터로 요약.
  • 이 요약 공간을 잠재 공간(latent space) 이라고 부른다.

1-2. 디코더 (Decoder): 요약을 복원하는 재생기

디코더는 인코더가 만든 요약 벡터를 받아, 원래 데이터와 최대한 비슷하게 다시 복원한다.

  • 32차원 벡터 → 784개 픽셀(28×28 이미지)로 재구성.
  • 압축 과정에서 일부 정보가 빠지므로 완전히 같지는 않지만, 본질적인 패턴은 유지하려고 한다.

학습 목표는 입력 = 타깃으로 두고, 복원 출력과의 오차(예: MSE, binary cross-entropy)를 줄이는 것이다.


2. 기본 오토인코더 구현 (Keras + MNIST)

MNIST로 입력 이미지를 자기 자신으로 복원하는 기본 구조다. 인코더는 Conv2D + MaxPooling으로 공간을 줄이고, 디코더는 Conv2D + UpSampling으로 다시 키운다. 출력 픽셀을 0~1로 두려면 마지막에 sigmoid를 쓰고, loss는 binary_crossentropy를 쓰면 된다.

from tensorflow.keras.datasets import mnist
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D, UpSampling2D
import matplotlib.pyplot as plt
import numpy as np

# 데이터 로드 & 전처리
(X_train, _), (X_test, _) = mnist.load_data()
X_train = X_train.reshape(-1, 28, 28, 1).astype('float32') / 255.0
X_test = X_test.reshape(-1, 28, 28, 1).astype('float32') / 255.0

# 오토인코더: 인코더 2회 다운샘플 ↔ 디코더 2회 업샘플 대칭
autoencoder = Sequential()
# Encoder: 28x28x1 -> 14x14x16 -> 7x7x8
autoencoder.add(Conv2D(16, kernel_size=3, padding='same', activation='relu', input_shape=(28, 28, 1)))
autoencoder.add(MaxPooling2D(pool_size=2, padding='same'))
autoencoder.add(Conv2D(8, kernel_size=3, padding='same', activation='relu'))
autoencoder.add(MaxPooling2D(pool_size=2, padding='same'))
# Decoder: 7x7x8 -> 14x14x8 -> 28x28x16 -> 28x28x1
autoencoder.add(Conv2D(8, kernel_size=3, padding='same', activation='relu'))
autoencoder.add(UpSampling2D())
autoencoder.add(Conv2D(16, kernel_size=3, padding='same', activation='relu'))
autoencoder.add(UpSampling2D())
autoencoder.add(Conv2D(1, kernel_size=3, padding='same', activation='sigmoid'))

autoencoder.compile(optimizer='adam', loss='binary_crossentropy')
autoencoder.fit(X_train, X_train, epochs=50, batch_size=128, validation_data=(X_test, X_test), verbose=1)

# 원본 vs 복원 시각화
random_idx = np.random.randint(X_test.shape[0], size=5)
recons = autoencoder.predict(X_test)
plt.figure(figsize=(10, 4))
for i, idx in enumerate(random_idx):
    ax = plt.subplot(2, 5, i + 1)
    plt.imshow(X_test[idx].squeeze(), cmap='gray')
    ax.axis('off')
    if i == 0:
        ax.set_title('Original')
    ax = plt.subplot(2, 5, 5 + i + 1)
    plt.imshow(recons[idx].squeeze(), cmap='gray')
    ax.axis('off')
    if i == 0:
        ax.set_title('Reconstructed')
plt.tight_layout()
plt.show()

3. Sparse Autoencoder

Sparse Autoencoder 는 잠재 공간에서 대부분의 뉴런이 0에 가깝고, 일부만 활성화되도록 제약을 거는 오토인코더다. L1 정규화나 KL Divergence로 희소성을 부여하면, 단순 복사가 아니라 중요한 특징만 선택적으로 쓰게 되어 해석 가능한 압축 표현을 얻기 쉽다. 차원 축소, 특징 추출, 이상 탐지 등에 활용된다.

구분 일반 오토인코더 Sparse 오토인코더
활성화 대부분 켜짐 대부분 꺼짐
특징 분리 약함 강함
역할 복사에 가까움 특징 추출기에 가까움

4. Denoising Autoencoder

Denoising Autoencoder입력에 노이즈를 넣은 뒤, 그 손상된 입력을 원래 깨끗한 데이터로 복원하도록 학습한다. 복사만 하면 노이즈까지 그대로 나오므로, 모델이 본질적인 패턴을 학습하게 되고, 잡음에 강한 표현을 얻을 수 있다. 노이즈 제거뿐 아니라 강건한 표현 학습(robust representation learning) 으로 널리 쓰인다.


5. PyTorch 오토인코더: Encoder / Decoder

CIFAR10 같은 컬러 이미지에 맞춘 CNN 기반 Encoder–Decoder 예시다. 인코더는 stride가 있는 Conv2D로 32×32 → 16×16 → 8×8 → 4×4 로 줄인 뒤 Flatten → Linear로 잠재 벡터를 만든다. 디코더는 Linear로 4×4 채널 맵을 복원한 뒤 ConvTranspose2D로 다시 32×32까지 키운다. 활성화는 GELU를 사용한다.

5-1. GELU를 쓰는 이유

  • 부드러움: 0 근처에서 미분이 연속이라 기울기 소실/폭주 완화에 유리하다. (ReLU는 0에서 미분이 끊긴다.)
  • 정보 보존: 작은 음수도 일부 통과시켜 ReLU처럼 뉴런이 “죽는” 현상을 줄인다.
  • 경험적 성능: BERT·GPT, 비전 최신 아키텍처에서 GELU가 ReLU보다 잘 나오는 경우가 많다.
import torch.nn as nn

class Encoder(nn.Module):
    def __init__(self, num_input_channels, base_channel_size, latent_dim):
        super().__init__()
        # [B, C, H, W] -> 32x32 => 16x16 => 8x8 => 4x4
        self.net = nn.Sequential(
            nn.Conv2d(num_input_channels, base_channel_size, kernel_size=3, padding=1, stride=2),
            nn.GELU(),
            nn.Conv2d(base_channel_size, base_channel_size, kernel_size=3, padding=1),
            nn.GELU(),
            nn.Conv2d(base_channel_size, 2 * base_channel_size, kernel_size=3, padding=1, stride=2),
            nn.GELU(),
            nn.Conv2d(2 * base_channel_size, 2 * base_channel_size, kernel_size=3, padding=1),
            nn.GELU(),
            nn.Conv2d(2 * base_channel_size, 2 * base_channel_size, kernel_size=3, padding=1, stride=2),
            nn.GELU(),
            nn.Flatten(),
            nn.Linear(2 * 16 * base_channel_size, latent_dim),
        )

    def forward(self, x):
        return self.net(x)


class Decoder(nn.Module):
    def __init__(self, num_input_channels, base_channel_size, latent_dim):
        super().__init__()
        self.linear = nn.Sequential(nn.Linear(latent_dim, 2 * 16 * base_channel_size), nn.GELU())
        self.net = nn.Sequential(
            nn.ConvTranspose2d(2 * base_channel_size, 2 * base_channel_size, kernel_size=3, output_padding=1, padding=1, stride=2),
            nn.GELU(),
            nn.Conv2d(2 * base_channel_size, 2 * base_channel_size, kernel_size=3, padding=1),
            nn.GELU(),
            nn.ConvTranspose2d(2 * base_channel_size, base_channel_size, kernel_size=3, output_padding=1, padding=1, stride=2),
            nn.GELU(),
            nn.Conv2d(base_channel_size, base_channel_size, kernel_size=3, padding=1),
            nn.GELU(),
            nn.ConvTranspose2d(base_channel_size, num_input_channels, kernel_size=3, output_padding=1, padding=1, stride=2),
            nn.Tanh(),
        )

    def forward(self, x):
        x = self.linear(x)
        x = x.reshape(x.shape[0], -1, 4, 4)
        return self.net(x)


class Autoencoder(nn.Module):
    def __init__(self, num_input_channels, base_channel_size, latent_dim):
        super().__init__()
        self.encoder = Encoder(num_input_channels, base_channel_size, latent_dim)
        self.decoder = Decoder(num_input_channels, base_channel_size, latent_dim)

    def forward(self, x):
        latent = self.encoder(x)
        output = self.decoder(latent)
        return latent, output


model = Autoencoder(num_input_channels=3, base_channel_size=64, latent_dim=256)

output_paddingConvTranspose2d 출력 크기가 stride에 따라 한 칸 어긋날 수 있을 때, 목표 해상도에 맞추기 위한 보정 값이다.


6. 데이터·전처리·학습 (CIFAR10)

torchvision.transforms.v2 로 학습 시에는 RandomResizedCrop·Flip·Normalize, 테스트 시에는 Resize·Normalize만 적용한다. 입력과 타깃을 둘 다 원본 이미지로 두고 MSE로 재구성 손실을 줄인다.

import torch
from torchvision.transforms import v2
from torchvision.datasets import CIFAR10

trn_transforms = v2.Compose([
    v2.ToImage(),
    v2.RandomResizedCrop(size=(32, 32), antialias=True),
    v2.RandomHorizontalFlip(p=0.5),
    v2.RandomVerticalFlip(p=0.5),
    v2.ToDtype(torch.float32, scale=True),
    v2.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
])
test_transforms = v2.Compose([
    v2.ToImage(),
    v2.Resize(size=(32, 32), antialias=True),
    v2.ToDtype(torch.float32, scale=True),
    v2.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
])

trn_dataset = CIFAR10(".", train=True, download=True, transform=trn_transforms)
test_dataset = CIFAR10(".", train=False, download=True, transform=test_transforms)
trn_loader = torch.utils.data.DataLoader(trn_dataset, batch_size=64, shuffle=True, num_workers=2)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=64, shuffle=False, num_workers=2)

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
model = model.to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

학습 루프에서는 model(inputs) 의 복원 출력만 쓰고, loss = criterion(outputs, inputs) 로 학습하면 된다. 에폭마다 검증 loss를 재고, 원본·복원 이미지를 imshow 로 비교해 보면 된다.


7. 잠재 벡터로 유사 이미지 탐색

학습된 인코더만 써서 모든 이미지를 잠재 벡터로 옮긴 뒤, 유클리드 거리로 가장 가까운/먼 이미지를 찾을 수 있다. 거리가 작을수록 비슷한 이미지, 클수록 다른 이미지로 해석하면 된다.

from tqdm import tqdm

def get_embed(model, data_loader):
    image_list, embed_list = [], []
    model.eval()
    with torch.no_grad():
        for inputs, _ in tqdm(data_loader):
            inputs = inputs.to(device)
            latents = model.encoder(inputs)
            image_list.append(inputs.cpu())
            embed_list.append(latents)
    return torch.cat(image_list, dim=0), torch.cat(embed_list, dim=0)


def find_similar_images(query_image, query_embed, key_images, key_embeds, k=7):
    dist = torch.cdist(query_embed[None, :], key_embeds, p=2)
    dist = dist.squeeze(dim=0)
    _, topk_indices = torch.topk(dist, k, largest=False)
    topk_images = torch.cat([query_image[None], key_images[topk_indices.cpu()]], dim=0)
    # 가장 먼 이미지
    _, bottomk_indices = torch.topk(dist, k, largest=True)
    bottomk_images = torch.cat([query_image[None], key_images[bottomk_indices.cpu()]], dim=0)
    return topk_images, bottomk_images


test_images, test_embeds = get_embed(model, test_loader)
# 예: 첫 번째 테스트 이미지와 나머지 중 유사/상이 이미지
topk, bottomk = find_similar_images(test_images[0], test_embeds[0], test_images[1:], test_embeds[1:])

8. Denoising 학습 (노이즈 추가 후 복원)

입력에 랜덤 노이즈를 더해 넣고, 타깃은 원본(깨끗한) 이미지로 두면 Denoising Autoencoder가 된다. 모델이 노이즈를 걸러 내고 구조만 복원하도록 학습한다.

def add_noise(inputs):
    noise = torch.randn(inputs.size()) * 0.2
    return inputs + noise


def train_with_noise(model, criterion, optimizer, trn_loader, test_loader, device, num_epochs):
    for epoch in range(num_epochs):
        model.train()
        for inputs, _ in tqdm(trn_loader):
            inputs_noisy = add_noise(inputs).to(device)
            inputs_clean = inputs.to(device)
            _, outputs = model(inputs_noisy)
            loss = criterion(outputs, inputs_clean)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        # 검증: 노이즈 넣은 입력 -> 복원 출력 vs 원본 시각화
        model.eval()
        with torch.no_grad():
            for inputs, _ in test_loader:
                inputs_noisy = add_noise(inputs).to(device)
                inputs_clean = inputs.to(device)
                _, outputs = model(inputs_noisy)
                # imshow(inputs_clean, "Inputs"), imshow(outputs.cpu(), "Reconstructed") 등
                break

동일한 구조에 train_with_noise 로 학습하면 노이즈가 섞인 입력에서도 원본에 가깝게 복원하는 모델을 얻을 수 있다.


9. PathMNIST 적용

의료 이미지 데이터셋인 MedMNIST 의 PathMNIST를 쓸 때도 데이터만 바꾸고, 전처리·모델·학습 루프는 그대로 둬도 된다. medmnist 패키지로 로드한 뒤 기존 trn_transforms / test_transforms 를 넣어 Dataset·DataLoader를 만들고, 위와 같은 train 또는 train_with_noise 로 학습하면 된다.

# pip install medmnist
import medmnist

pathmnist_info = medmnist.INFO['pathmnist']
DataClass = getattr(medmnist, pathmnist_info['python_class'])
pathmnist_trn = DataClass(split='train', download=True, transform=trn_transforms)
pathmnist_test = DataClass(split='test', download=True, transform=test_transforms)
pathmnist_trn_loader = torch.utils.data.DataLoader(pathmnist_trn, batch_size=64, shuffle=True, num_workers=2)
pathmnist_test_loader = torch.utils.data.DataLoader(pathmnist_test, batch_size=64, shuffle=False, num_workers=2)

# train(model, ...) 또는 train_with_noise(model, ...) 동일하게 사용

학습이 끝나면 get_embed·find_similar_images 로 PathMNIST 테스트셋에서도 유사/상이 이미지를 찾을 수 있다.


10. 정리

주제 핵심 포인트
오토인코더 인코더로 압축(잠재 공간), 디코더로 복원. 재구성 오류 최소화로 비지도 학습.
인코더/디코더 고차원 → 저차원 요약 / 요약 → 고차원 복원. CNN·Linear 조합으로 구현.
Sparse AE 잠재 뉴런 희소 활성화 제약 → 특징 추출·해석에 유리.
Denoising AE 노이즈 있는 입력 → 깨끗한 출력으로 학습해 강건한 표현 학습.
잠재 벡터 활용 인코더 출력으로 유클리드 거리 기반 유사/상이 이미지 탐색 가능.

마치며

  • 오토인코더는 압축·복원을 통해 데이터의 핵심 표현을 배우며, VAE·GAN의 기반이 된다.
  • 기본 AE는 MNIST/Keras로 빠르게 체험하고, PyTorch CNN AE로 CIFAR10·PathMNIST 재구성·잠재 공간 시각화(유사 이미지)까지 이어갈 수 있다.
  • Denoising 은 노이즈를 넣고 깨끗한 데이터를 타깃으로 두면 되며, 같은 구조로 전환만 하면 된다.