티스토리 뷰

https://www.kaggle.com/datasets/taeefnajib/used-car-price-prediction-dataset

 

Used Car Price Prediction Dataset

Predict the price of a used vehicle

www.kaggle.com

위 데이터셋을 이용해 중고차 가격 거품을 해결하는 가격 예측 분석 모델을 개발하고 거래를 위한 가격을 산정해 주는 추천 시스템을 개발해 신뢰성 있는 중고차 시장을 만들고자 하였습니다. 아울러 데이터셋의 특성을 살려 텍스트 데이터 전처리에 집중하였고 R2 score 0.6 이상의 지표를 산출하고자 노력했습니다

데이터셋의 변수 설명

 

EDA & Preprocessing

1. brand 카테고리 빈도수를 통한 전처리

brand 칼럼은 brand 카테고리들의 빈도 수의 사분위수를 기준으로 low, mid, high 값으로 변환하였습니다. 빈도 수가 1 사분위 수보다 작으면 low, 1 사분위 수  ~  3 사분위 수이면 mid, 3 사분위 수보다 크면 high로 변환하였습니다. 이를 통해 브랜드 종류가 전체 데이터에서 차지하는 비중을 사분위 수 범위로 표현해 인코딩하는 작업을 걸쳤습니다.

#빈도수의 기초통계량을 토대로 빈도수의 구간을 산정해 brand 칼럼을 인코딩
#low는 1사분위수보다 작은 것, mid는 1사분위수 ~ 3분위수, high는 3분위수보다 큰 것
value = data['brand'].value_counts()

# 빈도수의 기술통계량 계산
# 사분위수는 데이터를 25%, 50%, 75%로 구간을 나누어 표현하는 방법
q1 = value.quantile(0.25)  # 1사분위수 (Q1)
q3 = value.quantile(0.75)  # 3사분위수 (Q3)

low_freq_brands = value[value < q1].index  # 조건을 만족하는 brand 리스트
data.loc[data['brand'].isin(low_freq_brands), 'new_brand'] = 'low'

mid_freq_brands = value[(value >= q1) & (value < q3)].index
data.loc[data['brand'].isin(mid_freq_brands), 'new_brand'] = 'mid'

high_freq_brands = value[value >= q3].index
data.loc[data['brand'].isin(high_freq_brands), 'new_brand'] = 'high'

 

전처리 후의 brand 칼럼 시각화

2. engine specification을 활용한 파생변수 생성과 결측치 처리

위 데이터셋의 engine 칼럼은 engine specification(엔진 사양)을 표현한 칼럼입니다. 위 칼럼에서 엔진 사양을 판단하는 3가지 기준(배기량, 최대 출력, 엔진 형식)을 정규표현식을 활용해 추출하였습니다

  • 배기량 : 엔진의 실린더에서 연료와 공기가 폭발하는 총 용적 (단위 L) -> 숫자 L / 숫자 Liter의 규칙을 통해 추출
  • 최대 출력 (마력) : 한 마리 말이 1초 동안 75kg의 중량을 1m 움직일 수 있는 일의 크기 (단위 HP) -> 숫자 HP의 규칙을 통해 추출
  • 엔진 형식 : 실린더 배열 + 실린더 개수의 규칙을 통해 추출 (실린더 배열은 V, W, Electricity로 구성)

하지만 engine 칼럼은 위 세 가지 조건이 모두 포함되어 있지 않은 값이 존재해 추출 후 결측치를 대체해야 했습니다.

  • 배기량, 최대 출력, 엔진 형식이 모두 결측치인 24개의 데이터 삭제
  • 전기차의 경우 배기량은 0이므로 엔진 유형이 Electricity이면 0으로 대체   /   엔진 유형이 Electricity이 아니면 평균으로 대체  ->  배기량의 기초통계량 중 표준편차를 확인한 결과 값이 작으므로 평균으로 대체하여도 분포가 달라지지 않을 것으로 판단
  • 배기량과 마력은 양의 상관 관계(0.64)을 가지므로 이 관계를 살리기 위해 배기량이 평균보다 클 경우, 마력의 결측치를 평균으로 대체 (마력의 기초 통계량에서 평균이 중앙값보다 큼)
  • 범주형 형태의 엔진 유형은 실린더 개수로 변환한 후, 결측치인 데이터 삭제
  • 배기량, 마력 두 칼럼을 마력 대비 배기량 칼럼으로 통합
