Databricks ML Associate

Databricks ML Associate 시험 노트개념 정리15 MIN

원-핫 인코딩과 로그 스케일 변환

StringIndexer와 OneHotEncoder를 이어 붙였을 때 벡터가 어떤 모양으로 나오는지, 원-핫이 맞는 모델과 안 맞는 모델, 오른쪽 꼬리를 누르는 로그 변환과 log1p까지 숫자로 정리합니다.

앞 노트에서 빈 칸을 채웠으니 이제 값의 모양을 모델이 먹기 좋게 바꿀 차례입니다. 이 노트는 두 가지 변환을 다룹니다. 하나는 문자열 범주를 숫자 벡터로 바꾸는 원-핫 인코딩이고, 다른 하나는 오른쪽으로 길게 늘어진 숫자를 눌러 주는 로그 변환입니다. 둘 다 「언제 쓰는가」보다 「언제 쓰지 않는가」가 더 자주 문항이 됩니다.

원-핫 인코딩

번호 인코딩

Spark ML의 모델은 입력으로 숫자 벡터 하나를 받습니다. 그래서 「서울·부산·대구」 같은 문자열 열은 어떻게든 숫자로 바꿔야 합니다. 가장 쉬운 방법은 범주마다 0·1·2 같은 번호를 붙이는 것인데, 선형 회귀에 이 번호를 그대로 넣으면 모델은 대구(2)가 부산(1)의 두 배이고 서울(0)과 대구 사이에 부산이 있다고 읽습니다. 범주에는 없는 크기와 순서가 생겨 버리는 것입니다.

원-핫 인코딩(one-hot encoding)은 범주 하나를 길이 k의 벡터로 바꾸되, 자기 자리 하나만 1이고 나머지는 전부 0으로 두는 방법입니다. 범주들 사이의 거리가 모두 같아지므로 없는 순서가 생기지 않습니다.

StringIndexer와 OneHotEncoder

Spark에서는 두 단계를 거칩니다. StringIndexer가 문자열을 번호로 바꾸고, OneHotEncoder가 그 번호를 벡터로 바꿉니다. OneHotEncoder는 문자열을 직접 받지 않으므로 순서를 바꿀 수 없습니다.

from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer, OneHotEncoder

indexer = StringIndexer(inputCol="city", outputCol="city_idx", handleInvalid="keep")
encoder = OneHotEncoder(inputCols=["city_idx"], outputCols=["city_ohe"])
pipeline = Pipeline(stages=[indexer, encoder])
model = pipeline.fit(train_df)

StringIndexer는 기본값으로 자주 나온 범주부터 0번을 줍니다(stringOrderType="frequencyDesc"). 학습 데이터에 서울이 4행, 부산이 2행, 대구가 1행이면 서울 0, 부산 1, 대구 2입니다. handleInvalid는 학습 때 못 본 범주를 만났을 때의 처리로, "error"(기본값)는 오류를 내고 "skip"은 그 행을 버리고 "keep"은 마지막 번호 하나를 따로 줍니다. 추론 데이터에 새 도시가 들어올 수 있다면 "keep"이 안전합니다.

dropLast와 벡터 모양

OneHotEncoder의 dropLast는 기본값이 True입니다. 마지막 번호의 자리를 빼서 k개의 범주를 길이 k−1의 벡터로 만든다는 뜻입니다. 위 예에서 벡터 길이는 2이고 셋은 이렇게 나옵니다.

도시 번호 벡터
서울 0 (2,[0],[1.0])
부산 1 (2,[1],[1.0])
대구 2 (2,[],[])

대구는 모든 자리가 0인 벡터가 됩니다. 앞 둘이 모두 0이면 대구라는 것을 알 수 있으므로 자리 하나를 아껴도 정보가 줄지 않습니다. 선형 모델에서는 이렇게 해야 절편과 k개 열이 서로 겹치는 다중공선성도 피할 수 있습니다. handleInvalid="keep"을 주면 못 본 범주용 번호가 하나 늘어 벡터 길이도 그만큼 늘어납니다.

모델별 선택

선형 모델과 거리 기반 모델

원-핫이 꼭 필요한 곳은 입력값의 크기를 그대로 곱하거나 거리로 재는 모델입니다. 선형 회귀와 로지스틱 회귀는 열마다 계수를 곱해 더하므로, 번호 그대로면 「번호가 1 커질 때마다 예측이 계수만큼 변한다」는 뜻 없는 관계를 배웁니다. k-평균 같은 거리 기반 모델도 번호 0과 2가 번호 0과 1보다 멀다고 계산합니다. 원-핫으로 바꾸면 선형 모델은 범주마다 계수를 하나씩 따로 갖게 되고, 거리 기반 모델에서는 서로 다른 두 범주 사이의 거리가 모두 같아집니다.

