pytorch & sklearn pipeline

  • 저는 tabular data를 다룹니다.
  • 간혹 딥러닝을 하고 싶지만 표준화등 전처리도 해야 합니다.
  • 범주형 변수를 인코딩해서 feature importance도 보고 싶습니다.
  • skorch(sklearn + pytorch)를 사용하면 가능합니다.

1. skorch = sklearn + pytorch

skorch documentation
skorch tutorials

  • 저같은 사람들을 위해 skorch라는 라이브러리가 있습니다.
  • scikit-learn의 장점인 grid search 등을 딥러닝과 함께 사용할 수 있고
  • tutorial에서 transfer learning, U-Net, Seq2Seq 등을 지원합니다.


2. sklearn pipeline

scikit-learn.pipeline.Pipeline

  • scikit-learn의 파이프라인은 데이터 전처리에서 발생하는 불확실성을 줄여줍니다.
  • 데이터가 거쳐갈 길을 단단하게 만들어줌으로써 실수를 사전에 예방할 수 있습니다.
  • 특히 PCA나 One-hot encoding처럼 trainset의 정보를 기억해서 testset에 적용해야 할 때 좋습니다.

2.1. 예제 데이터셋

  • 펭귄 데이터셋을 사용해서 펭귄 체중 예측모델을 만들어 봅니다.
  • 편의를 위해 결측치까지 싹 지운 채로 시작합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
%matplotlib inline

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from copy import deepcopy

# 시각화 설정
sns.set_context("talk")
sns.set_style("white")
font_title = {"color":"gray"}

# Linux 한글 사용 설정
plt.rcParams['font.family']=['NanumGothic', 'sans-serif']
plt.rcParams['axes.unicode_minus'] = False

# 펭귄 데이터셋 불러오기
df_peng = sns.load_dataset("penguins")
df_peng.dropna(inplace=True)
df_peng.isna().sum()
  • 실행 결과: 결측치가 모두 제거되었습니다.
1
2
3
4
5
6
7
8
species              0
island 0
bill_length_mm 0
bill_depth_mm 0
flipper_length_mm 0
body_mass_g 0
sex 0
dtype: int64
  • 데이터셋을 준비합니다.
  • 펭귄 체중만 y, 나머지는 모두 X입니다.
1
2
3
y = df_peng["body_mass_g"]
X = df_peng.drop("body_mass_g", axis=1)
X.head(3)


  • trainset과 testset으로 나눕니다.
1
2
3
4
# data split
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

2.2. pipeline 구축

  • scikit-learn으로 pipeline을 구축합니다.

  • numerical feature는 회귀모델 적용을 고려한 PolynomialFeatures

  • 데이터 정규화를 위한 RobustScaler를 거칩니다.

  • categorical feature는 OneHotEncoder를 거칩니다.

  • 필요한 라이브러리를 불러옵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# encoder
from sklearn.preprocessing import OneHotEncoder
from sklearn.preprocessing import RobustScaler

# machine learning models
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import PolynomialFeatures

# pipeline
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer

# metrics
from sklearn.metrics import r2_score
from sklearn.metrics import mean_squared_error
  • pipeline을 구축하는 함수를 만듭니다.
  • get_model_0()을 실행하면 파이프라인이 만들어질 것입니다.
  • 전처리 후 머신러닝 모델로는 선형회귀와 랜덤포레스트를 선택할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def get_model_0(X_cols, degree=1, method="lr"):

X_cols_ = deepcopy(X_cols)

# 1-1.categorical feature에 one-hot encoding 적용
cat_features = list(set(X_cols) & set(["species", "island", "sex"]))
cat_transformer = OneHotEncoder(sparse=False, handle_unknown="ignore")

# 1-2.numerical feature는 Power Transform과 Scaler를 거침
num_features = list(set(X_cols) - set(cat_features))
num_features.sort()
num_transformer = Pipeline(steps=[("polynomial", PolynomialFeatures(degree=degree)),
("scaler", RobustScaler())
])

# 1. 인자 종류별 전처리 적용
preprocessor = ColumnTransformer(transformers=[("num", num_transformer, num_features),
("cat", cat_transformer, cat_features)])

# 2. 전처리 후 머신러닝 모델 적용
if method == "lr":
ml = LinearRegression(fit_intercept=True)
elif method == "rf":
ml = RandomForestRegressor()


# 3. Pipeline
model = Pipeline(steps=[("preprocessor", preprocessor),
("ml", ml)])

return model
  • 6번째, 10번째 행을 보시면 조금 특이한 처리가 들어가 있습니다.

  • feature selection에 사용되는 장치입니다.

  • feature 이름들을 하드코딩하면 feature selection이 불가능하기 때문에 이렇게 합니다.

  • 만들어진 구조를 확인합니다.

  • 일단 모든 인자를 모두 입력합니다.

