Python + NumPy로 신경망 만들기 (2) — 사진을 인공지능이 먹을 수 있게 손질하고, 조금씩 나눠 주기
이 글은
notebooks/02_dataloader.ipynb에서 한 일을 처음부터 다시 설명합니다. 앞 글(01)에서load_idx로 사진 6만 장을 꺼낸 다음 이야기입니다.
이 노트북에서 한 일, 한 줄로
“꺼낸 사진을 신경망에 넣기 좋은 모양과 숫자 범위로 바꾸고, 64장씩 섞어서 나눠 주는 함수를 만들었다.”
01에서 꺼낸 사진은 아직 “날것”입니다. 모양이 (60000, 28, 28)이고 값이 0~255인 uint8이죠. 신경망은 이걸 그대로 못 먹습니다. 식재료를 씻고 썰어서 접시에 조금씩 담아 주는 일, 그게 DataLoader입니다.
1. 왜 손질이 필요한가? 세 가지 문제
| 문제 | 지금 상태 | 신경망이 원하는 것 |
|---|---|---|
| 모양 | (60000, 28, 28) 사진 한 장이 28×28 격자 | 사진 한 장이 숫자 한 줄 |
| 숫자 범위 | 0~255 | 0~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개면 |
|---|---|---|
float32 | 4바이트 | 약 188MB |
float64 | 8바이트 | 약 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
한 줄씩:
- 번호표를 만듭니다.
np.arange는 0부터 차례로. shuffle이면 번호표를 섞습니다. 안 섞으면 원래 순서 그대로.- 번호표를 64개씩 잘라서, 그 번호의 사진과 정답을 같은 번호표로 꺼냅니다. 짝이 안 깨지는 이유가 이겁니다.
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. 이 노트북에서 배운 것
| 용어 | 뜻 |
|---|---|
| flatten | 28×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장씩 나눈 것 |
| 셔플 | 매번 순서를 섞어서 배치 구성이 달라지게 |
스스로 확인해 보기
images.reshape(60000, -1)에서-1자리에 numpy가 채우는 숫자는?np.uint8(250) + np.uint8(10)은 얼마가 나올까요?- 사진 배열과 정답 배열에
permutation을 따로 호출하면 무슨 문제가 생기나요? - 배치 크기를 100으로 하면 배치는 몇 개이고, 마지막 배치는 몇 장일까요?
iterate_batches에서shuffle=False일 때index는 무엇인가요?preprocess를 함수로 만들면 “같은 셀 두 번 실행” 문제가 왜 사라지나요?
답
- 784 (= 47,040,000 ÷ 60,000)
- 4 (260 − 256)
- 번호표가 달라서 사진과 정답의 짝이 엇갈립니다. 5 그림에 엉뚱한 정답이 붙습니다.
- 600개, 마지막 100장 (60000 % 100 = 0이라 딱 떨어짐)
np.arange(60000), 즉[0, 1, 2, ..., 59999]원래 순서- 함수는 매번 원본
images를 받아 처음부터 계산하니까,x를 거듭 255로 나누는 일이 없습니다.
이전 글: 01. 파일을 열어서 숫자 그림을 꺼내 보기
다음 글: 03. 784개 숫자를 받아 “0~9 중 뭘까” 점수 10개로 바꾸는 Linear 레이어와, 그 사이에 끼우는 ReLU
시리즈 소개: 00. 시작하며 (전체 목차)