Table of contents
Open Table of contents
들어가는 글
지난 글에서는 선형 회귀의 수학적 토대를 쌓았다. RSS를 최소화하는 최소제곱법, 그것이 MLE의 특수한 경우라는 점, 행렬 표기법으로 정리되는 정규 방정식까지 살펴봤다. 이론만 놓고 보면 선형 회귀는 꽤 아름답다.
하지만 이 아름다운 구조를 현실 데이터에 그대로 적용하려 하면 곧바로 문제가 생긴다. 지난 글에서 사용한 예시는 “공부 시간에 따른 시험 점수”였다. 입력도 숫자, 출력도 숫자, 관계도 선형에 가까운 아주 깔끔한 상황이었다. 실무에서 마주치는 데이터는 대개 이처럼 깔끔하지 않다.
예를 들어 부동산 가격 예측 모델을 만든다고 하자. 데이터를 열어보면 이런 컬럼들이 들어 있다.
- 평수: 24, 32, 45, 18, … (숫자, OK)
- 층수: 3, 15, 7, 22, … (숫자, OK)
- 지역: “강남”, “서초”, “송파”, “분당”, … (…?)
- 건축연도: 2015, 2003, 2021, 1998, … (숫자, OK)
- 역세권 여부: “역세권”, “비역세권” (…?)
- 관리상태: “매우 좋음”, “좋음”, “보통”, “나쁨” (…???)
숫자로 된 변수는 그나마 다루기 쉽다. 문제는 “강남”, “역세권”, “매우 좋음”처럼 문자열로 표현된 값들이다. 이런 값을 선형 회귀 수식에 그대로 넣을 수는 없다. 강남이라는 식은 수학적으로 성립하지 않는다. 그렇다고 이런 정보를 버릴 수도 없다. 지역, 역세권 여부, 관리상태는 부동산 가격에 큰 영향을 주기 때문이다.
이번 글에서는 바로 이 지점을 다룬다. 현실 데이터를 선형 회귀가 이해할 수 있는 형태로 바꾸고, 단순한 직선 모델만으로는 담기 어려운 패턴을 표현하는 방법을 살펴본다. 범주형 변수를 다루는 방법, 변수 간 상호작용을 모델에 넣는 방법, 비선형 관계를 선형 모델 안에서 표현하는 트릭, 수많은 피처 중 어떤 것을 쓸지 결정하는 방법까지 차례로 익혀보자. 여기까지 이해하면 선형 회귀가 생각보다 훨씬 넓은 범위의 문제를 다룰 수 있다는 사실이 보일 것이다.
범주형 변수 처리 - “강남”을 숫자로 바꾸는 기술
선형 회귀는 숫자를 받아 숫자를 내놓는 모델이다. 그러나 많은 변수는 숫자가 아니라 범주(category)다. 성별, 지역, 혈액형, 직업군, 제품 카테고리… 이런 변수를 다루려면 먼저 숫자로 바꿔야 한다.
가장 쉬운 접근은 거주 지역을 지역별로 나눠 각각 0, 1, 2 … 로 코딩하는 것이다. 얼핏 합리적으로 보인다. 하지만 이건 빠지기 쉬운 함정이다.
모델이 이를 학습하면 어떻게 될까. 선형 회귀는 이 숫자를 수치적으로 해석한다. “송파(2)는 서초(1)의 두 배”라는 잘못된 관계를 학습하는 것이다. 강남, 서초, 송파 사이엔 순서도, 배수 관계도 없다. 단지 서로 다른 범주일 뿐이다. 순서가 없는 범주에 숫자를 부여하면 모델은 존재하지 않는 수치 관계를 학습해 버린다. 모델이 멍청해서가 아니라, 우리가 잘못된 정보를 주었기 때문이다.
그럼 어떻게 해야 할까. 먼저 범주를 최대한 줄여 두개인 경우부터 해결해보자.
이진 변수: 값이 두 개뿐인 경우
값이 두 개뿐인 경우엔 비교적 쉽게 접근할 수 있다. 값을 0과 1로 분류하는 것이다. “역세권이면 1, 아니면 0”처럼. 이렇게 하면 숫자가 어떤 수치적 의미를 갖지 않고, 단순히 두 그룹을 구분하는 표지로만 작동한다.
import numpy as npimport pandas as pd
# 집 소유 여부에 따른 잔고 데이터data = pd.DataFrame({ 'balance': [500, 800, 300, 1200, 600], 'owns_house': [1, 1, 0, 1, 0] # 1=소유, 0=비소유})
# 모델: balance = beta_0 + beta_1 * owns_house# beta_0: 집이 없는 사람의 평균 잔고# beta_1: 집 소유에 따른 잔고 차이print(data) balance owns_house0 500 11 800 12 300 03 1200 14 600 0이 모델에서 는 집을 소유하지 않은 사람의 평균 잔고이고, 은 집을 소유한 사람이 그보다 평균적으로 얼마나 더(혹은 덜) 가졌는지다. 이 양수면 집 소유자의 잔고가 더 많다는 뜻이고, 음수면 반대다. 해석이 명료하다.
다중 범주 변수: K-1개의 더미 변수
범주가 셋 이상이면 이야기가 조금 복잡해진다. 이때 가장 널리 쓰는 방법은 원-핫 인코딩(one-hot encoding)이다. 범주가 K개라면 K개의 이진 변수를 만들고, 해당하는 범주만 1로 표시한다.
예를 들어 지역이 강남, 서초, 송파 세 가지라면 다음처럼 표현할 수 있다.
| 지역 | 강남 | 서초 | 송파 |
|---|---|---|---|
| 강남 | 1 | 0 | 0 |
| 서초 | 0 | 1 | 0 |
| 송파 | 0 | 0 | 1 |
각 범주를 하나의 스위치처럼 켜고 끌 수 있으므로 가장 합리적 형태로 보인다. 문제는 선형 회귀에는 보통 절편 항 가 함께 들어간다는 점이다.