1
2
3
4
from sklearn import set_config
set_config(display='diagram')
model_0 = get_model_0(list(X_train.columns), degree=1, method="lr")
model_0


2.3. pipeline 전처리 확인

  • pipeline에서 전처리 모듈만 떼어서 실행합니다.
  • pipeline의 모듈을 호출하는 방법은 모델이름[“모듈이름”]입니다.
  • 따라서 우리의 전처리 모듈은 model_0[“preprocessor”]입니다.
1
2
3
X_train_pp = model_0["preprocessor"].fit_transform(X_train)
print(X_train_pp.shape)
X_train_pp[0]
  • 실행 결과: 첫 행만 찍어봤습니다. 숫자가 많습니다
1
2
3
4
(266, 12)
array([ 0. , -0.80645161, 0.08579088, 1. , 1. ,
0. , 0. , 0. , 0. , 1. ,
0. , 1. ])
  • 6개의 인자를 넣었는데 12개가 나왔습니다.

  • 처음의 0은 LinearRegression에서 만든 intercept 항입니다.

  • 네번째 1부터는 species, island, sex의 one-hot encoding 결과물입니다.

  • 전처리 이후 데이터 분포도 확인합니다.

  • 시각화 코드는 다소 길고, 여기선 중요하지 않아서 접었습니다.

코드 보기/접기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Figure 생성
fig = plt.figure(figsize=(12, 8), constrained_layout=True)

# Subfigures 생성
subfigs = fig.subfigures(nrows=2, wspace=0.05)
subfigs[0].set_facecolor("lightgray")
subfigs[1].set_facecolor("beige")

# subfigs[0]: raw data
axs0 = subfigs[0].subplots(ncols=3, nrows=1)

sns.kdeplot(X_train["bill_depth_mm"], cut=0, fill=True, ax=axs0[0])
sns.kdeplot(X_train["bill_length_mm"], cut=0, fill=True, ax=axs0[1])
sns.kdeplot(X_train["flipper_length_mm"], cut=0, fill=True, ax=axs0[2])

# subfigs[1]: preprocessed data
axs1 = subfigs[1].subplots(ncols=3, nrows=1)

sns.kdeplot(X_train_pp[:,1], cut=0, fill=True, ax=axs1[0])
sns.kdeplot(X_train_pp[:,2], cut=0, fill=True, ax=axs1[1])
sns.kdeplot(X_train_pp[:,3], cut=0, fill=True, ax=axs1[2])

for ax in axs1:
ax.axvline(0, c="gray", alpha=0.5)

for axs in [axs0, axs1]:
for i, (ax, title) in enumerate(zip(axs, ['bill_depth_mm', 'bill_length_mm', 'flipper_length_mm'])):
ax.set_xlabel("")
ax.set_title(f"{title}", fontdict=font_title, pad=16)
if i > 0:
ax.set_ylabel(" \n")

subfigs[0].suptitle("raw data\n", fontweight="bold")
subfigs[1].suptitle("preprocessed data\n", fontweight="bold")
fig.suptitle(" ")


  • RobustScaler의 효과가 잘 보입니다.

2.3. pipeline 학습

  • pipeline 전체를 사용해서 학습시킵니다.
  • 명령은 scikit-learn 스타일 그대로 .fit()입니다.
1
model_0.fit(X_train, y_train)
  • 학습이 잘 되었는지 결과를 확인합니다.
  • parity plot 시각화 코드는 접어두었습니다.
코드 보기/접기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# parity plot
def plot_parity(model, y_true, y_pred=None, X_to_pred=None, ax=None, **kwargs):
if not ax:
fig, ax = plt.subplots(figsize=(5, 5))

if y_pred is None:
y_pred = model.predict(X_to_pred)
ax.scatter(y_true, y_pred, **kwargs)
xbound = ax.get_xbound()
xticks = [x for x in ax.get_xticks() if xbound[0] <= x <= xbound[1]]
ax.set_xticks(xticks)
ax.set_xticklabels([f"{x:.0f}" for x in xticks])
ax.set_yticks(xticks)
ax.set_yticklabels([f"{x:.0f}" for x in xticks])
dxbound = 0.05*(xbound[1]-xbound[0])
ax.set_xlim(xbound[0]-dxbound, xbound[1]+dxbound)
ax.set_ylim(xbound[0]-dxbound, xbound[1]+dxbound)

rmse = mean_squared_error(y_true, y_pred, squared=False)
r2 = r2_score(y_true, y_pred)
ax.text(0.95, 0.1, f"RMSE = {rmse:.2f}\nR2 = {r2:.2f}", transform=ax.transAxes,
fontsize=14, ha="right", va="bottom", bbox={"boxstyle":"round", "fc":"w", "pad":0.3})

