1. 범주형 데이터를 숫자로 바꿔야 하는 이유
머신러닝 모델의 연산은 행렬 곱, 거리 계산, 미분으로 구성된다. 세 연산 모두 실수 입력을 전제하므로 "사과", "서울", "A형" 같은 문자열은 모델에 직접 투입할 수 없다. 따라서 범주형 데이터(categorical data)는 학습 전에 반드시 수치 벡터로 변환된다.
변환 방식은 크게 두 가지다.
- 레이블 인코딩(Label Encoding): 각 범주에 고유한 정수를 부여한다. 열 수는 그대로다.
- 원-핫 인코딩(One-Hot Encoding): 범주마다 열을 만들고, 해당하는 하나의 열만 1, 나머지는 0으로 채운다.
선택 기준은 취향이 아니라 두 가지 질문으로 결정된다.
- 해당 변수에 순서(서열) 가 존재하는가?
- 사용하는 모델이 숫자 값을 어떻게 소비하는가? (거리·가중치 연산인가, 분기 기준인가)
2. 원-핫 인코딩의 동작 방식
1️⃣ 변환 규칙
범주의 개수가 k개면 k차원 벡터를 만들고, 자신의 자리만 1(Hot), 나머지 k-1개 자리는 0(Cold)으로 채운다. 이 때문에 '원-핫'이라는 이름이 붙었다.
| 원래 값 | 사과 열 | 바나나 열 | 포도 열 | 결과 벡터 |
| 사과 | 1 | 0 | 0 | [1, 0, 0] |
| 바나나 | 0 | 1 | 0 | [0, 1, 0] |
| 포도 | 0 | 0 | 1 | [0, 0, 1] |
생성된 벡터는 표준기저벡터(standard basis vector)와 동일하다. 즉 모든 범주가 서로 직교하며, 벡터의 L2 노름은 항상 1이고 임의의 두 범주 간 내적은 0이다.
2️⃣ 변환 구조

3️⃣ 레이블 인코딩이 만드는 잘못된 관계
사과=1, 바나나=2, 포도=3으로 정수를 부여하면 모델 입장에서는 다음 관계가 성립한다.
사과(1) < 바나나(2) < 포도(3)
사과(1) + 바나나(2) = 포도(3)
포도(3) / 사과(1) = 3 → "포도가 사과보다 3배 크다"
과일 이름에는 이런 서열도, 가산성도 존재하지 않는다. 문제는 선형·거리 기반 모델이 이 숫자를 액면 그대로 소비한다는 점이다. 유클리드 거리로 측정하면 왜곡이 수치로 드러난다.
import numpy as np
from scipy.spatial.distance import pdist, squareform
label = np.array([[1], [2], [3]]) # 사과=1, 바나나=2, 포도=3
onehot = np.array([[1,0,0], [0,1,0], [0,0,1]])
print(squareform(pdist(label)))
print(squareform(pdist(onehot)))
[레이블 인코딩] 사과 바나나 포도
사과 0.000 1.000 2.000
바나나 1.000 0.000 1.000
포도 2.000 1.000 0.000
[원-핫 인코딩] 사과 바나나 포도
사과 0.000 1.414 1.414
바나나 1.414 0.000 1.414
포도 1.414 1.414 0.000
레이블 인코딩에서는 사과–포도 거리가 사과–바나나 거리의 2배로 계산된다. KNN이라면 이 거리표가 그대로 이웃 판정에 쓰인다. 반면 원-핫 인코딩은 모든 범주 쌍의 거리가 √2로 동일하므로, 범주 간 우열이 거리 계산에 개입하지 않는다.