선형 회귀에서 절편 항은 입력 행렬에 전부 1로 채운 열을 하나 추가한 것과 같다. 그런데 위처럼 K개의 더미 변수를 모두 넣으면, 각 행에서 더미 변수의 합도 항상 1이 된다. 각 데이터는 정확히 하나의 범주에만 속하기 때문이다.
즉, 다음 관계가 항상 성립한다.
이 1은 절편 항을 위해 넣은 열과 똑같은 역할을 한다. 다시 말해 절편 항이 이미 있는데, 더미 변수 K개를 모두 넣으면 같은 정보를 한 번 더 넣는 셈이다. 이때 입력 행렬 안에 완벽한 선형 종속 관계가 생긴다.
지난 글의 정규 방정식 부분을 떠올려보자. 선형 종속이 있으면 역행렬이 존재하지 않는다. 결국 계수를 하나로 정할 수 없고, 모델을 안정적으로 풀 수 없다.
이를 더미 변수 함정(dummy variable trap)이라 부른다. 해결책은 단순하다. K개의 더미 변수 중 하나를 빼고, 남은 K-1개만 사용한다. 빠진 범주는 기준(baseline)이 된다. 나머지 범주는 이 기준과 얼마나 다른지로 해석한다.
# 거주 지역: 강남(기준), 서초, 송파# K=3이므로 더미 변수 2개 생성
# K-1 더미 변수 생성 (강남이 기준)def create_dummies(regions): dummies = np.zeros((len(regions), 2)) for i, region in enumerate(regions): if region == '서초': dummies[i, 0] = 1 elif region == '송파': dummies[i, 1] = 1 # 강남 → [0, 0] (기준) return dummies
regions = ['강남', '서초', '송파', '강남', '서초']X_dummies = create_dummies(regions)print(X_dummies)[[0. 0.] [1. 0.] [0. 1.] [0. 0.] [1. 0.]]이 방식에서 모델은 다음과 같이 해석된다.
- 는 강남 거주자의 평균값 (기준)
- 은 서초가 강남과 얼마나 다른지 (차이)
- 는 송파가 강남과 얼마나 다른지 (차이)
이 방식은 해석이 편하다. 이고 이라면 “서초는 강남보다 50만큼 높고, 송파는 강남보다 30만큼 낮다”는 의미다. 기준 범주를 무엇으로 두느냐에 따라 같은 데이터라도 계수의 모습이 달라진다. 물론 모델의 전체 예측력은 같다.
모든 상황에서 K-1 방식만 쓰지는 않는다. 절편을 제거하거나, 정규화가 들어간 모델을 쓰거나, 딥러닝처럼 범주를 임베딩으로 처리하는 경우에는 K개의 원-핫 변수를 모두 쓰기도 한다. 하지만 절편이 포함된 기본 선형 회귀에서는 K개를 모두 넣으면 선형 종속이 생기므로, K-1개를 사용한다고 이해하면 좋다.
실제 개발에서는 scikit-learn의 OneHotEncoder나 pandas의 get_dummies를 많이 쓴다. 두 도구 모두 범주형 변수를 0과 1로 이루어진 더미 변수로 바꿔준다. 다만 K-1개만 남기는 옵션 이름은 도구마다 다르다. pandas에서는 drop_first=True, scikit-learn에서는 drop='first'를 사용한다. 라이브러리가 알아서 처리해주더라도, 내부에서 무슨 일이 일어나는지 한 번쯤 직접 구현해보면 좋다. 그래야 이런 옵션을 만났을 때 왜 필요한지 이해할 수 있다.
순서가 있는 범주: 조금 다른 이야기
한 가지 예외가 있다. 순서 범주(ordinal variable)라면 오히려 숫자 사용이 합리적일 수 있다. “관리상태: 매우 나쁨(1), 나쁨(2), 보통(3), 좋음(4), 매우 좋음(5)” 같은 경우다. 여기서는 숫자 사이 순서가 실제 의미가 있다. 물론 “좋음(4)이 나쁨(2)의 두 배”라는 배수 관계까지 의미하지는 않으니 주의해야 한다. 보통은 순서 범주라도 일단 원-핫 인코딩으로 처리한 뒤, 성능을 비교하며 어느 쪽이 나은지 판단하는 경우가 많다.
변수 간 상호작용 - 혼자서는 보이지 않는 효과
단순 선형 모델은 각 변수가 따로따로 결과에 영향을 준다고 본다. 이 주는 영향과 가 주는 영향을 각각 계산한 뒤, 둘을 더하는 방식이다.
하지만 현실에서는 두 변수가 따로 움직이지 않는 경우가 많다. 어떤 변수는 혼자 있을 때보다 다른 변수와 함께 있을 때 더 큰 영향을 미친다.
광고를 예로 들어보자. TV 광고만 했을 때 매출이 조금 오르고, 라디오 광고만 했을 때도 매출이 조금 오른다고 하자. 그렇다면 TV 광고와 라디오 광고를 동시에 하면 두 효과를 단순히 더한 만큼만 매출이 오를까?
꼭 그렇지는 않다. TV에서 본 광고를 라디오에서 다시 들으면 기억에 더 오래 남을 수 있다. 이 경우 두 광고는 서로 힘을 보태며 단순 합보다 큰 효과를 낸다. 반대로 같은 광고를 너무 자주 접하면 피로감이 생겨 효과가 줄어들 수도 있다. 중요한 점은 이 변화가 TV 광고 하나, 라디오 광고 하나만 봐서는 보이지 않는다는 점이다. 두 광고가 함께 있을 때 드러난다.
이런 관계를 모델에 넣기 위해 상호작용 항(interaction term)을 사용한다. 방법은 단순하다. 두 변수를 곱한 값을 새로운 피처로 추가한다.
여기서 가 상호작용 항이다. 이 항은 두 변수가 동시에 커질 때 결과가 어떻게 달라지는지를 잡아낸다. 광고 예시라면 TV 광고비와 라디오 광고비를 곱한 값을 추가하는 셈이다.
가 양수면 두 변수가 함께 있을 때 결과를 더 끌어올리는 방향으로 작용한다. 음수면 함께 있을 때 오히려 효과가 줄어든다. 0에 가깝다면 두 변수를 함께 넣어도 추가로 설명할 부분이 거의 없다는 뜻이다.
상호작용 항이 들어가면 계수 해석도 달라진다. 의 효과는 더 이상 하나로 고정되지 않는다. 위 식을 기준으로 묶어보면 다음과 같다.
이 식을 보면 앞에 붙은 값이 이 아니라 임을 알 수 있다. 즉, 의 효과는 값에 따라 달라진다.
광고 예시로 말하면, TV 광고의 효과는 라디오 광고를 얼마나 했는지에 따라 달라질 수 있다. 라디오 광고를 거의 하지 않았다면 TV 광고의 효과는 에 가깝다. 라디오 광고를 많이 했다면 상호작용 항까지 더해져 TV 광고의 효과가 달라진다. 상호작용 항의 핵심은 바로 이 지점이다.
상호작용은 수치형 변수와 범주형 변수 사이에도 만들 수 있다. 상품 색상이 Red와 Blue 두 가지이고, 가격이 판매량에 미치는 영향을 분석한다고 하자. Red를 기준 색상으로 두고, Blue 여부를 나타내는 더미 변수 를 만들면 다음처럼 쓸 수 있다.
Red 상품은 이므로 식이 이렇게 줄어든다.
Blue 상품은 이므로 다음 식이 된다.
두 식을 비교해보자. Red 상품에서 가격의 효과는 이다. Blue 상품에서 가격의 효과는 이다. 즉, 같은 가격 변화라도 색상에 따라 판매량 변화가 달라질 수 있다.
이처럼 상호작용 항은 “한 변수의 효과가 다른 변수에 따라 달라지는 상황”을 표현한다. 선형 모델의 형태는 유지하면서, 현실에서 자주 나타나는 더 복잡한 관계를 담을 수 있다.
실제 광고 데이터에서도 이런 상호작용 항이 큰 차이를 만든다. TV 광고비와 라디오 광고비를 따로 넣은 모델보다, 두 변수의 곱을 함께 넣은 모델이 훨씬 높은 설명력을 보이는 경우가 있다. 겉보기에는 항 하나를 더했을 뿐이지만, 선형 모델이 놓치던 “함께 있을 때의 효과”를 잡아낸 결과다.

