Following the extension of the family, we decided to buy a new and bigger car, and hence to sell the old one (an Audi A3 sportsback). There exists online ressources to evaluate the price of used cars on the market (Eurotax for the Swiss market) but the car was too old to be listed (over 12 years) so I attempted to evaluate the price of my car based on the market, using web scraping and a bit of machine learning.
It turned out that I sold the car much below the market price due to a technical problem in the engine that I had first overlooked. But the exercise was nevertheless interesting and very useful to have a first idea of the price. It is also worth mentioning that a professional friend of mine helped me during the selling process. The business experience is often superior in these situations.
The adopted strategy consists in 3 steps:
- first acquire some data online including the price and the car characteristics
- build a pricing model using machine learning
- evaluate the price of my car given its characteristics
Preamble
As usual we import the necessary libraries.
# system libraries import os import sys import re from datetime import date, datetime, timedelta import numpy as np # for data handling import pandas as pd # for web scraping import requests from bs4 import BeautifulSoup # for plotting import matplotlib.pyplot as plt plt.rcParams.update({'font.size': 25}) # plt.style.use('seaborn') plt.style.use('seaborn-whitegrid') plt.rc('pdf',fonttype=42) import seaborn as sns sns.mpl.rc('figure', figsize = (14, 8)) sns.set_context( 'notebook', font_scale=2.5, rc={'lines.linewidth': 2.5})
Data acquisition
To obtain the data I need, I get the data from a swiss selling items web site, focusing on the used car ads. I had several choices but I found one site that was relatively easy to analyse while having many ads (about 200k used car ads).
As usual the goal is to find the best compromise between getting as much data as possible while saving ressources in time and computing power (from both sides of the server and the client). The approach I choose is to get the data from the ad preview, which already contains the most critical pieces of information (such as car model, price, age and kilometers).
Data exploration
The scraping has been done twice. The first time on June 22nd and one week later. The goal is to see which typical ads have disappeared in the interval (i.e. if they were sold rapidely).
Import data
data_dir = 'data' # read csv files df_1 = pd.read_csv(data_dir+'/22.06.2019_all.csv', index_col='item_id') df_2 = pd.read_csv(data_dir+'/29.06.2019_all.csv', index_col='item_id') # merge the two df_ads = df_1.join( df_2['age_ad_days'], rsuffix='_2', how='left', on='item_id') # create a new column: True if it disa select = (df_ads['age_ad_days_2'].isnull()) & (df_ads['date']>='2019-06-21') df_ads['less_than_a_week'] = select df_ads = df_ads.drop('age_ad_days_2', axis=1) # Display numbers print('Total: {0}. Disappeared in 1 week: {1}'.format(len(df_ads), sum(select)))
Total: 232642. Disappeared in 1 week: 1176
df_ads.drop('link', axis=1).head()
Cleaning
We take a quick look at the data and clean what’s necessary. Here we see that a number of ads are over 1000 days old and some have no valid date. This may correspond to promoted ads. We remove them. We also remove prices over CHF 1M.
Identically, some ads have a very high number of kilometers.
In total we get rid of 36% of ads.
fig, ax = plt.subplots(figsize=(15,8)) ax.hist( df_ads['age_ad_days'].dropna(), alpha=0.5, label='All', density=False) #, bins=np.linspace(10,50)) ax.set_xlabel('Age in days') ax.set_ylabel('Count') ax.set_title('Number of ads as a function of ad age in days') fig.savefig('figures/ad_age_distribution.png')
# cleaning select = df_ads['age_ad_days'] > 0 select &= df_ads['age_ad_days'] < 1000 select &= df_ads['price'] < 1.e6 select &= df_ads['price'] > 0 select &= df_ads['km'] > 0 select &= df_ads['km'] < 2.e5 df_ads_clean = df_ads[select] # how much is it dropped? N = len(df_ads) N_clean = len(df_ads_clean) #(len(df_ads)-len(df_ads_clean))/len(df_ads) print('N: {}, N(clean): {} , dropped fraction: {:.2f}%'.format( N, N_clean, 100.*(N-N_clean)/N))
N: 232642, N(clean): 149590 , dropped fraction: 35.70%
df_ads_clean.drop('link', axis=1).head()
Price distribution
The price is our target to estimate. We take a look at its distribtution. Interstingly, the price distribution is roughly log-normal, i.e. the logarithm of it normal, and hence skewed towards higher values. The median price is CHF 28’800.
We overplot the distribution of ads that disappeared within 1 week in red, “first off” the list. We see a significant decrease in price. The median price is CHF 8000. This is consistent with the fact that cheaper cars are sold faster.
fig, ax = plt.subplots(1,2, figsize=(15,8), sharey=False) ax[0].hist( df_ads_clean['price'], alpha=0.5, label='All', density=True, bins=np.linspace(0,2.e5)) ax[0].set_xlabel('Price (CHF)') ax[1].hist( np.log10(df_ads_clean['price']),density=True, alpha=0.5, label='All', bins=np.linspace(2,6)) select = df_ads_clean['less_than_a_week'] ax[1].hist( np.log10(df_ads_clean[select]['price']), alpha=0.5, label='First\noff', density=True, bins=np.linspace(2,6), color='red') ax[1].set_xlabel('log price (CHF)') ax[0].set_yticks([]) ax[1].set_yticks([]) ax[0].set_ylabel('Normalised count') ax[1].legend() print(df_ads_clean['price'].median(), df_ads_clean[select]['price'].median()) fig.savefig('figures/price_distribution.png') None
24800.0 8000.0
Features
Next, we explore here the distribution of features and the correlation between them. First we pick a number of features and see how the price depends on them, then we look at the distribution of the features.
attributes = ['km', 'age_car_years', 'age_ad_days'] # fig, ax = plt.subplots(figsize=(4,4)) n_i, n_j = 1, 3 fig, ax = plt.subplots( n_i, n_j, figsize=(15, 6), sharey = True) # plot price vs attribute for k, a in enumerate(attributes): df_ads_clean.plot.scatter( x=a, y='price', ax=ax[k], s=10, alpha=0.1) fig.tight_layout() fig.savefig('figures/price_vs_features.png') fig, ax = plt.subplots( n_i, n_j, figsize=(15, 6), sharey = True) # plot price vs attribute for k, a in enumerate(attributes): df_ads_clean[a].plot.hist( ax=ax[k], alpha=0.5, logy=False) ax[k].set_xlabel(a) fig.tight_layout() # ax=ax[k%n_i, k//n_i], # do not plot axes with no data #for k in range(len(attributes),n_i*n_j ): # ax[k%n_i, k//n_i].axis('off') fig.savefig('figures/features_distribution.png') None
Most cars have few kilometers and are relatively new. As expected the price increases with low km and small age.
Price modelling
We will now focus on a specific model, i.e. the one we attempt to model the price, an Audi A3.
Selection of the car model
We apply a number of filters on the brand, model and model details. As the text form is free, we are flexible in the search term.
# Brand and model select = df_ads_clean['brand'].fillna('').str.contains('Audi|AUDI|audi') select &= df_ads_clean['model'].fillna('').str.contains('A3|a3') # Select specifically the sportsback (5 doors) models select &= \ df_ads_clean['model details'].fillna('').str.contains('Sportback|sportback')\ |df_ads_clean['description'].fillna('').str.contains('Sportback|sportback')\ |df_ads_clean['name'].fillna('').str.contains('Sportback|sportback') # Engine type select &= \ df_ads_clean['model details'].fillna('').str.contains('TDI')\ |df_ads_clean['description'].fillna('').str.contains('TDI')\ |df_ads_clean['name'].fillna('').str.contains('TDI') # We exclude new cars select &= df_ads_clean['km'] > 1000. select &= df_ads_clean['age_car_years'] > 1 df_Audi_A3 = df_ads_clean[select].copy() print(sum(select))
247
Feature selection
We now plot the price as a function of km and age of the car for the Audi A3. We observe the same trends as before, i.e. that the price increases with decreasing km and age, but much clearly than with the full population (See above).
We then split the sample in two, above and below the median ad age (50 days). The former is plotted in blue (“newest”) whereas the latter is plotted in red (“oldest”). WHerease no significant trend is visible a priori on the plot, when we look at the mean price, however, oldest ads have a higher value, and both the ratio mean(price)/mean(age) and the ratio mean(price)/mean(km) are higher (around 30%) for old ads than new ads.
The two features km and car age will be our machine learning primary parameters to model the price. Since the price is an exponentially decreasing function of both parameters, it’s clear that some transformation will ease the modelling (see next section).
fig, ax = plt.subplots(1, 2, figsize=(15,8), sharey=True) select = df_Audi_A3['age_ad_days'] > df_Audi_A3['age_ad_days'].median() df_Audi_A3[~select].plot.scatter( marker='o', x='age_car_years', y='price', ax=ax[0], s=100, alpha=0.7, label='Newest ads') df_Audi_A3[~select].plot.scatter( marker='o', x='km', y='price', ax=ax[1], s=100, alpha=0.7, label='Newest ads ') print('Median age of the ad in days', df_Audi_A3['age_ad_days'].median()) means_newest = df_Audi_A3[~select].mean() means_oldest = df_Audi_A3[select].mean() print('Newest:\n', means_newest) print('Oldest:\n', means_oldest) print('Price to age ratio newest: {:.2f}'.format(means_newest['price']/means_newest['age_car_years'])) print('Price to age ratio oldest: {:.2f}'.format(means_oldest['price']/means_oldest['age_car_years'])) print('Price to km ratio newest: {:.2f}'.format(means_newest['price']/means_newest['km'])) print('Price to km ratio oldest: {:.2f}'.format(means_oldest['price']/means_oldest['km'])) # print('Price to km and age ratio newest: {:.3f}'.format(means_newest['price']/means_newest['km']/means_newest['age_car_years'])) # print('Price to km and age ratio oldest: {:.3f}'.format(means_oldest['price']/means_oldest['km']/means_oldest['age_car_years'])) if select.any(): df_Audi_A3[select].plot.scatter( x='age_car_years', y='price', ax=ax[0], s=100, alpha=0.7, marker='+', c='red', label='Oldest ads') df_Audi_A3[select].plot.scatter( x='km', y='price', ax=ax[1], s=100, alpha=0.7, marker='+', c='red', label='Oldest ads') select = df_Audi_A3['less_than_a_week'] if select.any(): df_Audi_A3[select].plot.scatter( x='age_car_years', y='price', ax=ax[0], s=100, alpha=0.7, marker='o', c='green', label='Sold in < 1 week') df_Audi_A3[select].plot.scatter( x='km', y='price', ax=ax[1], s=100, alpha=0.7, marker='o', c='green', label='Sold in < 1 week') # Aestetics ax[0].set_ylabel('Price in CHF') fig.tight_layout() fig.savefig('figures/price_vs_age_and_km.png')
Median age of the ad in days 58.0 Newest: km 97928.564516 price 16873.500000 age_car_years 6.537251 age_ad_days 29.209677 less_than_a_week 0.008065 log_age_car_years 0.747052 log_km 4.924243 dtype: float64 Oldest: km 86942.609756 price 19864.528455 age_car_years 6.073728 age_ad_days 172.414634 less_than_a_week 0.000000 log_age_car_years 0.696783 log_km 4.789348 dtype: float64 Price to age ratio newest: 2581.13 Price to age ratio oldest: 3270.57 Price to km ratio newest: 0.17 Price to km ratio oldest: 0.23
We also plot the km as a function of car age. As expected the former increases with the latter. What’s interesting is to see the high scatter for high values, and that’s what interests us given that our car is old but with relatively few km, so we will see to model it properly to see whether it increases its value.
And to start analysing we set the point color as the price. We clearly see the strong dependence on age. However, the km dependence seems to be less promominent. We will probably find that the age is the primary factor to estimate the price.
fig, ax = plt.subplots(figsize=(8,8)) df_Audi_A3.plot.scatter( marker='o', x='age_car_years', y='km', ax=ax, s=100, alpha=0.7, c='price', cmap='viridis') fig.savefig('figures/km_vs_age.png') None
Feature transformation
Since the price is an exponentially decreasing function of age and km, we apply a logarithmic transformation of the features (a particular case of cox-box transformation):
df_Audi_A3['log_age_car_years'] = df_Audi_A3['age_car_years'].apply(np.log10) df_Audi_A3['log_km'] = df_Audi_A3['km'].apply(np.log10) fig, ax = plt.subplots(1, 2, figsize=(15,8), sharey=True) df_Audi_A3.plot.scatter( marker='o', x='log_age_car_years', y='price', ax=ax[0], s=100, alpha=0.7) df_Audi_A3.plot.scatter( marker='o', x='log_km', y='price', ax=ax[1], s=100, alpha=0.7) fig.savefig('figures/price_vs_age_and_km_log.png') fig.tight_layout()
Price modelling
We finally apply a machine learning algorithm to model and predict the price. We test a linear, a random forest, and a support Vector machine (SVN) models. To test the model, we compute a mean square error (root squared) and the score provided by the model object (r^2 score in the linear case) on a “test” sample kept aside when training the model.
import sklearn from sklearn.linear_model import LinearRegression from sklearn.naive_bayes import GaussianNB, BernoulliNB, MultinomialNB from sklearn import svm from sklearn.neural_network import MLPRegressor from sklearn.ensemble import RandomForestRegressor, AdaBoostRegressor from sklearn import metrics
# functions def split_data(X, y): """ Split the data assuming training: 80%, test: 20% """ X_train, X_test, y_train, y_test = \ sklearn.model_selection.train_test_split( X, y, test_size=0.20) return X_train, X_test, y_train, y_test def fit_data(df, model, n_iter=100): # perform multiple regression attributes = ['log_age_car_years', 'log_km'] X = df[attributes] y = df['price'] error = np.zeros(n_iter) score = np.zeros(n_iter) for n in range(n_iter): X_train, X_test, y_train, y_test = split_data(X, y) model.fit(X_train, y_train) y_test_predict = model.predict(X_test) y_train_predict = model.predict(X_train) error[n] = np.sqrt(metrics.mean_squared_error(y_test, y_test_predict)) score[n] = model.score(X_test, y_test) return error.mean(), error.std(), score.mean(), score.std()
# Test several models models ={ 'Linear': LinearRegression(normalize=True, fit_intercept=True), 'Random Forest': RandomForestRegressor(n_estimators=20), # 'Neural network': MLPRegressor(hidden_layer_sizes=(10,)), 'SVM': svm.SVC(gamma='scale'), } for model_name in models.keys(): error, error_std, score, score_std = fit_data(df_Audi_A3, models[model_name]) error_str = 'error: {:.2f} (+/- {:.2f})'.format(error, error_std) score_str = ', score: {:.2f} (+/- {:.2f})'.format(score, score_std) print('{:20}'.format(model_name)+error_str,score_str)
Random Forest error: 3699.78 (+/- 530.80) , score: 0.85 (+/- 0.04) Linear error: 3249.61 (+/- 503.02) , score: 0.88 (+/- 0.04) SVM error: 11188.55 (+/- 1667.60) , score: 0.01 (+/- 0.01)
The linear model performs best. The random forest is also quite good whereas the SVM model is significantly worse than the other 2.
Audi price
In this final step, we predict the price of our Audi, given that it’s 13 years old and has 96’000 km.
We have seen previously that the model predictions were relatively noisy even for one model. Since the training process is fast, we randomise the process to evaluate the typical price variation due to the machine learning process. To do so, we repeat the training and prediction 100 times, then computing the mean and standard deviation of the price.
For the linear model, the predicted price from the market is CHF 9300 (+/- 170).
def estimate_price(df, model, n_iter=100): # perform multiple regression attributes = ['log_age_car_years', 'log_km'] X = df[attributes] y = df['price'] log_x = np.log10([13.0, 96000.]) price = np.zeros(n_iter) for n in range(n_iter): X_train, X_test, y_train, y_test = split_data(X, y) model.fit(X_train, y_train) price[n] = model.predict([log_x]) return price.mean(), price.std()
for model_name in models.keys(): price, price_std = estimate_price(df_Audi_A3, models[model_name]) price_str = 'price: {:.2f} (+/- {:.2f})'.format(price, price_std) print('{:20}'.format(model_name)+price_str)
Random Forest price: 8175.42 (+/- 613.93) Linear price: 9281.72 (+/- 172.44) SVM price: 11610.00 (+/- 2146.14)
Finally estimate using oldest and newest ads as training. The linear model is similar. The svm and random forest feature significant higher prices when trained with oldest ads.
newest = df_Audi_A3['age_ad_days'] > df_Audi_A3['age_ad_days'].median() oldest = df_Audi_A3['age_ad_days'] <= df_Audi_A3['age_ad_days'].median() for model_name in models.keys(): price, price_std = estimate_price(df_Audi_A3[newest], models[model_name]) price_str = 'price: {:.2f} (+/- {:.2f})'.format(price, price_std) print('{:20}'.format(model_name)+price_str) for model_name in models.keys(): price, price_std = estimate_price(df_Audi_A3[oldest], models[model_name]) price_str = 'price: {:.2f} (+/- {:.2f})'.format(price, price_std) print('{:20}'.format(model_name)+price_str)
Random Forest price: 7489.03 (+/- 746.82) Linear price: 9195.37 (+/- 270.22) SVM price: 9010.00 (+/- 705.62) Random Forest price: 10228.56 (+/- 589.40) Linear price: 9159.41 (+/- 215.46) SVM price: 12684.00 (+/- 1786.55)
# log_x = np.log10([13.0, 96000.]) # models['Linear'].predict([log_x])
Feature importance
A final interesting thing to do is explore the importance of features, which can be done with random forest. Since part of randomisation process is done on the feature, the algorithm is able to estimate which feature gives the most information.
Here we see that the age is the most important feature, contributing to almost 70% of the fitting process, whereas the number of km only contribute to only 30%.
attributes = ['log_age_car_years', 'log_km'] importances = models['Random Forest'].feature_importances_ std = np.std([tree.feature_importances_ for tree in models['Random Forest'].estimators_], axis=0) indices = np.argsort(importances)[::-1] # Print the feature ranking print("Feature ranking:") X = df_Audi_A3[attributes] for f in range(X.shape[1]): print("%d. feature %d (%f)" % (f + 1, indices[f], importances[indices[f]]), X.columns[f])
Feature ranking: 1. feature 0 (0.669670) log_age_car_years 2. feature 1 (0.330330) log_km
Conclusion
To conclude:
- choice of model: linear on log-transformed features seems to be the most robust
- best estimation: CHF 9’300
- most import feature: age of the car
- oldest ads have a higher price (probably meaning the price is too high)
The fact that the age of the car is the most important feature to estimate the price means that owning a car we don’t use really is a bad idea. Indeed, after some years, whether we used a car or not, its price will still significantly decreases. And that is the case here, although the number of kilometers is relatively low, it didn’t help to give it a better price on the market.
Also, it would be interesting to investigate a bit more, but it seems evident there is a minimum number of kilometers under which renting a car from time to time is more profitable than owning or leasing a car.