본문 바로가기

Euron/정리

[Week16] 08. 텍스트 분석 - 실습(2)

10. 텍스트 분석 실습 - 캐글 Mercari Price Suggestion  Challenge

Mercari Price Suggestion Challenge | Kaggle

 

Mercari Price Suggestion Challenge | Kaggle

 

www.kaggle.com

위에서 데이터 세트를 다운 받는다.

제공되는 데이터 세트 중 price 값을 예측한다. 이번 실습에서는 텍스트 형태의 비정형 데이터와 다른 정형 속성을 같이 적용해 회귀를 수행한다.

 

데이터 전처리

 

print(mercari_df.info())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1482535 entries, 0 to 1482534
Data columns (total 8 columns):
 #   Column             Non-Null Count    Dtype  
---  ------             --------------    -----  
 0   train_id           1482535 non-null  int64  
 1   name               1482535 non-null  object 
 2   item_condition_id  1482535 non-null  int64  
 3   category_name      1476208 non-null  object 
 4   brand_name         849853 non-null   object 
 5   price              1482535 non-null  float64
 6   shipping           1482535 non-null  int64  
 7   item_description   1482529 non-null  object 
dtypes: float64(1), int64(3), object(4)
memory usage: 90.5+ MB
None

brand_name 칼럼의 경우 price에 영향을 미치는 중요 요인으로 판단되지만 많은 데이터가 Null값으로 돼 있다. 따라서 이후 적절한 문자열로 치환한다.

 

Target 값인 price 칼럼의 데이터 분포도를 살펴본다.

import matplotlib.pyplot as plt
import seaborn as sns

y_train_df = mercari_df['price']
plt.figure(figsize=(6,4))
sns.histplot(y_train_df, bins=100)
plt.show()

데이터가 정규 분포 형태가 아니므로 왜곡될 가능성이 있다.

이를 로그 값으로 변환한다.

mercari_df['price'] = np.log1p(mercari_df['price'])
mercari_df['price'].head(3)
0    2.397895
1    3.970292
2    2.397895
Name: price, dtype: float64

 

print('Shipping 값 유형:\n',mercari_df['shipping'].value_counts())
print('item_condition_id 값 유형:\n',mercari_df['item_condition_id'].value_counts())
Shipping 값 유형:
 shipping
0    819435
1    663100
Name: count, dtype: int64
item_condition_id 값 유형:
 item_condition_id
1    640549
3    432161
2    375479
4     31962
5      2384
Name: count, dtype: int64

shopping 칼럼은 배송비 유무로, 값이 비교적 균일하다.

Item_condition_id는 판매자가 제공하는 제품 상태로서 1,2,3 값이 주를 이룬다.

 

boolean_cond= mercari_df['item_description']=='No description yet'
mercari_df[boolean_cond]['item_description'].count()
82489

item_description 칼럼의 경우 Null값은 거의 없으나 description에 대한 별도 설명이 없는 경우 'No description yet' 값으로 되어 있다. 

이러한 값이 82489 건 있으므로 적절한 값으로 변경해야 한다.

 

category_name 칼럼의 경우 '/'로 분리된 카테고리를 하나의 문자열로 나타내고 있다.

category_name은 텍스트이므로 피처 추출 시 tokenizer를 '/'를 기준으로 단어를 토큰화해 각각 별도의 피처로 저장하고 이를 이용해 알고리즘을 학습시킨다.

칼럼을 효과적으로 분리하기 위한 별도의 split_cat() 함수를 생성하고 이를 DataFrame의 apply lambda에 적용한다.

# apply lambda에서 호출되는 대,중,소 분할 함수 생성, 대,중,소 값을 리스트 반환
def split_cat(category_name):
    try:
        return category_name.split('/')
    except:
        return ['Other_Null' , 'Other_Null' , 'Other_Null']

# 위의 split_cat( )을 apply lambda에서 호출하여 대,중,소 컬럼을 mercari_df에 생성. 
mercari_df['cat_dae'], mercari_df['cat_jung'], mercari_df['cat_so'] = \
                        zip(*mercari_df['category_name'].apply(lambda x : split_cat(x)))

# 대분류만 값의 유형과 건수를 살펴보고, 중분류, 소분류는 값의 유형이 많으므로 분류 갯수만 추출
print('대분류 유형 :\n', mercari_df['cat_dae'].value_counts())
print('중분류 갯수 :', mercari_df['cat_jung'].nunique())
print('소분류 갯수 :', mercari_df['cat_so'].nunique())
대분류 유형 :
 cat_dae