# 상호작용 항 추가def create_interaction_features(X): # 원래 피처 + 두 피처의 곱 interaction = X[:, 0:1] * X[:, 1:2] # X1 * X2 return np.hstack([X, interaction])
# TV 광고비, 라디오 광고비np.random.seed(42)X = np.random.rand(100, 2) * 100X_with_interaction = create_interaction_features(X)print(f"원래 피처 수: {X.shape[1]}")print(f"상호작용 포함 피처 수: {X_with_interaction.shape[1]}")원래 피처 수: 2상호작용 포함 피처 수: 3계층적 원칙
상호작용 항을 다룰 때 반드시 지켜야 할 원칙이 하나 있다. 계층적 원칙(hierarchical principle)이다.
상호작용 항 를 포함하려면, 과 의 개별 항도 함께 포함해야 한다.

이 규칙이 왜 중요할까. 상호작용 항만 넣고 개별 항을 빼면 모델은 “두 변수가 함께 있을 때”의 효과만 학습한다. 개별 효과와 공동 효과를 분리해서 해석할 수 없다.
의 계수가 통계적으로 유의하지 않게 나오더라도, 같은 상호작용 항을 모델에 넣었다면 과 도 함께 남기는게 원칙이다. 개별 항을 빼고 상호작용 항만 남기면, 두 변수가 각각 미치는 영향과 함께 작용할 때의 효과가 뒤섞인다. 이 원칙이 의외로 자주 깨지는데, 그렇게 만든 모델은 결국 해석하기 어려워진다.
다항 항: 비선형 관계를 선형 모델 안으로
선형 회귀라는 이름에 “선형”이 들어가니 비선형 관계는 못 다룰 것 같지만, 사실 선형 모델 틀 안에서도 비선형 관계를 표현할 수 있다. 방법은 간단하다. , 같은 다항 항(polynomial term)을 새로운 피처로 추가하면 된다.
이 모델은 와 사이에 3차 곡선 관계를 표현한다. 하지만 계수 에 대해서는 여전히 선형이다. 그래서 이 모델도 선형 회귀로 분류한다.

