Blog

Python + NumPy로 신경망 만들기 (1) — 파일을 열어서 숫자 그림을 꺼내 보기

10 minute read

이 글은 notebooks/01_explore_data.ipynb에서 한 일을 처음부터 다시 설명합니다. 수학이나 인공지능을 몰라도 읽을 수 있게 썼습니다.

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

“압축된 파일 안에 손글씨 숫자 6만 장이 들어 있는데, 그걸 파이썬으로 꺼내서 눈으로 확인했다.”

인공지능에게 숫자를 가르치려면 먼저 “문제집”이 필요합니다. MNIST가 그 문제집이고, 이 노트북은 문제집을 펼쳐서 읽을 수 있게 만드는 단계입니다.


1. MNIST가 뭔가요?

사람들이 손으로 쓴 숫자 0~9를 모아 놓은 사진 묶음입니다.

  • 사진 한 장은 가로 28칸, 세로 28칸짜리 모눈종이입니다. 칸이 28×28 = 784개.
  • 칸마다 0부터 255까지의 숫자가 적혀 있습니다. 0은 완전 검정, 255는 완전 흰색, 중간은 회색.
  • 사진마다 정답(레이블)이 붙어 있습니다. “이 사진은 5야” 같은 식으로요.

파일은 4개입니다.

파일내용용도
train-images사진 60,000장공부용 (학습)
train-labels그 사진들의 정답 60,000개공부용
t10k-images사진 10,000장시험용 (테스트)
t10k-labels그 사진들의 정답 10,000개시험용

공부용과 시험용을 나누는 이유는, 공부한 문제로 시험을 보면 외워서 푼 건지 진짜 이해한 건지 알 수 없기 때문입니다.


2. 파일 받기

!mkdir -p data
!cd data && for f in ...; do curl -sO https://ossci-datasets.s3.amazonaws.com/mnist/$f.gz; done
!ls -lh data

노트북에서 줄 앞에 !를 붙이면 “파이썬이 아니라 터미널 명령으로 실행해라”는 뜻입니다. curl은 인터넷에서 파일을 내려받는 명령이고요.

파일 이름 끝의 .gz는 압축되어 있다는 표시입니다. 그래서 열 때 gzip이라는 도구가 필요합니다.


3. 파일 안을 들여다보기

여기가 이 노트북에서 가장 중요한 부분입니다.

3-1. 파일은 그냥 숫자의 나열이다

컴퓨터 파일은 결국 0~255 사이의 숫자(바이트)가 쭉 늘어선 것입니다. 사진 파일도, 음악 파일도요. MNIST 파일도 마찬가지인데, 앞부분에 “이 파일에 뭐가 들어 있는지”를 알려 주는 머리말(헤더)이 있고, 그 뒤에 진짜 내용(픽셀)이 이어집니다.

머리말이 16바이트라는 건 어떻게 아나?

무작위로 읽어 본 게 아닙니다. 알아내는 방법이 세 가지 있고, 노트북에서는 셋을 다 썼습니다.

방법 1: 파일 형식의 설명서를 읽는다

MNIST 파일은 IDX라는 형식입니다. 대부분의 파일 형식(PNG, MP3, IDX…)은 만든 사람이 “앞에서부터 몇 바이트가 무엇인지”를 문서로 남겨 둡니다. 이걸 스펙(specification)이라고 하고, IDX 스펙은 만든 사람(Yann LeCun)의 홈페이지에 있습니다. 이미지 파일 부분은 이렇습니다.

[위치]  [크기]          [값]            [뜻]
0000    4바이트 정수     2051            매직 넘버
0004    4바이트 정수     60000           사진 개수
0008    4바이트 정수     28              세로 칸 수
0012    4바이트 정수     28              가로 칸 수
0016    1바이트          ??              첫 번째 픽셀
0017    1바이트          ??              두 번째 픽셀
...

4바이트짜리 숫자가 4개니까 4 × 4 = 16바이트가 머리말이고, 16번째 위치부터 픽셀이 시작합니다. 그래서 f.read(16)을 한 겁니다.

정답(레이블) 파일의 스펙은 더 짧습니다.

[위치]  [크기]          [값]            [뜻]
0000    4바이트 정수     2049            매직 넘버
0004    4바이트 정수     60000           정답 개수
0008    1바이트          ??              첫 번째 정답
0009    1바이트          ??              두 번째 정답
...

4바이트짜리가 2개뿐이라 4 × 2 = 8바이트. 노트북에서 정답 파일을 f.read(8)로 읽은 이유입니다.