3. 레이블 인코딩과 원-핫 인코딩 비교
1️⃣ 비교표
| 구분 | 레이블 인코딩 | 원-핫 인코딩 |
| 변환 방식 | 범주 → 고유 정수 | 범주마다 열 생성, 0/1로 표기 |
| 순서·크기 의미 | 발생함 (0 < 1 < 2로 해석 가능) | 없음 (모든 범주가 직교, 동일 비중) |
| 열 개수 | 1개 유지 | 범주 수 k개만큼 증가 |
| 메모리 효율 | 높음 | 낮음 (k가 클수록 급격히 악화) |
| 미지 범주 처리 | 별도 규칙 필요 | handle_unknown 옵션으로 처리 가능 |
| 적합 데이터 | 순서형(ordinal) | 명목형(nominal) |
| 적합 모델 | 트리 기반 | 선형·거리·신경망 기반 |
2️⃣ 레이블 인코딩이 적합한 경우
🔵 순서형 데이터일 때
옷 사이즈(S < M < L), 고객 등급(브론즈 < 실버 < 골드), 학력, 만족도 리커트 척도가 해당된다. 이 경우 정수의 대소 관계가 데이터의 실제 구조와 일치하므로, 오히려 원-핫 인코딩이 순서 정보를 버리는 손실 변환이 된다.
🔵 트리 기반 모델을 사용할 때
의사결정나무, 랜덤 포레스트, XGBoost, LightGBM은 feature <= 임계값 형태의 분기를 반복한다. 즉 숫자를 곱하거나 거리로 재지 않고 분할 기준으로만 사용한다. 범주 A와 C를 한쪽으로 묶어야 한다면 분기를 여러 번 쌓아 처리할 수 있으므로, 정수 부여로 인한 왜곡의 영향이 작다. 다만 이는 "영향이 없다"가 아니라 "여러 번 분기하면 복원 가능하다"는 의미이므로, 트리 깊이가 얕게 제한된 모델에서는 성능 저하가 나타날 수 있다.
🔵 범주 수가 매우 많을 때
범주가 수천~수만 개인 고카디널리티(high-cardinality) 변수는 원-핫 인코딩 시 열 폭발을 일으키므로, 레이블 인코딩이나 후술할 대안 인코딩을 사용한다.
3️⃣ 원-핫 인코딩이 적합한 경우
🔵 명목형 데이터일 때
혈액형(A, B, O, AB), 지역(서울, 부산, 제주), 결제 수단, 운영체제 종류가 해당된다. A형(1) + B형(2) = O형(3)이라는 관계가 성립하지 않으므로, 수학적 관계 생성 자체를 차단해야 한다.
🔵 선형 방정식 또는 거리 기반 모델을 사용할 때
선형 회귀, 로지스틱 회귀, KNN, SVM, 인공신경망은 입력 값에 가중치를 곱하거나 거리를 직접 계산한다. 선형 회귀에 레이블 인코딩된 열을 넣으면 모델은 y = w * x + b 형태의 단일 계수 하나로 모든 범주를 표현하게 되며, 이는 "범주 코드가 1 증가할 때 y가 w만큼 증가한다"는 존재하지 않는 선형 제약을 강제한다. 원-핫 인코딩을 적용하면 범주별로 독립적인 계수가 할당되어 이 제약이 사라진다.
4. 실습: pandas와 scikit-learn 인코딩
1️⃣ 실습 환경
Python 3.x
pandas 3.0.2
scikit-learn 1.8.0
numpy 2.4.4
실습에 사용할 데이터프레임은 명목형 열(fruit)과 순서형 열(size), 수치형 열(price)을 함께 포함한다.
import pandas as pd
df = pd.DataFrame({
"fruit": ["사과", "바나나", "포도", "사과"],
"size": ["S", "L", "M", "M"],
"price": [3000, 1500, 8000, 3200],
})
print(df)
print(df.dtypes)
fruit size price
0 사과 S 3000
1 바나나 L 1500
2 포도 M 8000
3 사과 M 3200
fruit str
size str
price int64
dtype: object
dtype이 str로 표시되는 것은 pandas 3.0의 문자열 dtype 변경 결과다. pandas 2.x에서는 object로 출력된다.
2️⃣ pandas.get_dummies로 원-핫 인코딩
탐색적 분석 단계에서 가장 빠른 방법이다. columns로 대상 열을 지정하고 dtype=int로 0/1 정수를 얻는다.
print(pd.get_dummies(df, columns=["fruit"], dtype=int))
size price fruit_바나나 fruit_사과 fruit_포도
0 S 3000 0 1 0
1 L 1500 1 0 0
2 M 8000 0 0 1
3 M 3200 0 1 0
fruit 열 1개가 3개 열로 확장되었다. 형상 변화는 다음과 같다.
print("원본:", df.shape, "-> 인코딩:", pd.get_dummies(df, columns=["fruit"], dtype=int).shape)
원본: (4, 3) -> 인코딩: (4, 5)
🔵 drop_first로 첫 범주 제거
선형 회귀처럼 절편(intercept)을 포함하는 모델에서는 k개 열을 모두 넣으면 열의 합이 항상 1이 되어 절편 항과 완전 상관을 이룬다. 이 다중공선성 문제를 더미 변수 함정(dummy variable trap)이라 하며, 열 하나를 기준 범주로 제거해 해소한다.
print(pd.get_dummies(df, columns=["fruit"], dtype=int, drop_first=True))
size price fruit_사과 fruit_포도
0 S 3000 1 0
1 L 1500 0 0
2 M 8000 0 1
3 M 3200 1 0
fruit_바나나 열이 제거되고, 바나나는 나머지 두 열이 모두 0인 상태로 표현된다. 트리 기반 모델이나 정규화가 적용된 모델에서는 정보 손실만 발생할 수 있으므로 기본값(False)을 유지한다.
3️⃣ LabelEncoder와 OrdinalEncoder
LabelEncoder는 범주를 정렬한 순서대로 0부터 정수를 부여한다.
from sklearn.preprocessing import LabelEncoder
y = ["사과", "바나나", "포도", "사과"]
le = LabelEncoder()
print("classes_ :", le.fit(y).classes_)
print("transform:", le.transform(y))
print("inverse :", le.inverse_transform([0, 1, 2]))
classes_ : ['바나나' '사과' '포도']
transform: [1 0 2 1]
inverse : ['바나나' '사과' '포도']
여기서 주의할 점이 있다. 부여된 정수는 데이터에 등장한 순서가 아니라 정렬 순서를 따른다. 사과가 첫 행이지만 값은 1이고, 바나나가 0이다. 따라서 사이즈나 등급처럼 의도한 서열을 반영해야 하는 경우 LabelEncoder는 부적합하다. 순서를 직접 지정할 때는 OrdinalEncoder의 categories 인자를 사용한다.
import numpy as np
from sklearn.preprocessing import OrdinalEncoder
oe = OrdinalEncoder(categories=[["S", "M", "L"]])
X = np.array([["S"], ["L"], ["M"], ["M"]])
print(oe.fit_transform(X).ravel())
[0. 2. 1. 1.]
S=0, M=1, L=2로 의도한 서열이 그대로 반영되었다. 또한 LabelEncoder는 1차원 입력만 받도록 설계된 타깃(y) 전용 변환기이므로, 설명 변수(X) 변환에는 OrdinalEncoder를 사용하는 것이 올바른 용법이다.
4️⃣ OneHotEncoder: 학습과 변환의 분리
get_dummies의 구조적 한계는 데이터프레임에 존재하는 값만 보고 열을 만든다는 점이다. 훈련 데이터에 사과·바나나·포도가 있고 테스트 데이터에 사과·포도만 있다면, 두 결과의 열 개수와 순서가 달라져 학습된 모델에 투입할 수 없다. OneHotEncoder는 fit 시점에 범주 목록을 고정하고 transform에서 그 목록을 재사용해 이 문제를 해결한다.
import numpy as np
from sklearn.preprocessing import OneHotEncoder
train = np.array([["사과"], ["바나나"], ["포도"], ["사과"]])
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
ohe.fit(train)
print("categories_ :", ohe.categories_)
print("feature names:", ohe.get_feature_names_out())
print(ohe.transform(train).astype(int))
categories_ : [array(['바나나', '사과', '포도'], dtype='<U3')]
feature names: ['x0_바나나' 'x0_사과' 'x0_포도']
[[0 1 0]
[1 0 0]
[0 0 1]
[0 1 0]]
🔵 미지 범주(unknown category) 처리
테스트 데이터에 훈련 시점에 없던 값이 들어오는 상황은 실무에서 흔하다. 기본값인 handle_unknown="error"는 예외를 발생시킨다.
ohe = OneHotEncoder(sparse_output=False) # handle_unknown="error"
ohe.fit(train)
ohe.transform(np.array([["망고"]]))
ValueError: Found unknown categories [np.str_('망고')] in column 0 during transform
handle_unknown="ignore"로 설정하면 예외 대신 모든 열이 0인 벡터를 반환한다.
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore").fit(train)
print(ohe.transform(np.array([["포도"], ["망고"]])).astype(int))
[[0 0 1]
[0 0 0]]
첫 행(포도)은 정상적으로 세 번째 열이 1이 되었고, 두 번째 행(망고)은 전부 0으로 처리되면서 열 개수는 3개로 유지된다. 파이프라인이 중단되지 않는다는 장점이 있으나, 전부 0인 벡터는 "어떤 범주도 아님"이라는 별도 상태를 의미하므로 미지 범주 비율이 높은 데이터에서는 예측 품질 저하를 점검해야 한다.
5️⃣ fit / transform 호출 흐름 시퀀스 다이어그램
훈련 데이터로 한 번 fit한 인코더를 테스트·추론 단계에서 재사용하는 것이 핵심이다. 테스트 데이터에 fit_transform을 호출하면 범주 목록이 다시 계산되어 열 순서가 바뀌는 데이터 누수·정합성 오류가 발생한다.