# 다항 피처 생성def create_polynomial_features(x, degree=3): features = [x] for d in range(2, degree + 1): features.append(x ** d) return np.column_stack(features)
x = np.random.rand(100, 1)X_poly = create_polynomial_features(x, degree=3)print(f"피처 수: {X_poly.shape[1]}")피처 수: 3꼭 , 같은 다항 항만 가능한 것은 아니다. 문제에 따라 , 처럼 변환한 값을 새 피처로 넣을 수도 있다. 중요한 점은 새 피처를 어떻게 만들었느냐가 아니라, 모델이 여전히 그 피처들의 선형 결합 형태라는 사실이다. 피처를 비선형으로 바꿔 넣더라도 계수 에 대해서는 선형이므로, 선형 회귀의 틀 안에서 그대로 풀 수 있다.
이 기법은 강력하지만 조심해야 한다. 차수를 너무 올리면 지난 글에서 본 과적합이 바로 튀어나온다. 9차 다항식으로 점 10개를 피팅하면 학습 데이터에는 완벽히 맞지만, 새 데이터에서는 크게 흔들리던 사례 말이다. 다항 회귀의 차수는 보통 2~3차로 제한하고, 그 이상의 유연성이 필요하면 스플라인(spline)이나 GAM(generalized additive models) 같은 더 정교한 기법을 쓰는 편이 낫다.
결국 다항 항과 로그 변환은 사람이 직접 비선형 패턴을 예상하고, 그 패턴을 피처로 만들어 모델에 넣는 방식이다. 이런 작업을 피처 엔지니어링(feature engineering)이라고 부른다. 모델 자체는 선형이지만, 사람이 어떤 피처를 만들어 넣느냐에 따라 훨씬 복잡한 관계까지 표현할 수 있다.
현대 신경망은 이를 상당 부분 스스로 해결한다. ReLU, GELU 같은 활성화 함수와 여러 층의 조합을 통해 비선형 관계를 스스로 학습하는 것이다. 그러나 여전히 작은 데이터셋이나 해석이 중요한 문제에서는 사람이 직접 만든 피처가 더 효율적이고, 결과를 설명하기도 쉽다.
피처 선택 - 어떤 변수를 쓸 것인가
이제 까다로운 문제로 넘어간다. 피처가 수십 개, 수백 개라면 그중 어떤 것을 모델에 넣어야 할까. “많이 넣을수록 좋지 않을까?”라고 생각할 수 있지만, 그렇지 않다. 모두 넣으면 안 되는 까닭이 셋 있다.
첫째, 계산량. 지난 글에서 봤듯 정규 방정식 의 계산은 피처 수 에 대해 에 비례한다. 피처가 100개면 백만 번 연산, 1,000개면 십억 번 연산이다. 데이터 크기까지 곱하면 부담이 급격히 커진다.
둘째, 과적합. 불필요한 피처가 많으면 모델이 그 피처의 우연한 패턴, 곧 노이즈까지 학습한다. 학습 데이터에서는 성능이 좋아 보이지만 새 데이터에서는 오히려 나빠진다.
셋째, 해석의 어려움. 핵심을 빗겨간 피처 300개가 들어간 모델과, 정말 중요한 피처 5개로 만든 모델 중 어느 쪽이 이해하기 쉬울까. 실무에서 모델을 설명해야 할 상황(경영진에게, 규제 기관에게, 동료 연구자에게)이 오면 이 차이가 결정적이다.
그렇다면, 피처를 어떤 기준으로 선택해야 할까. 고전적인 세 가지 접근을 살펴보자.
Best Subset Selection - 완전 탐색의 정석
가장 직관적 방법은 모든 가능한 조합을 다 시도해보고 가장 좋은 것을 고르기다. 피처가 개라면 부분집합은 총 개. 각 부분집합에 대해 모델을 학습하고 성능을 측정해서 제일 나은 조합을 고른다.

from itertools import combinations
# 모든 피처 조합 중 최적의 조합 탐색def best_subset_selection(X, y, max_features=None): n_features = X.shape[1] if max_features is None: max_features = n_features
best_score = float('inf') best_subset = None
for k in range(1, max_features + 1): for subset in combinations(range(n_features), k): X_sub = X[:, list(subset)] # 절편 항 추가 후 정규 방정식 ones = np.ones((X_sub.shape[0], 1)) X_aug = np.hstack([ones, X_sub]) beta = np.linalg.lstsq(X_aug, y, rcond=None)[0] y_pred = X_aug @ beta rss = np.sum((y - y_pred) ** 2)
if rss < best_score: best_score = rss best_subset = subset
return best_subset, best_score
# 피처 5개 중 최대 3개를 골라 최적의 조합 찾기np.random.seed(42)X = np.random.rand(100, 5)true_beta = np.array([3, 0, 2, 0, 1]) # 피처 0, 2, 4만 실제로 영향y = X @ true_beta + np.random.randn(100) * 0.5
best, score = best_subset_selection(X, y, max_features=3)print(f"최적 피처 조합: {best}")print(f"RSS: {score:.4f}")최적 피처 조합: (0, 2, 4)RSS: 28.4777이 방법의 매력은 분명하다. 이론적으로 최적이다. 가능한 조합을 모두 시도했으니 더 나은 선택이 있을 수 없다.
문제는 이라는 숫자다. 피처가 10개면 1,024개 조합이니 컴퓨터에 맡길 만하다. 20개면 100만 개가 넘는다. 30개면 10억 개가 넘는다. 실무 데이터에서 피처가 30개 이하인 경우는 거의 없다.
“모든 조합을 시도한다”는 접근은 아주 예외적 상황을 제외하면 대부분 불가능하다. 비슷한 패턴이 머신러닝 분야에 여럿 있다. 하이퍼파라미터 튜닝, 네트워크 아키텍처 탐색, 프롬프트 최적화 모두 이 고민을 공유한다. 완전 탐색이 불가능할 때 어떻게 합리적으로 절충할 것인가. 피처 선택의 다음 두 접근이 그 답의 초기 형태다.
Forward Stepwise Selection - 빈 캔버스에서 그려가기
전진 선택법(Forward Selection)은 빈 모델에서 시작해 피처를 하나씩 추가한다. 매 단계마다 성능을 가장 크게 올리는 피처 하나를 골라 더한다. 일종의 탐욕 알고리즘(greedy algorithm)이다.

