MNIST Lab 데이터 증강 실험
기본 MNIST MLP 구현에서 출발해 학습 데이터에만 1픽셀 랜덤 이동을 적용하는 실험을 만든다.
핵심부터 말하면
기본 MNIST MLP 모델은 그대로 두고, 학습 batch에만 1픽셀 랜덤 이동을 적용해 본다. 모델 구조를 바꾸지 않고 입력 데이터만 살짝 흔들었을 때 학습 흐름이 어떻게 바뀌는지 확인하는 실험이다.
독자가 기본 MLP 코드가 있는 상태에서 증강 함수와 학습 루프 변경 지점을 추가해 같은 실험을 실행할 수 있어야 한다.
1. 왜 1픽셀만 움직이나
| 선택지 | 의미 | 이번 글의 판단 |
|---|---|---|
| 0픽셀 | 원본 그대로 학습 | 기본 모델 기준점이다. |
| 1픽셀 | 숫자를 위/아래/좌/우/대각선으로 조금 이동 | MNIST 숫자의 의미는 유지하면서 위치 변화에 익숙하게 만든다. |
| 큰 이동 | 숫자 위치가 크게 바뀜 | 초반 실험으로는 과하다. 빈 공간이 많아지고 숫자 일부가 잘릴 수 있다. |
x_train은 (N, 784)로 들어온다.
증강 순간에만 (N, 1, 28, 28)로 바꾼다.
상하좌우와 대각선 포함 9가지 위치 중 하나를 고른다.
기본 MLP가 받을 수 있게 (N, 784)로 되돌린다.
test 데이터는 절대 흔들지 않고 원본 그대로 평가한다.
2. random_shift_1px_flat 구현
기본 MLP는 입력을 784개 벡터로 받는다. 그래서 증강 함수 안에서만 잠깐 이미지 모양으로 바꿨다가 다시 펼친다. 이 점이 CNN용 random_shift와 가장 큰 차이다.
def random_shift_1px_flat(x, rng):
"""Shift flattened MNIST images by -1, 0, or +1 pixel on both axes."""
if x.ndim != 2 or x.shape[1] != 784:
raise ValueError("random_shift_1px_flat expects input shape (N, 784).")
images = x.reshape(-1, 1, 28, 28)
n, c, h, w = images.shape
padded = np.pad(images, ((0, 0), (0, 0), (1, 1), (1, 1)), mode="constant")
shifted = np.empty_like(images)
dy = rng.integers(-1, 2, size=n)
dx = rng.integers(-1, 2, size=n)
for move_y in (-1, 0, 1):
for move_x in (-1, 0, 1):
mask = (dy == move_y) & (dx == move_x)
if not np.any(mask):
continue
y0 = 1 + move_y
x0 = 1 + move_x
shifted[mask] = padded[mask, :, y0:y0 + h, x0:x0 + w]
return shifted.reshape(-1, 784)
3. 학습 루프에 끼우는 위치
증강은 batch를 뽑은 직후, forward 전에만 들어간다. label은 바꾸지 않는다. 숫자 7을 한 칸 옮겨도 여전히 숫자 7이기 때문이다.
def train_mlp_with_random_shift(model, optimizer, x_train, y_train, epochs, batch_size, seed):
rng = np.random.default_rng(seed)
num_train = x_train.shape[0]
loss_history = []
for epoch in range(epochs):
indices = rng.permutation(num_train)
epoch_loss_sum = 0.0
epoch_seen = 0
for start in range(0, num_train, batch_size):
batch_indices = indices[start:start + batch_size]
x_batch = random_shift_1px_flat(x_train[batch_indices], rng)
y_batch = y_train[batch_indices]
y_pred = model.forward(x_batch, train=True)
loss = cross_entropy_loss(y_pred, y_batch)
batch_size_actual = x_batch.shape[0]
dout = y_pred.copy()
dout[np.arange(batch_size_actual), y_batch] -= 1
dout /= batch_size_actual
model.backward(dout)
optimizer.update(model.params, model.grads)
epoch_loss_sum += float(loss) * batch_size_actual
epoch_seen += batch_size_actual
loss_history.append(epoch_loss_sum / epoch_seen)
print(f"epoch={epoch + 1:02d} loss={loss_history[-1]:.4f}", flush=True)
return loss_history
4. 실행 스크립트의 핵심
모델은 기본 글에서 만든 NeuralNetwork를 그대로 쓴다. 바뀌는 것은 입력 batch를 만드는 방식뿐이다.
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--epochs", type=int, default=15)
parser.add_argument("--batch-size", type=int, default=128)
parser.add_argument("--lr", type=float, default=0.001)
parser.add_argument("--dropout-ratio", type=float, default=0.5)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--train-limit", type=int, default=0)
parser.add_argument("--test-limit", type=int, default=0)
args = parser.parse_args()
np.random.seed(args.seed)
(x_train, y_train), (x_test, y_test) = load_mnist()
if args.train_limit:
x_train = x_train[:args.train_limit]
y_train = y_train[:args.train_limit]
if args.test_limit:
x_test = x_test[:args.test_limit]
y_test = y_test[:args.test_limit]
model = NeuralNetwork(
use_batchnorm=True,
use_dropout=True,
dropout_ratio=args.dropout_ratio,
)
optimizer = Adam(lr=args.lr)
started = time.time()
loss_history = train_mlp_with_random_shift(
model,
optimizer,
x_train,
y_train,
epochs=args.epochs,
batch_size=args.batch_size,
seed=args.seed,
)
elapsed = time.time() - started
accuracy, params = evaluate(model, x_test, y_test)
result = {
"model": "MLP(784)-Affine(512)-BN-ReLU-Dropout-Affine(256)-BN-ReLU-Dropout-Affine(10)-Softmax",
"augmentation": "random_shift_1px_train_only_flattened_mnist",
"optimizer": "Adam",
"lr": args.lr,
"epochs": args.epochs,
"batch_size": args.batch_size,
"dropout_ratio": args.dropout_ratio,
"seed": args.seed,
"train_size": int(x_train.shape[0]),
"test_size": int(x_test.shape[0]),
"loss_history": loss_history,
"final_test_accuracy": float(accuracy),
"params": int(params),
"elapsed_sec": elapsed,
}
out_dir = Path("results")
out_dir.mkdir(exist_ok=True)
limit_suffix = ""
if args.train_limit or args.test_limit:
limit_suffix = f"_train{args.train_limit or 'all'}_test{args.test_limit or 'all'}"
out_file = out_dir / f"mlp_random_shift_1px_e{args.epochs}_seed{args.seed}{limit_suffix}.json"
out_file.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False, indent=2))
print(f"saved={out_file}")
if __name__ == "__main__":
main()
5. 검증 결과
전체 데이터 1 epoch과 작은 데이터 2 epoch를 실제로 돌려 동작을 확인했다. 이 실험은 장시간 학습을 끝낸 성능 자랑 글이 아니라, 기본 MLP에서 데이터 증강을 독립적으로 연결하는 구현 글이다.
| 실험 | 설정 | 결과 | 해석 |
|---|---|---|---|
| 전체 데이터 빠른 확인 | train 60,000 / test 10,000 / 1 epoch | 95.94% | full 데이터 경로가 정상 동작함을 확인했다. |
| 소규모 빠른 확인 | train 5,000 / test 1,000 / 2 epoch | 86.00% | loss가 1.4097 -> 0.8067로 내려가 학습 연결이 정상임을 확인했다. |
{
"model": "MLP(784)-Affine(512)-BN-ReLU-Dropout-Affine(256)-BN-ReLU-Dropout-Affine(10)-Softmax",
"augmentation": "random_shift_1px_train_only_flattened_mnist",
"optimizer": "Adam",
"lr": 0.001,
"epochs": 1,
"batch_size": 128,
"dropout_ratio": 0.5,
"seed": 42,
"train_size": 60000,
"test_size": 10000,
"loss_history": [
0.5593071155725376
],
"final_test_accuracy": 95.94,
"params": 537354,
"elapsed_sec": 268.9510943889618
}
이 수치는 최종 성능 자랑이 아니라 증강이 실제 학습 루프에 연결되어 돌아가는지 확인한 기준점이다. 같은 모델에서 epoch 수와 seed를 맞춰 비교해야 증강 효과를 공정하게 말할 수 있다.
6. 이 실험의 한계
| 한계 | 이유 | 다음 판단 |
|---|---|---|
| 학습 시간이 길다 | NumPy CPU에서 6만 장 전체를 여러 epoch 돌리면 오래 걸린다. | 장시간 실험은 별도 로그로 관리하는 편이 낫다. |
| 1 epoch 수치는 최종 성능이 아니다 | 증강은 학습 초반에는 문제를 더 어렵게 만들 수 있다. | 최종 비교는 같은 epoch, 같은 seed, 같은 모델에서 해야 한다. |
| 증강이 항상 이득은 아니다 | MLP는 픽셀을 한 줄 벡터로 보므로 위치 이동을 구조적으로 이해하지는 못한다. | 같은 모델, 같은 epoch 조건에서 원본 학습과 비교해야 한다. |
7. 이 글에서 기억할 것
random shift 실험은 기본 MLP 모델을 유지한 채, 학습 batch만 살짝 흔들어 위치 변화에 더 견디는 입력을 만들어보는 실험이다.
- 왜 test 데이터에는 random shift를 적용하면 안 되는가?
- 기본 MLP에서 증강 함수가 입력을 다시 (N, 784)로 돌려줘야 하는 이유는 무엇인가?
- 증강을 batch 생성 전이 아니라 forward 직전에 넣는 이유는 무엇인가?
- 1 epoch 정확도를 최종 성능처럼 말하면 왜 위험한가?