Machine Learning Case Study

중고차 시세 예측 모델

실제 중고차 매물 426,880건을 정제해 187,707대의 차로 시세 예측 모델을 만들고, 점수가 부풀려지지 않는 평가 방식으로 시세표·선형 모델·Random Forest·XGBoost를 비교했습니다. 예측값과 함께 80% 시세 구간, 가격을 움직인 요인까지 돌려주는 API로 마무리했습니다.

데이터: Craigslist 미국 중고차 매물 (Kaggle 공개 데이터, 2021년 수집) · 실험 기록: MLflow · 모델: XGBoost (튜닝) · 서빙: FastAPI

요약

12.4%평가 데이터 오차율 중앙값
(시세표 방식 18.7%)
69.6%실제 가격의 ±20% 안에 든 비율
(시세표 방식 52.6%)
80.3%80% 시세 구간에 실제 가격이 든 비율
(보정 전 75.1%)

핵심 발견

  1. 원본 매물의 44%가 같은 차를 반복 게시한 것이었습니다. 이를 그대로 두고 무작위로 나누면 평가 데이터의 56%가 학습 데이터에 이미 있는 차여서 점수가 부풀려집니다.
  2. 가격 예측에 가장 큰 영향을 준 것은 차령, 모델, 주행거리입니다.
  3. 같은 모델 안에서 비교하면 트럭·픽업은 10년이 지나도 1~3년차 가격의 43%를 유지해 세단(37%)보다 값이 천천히 떨어집니다.

이렇게 판단했습니다

  1. 중복 매물을 지우고 차 단위로 평가 데이터를 떼어, 실제로 처음 보는 차에 대한 성능을 쟀습니다.
  2. 모델 비교와 선택은 검증 데이터로 하고, 평가 데이터는 고른 모델의 최종 성능을 재는 데 한 번만 썼습니다.
  3. 예측 구간이 실제로 80%를 담는지 확인하고, 모자라면 보정(CQR)했습니다.
01

데이터와 정제

Craigslist에 올라온 미국 중고차 매물 426,880건입니다. 가격·연식·주행거리·제조사·모델·차종·상태 등이 매물마다 판매자가 직접 입력한 값이라, 시세를 배우기 전에 '시세가 아닌 값'부터 걸러야 했습니다. 트럭·픽업이 정제 후 매물의 15%를 차지해, 트럭 시세를 따로 살펴볼 만큼 충분합니다.

정제 단계별 남은 매물 수
단계남은 매물제외기준
원본 매물426,880
가격 범위380,369-46,5111,000달러 미만(0·1달러 등 미기재)과 150,000달러 초과(입력 오류·수집차) 제외
연식 범위364,247-16,122연식 미기재와 1995년 이전 클래식카 제외
주행거리358,266-5,981미기재, 400,000마일 초과, 2년 넘은 차의 100마일 미만(자리표시) 제외
제조사·모델344,338-13,928제조사나 모델명이 없는 매물 제외
모델명 해석344,336-2모델명이 기호뿐이라 해석할 수 없는 매물 제외
중복 매물187,707-156,629같은 차(VIN, 없으면 제조사·모델·연식·주행거리)를 반복 게시한 매물은 최신 1건만
가장 많이 빠진 것은 중복 매물입니다. VIN(차대번호)이 있는 매물 265,838건 중 실제 차는 118,246대였습니다. 40,280대가 여러 번 올라왔고(최대 261번), 이런 차의 67%는 여러 지역에, 91%는 같은 가격으로 반복 게시됐습니다. 딜러가 같은 차를 여러 지역에 올린 것입니다. VIN이 없는 매물은 제조사·모델·연식·주행거리가 모두 같으면 같은 차로 봤는데, 주행거리가 100,000처럼 1,000 단위로 딱 떨어지면 서로 다른 차가 겹칠 수 있어 이때는 가격까지 같아야 같은 차로 봤습니다.

모델명도 자유 입력이라 'F150 XLT', 'f-150 lariat', 'super duty f-250'처럼 제각각이었습니다. 표기를 통일하고 트림을 떼어 기본 모델명으로 정리했고, 트럭은 1500·2500·3500 같은 적재 등급이 가격을 크게 가르기 때문에 'silverado 2500'처럼 등급까지 남기고 별도 변수(적재 등급)로도 뽑았습니다. 30건 이상 나온 378개 모델을 그대로 쓰고, 나머지는 제조사별 '기타'로 묶었습니다.