# 전진 선택법def forward_selection(X, y, max_features=None): n_features = X.shape[1] if max_features is None: max_features = n_features
selected = [] remaining = list(range(n_features))
for step in range(max_features): best_score = float('inf') best_feature = None
for feature in remaining: trial = selected + [feature] X_sub = X[:, trial] ones = np.ones((X_sub.shape[0], 1)) X_aug = np.hstack([ones, X_sub]) beta = np.linalg.lstsq(X_aug, y, rcond=None)[0] y_pred = X_aug @ beta rss = np.sum((y - y_pred) ** 2)
if rss < best_score: best_score = rss best_feature = feature
selected.append(best_feature) remaining.remove(best_feature) print(f"Step {step+1}: 피처 {best_feature} 추가, RSS = {best_score:.4f}")
return selected
selected = forward_selection(X, y, max_features=3)print(f"선택된 피처: {selected}")Step 1: 피처 0 추가, RSS = 73.6930Step 2: 피처 2 추가, RSS = 35.6126Step 3: 피처 4 추가, RSS = 28.4777선택된 피처: [0, 2, 4]계산량이 로, Best Subset의 보다 월등히 빠르다. 피처가 100개면 Best Subset은 번 조합을 봐야 하지만 Forward Selection은 1만 번이면 끝난다.
물론, 여기엔 대가가 있다. 한 번 선택한 피처는 되돌릴 수 없다는 점이다. 처음에는 피처 A가 최선으로 보였지만, 나중에보니 A 없이 B와 C를 쓰는게 훨씬 나은 경우가 있을 수 있다. Forward Selection은 이런 문제를 해결하지 못한다. 탐욕 알고리즘의 전형적 한계다. 지역 최적에 빠지는 것이다.
그럼에도 실무에서 Forward Selection이 자주 쓰이는 까닭은, 충분히 좋은 근사값을 내기 때문이다. Forward Selection은 비록 최선은 아니지만 Best Subset의 계산량 폭증 문제를 회피한 훌륭한 차선책이다.
Backward Stepwise Selection - 가득 채우고 덜어내기
후진 선택법(Backward Selection)은 반대다. 모든 피처를 포함한 모델에서 시작해 성능 저하가 가장 적은 피처를 하나씩 제거한다. 둘의 차이는 출발점이다. 빈 모델에서 쌓아가느냐, 꽉 찬 모델에서 덜어내느냐. Forward와 마찬가지로 계산량은 이다.
Backward Selection은 데이터 수가 피처 수보다 많아야 쓸 수 있다. 모든 피처를 포함한 초기 모델을 학습하려면 정규 방정식을 풀어야 하는데, 이면 가 특이 행렬이 되어 해가 없기 때문이다. 실전에서 꽤 흔한 상황이다. 유전학 데이터는 가 수만 개인데 은 수백 개에 불과한 경우가 많다. 자연어 처리의 단어 빈도 피처도 비슷하다. 이런 경우에는 Forward Selection이나 다른 방법(뒤에서 다룰 정규화)이 필요하다.
Forward Stagewise Selection - 더 조심스럽게 더하기
전진 단계별 선택법(Forward Stagewise Selection)도 빈 모델에서 시작한다. 이 점에서는 Forward Stepwise와 비슷하다. 차이는 피처를 추가한 뒤 모델을 얼마나 크게 바꾸느냐에 있다.
Forward Stepwise는 매 단계마다 피처 하나를 고른 뒤, 지금까지 선택된 피처 전체를 다시 사용해 모델을 새로 맞춘다. 앞에서 고른 피처의 계수도 다시 바뀔 수 있다.
반면 Forward Stagewise는 훨씬 조심스럽게 움직인다. 매 단계에서 현재 잔차를 가장 잘 줄일 수 있는 피처를 하나 고르고, 그 피처의 계수를 아주 조금만 바꾼다. 기존 계수는 그대로 둔다. 다음 단계에서도 같은 일을 반복한다.
직관적으로 말하면 이렇다. Forward Stepwise는 매 단계마다 그림을 다시 그리는 방식이다. Forward Stagewise는 이미 그린 그림 위에 선을 조금씩 덧칠하는 방식이다. 한 번에 크게 고치지 않고, 현재 가장 부족한 방향을 찾아 조금씩 보정한다.
이 과정을 이해하려면 먼저 잔차(residual)를 떠올리면 된다. 잔차는 실제값과 현재 모델 예측값의 차이다.
모델이 아직 설명하지 못한 부분이라고 볼 수 있다. Forward Stagewise는 매 단계마다 이 잔차를 가장 잘 설명하는 피처를 찾는다. 어떤 피처가 현재 잔차와 가장 비슷한 방향으로 움직인다면, 그 피처를 조금 더 넣었을 때 오차가 가장 많이 줄어들 가능성이 크다.
그래서 과정은 다음과 같다.
- 처음에는 평균값만 예측하는 아주 단순한 모델에서 시작한다.
- 현재 예측값과 실제값의 차이, 즉 잔차를 구한다.
- 잔차와 가장 관련이 큰 피처를 찾는다.
- 그 피처의 계수를 아주 작은 값만큼 조정한다.
- 이 과정을 여러 번 반복한다.
코드로 쓰면 다음과 같다. 비교를 쉽게 하기 위해 먼저 피처를 표준화한다. 피처의 스케일이 다르면 값이 큰 피처가 유리해질 수 있기 때문이다.
import numpy as np
# 전진 단계별 선택법def forward_stagewise_selection(X, y, n_steps=20, step_size=0.01): # 피처 스케일 맞추기 X_mean = X.mean(axis=0) X_std = X.std(axis=0) X_scaled = (X - X_mean) / X_std
# 절편은 y의 평균으로 시작 intercept = y.mean() y_centered = y - intercept
n_features = X.shape[1] beta = np.zeros(n_features)
for step in range(n_steps): # 현재 모델이 설명하지 못한 부분 y_pred = X_scaled @ beta residual = y_centered - y_pred
# 잔차와 가장 관련이 큰 피처 찾기 correlations = X_scaled.T @ residual best_feature = np.argmax(np.abs(correlations))
# 잔차와 같은 방향이면 계수를 올리고, 반대 방향이면 내리기 direction = np.sign(correlations[best_feature]) beta[best_feature] += step_size * direction
# 현재 RSS 계산 y_pred_full = intercept + X_scaled @ beta rss = np.sum((y - y_pred_full) ** 2)
print( f"Step {step+1}: 피처 {best_feature} 조정, " f"beta = {beta[best_feature]:.4f}, RSS = {rss:.4f}" )
return intercept, beta
intercept, beta = forward_stagewise_selection(X, y, n_steps=5)Step 1: 피처 0 조정, beta = 0.0100, RSS = 121.1102Step 2: 피처 0 조정, beta = 0.0200, RSS = 119.7430Step 3: 피처 0 조정, beta = 0.0300, RSS = 118.3958Step 4: 피처 0 조정, beta = 0.0400, RSS = 117.0686Step 5: 피처 0 조정, beta = 0.0500, RSS = 115.7614이 코드에서 중요한 부분은 step_size다. Forward Stagewise는 선택된 피처의 계수를 한 번에 크게 바꾸지 않는다. 0.01처럼 작은 값만큼만 움직인다. 그래서 같은 피처가 여러 번 선택될 수 있다.
예를 들어 피처 2가 첫 단계에서 선택되었다고 하자. 이때 피처 2의 계수는 조금만 커진다. 이후 다른 피처가 추가되면서 모델 모양이 바뀌면, 피처 2가 다시 필요해질 수 있다. 그러면 피처 2가 다시 선택되고 계수가 조금 더 커진다. 반대로 너무 많이 들어갔다고 판단되면 음의 방향으로 조정될 수도 있다.
세 방법의 차이를 정리하면 이렇다.
Best Subset Selection은 가능한 모든 피처 조합을 전부 시도한다. 가장 철저하지만 계산량이 너무 크다.
Forward Stepwise Selection은 빈 모델에서 시작해 피처를 하나씩 추가한다. 한 번 선택한 피처는 유지하지만, 매 단계마다 선택된 피처 전체의 계수를 다시 맞춘다.
Forward Stagewise Selection은 더 보수적이다. 매 단계에서 피처 하나를 고르고, 그 피처의 계수만 아주 조금 움직인다. 기존 계수는 크게 건드리지 않는다. 그래서 모델이 한 번에 확 바뀌지 않고 천천히 만들어진다.
이 방식은 최적의 피처 조합을 보장하지 않는다. 현재 잔차를 가장 잘 줄이는 방향으로 조금씩 움직이는 탐욕적 방법이기 때문이다. 하지만 피처가 많아 모든 조합을 볼 수 없을 때 유용한 근사가 된다. 또 계수를 조금씩 키워간다는 아이디어는 이후 정규화, 특히 Lasso를 이해할 때 중요한 배경이 된다.
더 나은 평가 지표의 필요성
위의 알고리즘은 RSS를 기준으로 피처를 골랐다. 여기에 미묘한 문제가 있다. RSS는 피처를 추가할수록 무조건 줄어든다. 완전히 랜덤한 피처를 추가해도 RSS는 줄어든다. 모델이 그 피처에서 아주 약간이라도 정보를 짜낼 수 있기 때문이다.
이 문제를 풀려면 “피처 추가에 대한 패널티”를 담은 지표가 필요하다. 대표적인 지표가 AIC (Akaike Information Criterion), BIC (Bayesian Information Criterion), 조정된 (Adjusted ) 같은 것이다.
AIC와 BIC는 음의 로그 우도에 피처 수에 비례하는 패널티를 더하는 구조다. 피처가 늘면 설명력은 올라가지만 동시에 패널티도 커지므로, 정말 의미 있는 피처만 살아남는다. BIC의 패널티가 AIC보다 크기 때문에 BIC는 더 간결한 모델을 선호한다. 어느 쪽을 쓸지는 목적에 따라 다르다. 예측 성능이 최우선이면 AIC, 해석 가능한 간결한 모델을 원하면 BIC가 일반적 지침이다.
이런 정보 기준은 단순히 RSS를 줄이는 데서 그치지 않고 모델 복잡도에 대한 페널티를 함께 고려하는 철학을 가진다. 다음 편에서 다룰 정규화와 같은 맥락이다.
모델 선택과 과적합 방지 - 일반화라는 진짜 목표
피처를 골랐다고 끝은 아니다. 이제 여러 후보 모델 중 어떤 모델을 선택할지 판단해야 한다. 이때 머신러닝 전체를 관통하는 중요한 원칙이 등장한다.
학습 데이터에서 잘 맞추는 것보다 새로운 데이터에서 잘 맞추는 것이 중요하다.
이를 일반화 성능(generalization performance)이라 부른다. 모델이 학습 데이터에만 지나치게 맞춰지고, 새로운 데이터에서는 성능이 떨어지는 현상이 지난 1편에서 다룬 과적합(overfitting)이다. 과적합을 피하지 못하면 아무리 정교해 보이는 모델도 실무에 사용할 수 없다.
과적합을 막는 가장 기본은 데이터를 나누는 것이다. 보통 데이터를 세 부분으로 나눈다.
훈련 세트(Training Set): 모델을 학습시키는 데 쓴다. 파라미터는 이 데이터에 맞춰진다.
검증 세트(Validation Set): 여러 후보 중 어떤 모델이 나은지 고르는 데 쓴다. 피처 선택, 하이퍼파라미터 튜닝, 모델 비교가 여기서 이루어진다.
테스트 세트(Test Set): 최종 선택된 모델의 성능을 평가하는 데 쓴다. 모델을 모두 확정한 뒤 한 번만 사용한다.
일반적인 비율은 70:15:15 또는 80:10:10이다. 데이터가 매우 크면 훈련:검증:테스트를 98:1:1 정도로 나누기도 한다. 데이터가 충분히 많다면 검증과 테스트 비율이 작아도 안정적 평가가 가능하기 때문이다.

