서론: 딥러닝 내 이미지 생성 과정 탐구
딥러닝 모델 중 이미지를 생성하거나 업스케일링하는 데 필수적인 전치 합곱 (Transposed Convolution) 은 그 이름과 달리 단순한 역연산이 아닙니다. 이 연산이 실제로 어떻게 픽셀 값을 보간하고 필터를 적용하는지에 대한 직관적인 이해는 종종 어렵습니다. 본 글에서는 복잡한 네트워크 구조 대신 단순화된 합성 데이터셋을 사용하여 오토인코더 모델을 구축함으로써, 전치 합곱 레이어의 동작 원리를 단계별로 분해하고 시각화하는 방법을 다룹니다.
또한, 학습 과정에서 발생할 수 있는 불안정성을 해결하기 위해 잠재 공간 (Latent Space) 과 입력 데이터에 노이즈를 주입하는 등 모델의 견고성을 높이는 기술적인 개선점도 함께 제시합니다.
데이터셋 구성: 1 차원 베제 곡선
실험의 목표는 인공적으로 생성된 1 차원 데이터 포인트를 네트워크가 압축했다가 다시 복원하는 것입니다. 여기서는 수학적으로 정의 가능한 3 차 베제 곡선을 사용합니다. 각 곡선은 두 개의 제어 파라미터로 결정되며, 이를 통해 임의의 형태의 웨이브폼을 생성할 수 있습니다.
import numpy as np
from matplotlib import pyplot as plt
# 데이터 파라미터 설정
TOTAL_SAMPLES = 1024 * 16
CURVE_LENGTH = 15
def synthesize_bezier_trace(param_a, param_b):
"""
두 개의 파라미터를 기반으로 1 차원 베제 곡선 포인트를 계산합니다.
"""
# 곡선 정의점 설정
p_start = {'x': 0, 'y': 0}
p_ctrl1 = {'x': 0.5, 'y': param_a}
p_ctrl2 = {'x': 0.5, 'y': param_b}
p_end = {'x': 1, 'y': 0}
# 높은 해상도로 초기화하여 리샘플링 수행
t_values = np.linspace(0, 1, CURVE_LENGTH * 10)
xs = (1-t_values)**3 * p_start['x'] + 3*(1-t_values)**2*t_values * p_ctrl1['x'] + \
3*(1-t_values)*t_values**2 * p_ctrl2['x'] + t_values**3 * p_end['x']
ys = (1-t_values)**3 * p_start['y'] + 3*(1-t_values)**2*t_values * p_ctrl1['y'] + \
3*(1-t_values)*t_values**2 * p_ctrl2['y'] + t_values**3 * p_end['y']
# 타겟 길이에 맞게 Y 좌표만 추출
target_xs = np.linspace(0, 1, CURVE_LENGTH)
resampled_ys = []
for tx in target_xs:
idx = np.argmin(np.abs(xs - tx))
resampled_ys.append(ys[idx])
return np.array(resampled_ys)
def prepare_training_dataset():
traces = []
for i in range(TOTAL_SAMPLES):
p1 = np.random.uniform() * 2 - 1
p2 = np.random.uniform() * 2 - 1
trace = synthesize_bezier_trace(p1, p2)
traces.append(trace)
if i < 9:
plt.subplot(3, 3, i+1)
plt.plot(trace)
plt.show()
return np.array(traces)
위 코드는 각 데이터 샘플이 15 개의 값으로 구성된 배열임을 확인할 수 있습니다. 네트워크는 [x, y] 쌍이 아닌单纯的인 y 값 배열을 처리하며, 최종적으로는 이 긴 시퀀스를 2 차원의 벡터로 압축하는 능력을 학습하게 됩니다.
모델 아키텍처: 패치 기반 분할 전략
전체 데이터 길이를 한 번에 처리하기보다, 먼저 작은 단위인 '패치' 단위로 학습을 진행하는 것이 효과적입니다. 예를 들어 15 픽셀의 곡선을 길이가 7 인 블록들로 나누어 인코더와 디코더를 먼저 훈련시킬 수 있습니다.
이 방식은 다음과 같은 장점을 가집니다:
- 국소적인 패턴 (7 픽셀 단위) 을 먼저 학습시킴
- 학습 후 전체 시퀀스로 확장 가능
- 각 층의 역할을 독립적으로 분석 용이
핵심 구현: 인코더와 디코더 모듈
PyTorch 를 활용하여 신호를 압축하는 인코더와 복원하는 디코더를 정의합니다. 여기서 중요한 점은 스트라이드 (Stride) 설정입니다. 필터 이동 단계를 조정하여 출력 크기를 제어해야 합니다.
import torch
import torch.nn as nn
class FeatureCompressor(nn.Module):
def __init__(self, latent_dim, kernel_size):
super().__init__()
# 채널 1 에서 latent_dim 으로 downsampling
self.conv_layer = nn.Conv1d(1, latent_dim, kernel_size, stride=4)
def forward(self, signal):
# 활성화 함수를 통해 비선형성 부여
return nn.Tanh()(self.conv_layer(signal))
class SignalRestorer(nn.Module):
def __init__(self, latent_dim, kernel_size):
super().__init__()
# 전치 합곱을 이용해 채널 1 로 upsampling
self.trans_conv = nn.ConvTranspose1d(latent_dim, 1, kernel_size, stride=4)
def forward(self, feature_vector):
return self.trans_conv(feature_vector)
스트라이드를 4 로 설정한 이유는 필터가 4 픽셀 간격으로 이동하면서 7 픽셀의 윈도우를 적용하면, 자연스럽게 15 픽셀 정도의 출력이 나오도록 설계되었기 때문입니다.
학습 프로세스 및 결과 분석
훈련은 여러 랜덤 시드 (Seed) 를 사용하여 반복됩니다. 이는 모델이 무작위 초기값에 의존하지 않고 일관된 특징을 학습하는지 검증하기 위함입니다.
def train_single_trial(seed_value, dataset_tensor, epochs=5000):
torch.manual_seed(seed_value)
# 데이터 로딩 및 배치 준비
loader = DataLoader(dataset_tensor, batch_size=128, shuffle=True)
encoder = FeatureCompressor(channels=3, kernel_size=7)
decoder = SignalRestorer(channels=3, kernel_size=7)
optimizer = torch.optim.Adam(
list(encoder.parameters()) + list(decoder.parameters()), lr=1e-4
)
loss_fn = nn.MSELoss()
for epoch in range(epochs):
total_err = 0
for batch_data in loader:
optimizer.zero_grad()
# 인코딩 -> 잠재벡터 -> 디코딩
compressed = encoder(batch_data)
restored = decoder(compressed)
error = loss_fn(restored, batch_data)
error.backward()
optimizer.step()
total_err += error.item()
if epoch % 10 == 0:
avg_loss = total_err / len(loader)
print(f"Epoch {epoch}: Loss {avg_loss:.4f}")
return encoder, decoder
필터 시각화를 통한 동작 분석
학습이 완료된 후 디코더의 가중치 행렬을 확인하면 흥미로운 현상을 관찰할 수 있습니다. 잠재 벡터 (Latent Vector) 의 각 요소가 필터를 어떻게 스케일링하여 조합하는지 애니메이션으로 추적하면, 특정 벡터 성분이 커질 때 해당 필터가 더 강하게 발현됨을 알 수 있습니다.
반면 인코더는 입력 신호와 필터 간의 내적 (Dot Product) 을 통해 점수화된 값을 생성합니다. 이는 입력의 국소적인 형태가 몇 번째 특징에 해당하는지를 판별하는 과정과 동일합니다.
문제점 도출 및 개선 방안
기본적인 구현은 잘 작동하지만, 다음과 같은 두 가지 주요 문제를 발견할 수 있습니다.
- 가중치의 부호와 순서 불일치: 시드를 바꾸면 학습된 필터의 모양이 비슷해도 순서나 부호가 뒤바뀌는 경우가 빈번함.
- 과도한 데이터 민감도: 합성 데이터가 너무 완벽해서 네트워크가 실제 데이터의 잡음이나 변형에 취약함.
해결책 1: 잠재 공간에 따른 잡음 주입
특징의 중요도를 계층화하기 위해 잠재 벡터에 크기가 다른 가우스 잡음을 추가합니다. 이는 PCA 와 유사하게 정보를 압축하는 방식을 강제합니다.
for batch in loader:
# ... 학습 루프 시작
z = encoder(batch)
# 각 차원마다 다른 세기의 노이즈 생성
noise_mask = torch.randn_like(z)
noise_mask[:, 0] *= 0.1 # 주요 특징: 낮은 잡음
noise_mask[:, 1] *= 0.01 # 보조 특징: 중간 잡음
noise_mask[:, 2] *= 0.001 # 세부 특징: 높은 잡음
# 노이즈를 섞어서 디코더에 전달
out = decoder(z + noise_mask)
# ... 손실 계산
이 방법을 적용하면, 가장 중요한 정보는 잡음이 적은 첫 번째 차원에 자동으로 배치되는 경향이 나타납니다. 결과적으로 필터들의 역할 분담이 명확해지고 모델 간 일관성이 향상됩니다.
해결책 2: 입력 데이터 라우징 (Jittering)
완벽한 베제 곡선 데이터는 네트워크가 정확한 좌표 값에만 집중하게 만들어 불필요한 패턴을 학습시킬 수 있습니다. 입력 데이터 자체에 미세한 노이즈를 추가하면, 네트워크는 개별 포인트보다는 전체적인 형태 (Smoothness) 를 학습하도록 유도됩니다.
# 입력에 직접 노이즈를 혼합
input_noise = torch.randn_like(batch) * 0.01
noisy_input = batch + input_noise
z = encoder(noisy_input)
# ... 나머지 프로세스
입력 잡음과 잠재 공간 잡음을 동시에 적용한 모델은 훨씬 더 매끄러운 필터 응답을 보여주며, 일반화 성능이 유의미하게 상승합니다.
최종 통합: 완전 모델 적용 및 오버랩 문제
각 패치 단위의 학습이 안정되면, 이제 이 모듈들을 연속적으로 연결하여 전체 길이 (15 포인트) 의 곡선을 처리하는 모델을 완성합니다. 다만, 전치 합곱의 특성상 필터들이 서로 겹치는 영역 (Overlap) 에서 값이 누적될 문제가 발생합니다.
스트라이드가 4 이고 커널이 7 인 경우, 출력 영역의 끝 부분들에서 가중치가 중복되어 합쳐집니다. 이를 해결하기 위해 디코더 출력 시 겹치는 지점의 가중치를 강제로 반감시키는 후처리 로직을 삽입할 수 있습니다.
def apply_overlap_correction(model_dec):
# 겹치지 않는 중앙 픽셀은 그대로 두고 나머지는 가중치 조정
# 인덱스 0, 1, 2 와 끝부분인 -1, -2, -3 대응
layers = model_dec.trans_conv.weight.data
# 경계 영역 필터 가중치 0.5 배로 축소하여 누적 효과 방지
adjustment_indices = [0, 1, 2, -1, -2, -3]
for idx in adjustment_indices:
layers[:, :, idx] *= 0.5
return model_dec
이러한 조정을 거친 모델은 전체 곡선을 부드럽게 재구성하며, 경계에서의 급격한 값 변화를 완화시킵니다. 다만 완벽한 복원을 위해서는 패딩 (Padding) 전략을 병행하거나 경계 영역을 손실 함수에서 제외하는 등의 추가 연구가 필요합니다. 현재 시점에서는 노이즈 주입과 가중치 보정이 합성 데이터상의 전치 합곱 동작을 시각화하고 최적화하는 핵심 기법으로 충분함을 확인할 수 있습니다.