Women                     664385
Beauty                    207828
Kids                      171689
Electronics               122690
Men                        93680
Home                       67871
Vintage & Collectibles     46530
Other                      45351
Handmade                   30842
Sports & Outdoors          25342
Other_Null                  6327
Name: count, dtype: int64
중분류 갯수 : 114
소분류 갯수 : 871

 

마지막으로 brand_name, category_name, item_description 칼럼의 Null값은 일괄적으로 'Other Null'로 동일하게 변경한다.

fillna() 함수를 적용해 일괄적으로 변경할 수 있다.

mercari_df['brand_name'] = mercari_df['brand_name'].fillna(value='Other_Null')
mercari_df['category_name'] = mercari_df['category_name'].fillna(value='Other_Null')
mercari_df['item_description'] = mercari_df['item_description'].fillna(value='Other_Null')

 

피처 인코딩과 피처 벡터화

 

데이터 세트에는 문자열 칼럼이 많다. 이중 레이블 또는 원-핫 인코딩을 수행하거나 피처 벡터화로 변환할 칼럼을 선별한다.

예측 모델은 회귀 모델을 기반으로 하여 선형 회귀 모델과 회귀 트리 모델을 모두 적용할 예정이며, 특히 선형 회귀의 경우 원-핫 인코딩 적용이 훨씬 선호되므로 인코딩할 피처는 모두 원-핫 인코딩을 적용한다.

피처 벡터화의 경우는 비교적 짧은 텍스트의 경우는 Count 기반의 벡터화, 긴 텍스트는 TF-IDF 기반의 벡터화를 적용한다.

 

1. brand_name

brand_name의 경우 대부분 명료한 문자열로 돼 있다.

별도의 피처 벡터화 형태 없이 인코딩 변환을 적용하면된다.

 

2. name

상품명을 의미하는 name 속성의 경우 종류가 매우 많고, 거의 고유한 상품명을 가지고 있다.

이러한 경우 Count 기반으로 피처 벡터화 변환을 적용한다.

 

3. category_name

이전에 전처리를 통해 대,중,소 분류 세 개의 칼럼인 cat_daw, cat_jung, cat_so 칼럼으로 분리하였다.

각각의 칼럼에 원-핫 인코딩을 적용한다.

 

4&5. shopping & item_condition

shopping은 배송비 무료 여부로서 0과 1 두 가지 유형의 값을 가진다. 

item_condition_id는 상품 상태로서 1,2,3,4,5 다섯 가지 유형의 값을 가진다.

두 칼럼 모두 원-핫 인코딩을 적용한다.

 

6. item_description

상품에 대한 간단 설명으로 데이터 세트에서 가장 긴 텍스트를 가지고 있다.

pd.set_option('max_colwidth', 200)

# item_description의 평균 문자열 개수
print('item_description 평균 문자열 개수:',mercari_df['item_description'].str.len().mean())

mercari_df['item_description'][:2]
item_description 평균 문자열 개수: 145.71139703278507
0                                                                                                                                                                              No description yet
1    This keyboard is in great condition and works like it came out of the box. All of the ports are tested and work perfectly. The lights are customizable via the Razer Synapse app on your PC.
Name: item_description, dtype: object

평균 문자열이 145자로 비교적 크므로 TF-IDF로 변환한다.

 

이제 주요 칼럼을 인코딩 및 피처 벡터화 변환한다.

name의 경우 CountVectorizer로, 기본 파라미터를 적용한다.

item_description 칼럼은 TfidVectorizer로, max_features는 50000으로 제한, n_gram은 (1,3), stop_words는 english로 설정한다.

# name 속성에 대한 feature vectorization 변환
cnt_vec = CountVectorizer()
X_name = cnt_vec.fit_transform(mercari_df.name)

# item_description 에 대한 feature vectorization 변환 
tfidf_descp = TfidfVectorizer(max_features = 50000, ngram_range= (1,3) , stop_words='english')
X_descp = tfidf_descp.fit_transform(mercari_df['item_description'])

print('name vectorization shape:',X_name.shape)
print('item_description vectorization shape:',X_descp.shape)
name vectorization shape: (1482535, 105757)
item_description vectorization shape: (1482535, 50000)

해당 벡터화가 fit_transform()을 통해 반환하는 데이터는 희소 행렬 형태이다.

희소 행렬 객체 변수인 X_name과 X_descp를 새로 결합해 새로운 데이터 세트로 구성해야 하고, 앞으로 인코딩될 칼럼들도 모두 X_name과 X_descp와 결합돼 ML 모델을 실행하는 기반 데이터 세트로 재구성돼야 한다.

 

이를 위해 인코딩 대상 칼럼도 희소 행렬 형태로 인코딩을 적용한 뒤, 함께 결합해야 한다.

