Blog

Python + NumPy로 신경망 만들기 (2) — 사진을 인공지능이 먹을 수 있게 손질하고, 조금씩 나눠 주기

10 minute read

이 글은 notebooks/02_dataloader.ipynb에서 한 일을 처음부터 다시 설명합니다. 앞 글(01)에서 load_idx로 사진 6만 장을 꺼낸 다음 이야기입니다.

이 노트북에서 한 일, 한 줄로

“꺼낸 사진을 신경망에 넣기 좋은 모양과 숫자 범위로 바꾸고, 64장씩 섞어서 나눠 주는 함수를 만들었다.”

01에서 꺼낸 사진은 아직 “날것”입니다. 모양이 (60000, 28, 28)이고 값이 0~255인 uint8이죠. 신경망은 이걸 그대로 못 먹습니다. 식재료를 씻고 썰어서 접시에 조금씩 담아 주는 일, 그게 DataLoader입니다.


1. 왜 손질이 필요한가? 세 가지 문제

문제지금 상태신경망이 원하는 것
모양(60000, 28, 28) 사진 한 장이 28×28 격자사진 한 장이 숫자 한 줄
숫자 범위0~2550~1 사이의 작은 수
자료형uint8 (1바이트 정수)float32 (소수)

그리고 하나 더:

문제이유
6만 장을 한꺼번에?너무 크고, 학습도 느림. 64장씩 나눠서 줘야 함

하나씩 풀어 보겠습니다.


2. 모양 바꾸기: 28×28을 한 줄 784개로 (flatten)

다음 글에서 만들 Linear 레이어는 사진 한 장을 숫자 한 줄로 받습니다. 그래서 28×28 격자를 한 줄로 쫙 펴야 합니다. 28 × 28 = 784개.

flat = images.reshape(images.shape[0], images.shape[1] * images.shape[2])
print(flat.shape)    # (60000, 784)

reshape는 01에서 배운 그 “접기”입니다. 이번엔 반대로 펴는 거예요. 숫자는 그대로고 모양만 (60000, 28, 28) → (60000, 784).

더 짧게: -1

flat = images.reshape(images.shape[0], -1)

reshape에 -1을 넣으면 “나머지는 네가 계산해”라는 뜻입니다. 전체 4704만 개를 60000으로 나누면 784가 되니 numpy가 알아서 채웁니다. 사진 크기가 바뀌어도 코드를 안 고쳐도 됩니다.

펴면 잃는 것

격자를 한 줄로 펴면 “어느 픽셀이 어느 픽셀 옆에 있는지”라는 위치 정보가 사라집니다. 그런데도 MNIST에서는 꽤 잘 됩니다. 위치 정보를 살리는 모델(CNN)은 나중에 확장 아이디어로 남겨 둡니다.


3. 자료형 바꾸기: uint8은 계산 중에 깨진다

이게 이 노트북에서 가장 중요한 실험입니다.

a = np.uint8(200)
b = np.uint8(100)
print(a + b)         # 44  ← 300이 아니다!

uint8은 0~255까지만 담을 수 있습니다. 255를 넘으면 에러가 나는 게 아니라 0으로 돌아가서 다시 셉니다. 자동차 주행거리계가 99999 다음에 00000이 되는 것과 같아요.

300 - 256 = 44

이걸 오버플로우(overflow)라고 합니다. 더 무서운 건, 숫자 하나로 계산하면 경고라도 뜨지만 배열 연산에서는 경고 없이 조용히 틀린 값을 돌려준다는 겁니다.

같은 계산을 float32로 하면:

a = np.float32(200)
b = np.float32(100)
print(a + b)         # 300.0  ← 정상

신경망 학습은 더하고, 빼고, 곱하고, 평균내는 계산의 연속입니다. uint8로 하면 어딘가에서 반드시 깨집니다. 그래서 학습 전에 반드시 float로 바꿉니다.

x = flat.astype(np.float32)
print(x.dtype)       # float32

astype은 원본을 바꾸지 않고 새 배열을 만들어 돌려줍니다. 그래서 x = ...로 받아야 해요.

왜 float64가 아니라 float32?

자료형크기60000 × 784개면
float324바이트약 188MB
float648바이트약 376MB

