손글씨 숫자 데이터셋으로 신경망을 학습시키면 Dense 층만 쌓아도 90%대 초반이 나와요. 그런데 그림판으로 숫자를 하나 그려 넣어보면 엉뚱한 답이 돌아오는 일이 있어요. 학습 정확도가 90%가 넘는데 내가 그린 3을 8이라고 하는 거예요.
이럴 때 모델 구조부터 손보기 쉬운데, 원인은 대개 전처리 쪽에 있어요. 학습할 때 이미지에 한 처리를 추론할 때 그대로 하지 않으면 모델이 보는 숫자의 범위 자체가 달라지거든요.
이미지를 넣기 전에 하는 세 가지
| 단계 | 하는 일 | 왜 |
|---|---|---|
| 플래트닝 | 28×28 배열을 784 벡터로 폄 | Dense 층이 1차원 벡터만 받아요 |
| 정규화 | 0~255 픽셀값을 255로 나눠 0~1로 | 큰 값이 들어가면 가중합과 기울기가 커져 학습이 출렁여요 |
| 라벨 이진화 | 3 → [0,0,0,1,0,0,0,0,0,0] | 출력이 10개 확률이라 정답도 같은 형태여야 비교돼요 |
trainX = trainX.reshape((trainX.shape[0], 28 * 28 * 1))라벨 이진화는 원핫 인코딩(one-hot encoding)이라고도 해요. 숫자를 그대로 두면 “3은 1보다 3배 크다”는 잘못된 순서 정보가 손실 계산에 섞여요.
astype을 먼저 부르는 진짜 이유
정규화 코드가 astype("float32")를 먼저 부르는 걸 보면 정수 나눗셈 때문이라고 짐작하게 되는데, numpy 동작을 따져보면 조금 달라요. trainX / 255처럼 새 배열을 만드는 나눗셈에서는 원본이 uint8이어도 알아서 실수로 올려줘요.
a = np.array([[0, 128, 255]], dtype=np.uint8)
a / 255 # [[0. , 0.50196078, 1. ]] — float64로 승격문제가 되는 쪽은 제자리에서 바꾸려 할 때예요.
UFuncTypeError: Cannot cast ufunc 'divide' output from dtype('float64') to dtype('uint8')trainX /= 255는 결과가 실수인데 담을 배열이 정수형이라 이 에러로 멈춰요. astype을 먼저 부르는 건 자료형을 실수로 맞춰 두는 일이고, float64 대신 float32를 고르는 건 메모리를 절반만 쓰기 때문이에요.
빠뜨려도 에러가 안 나요
추론 코드가 하는 일은 학습 때의 세 단계를 똑같이 반복하는 거예요. 하나라도 빠지거나 순서가 달라지면 결과가 어긋나요.
여기서 성가신 점은 빠뜨려도 예외도 경고도 안 난다는 거예요. 자신 있게 틀린 답이 나오니 발견이 늦어져요.
그래서 추론이 이상하면 모델보다 전처리를 먼저 의심하는 순서가 맞아요. 학습 코드와 추론 코드의 전처리 부분을 나란히 놓고 줄 단위로 비교하면 대개 거기서 끝나요.
검은 배경에 흰 글씨
전처리를 다 맞췄는데도 직접 그린 숫자만 틀린다면 남은 원인이 하나 더 있어요.
손글씨 숫자 데이터셋은 검은 배경에 흰 글씨예요. 반면 그림판에서 그린 숫자는 흰 배경에 검은 글씨고요. 한 줄로 뒤집어주면 해결돼요.
image = 255 - image이건 자료에 적혀 있지 않은데 커스텀 이미지를 넣어보면 반드시 부딪히는 부분이에요.
학습과 추론 사이의 계약
세 단계든 네 단계든, 핵심은 학습 때 정한 입력 형식이 추론 때도 지켜져야 한다는 거예요. 데이터를 다루는 코드가 여러 파일로 나뉘면 이 약속이 조용히 어긋나기 쉬워요. 학습 스크립트만 고치고 추론 스크립트를 그대로 두는 식으로요.
형식을 갈아탈 때마다 정보가 빠져나가는 문제는 CAD에서 URDF까지, 포맷 변환 사슬에서 잃는 정보에서 로봇 모델 쪽으로 다룬 적이 있어요. 도메인은 다른데 증상은 닮았어요. 중간 단계에서 잃어버린 것이 한참 뒤에 이상한 결과로 나타나고, 그 사이에 아무도 에러를 내주지 않아요.
학습이 왜 활성화 함수의 미분에 기대는지는 활성화 함수의 미분 가능성과 시그모이드 기울기 소실에 정리했어요.