Atwam

I do stuff, and write about it

Motorbike Prices

Being a data scientist (yup, that what we are called nowadays, apparently we are all the rage) and looking for my first bike to buy made me think (a lot) about what ride wanted.

Hundreds of pages on various forums talking about which bike is good, which one is better, reliability, position, torque, sound, etc etc. Yet in the end, once you have set your mind on one or two models, you still have dozens (if not hundreds) of classifieds for these models, and it is sometime hard to know whether the price is fair, a good deal, or an overpriced deal.

So, having lots of time on my hands at the moment, I started taking a quantitative approach.

  • Step 1 : Get all the data (or as much as I can).
  • Step 2 : Analysis, model
  • Step 3 : Profit (now that I have a model, I can know whether any ad is a good deal or not).

Step 1 : Get all the data

The basis of my data is a request on a reputable motorbike classifieds website, looking for bikes which cost more than £500 (to avoid all the “wanted”) posts, and with more than 200cc. I’m not claiming the data is perfect, far from it, but if you know a better source, let me know.

Step 2 : Analysis

libs=c('data.table','knitr','ggplot2','stringr','pander','dplyr','lubridate')
invisible(lapply(libs, require, character.only=TRUE))

theme_set(theme_bw())
data = fread('~/Dev/autoray/out.csv')

data = within(data, {
  cc = as.integer(cc)
  cc[cc > 4000] = NA
  seller_type = as.factor(seller_type)
  category = as.factor(category)
  year = as.integer(year)
  year[year > 2016 | year < 1915] = NA
  mileage = as.integer(mileage)
  age = 2016 - year
  title = str_to_lower(title)
  description = str_to_lower(description)
  search = paste(title, ' ', description)
  abs = str_detect(search, 'abs')
  panniers = str_detect(search, 'panniers')
  ad_age = as.integer(Sys.time() - parse_date_time(str_extract(url, "20\\d{6}"), 'Ymd'))
  ad_age[ad_age > 365*2] = NA # Let's ignore all ads older than 2 years for ad_age
})

As you can see below, I gathered prices and information for 17043 bikes. Let’s have a look at our data :

summary(data[,sapply(data, is.numeric), with=FALSE])
##      price          distance          year         mileage      
##  Min.   :  500   Min.   :  1.0   Min.   :1926   Min.   :     1  
##  1st Qu.: 4137   1st Qu.: 54.0   1st Qu.:2008   1st Qu.:  2000  
##  Median : 6000   Median :114.0   Median :2012   Median :  7266  
##  Mean   : 6857   Mean   :116.7   Mean   :2010   Mean   : 10844  
##  3rd Qu.: 8695   3rd Qu.:168.0   3rd Qu.:2015   3rd Qu.: 15900  
##  Max.   :55950   Max.   :445.0   Max.   :2016   Max.   :333902  
##                                  NA's   :521    NA's   :1832    
##        cc             ad_age            age        
##  Min.   : 191.0   Min.   :  3.00   Min.   : 0.000  
##  1st Qu.: 650.0   1st Qu.: 33.75   1st Qu.: 1.000  
##  Median : 900.0   Median : 99.00   Median : 4.000  
##  Mean   : 921.3   Mean   :133.55   Mean   : 5.573  
##  3rd Qu.:1170.0   3rd Qu.:182.00   3rd Qu.: 8.000  
##  Max.   :3072.0   Max.   :730.00   Max.   :90.000  
##  NA's   :19       NA's   :2011     NA's   :521
summary(data[,!sapply(data, is.numeric), with=FALSE])
##     title              brand                   seller_type    category 
##  Length:17043       Length:17043       Private seller:  558    :16950  
##  Class :character   Class :character   Trade seller  :16485   C:   57  
##  Mode  :character   Mode  :character                          D:   36  
##                                                                        
##      url            description         panniers          abs         
##  Length:17043       Length:17043       Mode :logical   Mode :logical  
##  Class :character   Class :character   FALSE:16123     FALSE:13347    
##  Mode  :character   Mode  :character   TRUE :920       TRUE :3696     
##                                        NA's :0         NA's :0        
##     search         
##  Length:17043      
##  Class :character  
##  Mode  :character  
##

Here are the factors I managed to get to explain the prices.

  • cc: The engine size, in cc
  • seller_type : Whether the ad was posted by a private or a trade seller
  • category : Whether the bike is in an insurance category (that is, a totaled bike).
  • year : Year of registration of the bike
  • mileage : Mileage, duh
  • title, description, search : Plain text about the bike, used to add some other factors or infer the brand, model, etc.
  • abs, panniers : I look for these words in the titles, description. Not perfect, especially because I look them as strings, not as words. So a description about an absolute steal would match an abs. Yeah, that sucks, but I haven’t had the time to research what NLP tools R offers for stemming.
  • ad_age : The age of the ad, calculated from a time stamp in the url. This one actually gets lots of N/A, because some urls are for ads and don’t show the timestamp.