5. 차원의 저주와 대응 방안
1️⃣ 문제의 규모 측정
범주가 10,000개인 변수를 원-핫 인코딩하면 데이터 포인트 하나를 표현하기 위해 0이 9,999개, 1이 1개인 길이 10,000 배열이 필요하다. 행 수가 늘어나면 메모리 요구량은 곱으로 증가한다. 실제로 측정해 보면 규모가 명확해진다.
import numpy as np
from sklearn.preprocessing import OneHotEncoder
rng = np.random.default_rng(0)
X = rng.integers(0, 10000, size=(50000, 1)).astype(str)
sp = OneHotEncoder(sparse_output=True).fit_transform(X)
rows, cols = sp.shape
dense_bytes = rows * cols * 8 # float64 밀집 행렬
sparse_bytes = sp.data.nbytes + sp.indices.nbytes + sp.indptr.nbytes
print("shape :", sp.shape)
print("0이 아닌 값 :", sp.nnz)
print("희소도 : %.4f%%" % (100 * sp.nnz / (rows * cols)))
print("dense 예상 : %.1f MB" % (dense_bytes / 1024 / 1024))
print("sparse 실측 : %.1f MB" % (sparse_bytes / 1024 / 1024))
print("절감 배수 : %.0f배" % (dense_bytes / sparse_bytes))
shape : (50000, 9930)
0이 아닌 값 : 50000
희소도 : 0.0101%
dense 예상 : 3788.0 MB
sparse 실측 : 0.8 MB
절감 배수 : 4965배
행 50,000개, 범주 9,930개 조합은 밀집 행렬로 약 3.7GB를 요구한다. 전체 원소 중 0이 아닌 값은 0.0101%뿐이며, 나머지 99.99%는 0을 저장하기 위한 낭비다.
2️⃣ 희소 행렬(sparse matrix)로 저장
sparse_output=True(기본값)는 0을 저장하지 않고 "값이 1인 위치"만 기록하는 CSR 형식으로 결과를 반환한다. 위 측정에서 0.8MB로, 밀집 표현 대비 약 4,965배 작다. scikit-learn의 선형 모델과 SVM은 희소 행렬을 입력으로 직접 지원하므로, 불필요하게 toarray()로 변환하지 않는 것이 좋다.
다만 희소 행렬은 메모리 문제만 해결한다. 열 수가 수천 개로 늘어나면서 발생하는 문제는 그대로 남는다.
- 각 열의 1 등장 빈도가 극히 낮아 파라미터 추정 분산이 커진다
- 트리 모델은 열마다 분할점을 탐색하므로 학습 시간이 열 수에 비례해 증가한다
- 고차원 공간에서 점들 간 거리 차이가 희석되어 거리 기반 모델의 판별력이 떨어진다
3️⃣ 고카디널리티 변수의 대안
| 방법 | 개요 | 유의점 |
| 빈도 인코딩 | 범주를 등장 빈도(또는 비율)로 치환 | 빈도가 같은 서로 다른 범주가 동일 값으로 충돌 |
| 타깃 인코딩 | 범주별 타깃 평균으로 치환 | 타깃 누수 위험 — 교차검증 폴드 내부에서만 계산 |
| 해싱 트릭 | 해시 함수로 고정 길이 벡터에 매핑 | 해시 충돌 발생, 역변환 불가 |
| 임베딩 | 범주를 학습 가능한 저차원 밀집 벡터로 변환 | 신경망 구조 전제, 학습 데이터량 필요 |
| 상위 N개 + 기타 | 빈도 상위 N개만 남기고 나머지를 기타로 병합 | 꼬리 범주의 정보 손실 |
가장 단순하고 실무에서 효과가 큰 것은 마지막 방법이다. 상위 20~50개 범주가 전체 데이터의 대부분을 설명하는 분포라면, 나머지를 하나의 범주로 병합한 뒤 원-핫 인코딩을 적용하는 것으로 차원 문제와 희소성 문제를 동시에 완화할 수 있다.
6. 선택 기준 정리