SQL로 다시 확인했습니다. 정제 결과를 SQLite에 원본(426,880건)·정제(344,336건)·차량(187,707대) 테이블로 나눠 저장하고, 품질 검사 10개를 SQL로 돌려 10개 모두 통과했습니다. 정제 단계별 건수와 중복 통계 15개를 SQL로 다시 계산한 값도 pandas 결과와 모두 같았습니다. 다만 한쪽 게시물에만 VIN을 적어 다른 차로 묶인 것으로 보이는 '중복 의심' 차가 평가 데이터에 141대(0.4%) 남아 있어 경고로 기록했습니다.
02

평가 설계: 점수가 부풀려지지 않게

같은 차가 학습 데이터와 평가 데이터에 함께 들어가면, 모델은 시세를 배운 것이 아니라 그 차의 가격을 외운 것으로도 좋은 점수를 받습니다. 같은 XGBoost 설정과 같은 절차로, 중복을 지웠는지만 다르게 해서 두 방식을 비교했습니다. 이 점검도 최종 평가 데이터에 속한 차는 빼고 나머지 데이터로만 했습니다.

평가 방식별 점수 비교

중복을 남긴 채 매물 단위로 무작위로 나누면 평가 매물의 56%가 학습 데이터에 이미 있는 차였고, ±20% 적중률이 80.8%로 나왔습니다. 중복을 지우고 차 단위로 나눈 정직한 평가에서는 68.9%입니다. 실제 서비스에서 만나는 것은 처음 보는 차이므로 이 프로젝트의 모든 수치는 뒤의 방식으로 쟀습니다.

중복을 지운 187,707대를 차 단위로 다섯 묶음으로 나눴습니다. 학습 103,241대, 조기 종료 9,385대(XGBoost 나무 수 결정), 검증 18,770대(모델 비교·선택과 튜닝), 보정 18,770대(예측 구간 보정), 평가 37,541대입니다. 조기 종료용을 검증용과 따로 둔 것은, 나무 수를 정한 데이터로 다시 채점하면 XGBoost만 검증 점수가 유리해지기 때문입니다. 평가 데이터는 모델 선택이 끝난 뒤 최종 모델의 성능을 재는 데만 썼습니다. 지표는 오차율 중앙값(예측이 실제 가격에서 몇 % 벗어나는지의 중앙값)과 ±10%·±20% 적중률을 중심으로 봤습니다. 비싼 차 몇 대의 큰 오차에 끌려가지 않고, 판매자가 체감하는 '시세가 맞는가'에 가깝기 때문입니다.
03

모델 비교

머신러닝 없이 만들 수 있는 기준선부터 세웠습니다. 같은 모델·연식 매물 가격의 중앙값을 쓰는 '시세표' 방식으로, 표본이 적으면 모델 → 제조사 → 전체 순으로 넓혔습니다. 모든 모델은 log(가격)을 예측하고, 오차는 달러로 되돌려 쟀습니다. 아래 표와 그림은 검증 데이터 점수이며, 이 점수로 최종 모델을 골랐습니다.

모델별 오차율
모델 (검증 데이터)오차율 중앙값±10% 적중±20% 적중평균 절대 오차R² (log)학습 시간
XGBoost (튜닝)12.4%42.0%69.5%$2,4540.86850초
XGBoost (기본 설정)12.7%40.9%69.3%$2,4760.86319초
Random Forest (평균가 인코딩)15.2%34.8%61.5%$2,9930.82625초
Ridge 회귀15.5%33.6%61.0%$3,2420.8212초
Random Forest16.8%31.7%57.0%$3,2800.80323초
기준선: 시세표18.7%29.3%52.5%$3,8080.7050초
기준선: 전체 중앙값53.1%8.7%16.1%$9,157-0.0040초

처음에는 Random Forest가 선형 모델(Ridge)보다도 못했습니다(오차율 16.8% 대 15.5%). 원인을 보니 378개 모델을 정수 코드로 넣어서, 나무가 '코드 번호가 큰 쪽과 작은 쪽'으로만 나눌 수 있어 모델 정보를 거의 쓰지 못하고 있었습니다. 모델·제조사·주의 평균 가격을 숫자로 바꿔 넣자(5겹 교차 방식의 타깃 인코딩) 15.2%로 좋아졌습니다. 모델을 비교할 때 한쪽만 불리한 입력을 쓰고 있지 않은지 먼저 확인해야 공정한 비교가 됩니다.