Step 2 : Analysis

Impact of the age on the price

ggplot(data) + geom_smooth(aes(y=price, x=age, color=category)) + scale_x_continuous(breaks = seq(0,90,10))

center

What can we see on this graph ?

  • Prices go down almost linearly during the first 15 years.
  • After that, you can expect prices to remain kind-of stable for bikes which are just about to become vintage.
  • Motorbikes more than 30 years old start to see a constant rise in value.
  • Cat C and Cat D bikes have a value that is constantly lower than the value of non-category motorbikes. They decrease in value steadily.

Now, let’s focus on bikes which are less than 15 years old, not in a category.

data = subset(data, age <= 15 & category == "")

We can evaluate this discount :

summary(lm(price ~ age, data))
## 
## Call:
## lm(formula = price ~ age, data = data)
## 
## Residuals:
##    Min     1Q Median     3Q    Max 
##  -8461  -2020   -479   1308  47431 
## 
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  8961.18      40.13  223.28   <2e-16 ***
## age          -442.60       6.81  -64.99   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 3236 on 15503 degrees of freedom
## Multiple R-squared:  0.2141, Adjusted R-squared:  0.2141 
## F-statistic:  4224 on 1 and 15503 DF,  p-value: < 2.2e-16

We see whe should expect a loss of value of £442/year, but it makes more sense to assume this loss in value should be a percentage of the original value, which would be 442/8961, or about 5%/year for the first 15 years.

Impact of brands

As a first approximation, we can take the first word of the ad title to get the brand. Brands that use several words will be trimmed, but that should be good enough for now.

data$brand = as.factor(word(data$title))
brands = data %>% group_by(brand) %>% tally(sort = TRUE)
big_brands = brands[1:8]$brand
data$big_brand = data$brand %in% big_brands