사이킷런은 원-핫 인코딩을 위해 OneHotEncoder와 LabelBinarizer 클래스를 제공한다. 이 중 LabelBinarizer 클래스는 sparse_out=True 파라미터 설정으로 희소 행렬 형태의 원-핫 인코딩 변환을 지원한다.

개별 칼럼으로 만들어진 희소 행렬은 사이파이 패키지 sparse 모듈의 hstack() 함수를 이용해 결합한다.

그림으로 정리

from sklearn.preprocessing import LabelBinarizer

# brand_name, item_condition_id, shipping 각 피처들을 희소 행렬 원-핫 인코딩 변환
lb_brand_name= LabelBinarizer(sparse_output=True)
X_brand = lb_brand_name.fit_transform(mercari_df['brand_name'])

lb_item_cond_id = LabelBinarizer(sparse_output=True)
X_item_cond_id = lb_item_cond_id.fit_transform(mercari_df['item_condition_id'])

lb_shipping= LabelBinarizer(sparse_output=True)
X_shipping = lb_shipping.fit_transform(mercari_df['shipping'])

# cat_dae, cat_jung, cat_so 각 피처들을 희소 행렬 원-핫 인코딩 변환
lb_cat_dae = LabelBinarizer(sparse_output=True)
X_cat_dae= lb_cat_dae.fit_transform(mercari_df['cat_dae'])

lb_cat_jung = LabelBinarizer(sparse_output=True)
X_cat_jung = lb_cat_jung.fit_transform(mercari_df['cat_jung'])

lb_cat_so = LabelBinarizer(sparse_output=True)
X_cat_so = lb_cat_so.fit_transform(mercari_df['cat_so'])

print(type(X_brand), type(X_item_cond_id), type(X_shipping))
print('X_brand_shape:{0}, X_item_cond_id shape:{1}'.format(X_brand.shape, X_item_cond_id.shape))
print('X_shipping shape:{0}, X_cat_dae shape:{1}'.format(X_shipping.shape, X_cat_dae.shape))
print('X_cat_jung shape:{0}, X_cat_so shape:{1}'.format(X_cat_jung.shape, X_cat_so.shape))
<class 'scipy.sparse._csr.csr_matrix'> <class 'scipy.sparse._csr.csr_matrix'> <class 'scipy.sparse._csr.csr_matrix'>
X_brand_shape:(1482535, 4810), X_item_cond_id shape:(1482535, 5)
X_shipping shape:(1482535, 1), X_cat_dae shape:(1482535, 11)
X_cat_jung shape:(1482535, 114), X_cat_so shape:(1482535, 871)

인코딩 변환된 데이터 세트가 CSR 형태로 변환된 csr_matrix 타입이다.

매우 많은 인코딩 칼럼이 생겼음을 확인할 수 있다.

 

from  scipy.sparse import hstack
import gc

sparse_matrix_list = (X_name, X_descp, X_brand, X_item_cond_id,
            X_shipping, X_cat_dae, X_cat_jung, X_cat_so)

# 사이파이 sparse 모듈의 hstack 함수를 이용하여 앞에서 인코딩과 Vectorization을 수행한 데이터 셋을 모두 결합. 
X_features_sparse= hstack(sparse_matrix_list).tocsr()
print(type(X_features_sparse), X_features_sparse.shape)

# 데이터 셋이 메모리를 많이 차지하므로 사용 용도가 끝났으면 바로 메모리에서 삭제. 
del X_features_sparse
gc.collect()
<class 'scipy.sparse._csr.csr_matrix'> (1482535, 161569)
0

앞에서 피처 벡터화 변환 혹은 희소 인코딩 변환된 데이터 세트를 결합했다.

결합된 데이터 세트는 메모리를 많이 잡아먹으므로 모델을 적용할 떄마다 다시 결합해 이용한다.

hstack()으로 결합한 데이터 세트는 crs_matrix 타입이며, 통 161569개의 피처를 가지게 됐다.

 

릿지 회귀 모델 구축 및 평가

 

여러 알고리즘 모델과 희소 행렬을 변환하고 예측 성능을 비교하면서 테스트를 수행하므로 수행에 필요한 로직을 함수화한다.

먼저 평가 로직을 함수화하는데, 평가 지표는 캐글에서 제시한 RMSLE 방식으로 수행한다.

RMSLE를 구하는 함수를 생성하는데, 원본 데이터  price 칼럼은 왜곡된 데이터 분포를 가지므로 로그 변환을 수행해 학습했고, 결과 또한 변환된 데이터 값 수준으로 예측하였다. 따라서 로그의 역변환인 지수 변환을 수행해 RMLSE를 적용해야한다.