#배기량 : 데이터가 소수점으로 되어 있고 글자가 L로 끝나는 패턴과 글자가 Liter로 끝나는 패턴 찾기
data['displacement'] = data['engine'].str.extract(r'(\d+\.\d+L|\d+\.\d+\s+Liter)')

#마력 : 데이터가 소수점으로 되어 있고 글자가 HP로 끝나는 패턴 찾기
data['horsepower'] = data['engine'].str.extract(r'(\d+\.\d+HP)')

#실린더 배열은 직렬, V형, W형, 수평대향이 존재한다
#숫자 + 실린더로 생긴 데이터는 그대로 숫자+실린더로 추출
#전기차의 경우 따로 전기차임을 표시
data['engine_form'] = data['engine'].str.extract(r'(\d+V|V\d+|\d+W|W\d+|Electric|\d+\sCylinder)')

#결측치 처리
idx = data[(data['new_displacement'].isnull()) & (data['new_horsepower'].isnull() & (data['engine_form'].isnull()))].index
data.drop(idx, inplace=True)
#전기차의 배기량은 0이므로, fuel_type이 electric이고 배기량이 결측치이면 0으로 대체
data.loc[(data['new_displacement'].isnull()) & (data['fuel_type']=='Electric'), 'new_displacement'] = 0

#fuel_type이 electric이 아니고 배기량이 결측치이면, 배기량의 평균으로 대체(배기량의 표준편차는 작으므로 평균으로 대체)
data.loc[(data['new_displacement'].isnull()) & (data['fuel_type']!='Electric'), 'new_displacement'] = data['new_displacement'].mean()

#마력과 배기량의 양의 상관관계를 살리기 위해 배기량이 크고 작음에 따라 마력의 결측치를 대체하기
#배기량 값이 배기량의 평균보다 크고 마력이 결측치이면, 결측치를 마력의 평균으로 대체(마력의 평균이 중앙값보다 크므로)
value1 = data['new_horsepower'].mean()
data.loc[(data['new_horsepower'].isnull()) & (data['new_displacement'] > data['new_displacement'].mean()), 'new_horsepower'] = value1

#배기량이 배기량의 평균보다 작거나 같고 마력이 결측치이면, 결측치를 마력의 중앙값으로 대체
value2 = data['new_horsepower'].median()
data.loc[(dat# 실린더 개수를 추출 함수
def extract_cylinders(engine):
    if engine == 'Electric':  # 전기차는 실린더 없으므로 실린더 개수를 0
        return 0
    # engine이 float인 경우 문자열로 변환
    # NaN 값은 'Electric'으로 변환하여 실린더 개수 0으로 처리
    engine_str = str(engine) if not isinstance(engine, float) else 'Electric'
    match = re.search(r'\d+', engine_str)  # 숫자 부분 추출
    return int(match.group()) if match else None  # 숫자 반환

# 실린더 개수 컬럼 추가
data['engine_form'] = data['engine_form'].apply(extract_cylinders)a['new_horsepower'].isnull()) & (data['new_displacement'] <= data['new_displacement'].mean()), 'new_horsepower'] = value2
data.dropna(subset=['engine_form'], inplace=True)

#마력 대비 배기량에 대한 칼럼 만들기
data['hp_per_disp'] = data['new_displacement'] / data['new_horsepower']

전처리 후의 배기량, 마력, 실린더 개수에 대한 시각화

3. transmission(변속기) : 종류에 따른 그룹화를 통한 인코딩

변속기는 자동차를 구동시키기 위해서는 엔진에서 발생한 힘을 회전력으로 변환시키는 역할을 합니다. 변속기의 종류를 자동, 수동, cvt 3가지로 그룹화하여 각각 1, 2, 3으로 인코딩 시키고 결측치와 같은 데이터는 0으로 대체했습니다.

# 자동 변속기 (1)
auto = set(data[data['transmission'].str.contains(r'\b(A/T|Automatic|Auto|AT|At)\b', regex=True, na=False)]['transmission'])
# 수동 변속기 (2)
mt = set(data[data['transmission'].str.contains(r'\b(Manual|M/T|MT|Mt)\b', regex=True, na=False)]['transmission'])
# cvt 변속기 (3)
cvt = set(data[data['transmission'].str.contains(r'\b(CVT|Variable)\b', regex=True, na=False)]['transmission'])