# 훈련/검증/테스트 세트 분할def train_val_test_split(X, y, val_ratio=0.15, test_ratio=0.15): n = len(y) indices = np.random.permutation(n)
test_size = int(n * test_ratio) val_size = int(n * val_ratio)
test_idx = indices[:test_size] val_idx = indices[test_size:test_size + val_size] train_idx = indices[test_size + val_size:]
return (X[train_idx], y[train_idx], X[val_idx], y[val_idx], X[test_idx], y[test_idx])
np.random.seed(42)X_train, y_train, X_val, y_val, X_test, y_test = train_val_test_split(X, y)print(f"훈련 세트: {X_train.shape}, {y_train.shape}")print(f"검증 세트: {X_val.shape}, {y_val.shape}")print(f"테스트 세트: {X_test.shape}, {y_test.shape}")훈련 세트: (70, 5), (70,)검증 세트: (15, 5), (15,)테스트 세트: (15, 5), (15,)원칙: 테스트 세트는 딱 한 번
모델 평가에서 가장 중요하지만 자주 깨지는 원칙이 있다.
테스트 세트는 모델이 완전히 확정된 뒤에 딱 한 번만 사용한다.
이 원칙이 중요한 이유는 간단하다. 테스트 세트의 성능을 보고 모델을 고치기 시작하면, 그 순간 테스트 세트는 더 이상 “처음 보는 데이터”가 아니게 된다. 모델 선택 과정에 테스트 세트가 끼어든 셈이다.
예를 들어 테스트 정확도가 82%였다고 하자. 하이퍼파라미터를 조금 바꿨더니 85%가 나왔다. 이 숫자만 보면 새 모델이 더 좋아 보인다. 하지만 이 결정을 테스트 세트 결과를 보고 내렸다면, 우리는 이미 테스트 세트에 맞춰 모델을 고른 것이다. 겉으로는 평가처럼 보이지만, 실제로는 테스트 세트를 이용해 모델을 튜닝한 셈이다.
이렇게 되면 진짜 새로운 데이터에서 성능이 82%에 가까울지, 85%에 가까울지 알 수 없다. 테스트 세트가 더 이상 공정한 평가 기준이 아니기 때문이다. 성능 측정의 의미가 무너진다.
Kaggle 같은 대회에서도 비슷한 일이 자주 벌어진다. 참가자가 퍼블릭 리더보드 점수에 맞춰 수백 번 제출하다가, 마지막 프라이빗 리더보드에서 순위가 크게 떨어지는 경우다. 퍼블릭 리더보드에 맞춰 모델과 제출 전략을 조정하다 보니, 그 데이터에 과적합된 것이다.
이를 막으려면 테스트 세트를 끝까지 열어보지 않아야 한다. 모델 선택, 피처 선택, 하이퍼파라미터 조정은 검증 세트에서 끝낸다. 테스트 세트는 모든 결정이 끝난 뒤, 최종 성능을 확인하는 데 한 번만 사용한다.
말은 쉽지만 실무에서는 지키기 어렵다. 성능이 잘 나오지 않으면 “테스트로 한 번만 확인해볼까”라는 유혹이 생긴다. 하지만 그 순간 평가의 신뢰도가 흔들린다. 테스트 세트는 마지막까지 남겨두는 편이 좋다.
교차 검증 - 데이터가 부족할 때
데이터가 부족하면 훈련/검증/테스트로 나누는 일이 부담스럽다. 전체 데이터가 1,000개뿐인데 훈련에는 700개만 쓰고, 나머지 300개를 검증과 테스트에 남겨두기 아까운 상황이 생긴다. 이럴 때 쓰는 방법이 교차 검증(Cross-Validation)이다.
가장 널리 쓰는 방식은 K-폴드 교차 검증(K-Fold Cross-Validation)이다. 데이터를 K개의 묶음으로 나눈 뒤, 그중 하나를 검증 세트로 쓰고 나머지 K-1개를 훈련 세트로 쓴다. 이 과정을 K번 반복하면 모든 묶음이 한 번씩 검증 세트가 된다. 마지막에는 K번의 검증 성능을 평균 내어 모델 성능을 추정한다.
예를 들어 5-폴드 교차 검증이라면 데이터를 다섯 묶음으로 나눈다. 첫 번째 실험에서는 1번 묶음을 검증에 쓰고, 2~5번 묶음으로 학습한다. 두 번째 실험에서는 2번 묶음을 검증에 쓰고, 나머지로 학습한다. 이렇게 다섯 번 반복한다. 모든 데이터가 훈련에도 쓰이고 검증에도 한 번씩 쓰이므로, 단순한 한 번의 분할보다 데이터 활용률이 높다.