새로운 파일 형식을 만났을 때의 순서는 보통 이렇습니다.

  1. 스펙을 찾는다. “IDX file format”으로 검색하면 위 표가 나옵니다.
  2. 스펙대로 읽어 보고 숫자가 말이 되는지 확인한다. 60000, 28, 28이 나오면 맞게 읽은 것이고, 엉뚱한 숫자가 나오면 스펙을 잘못 읽었거나 big-endian을 반대로 한 겁니다.
  3. 스펙이 없을 때만 바이트를 찍어 보며 추측한다. (이번엔 필요 없었습니다.)

방법 2: 파일 이름을 본다

train-images-**idx3**-ubyte, train-labels-**idx1**-ubyte. 이름 속 idx3은 “3차원”, idx1은 “1차원”이라는 뜻입니다. ubyte는 “unsigned byte”, 즉 값 하나가 1바이트(0~255)라는 뜻이고요. 스펙을 안 봐도 이름만으로 “이미지는 크기 정보가 3개, 정답은 1개구나”를 알 수 있게 지어 놓은 겁니다.

방법 3: 파일이 스스로 말해 준다 (가장 중요)

스펙을 잘 보면 처음 4바이트(매직 넘버)만 고정입니다. 그리고 매직 넘버의 마지막 바이트가 “뒤에 크기 숫자가 몇 개 오는지”(차원 수)를 알려 줍니다.

머리말 길이 = 4 (매직 넘버) + 4 × 차원 수

이미지: 4 + 4 × 3 = 16
정답:   4 + 4 × 1 = 8

즉 파일이 자기 머리말 길이를 스스로 말해 주는 구조입니다. 그래서 뒤에서 만들 load_idx 함수에는 16도 8도 안 나옵니다. 4바이트를 읽고, 마지막 바이트를 보고, 그만큼 더 읽습니다.

magic = f.read(4)          # 고정된 4바이트
ndim = magic[3]            # 마지막 바이트 = 차원 수
head = f.read(4 * ndim)    # 차원 수만큼 크기 정보를 더 읽기

처음 탐색할 때는 방법 1로 “16”을 알고 시작했지만, 최종 코드는 방법 3으로 “규칙”을 쓴 겁니다. 숫자를 외운 코드는 다른 파일이 오면 깨지고, 규칙을 쓴 코드는 2차원이든 4차원이든 그대로 읽힙니다.

이미지 파일의 머리말 16바이트를 읽어서 숫자로 찍어 보면 이렇게 나옵니다.

[0, 0, 8, 3,   0, 0, 234, 96,   0, 0, 0, 28,   0, 0, 0, 28]

4개씩 끊어 읽으면 됩니다. 4바이트가 숫자 하나입니다.

4바이트뜻값
0, 0, 8, 3매직 넘버 (파일 종류 표시)2051
0, 0, 234, 96사진이 몇 장인지60000
0, 0, 0, 28세로 몇 칸인지28
0, 0, 0, 28가로 몇 칸인지28

3-2. 4바이트가 어떻게 숫자 하나가 되나? (256진수)

우리가 쓰는 10진수에서 234는 “2×100 + 3×10 + 4”입니다. 자리마다 10배씩 커지죠.

바이트는 0~255까지 256가지 값을 가지니까, 바이트 4개를 이어 붙이면 자리마다 256배씩 커지는 수가 됩니다.

[0, 0, 234, 96]
= 0×256³ + 0×256² + 234×256 + 96
= 0 + 0 + 59904 + 96
= 60000

사진이 6만 장이라는 뜻이 정확히 나옵니다.

이렇게 큰 자리를 앞에 쓰는 방식을 big-endian이라고 합니다. 우리가 숫자를 쓰는 방식과 같아요. 파이썬에서는 이렇게 계산합니다.

int.from_bytes(head[4:8], "big")    # → 60000

head[4:8]은 “4번째부터 7번째까지 4바이트를 잘라라”입니다. 끝 번호는 포함되지 않습니다.

3-3. 매직 넘버의 마지막 바이트 = 차원 수

[0, 0, 8, 3]에서

  • 8은 “값 하나가 1바이트짜리 0~255 숫자다”라는 뜻이고
  • 3은 “이 데이터는 3차원이다”라는 뜻입니다.

3차원이란 “(사진 번호, 세로, 가로)”처럼 숫자 3개로 위치를 가리킨다는 뜻입니다. 그래서 매직 넘버 뒤에 크기 정보가 3개 따라온 거예요 (60000, 28, 28).