float64가 더 정밀하지만 신경망에는 그 정밀도가 필요 없고, 메모리를 2배 씁니다. PyTorch, TensorFlow도 기본값이 float32입니다.


4. 숫자 범위 줄이기: 0~255를 0~1로

x = x / 255
print(x.min(), x.max(), x.dtype)    # 0.0 1.0 float32

255로 나누면 0은 0.0, 255는 1.0이 됩니다. 이걸 정규화(normalization)라고 합니다.

왜 줄이나?

Linear 레이어는 입력 784개에 각각 가중치를 곱해서 더합니다. 입력이 0~255면 그 합이 수천, 수만이 되고, 학습할 때 가중치가 한 번에 너무 크게 흔들려서 학습이 튀거나 발산합니다. 0~1로 줄이면 계산이 얌전해집니다.

주의: 같은 셀을 두 번 실행하면?

x = x / 255     # 한 번 더 실행하면 x의 최댓값이 0.0039가 된다

노트북은 셀을 아무 순서로나 몇 번이든 실행할 수 있어서, 같은 변수를 덮어쓰면 이런 사고가 납니다. 에러도 안 나고 값만 조용히 틀어져요. 이걸 막는 가장 좋은 방법이 다음 절의 함수로 묶기입니다.


5. 함수로 묶기: preprocess

2, 3, 4절을 합치면 이겁니다.

def preprocess(images):
    flat = images.reshape(images.shape[0], -1)   # 1. 한 줄로 펴기
    x = flat.astype(np.float32)                   # 2. float32로
    x = x / 255                                   # 3. 0~1로
    return x

x = preprocess(images)
print(x.shape, x.dtype, x.min(), x.max())    # (60000, 784) float32 0.0 1.0

함수는 매번 원본 images를 받아서 처음부터 계산하니까 몇 번을 실행해도 결과가 같습니다. 노트북에서 두 번 연속 실행해서 같은 결과가 나오는 걸 확인했습니다.


6. 섞기: permutation과 팬시 인덱싱

이제 “64장씩 나눠 주기”를 준비합니다. 그 전에 섞는 방법부터 봅니다.

왜 섞나?

데이터가 0, 0, 0, …, 1, 1, 1, … 순서로 정렬되어 있다면, 모델은 한동안 “정답은 무조건 0”이라고 배우다가 다음엔 “무조건 1”로 휩쓸립니다. 매번 새로 섞으면 배치마다 여러 숫자가 골고루 섞여 들어가서 특정 순서에 길들지 않습니다.

섞인 번호표 만들기

idx = np.random.permutation(10)
print(idx)    # [0 2 1 9 4 8 7 3 5 6]  (실행할 때마다 다름)

permutation(10)은 0~9를 무작위 순서로 늘어놓은 배열입니다. 이게 “몇 번째를 먼저 꺼낼지” 번호표예요.

사진과 정답을 같은 순서로 섞기

여기가 핵심입니다. 사진과 정답을 따로 섞으면 짝이 엇갈립니다. 5 그림에 “3”이라는 정답이 붙는 거죠. 그래서 번호표 하나를 둘 다에 씁니다.

a = np.array([10, 11, 12, 13, 14])          # 사진이라고 생각
b = np.array(["가", "나", "다", "라", "마"])  # 정답이라고 생각

idx = np.random.permutation(len(a))
print(idx)       # [3 1 2 0 4]
print(a[idx])    # [13 11 12 10 14]
print(b[idx])    # ['라' '나' '다' '가' '마']

섞인 뒤에도 13↔라, 10↔가처럼 짝이 그대로입니다.

a[idx]가 하는 일: 팬시 인덱싱

a[idx]는 “idx에 적힌 번호의 원소를, idx 순서대로 꺼내서 새 배열을 만든다”입니다.

idx    = [3,    1,    2,    0,    4   ]
a[idx] = [a[3], a[1], a[2], a[0], a[4]]
       = [13,   11,   12,   10,   14  ]

번호 하나가 아니라 번호 배열을 넣어서 여러 개를 한 번에 꺼내는 걸 팬시 인덱싱(fancy indexing)이라고 합니다. 이 글 뒤에서도, 04(손실 함수)에서도 계속 나옵니다.


7. 자르기: 64장씩

먼저 계산