실무 적용 시 확인할 사항은 다음 네 가지로 압축된다.
- 서열이 있으면 순서형 인코딩, 없으면 원-핫 인코딩이 기본 원칙이다.
- 모델이 숫자를 거리·가중치로 소비하면(선형·KNN·SVM·신경망) 원-핫 인코딩이 사실상 필수다. 분기 기준으로만 소비하면(트리 계열) 레이블 인코딩이 허용된다.
- 인코더는 **훈련 데이터에만 fit**하고, 테스트·추론에는 동일 인코더의 transform을 재사용한다.
- 범주 수가 큰 변수는 희소 행렬 저장, 상위 N개 병합, 임베딩 등으로 차원을 통제한다.
'Journey to CS > 머신러닝' 카테고리의 다른 글
| 머신러닝의 동작 구조 - 결정함수란? (feat. 악성코드 탐지 예시) (0) | 2026.09.12 |
|---|---|
| [ML 스터디 4주차] 고유값과 고유 벡터 - 특성방정식과 정규화 (0) | 2026.08.14 |
| [ML 스터디 4주차] 행렬식(Determinant)의 기하학적 의미 (0) | 2026.08.14 |
| [ML 스터디 4주차] p값과 통계적 유의성의 논리 구조 - 우연인가 진짠가? (0) | 2026.08.10 |
| [ML 스터디 4주차] 중심 극한 정리와 신뢰 구간 - 표본으로 모집단을 추론 (0) | 2026.08.09 |