ax.grid(True)

return ax

fig, axs = plt.subplots(ncols=2, figsize=(8, 4), constrained_layout=True, sharey=True)
plot_parity(model_0, y_train, X_to_pred=X_train, ax=axs[0], c="g", s=10, alpha=0.5)
plot_parity(model_0, y_test, X_to_pred=X_test, ax=axs[1], c="m", s=10, alpha=0.5)

for ax, title in zip(axs, ["train", "test"]):
ax.set_title(title, fontdict=font_title, pad=16)


  • 단순 선형 회귀 모델인데 제법 쓸만합니다.
  • 이제 pipeline에 랜덤포레스트 모델을 탑재해서 돌려봅니다.
1
2
3
model_1 = get_model_0(list(X_train.columns), degree=1, method="rf")
model_1.fit(X_train, y_train)
model_1


  • 과적합이 의심되긴 하지만 랜덤포레스트도 잘 나오네요.

  • 이번에는 feature selection도 되는지 확인합니다.

  • 부리 길이bill_length_mm와 종species만 가지고 결과를 예측해봅니다.

1
2
model_2 = get_model_0(["bill_length_mm", "species"], degree=1, method="rf")
model_2.fit(X_train, y_train)


  • 멀쩡한 인자들을 제외했으니 성능이 떨어지는 건 정상입니다.
  • pipeline을 작성하기에 따라 feature 중 일부만 넣어도 동작한다는 것이 중요합니다.

3. pytorch deep learning

  • 딥러닝은 다른 방법에 비해 복잡하고 연산자원이 많이 들지만 장점이 많습니다.
  • 이미지나 시계열을 다룰 때 큰 힘을 발휘하는데, 간혹 tabular data에도 필요합니다.
  • pytorch만을 사용해서 모델을 만들어보고 pipeline에 탑재해서도 결과를 얻어봅니다.

3.1. pytorch only

  • 파이토치로 신경망 모델을 만들고 같은 데이터로 같은 문제를 풀어봅니다.
  • 간단한 신경망 모델을 만듭니다. 나중에 pipeline 안에 넣을 겁니다.
  • feature selection을 대비해서 input dimension을 가변적으로 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from torch import optim
from torch.optim.lr_scheduler import CyclicLR

import torch
import torch.nn as nn

class RegressorModule(nn.Module):
def __init__(self, ninput=11, init_weights=True):
super(RegressorModule, self).__init__()

self.model = nn.Sequential(nn.Linear(ninput, 16),
nn.ReLU(),
nn.Linear(16, 16),
nn.ReLU(),
nn.Linear(16, 12),
nn.ReLU(),
nn.Linear(12, 8),
nn.ReLU(),
nn.Linear(8, 1),
)
if init_weights:
self._initialize_weights()

def forward(self, X, **kwargs):
return self.model(X)

def _initialize_weights(self):
for m in self.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
nn.init.constant_(m.bias, 0)
  • pytorch에 데이터를 넣으려면 tensor로 만들어야 합니다.
1
2
X_train_tensor = torch.Tensor(pd.get_dummies(X_train).astype(np.float32).values)
y_train_tensor = torch.Tensor(y_train.astype(np.float32).values)
  • 지금 만든 모델에 학습을 시킬 수 있는 코드를 구현합니다.
  • 1만 epoch동안 충분히 데이터를 넣어봅니다.
  • loss function으로는 RMSELoss를 구현해서 사용했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
net = RegressorModule()

class RMSELoss(nn.Module):
def __init__(self, eps=1e-6):
super().__init__()
self.mse = nn.MSELoss()
self.eps = eps

def forward(self,yhat,y):
loss = torch.sqrt(self.mse(yhat,y) + self.eps)
return loss

loss_func = RMSELoss()
optimizer = optim.Adam(net.parameters(), lr=0.001)

losses = []
for i in range(10000):
optimizer.zero_grad()
output = net.forward(X_train_tensor)
loss = loss_func(output, y_train_tensor.view(-1, 1))
loss.backward()
optimizer.step()

losses.append(loss)

plt.plot(losses)


  • 제법 학습이 잘 된 것 같습니다.
  • 예측 성능을 확인합니다.
1
2
3
4
5
6
7
8
9
10
# numpy array를 pytorch tensor로 변환
X_test_tensor = torch.Tensor(pd.get_dummies(X_test).astype(np.float32).values)

# 예측값
y_pred_train_tensor = net.forward(X_train_tensor)
y_pred_test_tensor = net.forward(X_test_tensor)

# pytorch tensor를 다시 numpy array로 변환
y_pred_train = y_pred_train_tensor.detach().numpy()
y_pred_test = y_pred_test_tensor.detach().numpy()


  • 딥러닝으로도 제법 괜찮은 성능이 나오는 것을 확인했습니다.