6만 장을 64장씩 자르면?

n_full = 60000 // 64      # 937  ← 꽉 찬 배치 개수 (몫)
remainder = 60000 % 64    # 32   ← 마지막에 남는 장수 (나머지)

937개 + 마지막 32장짜리 1개 = 938개. 이 숫자는 나중에 테스트에서 정답으로 씁니다.

작은 배열로 원리 확인

10개를 3개씩 자르는 예:

a = np.arange(10)
batch_size = 3

for start in range(0, len(a), batch_size):
    batch = a[start : start + batch_size]
    print(start, batch)
0 [0 1 2]
3 [3 4 5]
6 [6 7 8]
9 [9]        ← 마지막은 1개만

range(0, 10, 3)은 0, 3, 6, 9를 만들고, 거기서 3개씩 자릅니다.

마지막 배치가 왜 에러 없이 잘리나?

start = 9일 때 a[9:12]를 꺼내는데, 배열에는 9번까지밖에 없습니다. 그런데 슬라이스는 범위를 넘으면 있는 데까지만 돌려줍니다. 그래서 [9]가 나와요. 반면 a[12]처럼 원소 하나를 직접 지정하면 에러가 납니다. 슬라이스와 인덱싱은 이 점이 다릅니다.

이 성질 덕분에 마지막 32장짜리 배치를 위한 특별 처리가 필요 없습니다.


8. yield: 한 번에 하나씩 건네주기

938개 배치를 전부 미리 만들어서 리스트에 담으면 메모리를 많이 씁니다. 대신 필요할 때 하나씩 만들어 주는 방법이 yield입니다.

def count_up(n):
    for i in range(n):
        yield i

for v in count_up(3):
    print(v)        # 0, 1, 2

return은 값을 하나 돌려주고 함수를 끝냅니다. yield는 값을 하나 돌려주고 함수를 잠시 멈춰 둡니다. 다음 값이 필요하면 멈춘 자리부터 이어서 실행합니다. 그래서 for 문에서 하나씩 꺼내 쓸 수 있어요.


9. 합치기: iterate_batches

6, 7, 8절을 합치면 DataLoader가 됩니다.

def iterate_batches(x, y, batch_size, shuffle=True):
    index = np.arange(len(x))                    # 번호표: [0, 1, 2, ..., 59999]
    if shuffle:
        index = np.random.permutation(len(x))    # 섞을 거면 번호표를 섞는다

    for start in range(0, len(x), batch_size):
        x_batch = x[index[start:start+batch_size]]   # 번호표에서 64개 잘라서, 그 번호의 사진
        y_batch = y[index[start:start+batch_size]]   # 같은 번호의 정답
        yield x_batch, y_batch

한 줄씩:

  1. 번호표를 만듭니다. np.arange는 0부터 차례로.
  2. shuffle이면 번호표를 섞습니다. 안 섞으면 원래 순서 그대로.
  3. 번호표를 64개씩 잘라서, 그 번호의 사진과 정답을 같은 번호표로 꺼냅니다. 짝이 안 깨지는 이유가 이겁니다.
  4. yield로 하나씩 건네줍니다.

이 설계의 좋은 점

사진 6만 장 배열을 통째로 섞지 않고 번호표만 섞습니다. 번호표는 숫자 6만 개라 가볍고, 사진은 배치를 꺼낼 때 필요한 64장만 복사합니다. 그리고 shuffle=False일 때는 np.arange로 원래 순서를 쓰니까 같은 코드로 두 경우를 다 처리합니다.

확인

batches = list(iterate_batches(x, labels, 64))
print(len(batches))                                  # 938
print(batches[0][0].shape, batches[0][1].shape)      # (64, 784) (64,)
print(batches[-1][0].shape, batches[-1][1].shape)    # (32, 784) (32,)

7절에서 손으로 계산한 938, 32가 그대로 나옵니다.

batches = list(iterate_batches(x, labels, 64, shuffle=False))
print(batches[0][1][:10])    # [5 0 4 1 9 2 1 3 1 4]
print(labels[:10])           # [5 0 4 1 9 2 1 3 1 4]  ← 같다

안 섞으면 원본 순서 그대로. 01에서 본 첫 정답 5가 맨 앞에 있습니다.