정답(레이블) 파일은 사진마다 숫자 하나뿐이라 1차원이고, 매직 넘버가 [0, 0, 8, 1], 크기 정보는 60000 하나만 있습니다. 그래서 머리말이 4 + 4 = 8바이트입니다.

머리말 길이 = 4 (매직 넘버) + 4 × 차원 수

이 공식이 나중에 “파일 종류에 상관없이 읽는 함수”를 만드는 열쇠가 됩니다.


4. 픽셀을 numpy 배열로 바꾸기

머리말 16바이트 다음부터는 픽셀 값이 쭉 이어집니다. 전부 읽어서 numpy 배열로 바꿉니다.

data = np.frombuffer(f.read(), dtype=np.uint8)
print(len(data))     # 47040000

np.frombuffer는 “바이트 덩어리를 숫자 배열로 바꿔라”이고, dtype=np.uint8은 “숫자 하나가 1바이트(0~255)다”라는 뜻입니다.

47,040,000이라는 숫자를 확인해 봅시다. 60000장 × 28 × 28 = 47,040,000. 머리말이 말한 크기와 실제 데이터 길이가 정확히 일치합니다. 파일이 제대로 읽혔다는 뜻이에요.

지금은 숫자 4704만 개가 한 줄로 늘어서 있습니다. 이걸 “사진 6만 장, 각각 28×28”로 모양을 바꿉니다.

images = data.reshape(n, rows, cols)
print(images.shape)    # (60000, 28, 28)

reshape는 숫자를 바꾸는 게 아니라 같은 숫자들을 다른 모양으로 접는 것입니다. 종이 한 줄로 쓴 글자를 28글자씩 끊어 28줄로 접은 것과 같아요.

왜 reshape(60000, 28, 28)이 아니라 reshape(n, rows, cols)인가? 숫자를 직접 적으면(하드코딩) 시험용 파일(1만 장)을 읽을 때 코드를 고쳐야 합니다. 머리말에서 읽은 변수를 쓰면 파일이 알아서 알려 줍니다.


5. 그림으로 확인하기

숫자만 보면 제대로 읽었는지 알 수 없으니, 첫 번째 사진을 글자로 그려 봅니다.

img = images[0]              # 첫 번째 사진, (28, 28)
for row in img:              # 한 줄씩
    print("".join("#" if p > 128 else "." for p in row))

픽셀 값이 128보다 크면(밝으면) #, 아니면 .을 찍습니다.

............................
............................
.................##.###.....
...........############.....
........##########..........
........##########..........
.........#.###...#..........
...........##...............
...........###..............
............##..............
.............###............
..............###...........
...............####.........
.................###........
.................###........
...............#####........
.............#######........
............######..........
..........######............
.......#######..............
.....########...............
....#######.................
............................

5가 보입니다. 파일을 제대로 읽은 겁니다.

기준값을 128 대신 0으로 하면 획이 더 두껍게 나옵니다. 글씨 테두리에 흐릿한 회색 픽셀(1~127)이 많기 때문이에요. MNIST는 흑백이 아니라 회색조 사진입니다.


6. 정답 파일 읽기

정답 파일도 같은 방식입니다. 머리말이 8바이트라는 것만 다릅니다.

f = gzip.open("data/train-labels-idx1-ubyte.gz", "rb")
print(list(f.read(8)))        # [0, 0, 8, 1, 0, 0, 234, 96]
labels = np.frombuffer(f.read(), dtype=np.uint8)
labels.shape                  # (60000,)
labels[0]                     # 5

첫 번째 정답이 5. 위에서 그린 그림과 일치합니다. 사진과 정답이 짝이 맞게 읽혔다는 뜻이에요.


7. 하나의 함수로 합치기: load_idx

이미지 파일과 정답 파일을 읽는 코드가 거의 똑같다는 걸 눈치채셨을 겁니다. 다른 건 머리말 길이와 모양뿐이고, 둘 다 매직 넘버의 마지막 바이트(차원 수)만 알면 계산할 수 있습니다. 그래서 함수 하나로 합칩니다.

def load_idx(path):
    with gzip.open(path, "rb") as f:
        magic = f.read(4)                      # 매직 넘버 4바이트
        ndim = magic[3]                        # 마지막 바이트 = 차원 수
        head = f.read(4 * ndim)                # 크기 정보: 차원 수 × 4바이트
        data = np.frombuffer(f.read(), dtype=np.uint8)   # 나머지 전부

    shape = []
    for i in range(ndim):                      # 크기 숫자를 ndim개 꺼내서
        shape.append(int.from_bytes(head[i*4:(i+1)*4], "big"))
    return data.reshape(shape)                 # 그 모양으로 접기