트리 계열 모델

결정 트리·랜덤 포레스트·그레이디언트 부스팅 같은 트리 계열 모델은 값을 곱하지 않고 「이 값이 어느 쪽인가」로 가지를 나눕니다. 여기에 원-핫을 넣으면 한 범주 열이 0/1 열 수십 개로 쪼개져, 트리는 한 번에 한 범주만 떼어 내는 가지밖에 만들지 못합니다. 가지가 깊어지고 열 하나하나의 중요도가 흩어집니다.

Spark의 트리 모델은 StringIndexer가 열에 남기는 범주 메타데이터를 읽어 번호를 범주로 다룰 수 있습니다. 그래서 트리에는 StringIndexer만 거친 번호 열을 넣는 것이 보통입니다. 이때 트리의 maxBins(기본값 32)가 가장 큰 범주 수보다 작으면 학습이 실패하므로 값을 올려 줍니다.

고카디널리티

카디널리티(cardinality)는 한 열이 갖는 서로 다른 값의 개수입니다. 우편번호처럼 값이 3만 가지인 열을 원-핫하면 그 열 하나가 길이 29,999의 벡터가 됩니다. 행마다 자리 하나만 1이라 대부분 비어 있고, 대부분의 범주는 몇 행밖에 안 나와 계수를 제대로 배우지 못합니다. 이런 고카디널리티 열은 원-핫 대신 상위 몇 범주만 남기고 나머지를 「기타」로 묶거나, 시·도처럼 더 굵은 단위로 올려 쓰거나, 번호 열 그대로 트리에 넣습니다.

차원 폭증과 희소 벡터

차원 증가

원-핫은 범주 열 하나를 k−1개의 열로 늘립니다. 범주가 5개, 12개, 200개인 열 셋을 원-핫하면 4+11+199=2144 + 11 + 199 = 214 개의 자리가 생깁니다. 숫자 열이 열 개뿐인 데이터라도 피처 벡터는 224차원이 됩니다. 이렇게 차원이 늘면 학습이 느려지고, 행 수에 비해 열이 많아져 과적합하기 쉬워집니다.

SparseVector

그래도 Spark가 이를 감당할 수 있는 이유는 희소 벡터(sparse vector) 덕분입니다. 희소 벡터는 0이 아닌 칸의 위치와 값만 적어 두는 저장 방식이고, (2,[0],[1.0])은 「길이 2, 0번 자리에 1.0」이라는 뜻입니다. 길이가 29,999여도 실제로 저장하는 것은 숫자 몇 개뿐입니다. OneHotEncoder의 출력은 이 희소 벡터이고, 뒤에 VectorAssembler로 숫자 열과 합쳐도 희소함이 유지됩니다. 반대로 toPandas()로 꺼내 밀집 배열로 펼치면 메모리가 순식간에 불어나니 주의합니다.

로그 변환

오른쪽 꼬리와 배수 관계

소득·구매액·조회수처럼 대부분이 작고 몇몇이 매우 큰 값은 오른쪽 꼬리가 깁니다. 이런 열을 그대로 쓰면 선형 모델이 소수의 큰 값에 끌려가고, 앞 노트에서 본 것처럼 평균과 표준편차도 극단값에 흔들립니다.

로그 변환은 값 xx 를 log⁡x\log x 로 바꾸는 변환이고, 곱셈을 덧셈으로 바꾸는 로그의 성질 log⁡(ab)=log⁡a+log⁡b\log(ab) = \log a + \log b 를 이용합니다. 상용로그로 보면 100, 1,000, 10,000이 2, 3, 4로 바뀌어, 열 배씩 벌어지던 간격이 1씩 고르게 됩니다. 그래서 로그 변환이 맞는 자리는 두 가지입니다 — 오른쪽 꼬리가 긴 분포를 대칭에 가깝게 누를 때, 그리고 「얼마나 더」보다 「몇 배」가 뜻을 갖는 관계를 다룰 때입니다. 반대로 이미 대칭인 열이나 음수가 많은 열에는 쓰지 않습니다.

log1p와 0·음수

로그는 0에서 정의되지 않습니다. 구매 횟수처럼 0이 흔한 열에 그대로 로그를 씌우면 파이썬 넘파이는 음의 무한대를, Spark의 F.log는 null을 돌려줍니다. 결측을 막 채운 열에 새 결측이 생기는 셈입니다.

그래서 1을 더한 뒤 로그를 씌우는 log1p를 씁니다. log⁡(1+x)\log(1 + x) 이므로 0은 0으로 가고, 큰 값에서는 log⁡x\log x 와 거의 같습니다. 되돌릴 때는 짝인 expm1, 곧 ey−1e^{y} - 1 을 씁니다. 타깃에 log1p를 씌워 학습했다면 예측값에 expm1을 걸어야 원래 단위의 값이 나옵니다.

