29일차는 오전에는 SVM의 기본 아이디어를 보고, 이후에는 Kaggle House Prices 데이터를 이용해 회귀 모델링 전처리 흐름을 정리한 날이었다. SVM에서는 “데이터를 어떤 경계로 나눌 것인가”를 봤고, House Prices 실습에서는 “train과 test를 같은 기준으로 전처리하려면 어떻게 해야 하는가”를 봤다.
이날 가장 크게 남은 건 모델 자체보다 전처리 기준을 train에서만 만들어야 한다는 점이었다. 결측치를 채우는 기준, 인코딩 규칙, 스케일링 기준까지 전부 train에서 만들고 test에는 적용만 해야 했다. 코드가 길어지는 이유도 결국 이 기준을 지키기 위해서였다.
1. SVM은 margin이 넓은 경계선을 찾는다
SVM은 데이터를 구분하는 경계선을 찾는 모델이다. 2차원에서는 직선, 3차원에서는 평면, 더 높은 차원에서는 초평면으로 데이터를 나눈다. 단순히 아무 선이나 긋는 것이 아니라, 두 클래스 사이의 margin이 가장 넓어지는 경계선을 찾는다는 점이 핵심이었다.
처음에는 선형회귀처럼 오차를 줄이는 모델과 비슷하게 느껴졌는데, SVM은 관점이 조금 달랐다. 회귀는 예측값과 실제값의 차이를 줄이는 쪽이라면, SVM은 양쪽 클래스가 경계선에서 얼마나 떨어져 있는지를 본다.
SVM의 기본 흐름
1. 데이터를 나눌 수 있는 경계선을 찾는다.
2. 양쪽 클래스에서 가장 가까운 점들을 기준으로 margin을 본다.
3. margin이 가장 넓어지는 경계선을 선택한다.
다만 현실 데이터는 항상 깔끔하게 나뉘지 않는다. 그래서 일부 오분류를 어느 정도 허용할지 정해야 하는데, 이때 중요한 파라미터가 C였다.
C가 크다
→ 개별 데이터의 에러를 강하게 줄이려 함
→ train 데이터에 타이트하게 맞을 수 있음
C가 작다
→ 일부 에러를 허용
→ 경계가 더 느슨해질 수 있음
이 부분은 KNN에서 k값을 조절하던 것과 비슷하게 느껴졌다. 모델마다 조절하는 방식은 다르지만, 결국 underfitting과 overfitting 사이에서 적당한 지점을 찾는 과정이었다.
2. House Prices는 회귀 문제지만 시계열은 아니었다
이후에는 Kaggle의 House Prices 데이터를 사용했다. train에는 정답인 SalePrice가 있고, test에는 이 정답 컬럼이 빠져 있었다.
train_df = pd.read_csv(train_path)
test_df = pd.read_csv(test_path)
print(train_df.shape)
print(test_df.shape)
(1460, 81)
(1459, 80)
train과 test의 컬럼 수가 1개 차이 나는 이유는 SalePrice 때문이다. 이 데이터의 목적은 특정 조건을 가진 주택의 가격을 예측하는 것이다. 주택 가격 데이터라서 시간 흐름을 떠올릴 수도 있지만, 이번 실습은 “시간에 따라 가격이 어떻게 변할까?”가 아니라 “이런 조건의 주택은 얼마일까?”를 푸는 회귀 문제였다.
이 차이를 먼저 잡는 게 중요했다. 같은 집값 데이터라도 시간 흐름을 예측하면 시계열이고, 특정 시점의 속성으로 가격을 예측하면 회귀 문제가 된다.
3. 회귀에서는 target 분포와 이상치를 먼저 봤다
회귀 문제에서는 target의 분포와 이상치를 먼저 확인해야 했다. GrLivArea와 SalePrice를 그려보니, 주거 면적이 4000을 넘는 일부 샘플이 튀어 보였다.
sns.lmplot(data=train_df, y="SalePrice", x="GrLivArea")
train_df[train_df["GrLivArea"] > 4000]
해당 샘플은 4개였고, 이번 실습에서는 전체 회귀 관계를 크게 왜곡할 수 있다고 보고 제거했다.
train_df.drop(
train_df[train_df["GrLivArea"] > 4000].index,
inplace=True
)
train_df.shape
(1456, 81)
다만 이상치는 무조건 지우는 것이 아니라는 점도 같이 남았다. 고가 주택 시장까지 잘 맞춰야 하는 목적이라면 오히려 중요한 샘플일 수 있다. 이번에는 전체적인 회귀 모델의 안정성을 위해 제거한 것이고, 실제 프로젝트에서는 목적에 따라 판단해야 한다.
그다음에는 SalePrice 분포를 봤다.
sns.histplot(train_df["SalePrice"])
가격 데이터는 오른쪽으로 긴 꼬리를 가진 형태였다. 고가 주택이 적지만 값이 매우 크기 때문에 분포가 한쪽으로 쏠려 있었다. 그래서 로그 변환을 적용했다.
train_df["SalePrice"] = np.log1p(train_df["SalePrice"])
sns.histplot(train_df["SalePrice"])
모델은 로그 변환된 값을 예측하게 된다. 따라서 나중에 제출할 때는 np.expm1()으로 다시 원래 가격 단위로 되돌려야 했다.
4. 결측치는 컬럼 의미에 따라 다르게 채웠다
전처리에서는 먼저 Id를 따로 보관하고 제거했다. Id는 학습에는 필요 없지만 Kaggle 제출에는 필요하기 때문이다.
train_id = train_df.loc[:, "Id"]
test_id = test_df.loc[:, "Id"]
train_df.drop("Id", axis=1, inplace=True)
test_df.drop("Id", axis=1, inplace=True)
그리고 train에서 정답지인 SalePrice를 분리했다.
y_df = train_df.loc[:, "SalePrice"]
train_df.drop("SalePrice", axis=1, inplace=True)
이제 train과 test는 같은 feature 컬럼을 가져야 한다. 결측치를 확인해보니 PoolQC, MiscFeature, Alley, Fence처럼 대부분 비어 있는 컬럼도 있었고, LotFrontage처럼 일부만 비어 있는 컬럼도 있었다.
train_ms = pd.DataFrame(
train_df.isnull().sum(),
columns=["MissCount"]
)
train_ms = train_ms.loc[train_ms["MissCount"] > 0, :]
train_ms["MissPrecent"] = (train_ms["MissCount"] / len(train_df)) * 100
train_ms.sort_values(by="MissPrecent", ascending=False)
결측치 처리는 하나의 방식으로 밀어붙이지 않았다. 값이 없다는 것 자체가 의미인 범주형 컬럼은 "None"으로 채웠고, 시설이 없어서 면적이나 연도가 없는 경우에 가까운 수치형 컬럼은 0으로 채웠다.
for col in nones:
train_df.fillna({col: "None"}, inplace=True)
test_df.fillna({col: "None"}, inplace=True)
for col in zeros:
train_df.fillna({col: 0}, inplace=True)
test_df.fillna({col: 0}, inplace=True)
또 Utilities처럼 대부분 같은 값으로 들어 있는 컬럼은 feature로 쓰기 어렵다고 보고 제거했다.
train_df.drop("Utilities", axis=1, inplace=True)
test_df.drop("Utilities", axis=1, inplace=True)
이 과정에서 계속 신경 써야 했던 것은 train에서 판단한 기준을 test에도 그대로 적용한다는 점이었다.
5. LotFrontage는 Neighborhood별 중앙값으로 채웠다
가장 기억에 남는 전처리는 LotFrontage였다. 이 컬럼은 도로와 연결된 선형 피트 정보인데, 단순히 전체 평균으로 채우면 지역별 차이가 사라질 수 있다. 그래서 Neighborhood별 중앙값을 기준으로 채웠다.
train에서는 groupby().transform()을 사용했다.
train_df["LotFrontage"] = train_df.groupby(
by="Neighborhood"
)["LotFrontage"].transform(
lambda x: x.fillna(x.median())
)
test에는 train에서 만든 기준을 적용해야 하므로, 먼저 train 기준의 reference table을 만들었다.
ref_table = train_df.groupby(
by="Neighborhood"
)["LotFrontage"].agg("median")
그다음 test에 merge해서 결측치에만 train 기준 중앙값을 넣었다.
test_df = pd.merge(
test_df,
ref_table,
how="left",
left_on="Neighborhood",
right_on="Neighborhood"
)
merge 이후에는 LotFrontage_x, LotFrontage_y처럼 컬럼명이 나뉘어서, 기존 test 값이 비어 있는 경우에만 LotFrontage_y를 넣고 다시 컬럼명을 정리했다.
이 부분은 단순 결측치 처리보다 훨씬 실전적인 느낌이었다. 전체 평균으로 한 번에 채우는 것보다, 유사한 그룹을 기준으로 값을 채우는 방식이 더 자연스러울 수 있었다.
6. 인코딩과 스케일링도 train 기준으로 맞췄다
문자형 컬럼은 모델이 바로 사용할 수 없기 때문에 인코딩이 필요했다. 순서나 등급성이 있다고 본 컬럼은 Label Encoding을 적용했다. 여기서도 fit()은 train에만 사용했다.
for col in ordinals:
le = LabelEncoder()
le.fit(train_df[col])
train_df[col] = le.transform(train_df[col])
prev_classes = list(le.classes_)
for label in np.unique(test_df[col]):
if label not in prev_classes:
prev_classes.append(label)
le.classes_ = np.array(prev_classes)
test_df[col] = le.transform(test_df[col])
test에서 처음 보는 값이 나올 수 있어서 unseen label도 처리했다. 코드가 길어지는 이유는 결국 train 기준을 유지하면서도 test에서 에러가 나지 않도록 하기 위해서였다.
One-Hot Encoding에서는 train과 test의 컬럼 수가 달라졌다.
train_df = pd.get_dummies(train_df, columns=nominals, prefix_sep="_")
test_df = pd.get_dummies(test_df, columns=nominals, prefix_sep="_")
print(train_df.shape)
print(test_df.shape)
(1456, 248)
(1459, 236)
train에만 있는 더미 컬럼은 test에 0으로 추가하고, test에만 생긴 더미 컬럼은 제거했다. 컬럼 수가 같아진 뒤에도 컬럼 순서를 train 기준으로 다시 맞췄다.
test_df = pd.DataFrame(test_df, columns=train_df.columns)
스케일링도 마찬가지였다. StandardScaler는 train에만 fit()하고, test에는 transform()만 적용했다.
scaler = StandardScaler()
scaler.fit(train_df)
X_train = scaler.transform(train_df)
X_train = pd.DataFrame(X_train, columns=train_df.columns)
X_test = scaler.transform(test_df)
X_test = pd.DataFrame(X_test, columns=train_df.columns)
마지막에 컬럼 수와 순서가 맞는지 확인했다.
print(X_train.shape)
print(X_test.shape)
print((X_train.columns != X_test.columns).sum())
(1456, 248)
(1459, 248)
0
이 상태가 되어야 모델에 넣을 준비가 된 것이다.
7. Lasso와 Ridge로 회귀 모델을 돌렸다
모델링에서는 K-Fold를 사용했다. 이번에는 validation을 따로 빼기보다, train 내부에서 K-Fold로 성능을 확인하는 흐름이었다.
kfold = KFold(
n_splits=6,
shuffle=True,
random_state=1234
)
평가 지표는 MSE 계열을 사용했다. scikit-learn에서는 작을수록 좋은 지표를 scoring에 사용할 때 neg_가 붙는 구조라서, 다시 음수를 붙여 양수 MSE로 바꿔 사용했다.
def mse_baseline(model):
mse = -cross_val_score(
model,
X_train,
y_df,
cv=kfold,
scoring="neg_mean_squared_error"
)
return mse
먼저 Lasso를 돌리고, 이후 RandomizedSearchCV로 alpha, max_iter, tol을 튜닝했다.
lasso_model = Lasso(random_state=1234)
parameters = {
"alpha": [1, 0.001, 0.1, 2],
"max_iter": [1000, 3000],
"tol": [0.0001, 0.00001, 0.1]
}
lasso_rgs = RandomizedSearchCV(
lasso_model,
param_distributions=parameters,
n_iter=10,
cv=kfold,
random_state=1234,
n_jobs=-1,
scoring="neg_mean_squared_error"
)
lasso_rgs.fit(X_train, y_df)
예측값은 로그 스케일이므로 다시 원래 가격 단위로 바꿨다.
lasso_rgs_ypred = lasso_rgs.best_estimator_.predict(X_test)
y_pred1 = np.expm1(lasso_rgs_ypred)
Ridge도 비슷한 흐름으로 진행했다. Lasso는 L1 규제, Ridge는 L2 규제를 사용한다. 둘 다 선형회귀 기반이지만, feature가 많고 one-hot 컬럼이 많이 생긴 상황에서 규제를 통해 과적합을 줄이는 역할을 한다.
마무리
29일차는 SVM의 margin 개념과 House Prices 회귀 전처리 흐름을 함께 본 날이었다. SVM에서는 단순히 경계선을 긋는 것이 아니라, 두 클래스 사이의 margin을 최대화하고 C값으로 에러 허용 정도를 조절한다는 점이 남았다.
House Prices 실습에서는 회귀 모델링 전의 준비가 훨씬 크게 다가왔다. 이상치를 확인하고, target을 로그 변환하고, 결측치를 컬럼 의미에 맞게 채우고, 인코딩과 스케일링까지 train 기준으로 맞춰야 했다. 특히 LotFrontage를 Neighborhood별 중앙값으로 채우는 과정은 단순 평균 대체보다 더 현실적인 전처리 방식처럼 느껴졌다.
결국 이 날의 핵심은 모델을 돌리기 전에 train과 test를 같은 구조로 만드는 것이었다. 컬럼 수뿐 아니라 컬럼 순서, 인코딩 기준, 스케일링 기준까지 맞춰야 모델이 test를 제대로 해석할 수 있다.
29일차는 SVM의 경계면 개념을 잡고, House Prices 데이터로 train 기준 전처리와 회귀 모델링의 기본 흐름을 정리한 날이었다.
'[SK플래닛] ASAC 빅데이터전문가 11기 > 학습기록' 카테고리의 다른 글
| [SK플래닛] ASAC 빅데이터전문가 11기 | 32일차 (0) | 2026.06.02 |
|---|---|
| [SK플래닛] ASAC 빅데이터전문가 11기 | 31일차 (3) | 2026.06.01 |
| [SK플래닛] ASAC 빅데이터전문가 11기 | 28일차 (0) | 2026.05.28 |
| [SK플래닛] ASAC 빅데이터전문가 11기 | 27일차 (0) | 2026.05.27 |
| [SK플래닛] ASAC 빅데이터전문가 11기 | 26일차 (0) | 2026.05.26 |