# 자동 + 수동 변속기 → 자동으로 변경
auto_mt = auto & mt
auto |= auto_mt  # 자동으로 통합

# 자동 + CVT 변속기 → 자동으로 변경
auto_cvt = auto & cvt
auto |= auto_cvt  # 자동으로 통합

# CVT + 수동 변속기는 없는지 확인
cvt_mt = cvt & mt  # 일반적으로 없을 것이므로 체크만 진행

# 변속기 타입 매핑 함수
def classify_transmission(value):
    if pd.isna(value):
        return None  # 결측값 처리
    elif value in auto:
        return 1  # 자동 변속기
    elif value in mt:
        return 2  # 수동 변속기
    elif value in cvt:
        return 3  # CVT 변속기
    else:
        return 0  # 분류되지 않은 값

# 새로운 컬럼 생성
data['transmission'] = data['transmission'].apply(classify_transmission)

전처리 후 Transmission의 시각화

 

4. 차량 내외부 색상 : 빈도 수를 기준으로 그룹화

내외부 색상에 대한 카테고리 수를 상위 5개로 그룹화한 후 인코딩을 하였습니다. (ex. Black/Gray -> Gray, Sand Beige -> Beige)

color_keywords = ["black", "beige", "gray", "brown", "red"]   #카테고리 수를 빈도 수가 많은 상위 5개로 묶기
top_colors = ["Black", "White", "Gray", "Silver", "Blue"]    #빈도수가 많은 상위 5개로 묶기

def simplify_color(c):
    # 문자열이 아니면 그대로 반환 (NaN, 숫자 등)
    if not isinstance(c, str):
        return c
    c_lower = c.lower()     # 소문자로 만들어서 검색
    # color_keywords의 색상이 c_lower에 포함되어 있으면 해당 색상으로 치환
    for color in color_keywords:
        if color in c_lower:
            return color.capitalize()
    return "Other" #해당되는 color가 없으면 other로 대체

def simplify_ext_color(c):
    if not isinstance(c, str):
        return c
    c_lower = c.lower()
    for color in top_colors:
        if color.lower() in c_lower:
            return color.capitalize()
    return "Other"

data['int_col'] = data['int_col'].apply(simplify_color)
data['ext_col'] = data['ext_col'].apply(simplify_ext_color)

전처리 후의 차량 내외부 색상에 대한 시각화

5. 기타 칼럼 전처리

  • accident (사고 이력 여부) : 'None reported' 값은 0   /   'At least 1 accident or damage reported' 값은 1로 변환, 결측치인 데이터는 삭제
  • clean title (법적 여부) : 결측치는 0   /   'Yes'인 경우 1로 변환
  • 이상치 처리 : 이상치는 상관관계를 과장, 과소시키는 영향이 있으므로 연속형 변수의 상관계수를 피어슨 방법으로 구한 후, 절댓값이 가장 큰 변수 / 작은 변수의 이상치를 제거
  • price의 이상치인 경우 $1,500,000 초과인 것은 제거
  • model year(제조 연도)은 데이터 출처에 따라 수집 연도(2024)에 제조 연도와의 차이로 값 변환

Modeling

  • 학습에 사용한 독립변수 : transmission, ext_col, int_col, accident', clean_title, engine_form, new_milage, year_new, hp_per_disp, fuel_type_encoded, new_brand_encoded
  • 전체 데이터셋을 test_size = 0.2로 홀드아웃 분할
  • optuna을 통한 GradientBoostingRegressor 최적화
    • n_estimators : 생성할 트리의 개수 (705)
    • learning_rate : 새로 추가될 트리가 기존 모델을 얼마나 수정할지의 변동 값 (0.04)
    • max_depth : 생성할 트리의 깊이 (5)
    • min_samples_leaf : 노드를 분할할 때 최소한 필요한 데이터의 개수 (11)
  • 최종 모델의 평가 지표
    • Train R2 score : 0.949
    • Test rmse : $ 17086.82
    • Test R2 score : 0.805

(약간의 과적합은 아쉽ㅠㅠㅠㅠㅠ)

회고록) 막막했던 텍스트 데이터 전처리에 시간을 많이 투자하여 정교한 모델링을 못한 것이 아쉬웠다

Regression 문제를 자주 풀어보며 머신러닝 프로젝트에 대한 편식 줄이기

TAG more
글 보관함
최근에 올라온 글