XGBoost는 Optuna로 25번 후보를 탐색했습니다. 시간을 아끼려고 학습 데이터 일부(6만 건)와 높은 학습률로 후보를 고르고, 고른 설정을 전체 학습 데이터와 낮은 학습률로 다시 학습했습니다. 탐색의 기준도 검증 데이터였습니다. 튜닝으로 줄어든 검증 오차율은 0.3%p로 작았습니다. 기본 설정도 이미 충분히 좋았다는 뜻이고, 큰 차이는 튜닝보다 데이터 정제와 모델 종류에서 나왔습니다.

최종 평가. 검증 오차율이 가장 낮은 XGBoost (튜닝)을 최종 모델로 정한 뒤, 평가 데이터 37,541대로 한 번 채점했습니다. 오차율 중앙값 12.4%, ±20% 적중 69.6%, 평균 절대 오차 $2,457로 검증 점수(12.4%)와 비슷해, 검증 데이터에 과하게 맞춘 흔적은 보이지 않았습니다. 같은 평가 데이터에서 시세표 방식은 오차율 18.7%, 평균 절대 오차 $3,778였습니다(비교 참고용, 선택에는 쓰지 않음).
MLflow 실험 기록 화면

모든 실행(기준선, 비교 모델, 튜닝 25회, 평가 방식 점검, 최종 평가)의 설정과 지표를 MLflow에 기록했고, 최종 모델은 모델 레지스트리에 'used-vehicle-price'로 등록했습니다.

예측 가격과 실제 가격
04

무엇이 가격을 정하나

XGBoost가 계산하는 SHAP 값으로 각 변수가 예측 가격을 얼마나 움직였는지 봤습니다. 이는 모델이 가격을 어떻게 판단했는지를 보여주는 것이고, 변수가 가격을 '일으킨' 원인이라는 뜻은 아닙니다.

변수별 영향 크기
차령과 주행거리의 영향

다른 조건이 같을 때 차령 2년인 차는 예측 가격을 모델의 평균 예측보다 약 95% 끌어올리고, 차령이 늘수록 영향이 줄어 10년 전후에서 0이 된 뒤 15년차에는 약 33% 끌어내립니다. 감가는 처음 10년 동안 가파르고 그 뒤로 완만해집니다. 주행거리도 비슷하게 늘수록 가격을 내리는데, 차령과 주행거리를 함께 넣었기 때문에 '같은 연식이라면 더 많이 탄 차가 얼마나 싼가'를 따로 볼 수 있습니다.

05

트럭·픽업

차종별로 오차를 나눠 봤습니다. 트럭·픽업은 시세표 방식의 오차가 20.4%로 큰 편이었는데, 모델은 이를 12.6%로 줄여 차종 가운데 가장 크게 개선했습니다(38% 감소). 같은 모델·연식 안에서도 적재 등급, 구동 방식, 디젤 여부에 따라 가격이 크게 달라지는데, 시세표는 이 차이를 담지 못하기 때문으로 보입니다.

차종별 오차율
차종평가 매물시세표 오차율모델 오차율±20% 적중80% 구간 포함률
트럭·픽업5,53920.4%12.6%69.9%79.9%
SUV7,97315.6%10.8%75.3%80.4%
세단8,43817.2%11.6%72.9%81.1%
기타 차종6,77319.8%12.4%69.6%80.1%
차종 미기재8,81821.4%15.0%61.1%79.6%

트럭만으로 따로 학습한 모델도 만들어 봤습니다. 운영 방식을 정하는 판단이라 평가 데이터가 아닌 검증 데이터로 비교했습니다. 검증 데이터의 트럭 2,738대에서 트럭 전용 모델의 오차율은 12.85%, 전체 데이터로 학습한 모델은 12.49%였습니다. 전체 모델이 0.4%p 낮았습니다. 다른 차종에서 배운 감가 패턴이 트럭에도 도움이 되는 것으로 보여, 하나의 모델로 운영하는 편이 낫다고 판단했습니다.

연식별 가격 유지율

유지율은 차종 구성이 다른 효과를 피하려고, 모델마다 '차령 1~3년 가격 중앙값' 대비 비율을 먼저 구한 뒤 차종별로 모델들의 중앙값을 냈습니다. 매물 가격 기준이라 실제 거래 가격과는 다를 수 있습니다.

06

시세 구간