def rmsle(y , y_pred):
    # underflow, overflow를 막기 위해 log가 아닌 log1p로 rmsle 계산 
    return np.sqrt(np.mean(np.power(np.log1p(y) - np.log1p(y_pred), 2)))

def evaluate_org_price(y_test , preds): 
    
    # 원본 데이터는 log1p로 변환되었으므로 exmpm1으로 원복 필요. 
    preds_exmpm = np.expm1(preds)
    y_test_exmpm = np.expm1(y_test)
    
    # rmsle로 RMSLE 값 추출
    rmsle_result = rmsle(y_test_exmpm, preds_exmpm)
    return rmsle_result

 

 

이제 학습용 데이터를 생성하고, 모델을 학습/예측하는 로직을 함수로 만든다.

model 인자로 회쉬 estimator 객체, matrix 인자로 결합할 희소 행렬 리스트를 가진다.

import gc 
from  scipy.sparse import hstack

def model_train_predict(model,matrix_list):
    # scipy.sparse 모듈의 hstack 을 이용하여 sparse matrix 결합
    X= hstack(matrix_list).tocsr()     
    
    X_train, X_test, y_train, y_test=train_test_split(X, mercari_df['price'], 
                                                      test_size=0.2, random_state=156)
    
    # 모델 학습 및 예측
    model.fit(X_train , y_train)
    preds = model.predict(X_test)
    
    del X , X_train , X_test , y_train 
    gc.collect()
    
    return preds , y_test

 

개별 함수를 만들었으므로 이를 이용해 먼저 Ridge로 회귀 예측을 수행한다.

수행 전 item_description과 같은 텍스트 형태의 속성이 얼마나 영향을 미치는지 알아보기 위해 예측 성능을 비교해본다.

linear_model = Ridge(solver = "lsqr", fit_intercept=False)

sparse_matrix_list = (X_name, X_brand, X_item_cond_id,
                      X_shipping, X_cat_dae, X_cat_jung, X_cat_so)
linear_preds , y_test = model_train_predict(model=linear_model ,matrix_list=sparse_matrix_list)
print('Item Description을 제외했을 때 rmsle 값:', evaluate_org_price(y_test , linear_preds))

sparse_matrix_list = (X_descp, X_name, X_brand, X_item_cond_id,
                      X_shipping, X_cat_dae, X_cat_jung, X_cat_so)
linear_preds , y_test = model_train_predict(model=linear_model , matrix_list=sparse_matrix_list)
print('Item Description을 포함한 rmsle 값:',  evaluate_org_price(y_test ,linear_preds))
Item Description을 제외했을 때 rmsle 값: 0.4983969056654421
Item Description을 포함한 rmsle 값: 0.4680048234163931

item_description을 포함했을 때 rmsle 값이 많이 감소했으므로 해당 칼럼이 중요함을 알 수 있다.

 

다음으로는 LightGBM을 이용해 회귀를 수행한 뒤, 위에서 구한 릿지 모델 예측값과 LightGBM 모델 예측값을 간단한 앙상블 방식으로 섞어서 최종 회귀 예측값을 평가한다.

from lightgbm import LGBMRegressor

sparse_matrix_list = (X_descp, X_name, X_brand, X_item_cond_id,
                      X_shipping, X_cat_dae, X_cat_jung, X_cat_so)

lgbm_model = LGBMRegressor(n_estimators=200, learning_rate=0.5, num_leaves=125, random_state=156)
lgbm_preds , y_test = model_train_predict(model = lgbm_model , matrix_list=sparse_matrix_list)
print('LightGBM rmsle 값:',  evaluate_org_price(y_test , lgbm_preds))
LightGBM rmsle 값: 0.4562664499998407

Ridge보다 예측 성능이 더 나아졌다.

 

preds = lgbm_preds * 0.45 + linear_preds * 0.55
print('LightGBM과 Ridge를 ensemble한 최종 rmsle 값:',  evaluate_org_price(y_test , preds))
LightGBM과 Ridge를 ensemble한 최종 rmsle 값: 0.4465126678611385

간단한 앙상블 방식으로 예측 성능을 더 개선했다.

 


# 회고

 

텍스트 분석 끝~

텍스트 분석을 위한 텍스트 정규화 작업 수행이 굉장히 많고 종류도 여러가지이고 개념도 어려웠다.

하지만 이런 비정형 데이터를 다룰 수 있게되면 할 줄 아는게 훨씬 많아질 것이다. 이전의 ML 역사도 그랬고.

아무튼 어렵당ㅎ