섞어도 짝이 맞는지는 어떻게 아나?

레이블만 봐서는 모릅니다. 01처럼 그림을 그려서 정답과 비교하면 됩니다.

xb, yb = next(iterate_batches(x, labels, 64))    # 첫 배치만 꺼내기
img = xb[0].reshape(28, 28)                       # 784 → 28×28로 다시 접기
for row in img:
    print("".join("#" if p > 0.5 else "." for p in row))
print("label:", yb[0])

실행할 때마다 다른 그림이 나오지만, 그림과 label은 항상 같은 숫자입니다. 기준값이 128이 아니라 0.5인 건 이미 255로 나눠서 값이 0~1이기 때문이에요.


10. 만들면서 겪은 실수 세 가지

실수 1: 들여쓰기로 함수가 중간에 끝남 for 문 앞에서 들여쓰기가 빠져서 Python이 “함수는 여기서 끝”이라고 보고, 밖에 있는 yield에서 에러가 났습니다. Python은 들여쓰기로 “어디까지가 함수인지”를 판단합니다.

실수 2: 반대로 for 문이 if 안에 들어감 고치다가 이번엔 for 문을 if shuffle: 안으로 들여써 버렸습니다. 그러면 shuffle=False일 때 배치가 0개 나옵니다. shuffle=True로만 테스트해서 한동안 몰랐어요. 그래서 테스트에 shuffle=False 확인을 꼭 넣었습니다.

실수 3: 나머지가 있으면 batch_size를 1 늘림 마지막 배치가 32장으로 작게 남는 게 “문제”라고 생각해서 batch_size += 1을 넣었는데, 그러면 배치 크기가 65가 되고 개수가 924개로 틀어집니다. 마지막 배치가 작은 건 정상이고, 배치 크기는 사용자가 정한 값이라 함수가 바꾸면 안 됩니다.


11. 이 노트북에서 배운 것

용어뜻
flatten28×28 격자를 784개 한 줄로 펴기
reshape(n, -1)“-1은 네가 계산해”
오버플로우uint8에서 255를 넘으면 0으로 돌아가는 것. 200+100=44
astype(np.float32)자료형 바꾸기. 새 배열을 돌려줌
정규화0~255를 255로 나눠 0~1로
np.random.permutation(n)0~n-1을 무작위 순서로
팬시 인덱싱a[idx], 번호 배열로 여러 개를 한 번에 꺼내기
//, %몫, 나머지
슬라이스a[9:12], 범위를 넘어도 있는 데까지만
yield값을 하나 주고 멈춰 뒀다가 이어서 실행
미니배치전체를 64장씩 나눈 것
셔플매번 순서를 섞어서 배치 구성이 달라지게

스스로 확인해 보기

  1. images.reshape(60000, -1)에서 -1 자리에 numpy가 채우는 숫자는?
  2. np.uint8(250) + np.uint8(10)은 얼마가 나올까요?
  3. 사진 배열과 정답 배열에 permutation을 따로 호출하면 무슨 문제가 생기나요?
  4. 배치 크기를 100으로 하면 배치는 몇 개이고, 마지막 배치는 몇 장일까요?
  5. iterate_batches에서 shuffle=False일 때 index는 무엇인가요?
  6. preprocess를 함수로 만들면 “같은 셀 두 번 실행” 문제가 왜 사라지나요?
답
  1. 784 (= 47,040,000 ÷ 60,000)
  2. 4 (260 − 256)
  3. 번호표가 달라서 사진과 정답의 짝이 엇갈립니다. 5 그림에 엉뚱한 정답이 붙습니다.
  4. 600개, 마지막 100장 (60000 % 100 = 0이라 딱 떨어짐)
  5. np.arange(60000), 즉 [0, 1, 2, ..., 59999] 원래 순서
  6. 함수는 매번 원본 images를 받아 처음부터 계산하니까, x를 거듭 255로 나누는 일이 없습니다.

이전 글: 01. 파일을 열어서 숫자 그림을 꺼내 보기

다음 글: 03. 784개 숫자를 받아 “0~9 중 뭘까” 점수 10개로 바꾸는 Linear 레이어와, 그 사이에 끼우는 ReLU

시리즈 소개: 00. 시작하며 (전체 목차)