3.2. pytorch @pipeline

  • skorch를 이용해서 pytorch를 pipeline 안에 탑재합니다.

  • skorch은 pytorch를 scikit-learn 객체처럼 만들어주는 일을 합니다.

  • 그래서 skorch로 감싼 pytorch 객체의 학습은 fit()이고,

  • 예측은 .forward()</b>가 아니라 <b>.predict()입니다.

  • skorch의 NeuralNetRegressor()로 딥러닝 모듈 전체를 감싸고,

  • 학습에 필요한 인자를 매개변수로 전달합니다.

  • 그리고 중요한 사항이 하나 있습니다.

  • scikit-learn이 뱉는 np.float64np.float32로 변환해야 합니다.

  • 이를 위해 custom transformer를 만들어 적용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from skorch import NeuralNetRegressor
from sklearn.base import BaseEstimator, TransformerMixin

def get_model_T(X_cols, degree=1, method="lr"):

X_cols_ = deepcopy(X_cols)

# 1-1.categorical feature에 one-hot encoding 적용
cat_features = list(set(X_cols) & set(["species", "island", "sex"]))
cat_transformer = OneHotEncoder(sparse=False, handle_unknown="ignore")

# 1-2.numerical feature는 Power Transform과 Scaler를 거침
num_features = list(set(X_cols) - set(cat_features))
num_features.sort()
num_transformer = Pipeline(steps=[("polynomial", PolynomialFeatures(degree=degree)),
("scaler", RobustScaler())
])

# 1. 인자 종류별 전처리 적용
preprocessor = ColumnTransformer(transformers=[("num", num_transformer, num_features),
("cat", cat_transformer, cat_features)])

# 2. float64를 float32로 변환
class FloatTransformer(BaseEstimator, TransformerMixin):
def __init__(self):
pass
def fit(self, X, y=None):
return self
def transform(self, x):
return np.array(x, dtype=np.float32)

# 3. 전처리 후 머신러닝 모델 적용
if method == "lr":
ml = LinearRegression(fit_intercept=True)
elif method == "rf":
ml = RandomForestRegressor()
elif method == "torch":
ninput = len(num_features) + 1
if "species" in cat_features:
ninput += 3
if "island" in cat_features:
ninput += 3
if "sex" in cat_features:
ninput += 2

net = NeuralNetRegressor(RegressorModule(ninput=ninput, init_weights=False),
max_epochs=1000, verbose=0,
warm_start=True,
# device='cuda',
criterion=RMSELoss,
optimizer = optim.Adam,
optimizer__lr = 0.01
)
ml = net


# 3. Pipeline
model = Pipeline(steps=[("preprocessor", preprocessor),
("float64to32", FloatTransformer()),
("ml", ml)])

return model
  • 모델을 만들고 확인합니다.
  • 앞서 pytorch로 구현한 뉴럴넷 구조가 그대로 들어가 있습니다.
1
2
3
model_T = get_model_T(list(X_train.columns), degree=1, method="torch")
model_T.fit(X_train, y_train.astype(np.float32).values.reshape(-1, 1))
model_T


  • 성능을 확인합니다. 준수하네요.

4. permutation feature importance

  • 같은 파이프라인에서 선형, 트리, 딥러닝이 모두 구현되었습니다.
  • 각각의 인자 중요도를 한번 확인해보겠습니다.
  • permutation importance를 사용합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from sklearn.inspection import permutation_importance

# Linear Regression
pi_0 = permutation_importance(model_0, X_test, y_test, n_repeats=30, random_state=0)

# Random Forest
pi_1 = permutation_importance(model_1, X_test, y_test, n_repeats=30, random_state=0)

# Neural Network
pi_T = permutation_importance(model_T, X_test, y_test, n_repeats=30, random_state=0)

# 시각화
fig, axs = plt.subplots(ncols=3, figsize=(15, 5), constrained_layout=True, sharey=True)

for ax, pi, title in zip(axs, [pi_0, pi_1, pi_T], ["Linear Reg.", "Random Forest", "Neural Net"]):
ax.barh(X_test.columns, pi.importances_mean, xerr=pi.importances_std, color="orange")
ax.invert_yaxis()
ax.set_xlim(0, )
ax.set_title(title, fontdict=font_title, pad=16)


  • 입력 feature별 인자 중요도가 깔끔하게 정리되었습니다.
  • 양상도 전반적으로 비슷하게 나오네요.
  • 사소한 기능같지만 tabular data를 딥러닝으로 돌렸을 때 이 그림을 그리기가 어려웠습니다.
  • 이 글과 코드가 비슷한 어려움을 겪는 여러분께 도움이 되면 좋겠습니다.


도움이 되셨나요? 카페인을 투입하시면 다음 포스팅으로 변환됩니다

Share