# K-폴드 교차 검증def k_fold_cross_validation(X, y, k=5): n = len(y) indices = np.random.permutation(n) folds = np.array_split(indices, k) scores = []
for i in range(k): # i번째 폴드를 검증 세트로 사용 val_idx = folds[i] train_idx = np.concatenate([ folds[j] for j in range(k) if j != i ])
X_train, y_train = X[train_idx], y[train_idx] X_val, y_val = X[val_idx], y[val_idx]
# 정규 방정식으로 학습 ones_train = np.ones((len(y_train), 1)) X_train_aug = np.hstack([ones_train, X_train]) beta = np.linalg.lstsq(X_train_aug, y_train, rcond=None)[0]
# 검증 세트에서 평가 ones_val = np.ones((len(y_val), 1)) X_val_aug = np.hstack([ones_val, X_val]) y_pred = X_val_aug @ beta
rmse = np.sqrt(np.mean((y_val - y_pred) ** 2)) scores.append(rmse)
return np.mean(scores), np.std(scores)
np.random.seed(42)mean_rmse, std_rmse = k_fold_cross_validation(X, y, k=5)print(f"5-Fold CV RMSE: {mean_rmse:.4f} ± {std_rmse:.4f}")5-Fold CV RMSE: 0.5581 ± 0.0546K는 보통 5나 10을 쓴다. K가 커질수록 한 번에 더 많은 데이터를 훈련에 쓸 수 있지만, 그만큼 모델을 여러 번 학습해야 하므로 계산 비용도 늘어난다. 극단적으로 K를 데이터 개수와 같게 두면 Leave-One-Out Cross-Validation(LOOCV)이 된다. 데이터 하나를 검증에 쓰고 나머지 전부로 학습하는 과정을 데이터 개수만큼 반복하는 방식이다. 데이터를 거의 버리지 않는다는 장점이 있지만, 계산 비용이 매우 크다. 데이터가 몇 백 개 수준으로 작을 때나 쓸 만하다.
K-폴드의 또 다른 장점은 성능의 흔들림을 함께 볼 수 있다는 점이다. 한 번만 나누면 성능 숫자 하나만 얻는다. 반면 5-폴드 교차 검증을 하면 다섯 번의 성능이 나온다. 여기서 평균과 표준편차를 계산하면 “이 모델은 RMSE 5.2 ± 0.3”처럼 성능이 어느 정도 안정적인지도 함께 말할 수 있다.
시계열 데이터의 함정
주의할 점이 있다. K-폴드 교차 검증은 데이터를 무작위로 섞어도 문제가 없을 때 잘 작동한다. 보통 이런 데이터를 IID 데이터라고 부른다. 각 데이터가 서로 독립이고, 같은 분포에서 나왔다고 보는 가정이다.
시계열 데이터에서는 이 가정이 깨진다. 주가, 매출, 날씨, 로그 데이터처럼 시간 순서가 중요한 데이터는 과거와 미래가 서로 이어져 있다. 이런 데이터를 무작위로 섞어 K-폴드 교차 검증을 하면 미래 데이터를 보고 과거를 예측하는 상황이 생길 수 있다. 실제 서비스에서는 불가능한 방식으로 평가하는 셈이다.
이런 경우에는 시간 순서를 보존해야 한다. 과거 데이터로 학습하고, 그보다 미래의 데이터로 검증해야 한다. 대표적인 방식이 Walk-Forward Validation이나 Expanding Window다. 이름은 낯설어도 원리는 단순하다. 시간을 거슬러 올라가지 않고, 실제 예측 상황처럼 과거에서 미래 방향으로만 평가한다.
LLM 학습 데이터에도 비슷한 문제가 있다. 최근 자주 이야기되는 데이터 오염(data contamination) 문제다. 평가에 써야 할 데이터가 이미 훈련 데이터에 들어가 있거나, 미래 사건에 대한 정보가 훈련 과정에 섞이면 모델 성능을 제대로 측정할 수 없다. 기본 원칙은 같다. 훈련과 평가는 엄격히 분리해야 한다. 규모가 커질수록 이 원칙을 지키기 더 어려워질 뿐이다.
정리와 다음 글 예고
여기까지 선형 회귀를 현실 데이터에 적용하기 위한 확장을 살펴봤다. 정리하면 다음과 같다.
- 범주형 변수: 이진 변수는 0/1로, 다중 범주는 K-1개의 더미 변수로 변환한다. 순서 범주는 예외로 숫자 코딩이 합리적일 수 있다.
- 상호작용 항: 두 변수가 함께 있을 때 달라지는 효과를 같은 곱으로 표현한다. 이때 계층적 원칙을 지켜야 한다.
- 다항 항과 변환 피처: , 뿐 아니라 , 같은 변환으로 비선형 관계를 선형 모델 안에 담을 수 있다. 다만 차수를 너무 높이면 과적합이 생긴다.
- 피처 선택: Best Subset은 철저하지만 느리고, Forward/Backward Stepwise는 빠른 근사다. Forward Stagewise는 더 조심스럽게 계수를 키워가는 방식이다. 모델 비교에는 AIC, BIC 같은 정보 기준을 쓸 수 있다.
- 모델 평가: 훈련/검증/테스트 세트로 나누고, 데이터가 부족하면 K-폴드 교차 검증을 사용한다. 테스트 세트는 최종 평가 때 한 번만 사용한다.
이 정도면 선형 회귀를 실제 데이터에 적용할 때 마주치는 주요 문제를 꽤 많이 다룬 셈이다. 숫자가 아닌 변수를 어떻게 넣을지, 변수끼리 함께 작용하는 효과를 어떻게 표현할지, 곡선 관계를 어떻게 담을지, 많은 피처 중 무엇을 고를지까지 살펴봤다.
아직 남은 문제도 있다. 피처 선택은 “어떤 변수를 쓸까”의 문제다. 여기서 한 걸음 더 나아가면 “선택한 변수의 영향력을 얼마나 허용할까”라는 질문이 나온다. 변수를 아예 빼지는 않더라도, 계수가 너무 커지지 않도록 제한하는 방식이다.
이 질문은 정규화(regularization)로 이어진다. 능형 회귀(Ridge), 라쏘(Lasso), 엘라스틱넷(Elastic Net)이 대표적이다. 이 주제는 선형 회귀와 분류를 모두 훑은 뒤 별도로 다룬다.
다음 글에서는 먼저 분류(Classification) 문제로 넘어간다. 지금까지는 연속된 숫자를 예측하는 회귀를 다뤘지만, 현실에는 “예/아니오”, “A/B/C”처럼 범주를 예측해야 하는 문제도 많다. 그 출발점으로 로지스틱 회귀(Logistic Regression)를 살펴본다.