시세는 한 숫자보다 '대략 얼마에서 얼마 사이'로 보여주는 것이 쓸모 있습니다. XGBoost 분위수 회귀로 10%·90% 분위수를 함께 학습해 80% 구간을 만들었는데, 평가 데이터에서 실제 가격이 구간에 든 비율이 75.1%로 목표에 미치지 못했습니다. 그래서 보정용 데이터에서 구간을 벗어난 정도를 재고 그만큼 구간을 조정하는 CQR(Conformalized Quantile Regression)을 적용해 80.3%로 맞췄습니다. 구간 폭의 중앙값은 예측 가격의 54%입니다.

예측 구간 포함률
07

시세와 크게 다른 매물

평가 매물 중 실제 가격이 예측의 0.4배 미만인 매물이 492건(1.3%), 2.5배를 넘는 매물이 138건(0.4%)이었습니다. 낮은 쪽 중 62건은 5년 이내 차인데 가격이 5천 달러 미만이었습니다. 예를 들어 2020년식 캐딜락 에스컬레이드가 1,093달러로 올라와 있었는데, 할부 선납금이나 계약금을 가격 칸에 적은 광고일 가능성이 큽니다. 정제 단계에서 1,000달러 미만은 걸러냈지만 1,000달러를 살짝 넘긴 이런 값은 규칙으로 잡히지 않았습니다. 높은 쪽에서는 111,111달러, 123,456달러처럼 같은 숫자를 반복하거나 순서대로 친 가격이 보였습니다. 매물 설명 원문이 데이터에 없어 확인할 수는 없지만, 이런 매물은 모델 오차라기보다 매물 정보의 문제로 보입니다. 규칙만으로 거르기 어려운 이상 가격을 모델이 '시세 대비 이상 매물'로 짚어 검수 대상으로 넘길 수 있다는 뜻이기도 합니다.

매물주행거리등록 가격예측 시세가격/시세
2020 cadillac escalade platinum10,031$1,093$76,8670.01배
2018 cadillac escalade esv49,342$1,091$61,1940.02배
2020 lincoln navigator19,646$1,312$64,5630.02배
2019 ram 25006,130$1,116$41,5320.03배
2020 chevrolet silverado 2500hd ltz23,500$1,175$34,9480.03배
2017 chevrolet camaro31,000$1,111$27,7830.04배
2009 ford escape182,415$150,000$4,41833.95배
2009 volkswagen gti111,111$111,111$4,90022.68배
2008 ford explorer xlt 4x4185,000$123,456$5,77121.39배
1998 chevrolet camaro100$49,000$3,46414.15배
08

가격대·연식별 오차

실제 가격대매물오차율평균 절대 오차
5천 달러 미만6,48921.6%$1,367
5천~1만10,01613.3%$1,317
1만~2만11,02710.7%$2,084
2만~4만8,0999.9%$3,658
4만 달러 이상1,91012.1%$9,203
차령매물오차율±20% 적중
0~3년2,7609.6%81.1%
4~7년9,8929.7%80.6%
8~12년11,56311.6%73.5%
13~18년9,41215.6%60.5%
19년 이상3,91423.1%44.2%

5천 달러 미만 저가 차는 오차율이 가장 큽니다. 같은 금액 차이도 저가 차에서는 비율이 커지고, 7절에서 본 자리표시 가격도 이 구간에 몰려 있습니다. 차령이 19년을 넘으면 ±20% 적중률이 절반 아래로 떨어집니다. 오래된 차일수록 관리 상태가 가격을 크게 가를 텐데, 상태 정보는 판매자의 자기 평가이고 절반 가까이 비어 있어 모델이 이 차이를 담기 어려운 것으로 보입니다.

09

서비스로 쓰기

학습한 모델을 FastAPI로 감싸, 매물 정보를 보내면 예측 시세와 80% 구간, 가격을 가장 크게 움직인 요인을 돌려주게 했습니다. 판매자에게 '왜 이 가격인지'를 함께 보여줄 수 있습니다.

POST /predict
{"year": 2016, "manufacturer": "ford", "model": "F-150 XLT",
  "odometer": 98000, "type": "pickup", "drive": "4wd", "state": "tx"}

→ {"estimate_usd": 30030, "range_80_usd": [24570, 38260],
    "model_key": "ford f-150",
    "top_factors": [{"feature": "차령", "effect_pct": 50.1},
                    {"feature": "모델", "effect_pct": 39.3}, …]}

정제 규칙과 API는 pytest로 검증합니다. 처음 보는 모델명도 제조사별 '기타'로 처리해 예측하고, 범위를 벗어난 입력(1995년 이전 연식 등)은 거부합니다. 학습 데이터에 주(state)가 빠진 매물이 없었기 때문에, 주는 필수 입력으로 받습니다.

10

한계와 다음 단계