한 줄씩 읽어 보면:

  1. 매직 넘버 4바이트를 읽고, 마지막 바이트에서 차원 수를 꺼냅니다.
  2. 차원 수 × 4바이트만큼 더 읽어서 크기 정보를 가져옵니다.
  3. 나머지를 전부 읽어 배열로 만듭니다.
  4. 크기 정보를 4바이트씩 끊어 숫자로 바꿔 리스트에 모읍니다. 이미지면 [60000, 28, 28], 정답이면 [60000].
  5. 그 모양으로 접어서 돌려줍니다.

이 함수 하나로 파일 4개가 전부 읽힙니다.

load_idx("data/train-images-idx3-ubyte.gz").shape   # (60000, 28, 28)
load_idx("data/train-labels-idx1-ubyte.gz").shape   # (60000,)
load_idx("data/t10k-images-idx3-ubyte.gz").shape    # (10000, 28, 28)
load_idx("data/t10k-labels-idx1-ubyte.gz").shape    # (10000,)

with gzip.open(...) as f:는 “다 쓰면 파일을 자동으로 닫아라”는 뜻입니다.

만들면서 겪은 실수 두 가지

이 함수는 한 번에 나오지 않았습니다. 두 번 틀렸고, 둘 다 기록해 둘 만합니다.

실수 1: 3차원이라고 가정함 처음에는 rows, cols를 항상 꺼냈습니다. 정답 파일은 크기 정보가 1개뿐이라 없는 자리를 읽어서 0이 나왔고, reshape(60000, 0, 0) 에러가 났습니다. 해결은 “크기 숫자를 차원 수만큼만 꺼내는 반복문”이었습니다.

실수 2: 한 칸 길이와 전체 길이를 헷갈림 반복문에서 head[i*4*ndim : (i+1)*4*ndim]이라고 썼습니다. 4*ndim은 머리말 전체 길이인데, 숫자 하나는 4바이트입니다. 그래서 head[i*4 : (i+1)*4]가 맞습니다.


8. 이 노트북에서 배운 것

용어뜻
바이트0~255 사이 숫자 하나. 파일은 바이트의 나열
헤더(머리말)파일 앞부분에 있는 “내용 설명서”
매직 넘버파일 종류를 표시하는 앞 4바이트
big-endian큰 자리를 앞에 쓰는 방식 (우리가 숫자 쓰는 방식)
int.from_bytes바이트 여러 개를 숫자 하나로
np.frombuffer바이트 덩어리를 numpy 배열로
uint81바이트 부호 없는 정수. 0~255
shape배열의 모양. (60000, 28, 28)
reshape같은 숫자를 다른 모양으로 접기
하드코딩값을 코드에 직접 박아 넣는 것. 가능하면 변수로

스스로 확인해 보기

답을 보지 말고 먼저 생각해 보세요.

  1. [0, 0, 0, 28]을 256진수로 계산하면 왜 28인가요?
  2. 정답 파일의 머리말은 왜 16바이트가 아니라 8바이트인가요?
  3. data.reshape(n, rows, cols)에서 n × rows × cols가 len(data)와 다르면 어떻게 될까요?
  4. images[0]의 shape은 무엇이고, images[0][0]의 shape은 무엇일까요?
  5. load_idx에서 ndim을 읽지 않고 머리말을 무조건 16바이트로 읽으면, 정답 파일에서 무슨 일이 생길까요?
답
  1. 0×256³ + 0×256² + 0×256 + 28 = 28
  2. 1차원이라 크기 정보가 1개뿐. 4 + 4×1 = 8
  3. reshape가 에러를 냅니다. 숫자 개수가 안 맞으면 접을 수 없으니까요. 이게 오히려 좋은 검증이 됩니다.
  4. (28, 28), (28,). 사진 한 장, 그 사진의 첫 줄.
  5. 머리말 8바이트 다음의 정답 8개를 머리말로 잘못 읽고, 정답 데이터도 8개가 빠진 채로 읽힙니다. 에러 없이 조용히 틀립니다.

다음 글: 02. 꺼낸 사진을 인공지능이 먹을 수 있는 형태로 손질하고, 64장씩 나눠 주기

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