from pyspark.sql import functions as F

df = df.withColumn("spend_log", F.log1p("monthly_spend"))
pred = pred.withColumn("prediction_won", F.expm1("prediction"))  # 타깃을 되돌릴 때

음수가 섞인 열에는 log1p도 안 됩니다. −1-1 이하에서 정의되지 않기 때문입니다. 이럴 때는 최솟값만큼 밀어 모든 값을 0 이상으로 만든 뒤 씌우거나, 부호를 따로 두고 절댓값에 log1p를 씌웁니다.

로그 변환은 앞 노트의 대치와 달리 데이터에서 배우는 값이 없습니다. 행마다 따로 계산하므로 분할 전에 해도 누수가 생기지 않습니다. 다만 최솟값만큼 미는 경우에는 그 최솟값을 학습 세트에서 정해야 하므로 대치와 같은 규칙을 따릅니다.

연습 문제

  1. 학습 데이터의 city 값이 서울 4행, 부산 2행, 대구 1행입니다. 기본 설정의 StringIndexer가 대구에 주는 번호는?
    ① 0
    ② 1
    ③ 2
    ④ 3
    ③. 기본값 frequencyDesc라 가장 많은 서울이 0, 부산이 1, 대구가 2입니다.
  2. 1번에 이어 기본 설정의 OneHotEncoder를 적용하면 대구의 벡터는?
    ① (3,[2],[1.0])
    ② (2,[1],[1.0])
    ③ (2,[],[])
    ④ (3,[],[])
    ③. dropLast=True라 범주 3개가 길이 2의 벡터가 되고, 마지막 번호인 대구는 모든 자리가 0입니다.
  3. 범주 수가 6, 9, 25인 세 열을 기본 설정으로 원-핫하고 숫자 열 8개와 VectorAssembler로 합쳤습니다. 최종 벡터의 길이는?
    ① 37
    ② 45
    ③ 48
    ④ 40
    ②. 원-핫 쪽이 5+8+24=375 + 8 + 24 = 37 이고 숫자 열 8을 더해 45입니다.
  4. 원-핫 인코딩에 대한 설명으로 옳은 것을 둘 고르시오.
    ① 로지스틱 회귀에 범주 번호를 그대로 넣으면 없는 순서가 생긴다
    ② 트리 계열 모델에는 반드시 원-핫을 거쳐야 한다
    ③ OneHotEncoder는 문자열 열을 직접 받는다
    ④ OneHotEncoder의 출력은 희소 벡터다
    ⑤ 카디널리티가 높을수록 원-핫이 유리하다
    ①과 ④. 트리 모델은 StringIndexer 번호를 범주로 다룰 수 있고, OneHotEncoder는 번호 열을 받으며, 고카디널리티 열은 원-핫하면 차원이 폭증합니다.
  5. 구매 횟수 열에 0이 흔합니다. F.log를 그대로 씌운 결과와 알맞은 대안은?
    ① 0이 0이 된다. 대안은 필요 없다
    ② 0 자리가 null이 된다. F.log1p를 쓴다
    ③ 0 자리가 1이 된다. F.expm1을 쓴다
    ④ 0 자리가 음의 무한대로 저장된다. F.sqrt를 쓴다
    ②. Spark의 로그는 0 이하 입력에 null을 돌려줍니다. log1p는 0을 0으로 보냅니다.
  6. 값 10, 100, 1,000, 10,000에 상용로그를 씌운 뒤 이웃한 값 사이의 간격은?
    ① 모두 9배
    ② 모두 1
    ③ 점점 커진다
    ④ 점점 작아진다
    ②. 1, 2, 3, 4가 되어 간격이 모두 1입니다. 열 배씩 벌어지던 관계가 고른 간격이 됩니다.
  7. 새 도시가 추론 데이터에 들어와도 파이프라인이 멈추지 않게 하려면 StringIndexer의 handleInvalid를 무엇으로 두어야 하는가?
    ① "error"
    ② "skip"
    ③ "keep"
    ④ "drop"
    ③. "skip"도 멈추지는 않지만 그 행을 버려 예측이 빠집니다. "drop"이라는 값은 없습니다.

정리하면 원-핫은 값을 곱하고 거리를 재는 모델을 위한 것이고, 로그는 몇 배가 뜻을 갖는 오른쪽 꼬리를 위한 것입니다. 트리와 고카디널리티에는 원-핫을 아끼고, 0이 있으면 log1p를 씁니다.

Databricks ML Associate 시험 노트 전체 보기