# Let's try a colorblind friendly version
cbPalette <- c("#999999", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7")
ggplot(data[data$big_brand]) + geom_smooth(aes(y=price, x=age, color=brand), se = FALSE) + scale_colour_manual(values=cbPalette)

center

Here we can see not only the difference in average price between brands (be careful, I haven’t distinguished between models here), but also how these decay with time.

We can see that Harleys are consistently more expensive, but seem to hold their value relative to other bikes quite well. Ducatis see a sharp drop at around 5 years. BMW are more expensive than the other brands as well in their first 7 years or so, but then drop in price to be actually cheaper than other brands. My guess is that because they are more expensive, they tend to be favoured by people able to buy them new, but the second-hand market for them isn’t that great.

Engine size

ggplot(data) + geom_smooth(aes(y=price, x=cc))

center

Not much to see here by default, we can just see that the price of a bike grows (almost linearly) with the engine size. That is until 1800cc (Goldwings), then beyond 2000cc you enter the realm of the Rocket III which is just less expensive than the goldwing.

Mileage

Some bikes seem to have crazy mileage (300k), so let’s stick to stuff with reasonable values, less than 100k.

ggplot(subset(data, mileage < 100000)) + geom_smooth(aes(y=price, x=mileage))

center

Big surprise, the price drops as the mileage increase. The effect seems almost linear to start with, then seems to decay. We can probably work with a linear decay for the first 25k, beyond that it just makes sense to use a log decay…

summary(lm(price ~ mileage, subset(data, mileage < 25000)))
## 
## Call:
## lm(formula = price ~ mileage, data = subset(data, mileage < 25000))
## 
## Residuals:
##    Min     1Q Median     3Q    Max 
##  -7708  -2237   -612   1667  47778 
## 
## Coefficients:
##               Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  8.208e+03  4.409e+01  186.18   <2e-16 ***
## mileage     -1.569e-01  4.401e-03  -35.65   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 3349 on 12764 degrees of freedom
## Multiple R-squared:  0.09055,    Adjusted R-squared:  0.09048 
## F-statistic:  1271 on 1 and 12764 DF,  p-value: < 2.2e-16
summary(lm(price ~ log(1 + mileage), data))
## 
## Call:
## lm(formula = price ~ log(1 + mileage), data = data)
## 
## Residuals:
##    Min     1Q Median     3Q    Max 
##  -8961  -2347   -603   1613  48229 
## 
## Coefficients:
##                  Estimate Std. Error t value Pr(>|t|)    
## (Intercept)       9715.54      91.98   105.6   <2e-16 ***
## log(1 + mileage)  -366.54      10.97   -33.4   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 3370 on 14061 degrees of freedom
##   (1442 observations deleted due to missingness)
## Multiple R-squared:  0.0735, Adjusted R-squared:  0.07344 
## F-statistic:  1115 on 1 and 14061 DF,  p-value: < 2.2e-16

What we can see here is that the price of the bike seems to drop by 0.1569 per mile, or £156/1000miles. Translating again to a percentage of the intercept, we get 0.0190058 or about 1.9% per 1000 miles.

This figure could depend on the brand quite heavily (and more importantly on the type/model) of bike. You’d expect tourers, VFRs and R1200RT to hold their value with mileage a lot better than some shiny supersport bikes. We’ll have a look at that in another post.

Mileage + age

Mileage and age are obviously very (read 0.6531262 = 65%) correlated, so we’ll have to use both in our price regression.

summary(lm(price ~ age * log(1+mileage), subset(data, mileage < 100000)))
## 
## Call:
## lm(formula = price ~ age * log(1 + mileage), data = subset(data, 
##     mileage < 1e+05))
## 
## Residuals:
##    Min     1Q Median     3Q    Max 
##  -7869  -1921   -400   1292  47533 
## 
## Coefficients:
##                      Estimate Std. Error t value Pr(>|t|)    
## (Intercept)          8325.324     98.211  84.770  < 2e-16 ***
## age                    -3.961     45.923  -0.086    0.931    
## log(1 + mileage)       62.846     13.223   4.753 2.02e-06 ***
## age:log(1 + mileage)  -45.364      4.654  -9.748  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 3075 on 14055 degrees of freedom
## Multiple R-squared:  0.2289, Adjusted R-squared:  0.2287 
## F-statistic:  1390 on 3 and 14055 DF,  p-value: < 2.2e-16

We can pretty much ignore the coef in front of age (low tstat), we get something that is roughly : $ price = 8325 + logMileage * (62 - 45 * age) $

Are private seller cheaper than trade sellers ?

ggplot(data) + geom_smooth(aes(y=price, x=age, color=seller_type)) + scale_x_continuous(breaks = seq(0,90,10))

center

The answer seems consistently yes !

Do good deals go quickly ?

It seems intuitive to think that good deals will be sold quickly, and the longer an ad has been there, the more likely it is that the bike is above its market price. Trade sellers and their automated posting software would skew the results, by having software that refreshes ads every-so-often, but we should still see some effect.

ggplot(data) + geom_smooth(aes(y=price, x=ad_age)) + scale_x_continuous(breaks = seq(0,365*2,30))

center

So yes, our intuition holds, it looks like the average price for new ads is around £1000 less than for ads that are 2 months old. There is a bias here, because many ads for new models are put by dealers and left forever. Let’s run the query again, this time only looking a bikes which are more than 2 years old, and comparing dealers/private sellers.

ggplot(data[age >= 2]) + geom_smooth(aes(y=price, x=ad_age, color=seller_type)) + scale_x_continuous(breaks = seq(0,365*2,30))

center

What we can see here is still that good deals move quickly, and this difference is even more pronounced for private sellers than it is for dealers. An ad that has been out for two months is on average £1000 more expensive than the one that will get sold within its first few days.

We’ll see in a next post whether it still holds when looking at data for one single model.

ABS

ggplot(data) + geom_smooth(aes(y=price, x=age, color=abs)) + scale_x_continuous(breaks = seq(0,90,10))

center

Well, we can see that ABS definitely seems to keep its increased price, at least for recent bikes. One has to be careful though, because ABS is usually found on higher end bikes, so this difference in price could just be a bias. One would have to do a price comparison for a specific model (with enough data).

Distance to london

ggplot(data) + geom_smooth(aes(y=price, x=distance))

center

Hard to say anything here. Could be some bias from big cities that appear at some distance from London. Given we don’t have the postcode for all ads, it’s hard to go beyond this useless graph.

Conclusion

I’ve only looked at a few things, and it seems obvious here that many results are probably biased given I’m not filtering to a specific model.

Next time, I’ll look at the most frequent bike of the most frequent brand, and we’ll see if these results still hold. Plus yeah, we’ll have a first look at our pricing model and try to find some underpriced bikes.

Stay tuned (and feel free to guess what model we’ll be looking at in the comments, or let me know if you’d be interested in another specific analysis).

If you want to re-publish all or part of this data/graphs/article, get in touch. Just ask.

I’d like to get some data on insurance as well, but it’s a lot harder to gather. If you know a way, let me know…

Edit 1 : 2015-02-04 11:24 GMT - Changed the brand graph to only use 8 brands but with colorblinds-friendly palette.

Comments