DEV Community

Cover image for Machine Learning: How to Predict the Sale Price of Bulldozers
Nicolas Vallée
Nicolas Vallée

Posted on

Machine Learning: How to Predict the Sale Price of Bulldozers

In this tutorial, we're going to walk through an example Machine Learning project where the goal is to predict the sale price of bulldozers.

This kind of problem is known as a regression problem because we're trying to determine a price, which is a continuous variable.

The data is from the Kaggle Bluebook for Bulldozers competition. We'll also use the same evaluation metric, root mean square log error, or RMSLE.

This exercise is a milestone project from the course Complete Machine Learning & Data Science Bootcamp, which I completed in March 2022.

You can find the Jupyter notebook in my GitHub repo.

The Workflow

Let's consider how we're approaching this problem, and the different steps to solve it.

We have a dataset provided to us. We'll approach the problem with the following 6-step machine learning modelling framework.

6 Step Machine Learning Modelling Framework

We'll use pandas, Matplotlib, and NumPy for data anaylsis. And we'll use Scikit-Learn for machine learning and modelling tasks.

Tools which can be used for each step of the machine learning modelling process

After working through each step of this workflow, we'll have a trained machine learning model. And ideally, this trained model will be able to accurately predict the sale price of a bulldozer given different characteristics about it.


1. Problem Definition

The question that we're trying to answer is:

Given the characteristics of a particular bulldozer and data related to past sales of similar bulldozers, how well can we predict the future sale price of this bulldozer?


2. Data

Let's have a look at the dataset from Kaggle. First, we can see that it's a time series problem. That means the dataset contains a time attribute.

In this case, we're working with historical sales data for bulldozers. The dataset includes attributes such as model type, size, sale date, and more.

The 3 datasets available are:

  1. Train.csv - Historical bulldozer sales data up to 2011 (with close to 400,000 examples with 50+ different attributes, including SalePrice which is the target variable).
  2. Valid.csv - Historical bulldozer sales data from January 1st, 2012 to April 30th, 2012 (close to 12,000 examples with the same attributes as Train.csv).
  3. Test.csv - Historical bulldozer sales data from May 1st, 2012 to November 30th, 2012 (close to 12,000 examples, but missing the SalePrice attribute, as this is what we aim to predict).

3. Evaluation

For this competition, Kaggle has set the evaluation metric to the root mean squared log error (RMSLE). The goal will be to get this value as low as possible.

To check how well our model is doing, we'll calculate the RMSLE and then compare our results to the Kaggle leaderboard.


4. Features

Features are the different parts of the data. During this step, we want to fin out what we can about the data and become more familiar with it.

A common way to do this is to create a data dictionary.

For this dataset, Kaggle provides a data dictionary containing information about what each attribute of the dataset means. We can download this file directly from the Kaggle competition page (account required) or view it on Google Sheets.

First, we'll import the dataset and start exploring it. And since we know the evaluation metric we're trying to minimise (RMSLE), our first goal is to build a baseline model and see how it compares against the competition.

Importing the data and preparing it for modeling

# Import data analysis tools 
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
Enter fullscreen mode Exit fullscreen mode

I've downloaded the data from Kaggle and stored it under the file path "./data/".

# Import the training and validation set
df = pd.read_csv("./data/bluebook-for-bulldozers/TrainAndValid.csv",
                 low_memory=False)
Enter fullscreen mode Exit fullscreen mode

We can now start exploring our dataset with df.info(). Let's use matplotlib to visualize some of the data .

fig, ax = plt.subplots()
ax.scatter(df["saledate"][:1000], df["SalePrice"][:1000]);
Enter fullscreen mode Exit fullscreen mode

Matplotlib scatter plot

df.SalePrice.plot.hist();
Enter fullscreen mode Exit fullscreen mode

Matplotlib hist plot

Parsing dates

When working with time series data, it's a good practice to make sure dates are in the format of a datetime object (a Python data type which encodes specific information about dates).

df = pd.read_csv("./data/bluebook-for-bulldozers/TrainAndValid.csv",
                 low_memory=False,
                 parse_dates=["saledate"])
Enter fullscreen mode Exit fullscreen mode

Now with parse_dates, we can see when calling df.info() that the dtype of "saledate" is datetime64[ns].

fig, ax = plt.subplots()
ax.scatter(df["saledate"][:1000], df["SalePrice"][:1000]);
Enter fullscreen mode Exit fullscreen mode

Matplotlib scatter plot

To visualize the attributes of the DataFrame more easily, a trick is to transpose it with df.head().T.

Sort DataFrame by saledate

It makes sense to sort our data by date because we're working on a time series problem where we predict future values given past examples.

# Sort DataFrame by date (in ascending order)
df.sort_values(by=["saledate"], inplace=True, ascending=True)
df.saledate.head(5)
Enter fullscreen mode Exit fullscreen mode
205615   1989-01-17
274835   1989-01-31
141296   1989-01-31
212552   1989-01-31
62755    1989-01-31
Name: saledate, dtype: datetime64[ns]
Enter fullscreen mode Exit fullscreen mode

Make a copy of the original DataFrame

Since we're going to manipulate the data, it's better to make a copy of the original DataFrame and perform our changes there. This way, we keep the original DataFrame intact in case we need it again.

# Make a copy of the original DataFrame
df_tmp = df.copy()
Enter fullscreen mode Exit fullscreen mode

Add datetime parameters for saledate column

We're doing this to enrich our dataset with as much information as possible.

Since we imported the data using read_csv() and parsed the dates using parase_dates=["saledate"], we can now access the different datetime attributes of the saledate column.

# Add datetime parameters for saledate
df_tmp["saleYear"] = df_tmp.saledate.dt.year
df_tmp["saleMonth"] = df_tmp.saledate.dt.month
df_tmp["saleDay"] = df_tmp.saledate.dt.day
df_tmp["saleDayofweek"] = df_tmp.saledate.dt.dayofweek
df_tmp["saleDayofyear"] = df_tmp.saledate.dt.dayofyear

# Drop original saledate
df_tmp.drop("saledate", axis=1, inplace=True)
Enter fullscreen mode Exit fullscreen mode

5. Modeling

We've explored our dataset and enriched it with some datetime attributes. We could spend more time doing exploratory data analysis (EDA), finding more out about the data ourselves. But instead, we'll use a machine learning model to help us do EDA.

After all, one of the objectives when starting a new machine learning project is to reduce the time between experiments. So, let's move on to the model.

Following the Scikit-Learn machine learning map, we find that a RandomForestRegressor() is a good candidate.

from sklearn.ensemble import RandomForestRegressor
Enter fullscreen mode Exit fullscreen mode

However, fitting the RandomForestRegressor() model with our training data would'nt work yet, because we've got missing numbers as well as attributes in a non-numerical format.

# Check for missing categories and different datatypes
df_tmp.info()
Enter fullscreen mode Exit fullscreen mode

We see many columns have a type object.

# Check for missing values
df_tmp.isna().sum()
Enter fullscreen mode Exit fullscreen mode

Likewise, many columns have missing values.

Convert strings to categories

One way to turn our data into numbers is to convert the columns with the string datatype into a category datatype.

To do this, we can use the pandas types API which allows us to interact and manipulate the types of data.

# These columns contain strings
for label, content in df_tmp.items():
    if pd.api.types.is_string_dtype(content):
        print(label)
Enter fullscreen mode Exit fullscreen mode

This gives us a list of all the columns for which the data is in a string format.

# This will turn all of the string values into category values
for label, content in df_tmp.items():
    if pd.api.types.is_string_dtype(content):
        df_tmp[label] = content.astype("category").cat.as_ordered()
Enter fullscreen mode Exit fullscreen mode

Calling df_tmp.info(), we see that the type object has been converted to category.

All of our data is now categorical. That means we can now turn the categories into numbers. However, we still have to deal with missing values.

In the format it's in, the data is good to be worked with, so let's save it to a file and reimport it so we can continue on.

Save processed data

# Save preprocessed data
df_tmp.to_csv("./data/bluebook-for-bulldozers/train_tmp.csv",
              index=False)

# Import preprocessed data
df_tmp = pd.read_csv("./data/bluebook-for-bulldozers/train_tmp.csv",
                     low_memory=False)
Enter fullscreen mode Exit fullscreen mode

Our preprocessed DataFrame has the columns we added to it, but it's still missing some values. We can see where values are missing with df_tmp.isna().sum().

Fill missing values

Here are two things to know about machine learning models:

  1. All of our data has to be numerical
  2. There can't be any missing values

And as we've seen using df_tmp.isna().sum() our data still has plenty of missing values.

Filling numerical value

First, we're going to fill any column with missing values with the median of that column.

for label, content in df_tmp.items():
    if pd.api.types.is_numeric_dtype(content):
        print(label)
Enter fullscreen mode Exit fullscreen mode
SalesID
SalePrice
MachineID
ModelID
datasource
auctioneerID
YearMade
MachineHoursCurrentMeter
saleYear
saleMonth
saleDay
saleDayofweek
saleDayofyear
Enter fullscreen mode Exit fullscreen mode
# Check for which numeric columns have null values
for label, content in df_tmp.items():
    if pd.api.types.is_numeric_dtype(content):
        if pd.isnull(content).sum():
            print(label)
Enter fullscreen mode Exit fullscreen mode
auctioneerID
MachineHoursCurrentMeter
Enter fullscreen mode Exit fullscreen mode
# Fill numeric rows with the median
for label, content in df_tmp.items():
    if pd.api.types.is_numeric_dtype(content):
        if pd.isnull(content).sum():
            # Add a binary column which tells if the data was missing our not
            df_tmp[label+"_is_missing"] = pd.isnull(content)
            # Fill missing numeric values with median since it's more robust than the mean
            df_tmp[label] = content.fillna(content.median())
Enter fullscreen mode Exit fullscreen mode

We can easily fill all of the missing numeric values in our dataset with the median. However, a value may be missing for a good reason. That's why we added a binary column which indicates whether the value was missing or not. This step helps to retain this information.

# Check if there's any null values
for label, content in df_tmp.items():
    if pd.api.types.is_numeric_dtype(content):
        if pd.isnull(content).sum():
            print(label)
Enter fullscreen mode Exit fullscreen mode

Good, we no longer have any missing values.

Filling and turning categorical variables to numbers

Now, let's fill the categorical values. At the same time, we'll turn them into numbers.

# Turn categorical variables into numbers
for label, content in df_tmp.items():
    # Check columns which *aren't* numeric
    if not pd.api.types.is_numeric_dtype(content):
        # Add binary column to inidicate whether sample had missing value
        df_tmp[label+"_is_missing"] = pd.isnull(content)
        # We add the +1 because pandas encodes missing categories as -1
        df_tmp[label] = pd.Categorical(content).codes+1
Enter fullscreen mode Exit fullscreen mode

All of our data is now numeric, and there are no missing values.

We should be able to build a machine learning model! Let's instantiate our RandomForestRegressor.

This will take a few minutes, which is too long for interacting with it. So, what we'll do is create a subset of rows to work with.

Splitting data into training and validation sets

According to the Kaggle data page, the validation set and test set are split according to dates. This makes sense since we're working on a time series problem.

Knowing this, randomly splitting our data into train and test sets using something like train_test_split() wouldn't work.

Instead, we'll split our data into training, validation, and test sets according to the date of the sample.

In our case:

  • Training = all samples up until 2011
  • Valid = all samples form January 1, 2012 - April 30, 2012
  • Test = all samples from May 1, 2012 - November 2012

For more on making good training, validati, and test sets, check out the post How (and why) to create a good validation set by Rachel Thomas.

# Split data into training and validation sets
df_train = df_tmp[df_tmp.saleYear != 2012]
df_val = df_tmp[df_tmp.saleYear == 2012]

len(df_train), len(df_val)
Enter fullscreen mode Exit fullscreen mode
(401125, 11573)
Enter fullscreen mode Exit fullscreen mode
# Split data into X & y
X_train, y_train = df_train.drop("SalePrice", axis=1), df_train.SalePrice
X_valid, y_valid = df_val.drop("SalePrice", axis=1), df_val.SalePrice

X_train.shape, y_train.shape, X_valid.shape, y_valid.shape
Enter fullscreen mode Exit fullscreen mode
((401125, 102), (401125,), (11573, 102), (11573,))
Enter fullscreen mode Exit fullscreen mode

Building an evaluation function

The evaluation function they use for this Kaggle competition is the root mean squared log error (RMSLE).

It's important to understand the evaluation metric we're going for. The RMSLE is the metric of choice when we care more about the consequences of being off by 10% than being off by $10. That is, when we care more about ratios than differences. The MAE (mean absolute error) is more about exact differences.

Since Scikit-Learn doesn't have a built-in function for RMSLE, we'll create our own.

We can do this by taking the square root of Scikit-Learn's mean_squared_log_error (MSLE). MSLE is the same as taking the log of mean squared error (MSE).

While we're at it, we'll also calculate the MAE and R^2.

# Create evaluation function (the competition uses Root Mean Square Log Error)
from sklearn.metrics import mean_squared_log_error, mean_absolute_error

def rmsle(y_test, y_preds):
    return np.sqrt(mean_squared_log_error(y_test, y_preds))

# Create function to evaluate our model
def show_scores(model):
    train_preds = model.predict(X_train)
    val_preds = model.predict(X_valid)
    scores = {"Training MAE": mean_absolute_error(y_train, train_preds),
              "Valid MAE": mean_absolute_error(y_valid, val_preds),
              "Training RMSLE": rmsle(y_train, train_preds),
              "Valid RMSLE": rmsle(y_valid, val_preds),
              "Training R^2": model.score(X_train, y_train),
              "Valid R^2": model.score(X_valid, y_valid)}
    return scores
Enter fullscreen mode Exit fullscreen mode

Testing our model on a subset of data

Traing an entire model would take far too long to continue experimenting as fast as we want to.

So, what we'll do is take a sample of the training set and tune the hyperparameters on that subset before training a larger model.

If our experiments are taking longer than 10-seconds, we should try to speed things up, by sampling less data or using a faster computer.

len(X_train)
Enter fullscreen mode Exit fullscreen mode
401125
Enter fullscreen mode Exit fullscreen mode

Let's alter the number of samples each n_estimator in the RandomForestRegressor sees using the max_samples parameter.

# Change max samples in RandomForestRegressor
model = RandomForestRegressor(n_jobs=-1,
                              max_samples=10000)
Enter fullscreen mode Exit fullscreen mode

Setting max_samples to 10,000 means every n_estimator (by default 100) in our RandomForestRegressor will only see 10,000 random samples from our DataFrame, instead of the entire 400,000.

In other words, we'll be looking at 40x less samples, which means we'll get faster computation speeds. Though, we should expect our results to worsen (because the model has less samples to learn patterns from).

%%time
# Cutting down the max number of samples each tree can see improves training time
model.fit(X_train, y_train)
Enter fullscreen mode Exit fullscreen mode
CPU times: user 43.5 s, sys: 1.39 s, total: 44.9 s
Wall time: 9.3 s
RandomForestRegressor(max_samples=10000, n_jobs=-1)
Enter fullscreen mode Exit fullscreen mode
show_scores(model)
Enter fullscreen mode Exit fullscreen mode
{'Training MAE': 5557.476079825491,
 'Valid MAE': 7145.432947377517,
 'Training RMSLE': 0.2576354132701014,
 'Valid RMSLE': 0.2932065424618794,
 'Training R^2': 0.860725458431776,
 'Valid R^2': 0.8334321554498396}
Enter fullscreen mode Exit fullscreen mode

Hyperparameter tuning with RandomizedSearchCV

We can increase n_iter to try more combinations of hyperparameters but, in our case, we'll try 20 and see where it gets us. We're trying to reduce the amount of time between experiments.

%%time
from sklearn.model_selection import RandomizedSearchCV

# Different RandomForestClassifier hyperparameters
rf_grid = {"n_estimators": np.arange(10, 100, 10),
           "max_depth": [None, 3, 5, 10],
           "min_samples_split": np.arange(2, 20, 2),
           "min_samples_leaf": np.arange(1, 20, 2),
           "max_features": [0.5, 1, "sqrt", "auto"],
           "max_samples": [10000]}

rs_model = RandomizedSearchCV(RandomForestRegressor(),
                              param_distributions=rf_grid,
                              n_iter=20,
                              cv=5,
                              verbose=True)

rs_model.fit(X_train, y_train)
Enter fullscreen mode Exit fullscreen mode
Fitting 5 folds for each of 20 candidates, totalling 100 fits
CPU times: user 9min 43s, sys: 38.7 s, total: 10min 22s
Wall time: 10min 30s
RandomizedSearchCV(cv=5, estimator=RandomForestRegressor(), n_iter=20,
                   param_distributions={'max_depth': [None, 3, 5, 10],
                                        'max_features': [0.5, 1, 'sqrt',
                                                         'auto'],
                                        'max_samples': [10000],
                                        'min_samples_leaf': array([ 1,  3,  5,  7,  9, 11, 13, 15, 17, 19]),
                                        'min_samples_split': array([ 2,  4,  6,  8, 10, 12, 14, 16, 18]),
                                        'n_estimators': array([10, 20, 30, 40, 50, 60, 70, 80, 90])},
                   verbose=True)
Enter fullscreen mode Exit fullscreen mode
# Find the best parameters from the RandomizedSearch 
rs_model.best_params_
Enter fullscreen mode Exit fullscreen mode
{'n_estimators': 60,
 'min_samples_split': 6,
 'min_samples_leaf': 1,
 'max_samples': 10000,
 'max_features': 'auto',
 'max_depth': None}
Enter fullscreen mode Exit fullscreen mode
# Evaluate the RandomizedSearch model
show_scores(rs_model)
Enter fullscreen mode Exit fullscreen mode
{'Training MAE': 5623.00659389663,
 'Valid MAE': 7221.500628014852,
 'Training RMSLE': 0.2600757896526271,
 'Valid RMSLE': 0.2938688696407002,
 'Training R^2': 0.857223776894714,
 'Valid R^2': 0.8302372473994826}
Enter fullscreen mode Exit fullscreen mode

Train a model with the best parameters

The course instructor, Daniel Bourke, tried 100 different combinations of hyperparameters (setting n_iter to 100 in RandomizedSearchCV) and found that the best results came from the ones we'll use below.

Note: This kind of search with n_iter = 100 can take about 2 hours on a powerful laptop. The search above with n_iter = 10 took only 10 minutes on my MacBook Air.

We'll instantiate a new model with these discovered hyperparameters and reset the max_samples back to its original value.

%%time
# Most ideal hyperparameters
ideal_model = RandomForestRegressor(n_estimators=90,
                                    min_samples_leaf=1,
                                    min_samples_split=14,
                                    max_features=0.5,
                                    n_jobs=-1,
                                    max_samples=None)
ideal_model.fit(X_train, y_train)
Enter fullscreen mode Exit fullscreen mode
CPU times: user 10min 54s, sys: 5.69 s, total: 11min
Wall time: 1min 39s
RandomForestRegressor(max_features=0.5, min_samples_split=14, n_estimators=90,
                      n_jobs=-1)
Enter fullscreen mode Exit fullscreen mode
show_scores(ideal_model)
Enter fullscreen mode Exit fullscreen mode
{'Training MAE': 2927.714226014933,
 'Valid MAE': 5893.170337485774,
 'Training RMSLE': 0.14329949046791518,
 'Valid RMSLE': 0.2436971060471662,
 'Training R^2': 0.9596781605243438,
 'Valid R^2': 0.884359696206594}
Enter fullscreen mode Exit fullscreen mode

With these hyperparameters and when using all the samples, we can see an improvement to our model's performance.

We could make a faster model by altering some of the hyperparameters. Particularly by lowering n_estimators since each increase in n_estimators is like building another small model.

However, lowering n_estimators or altering other hyperparameters may lead to poorer results.

Make predictions on test data

We've got a trained model so let's make predictions on the test data.

What we've done so far is training our model on data prior to 2011. However, the test data is from May 2012 to November 2012.

What we'll do is use the patterns that our model has learned on the training data to predict the sale price of a bulldozer with characteristics it has never seen before, but that are assumed to be similar to those found in the training data.

df_test = pd.read_csv("./data/bluebook-for-bulldozers/Test.csv",
                      parse_dates=["saledate"])
Enter fullscreen mode Exit fullscreen mode

Unfortunately, the test data isn't in the same format as our other data, so we have to fix it. Let's create a function to preprocess our data.

Preprocessing the data

Our model has been trained on data formatted in a certain way. So, to make predictions on the test data, we need to take the same steps we used to preprocess the training data to preprocess the test data.

Note: Whatever we do to the training data, you have to do the same to the test data.

Let's create a function for doing so (by copying the preprocessing steps we used above).

def preprocess_data(df):
    # Add datetime parameters for saledate
    df["saleYear"] = df.saledate.dt.year
    df["saleMonth"] = df.saledate.dt.month
    df["saleDay"] = df.saledate.dt.day
    df["saleDayofweek"] = df.saledate.dt.dayofweek
    df["saleDayofyear"] = df.saledate.dt.dayofyear

    # Drop original saledate
    df.drop("saledate", axis=1, inplace=True)

    # Fill numeric rows with the median
    for label, content in df.items():
        if pd.api.types.is_numeric_dtype(content):
            if pd.isnull(content).sum():
                df[label+"_is_missing"] = pd.isnull(content)
                df[label] = content.fillna(content.median())

        # Turn categorical variables into numbers
        if not pd.api.types.is_numeric_dtype(content):
            df[label+"_is_missing"] = pd.isnull(content)
            # We add the +1 because pandas encodes missing categories as -1
            df[label] = pd.Categorical(content).codes+1        

    return df
Enter fullscreen mode Exit fullscreen mode

Note: This function could break if the test data has different missing values compared to the training data.

Now that we've got a function for preprocessing data, let's preprocess the test dataset into the same format as our training dataset.

We can see that our test dataset (after preprocessing) has 101 columns. But, our training dataset X_train has 102 columns (after preprocessing). Let's find the difference.

# We can find how the columns differ using sets
set(X_train.columns) - set(df_test.columns)
Enter fullscreen mode Exit fullscreen mode
{'auctioneerID_is_missing'}
Enter fullscreen mode Exit fullscreen mode

There wasn't any missing auctioneerID field in the test dataset.

To fix this, we'll add a column to the test dataset called auctioneerID_is_missing and fill it with False, since none of the auctioneerID fields are missing in the test dataset.

# Match test dataset columns to training dataset
df_test["auctioneerID_is_missing"] = False
Enter fullscreen mode Exit fullscreen mode

Now the test dataset matches the training dataset and we should be able to make predictions on it using our trained model.

# Make predictions on the test dataset using the best model
test_preds = ideal_model.predict(df_test)
Enter fullscreen mode Exit fullscreen mode

When looking at the Kaggle submission requirements, we see that to make a submission, the data must be in a certain format: a DataFrame containing the SalesID and the predicted SalePrice of the bulldozer.

# Create DataFrame compatible with Kaggle submission requirements
df_preds = pd.DataFrame()
df_preds["SalesID"] = df_test["SalesID"]
df_preds["SalePrice"] = test_preds
Enter fullscreen mode Exit fullscreen mode
# Export to csv...
df_preds.to_csv("./data/bluebook-for-bulldozers/predictions.csv",
               index=False)
Enter fullscreen mode Exit fullscreen mode

Feature Importance

By now, we've built a model which is able to make predictions. The people we share these predictions with might be curious to know what parts of the data led to these predictions.

This is where feature importance comes in. Feature importance seeks to find out which attributes of the data were the most important in predicting the target variable.

In our case, which bulldozer sale attributes had the most weight in the model to predict its overall sale price?

Beware: the default feature importances for random forests can lead to non-ideal results.

To figure out which features were most important in a machine learning model, a good idea is to search something like "[MODEL NAME] feature importance".

Doing this for our RandomForestRegressor leads us to find the feature_importances_ attribute.

Let's check it out.

# Find feature importance of our best model
ideal_model.feature_importances_

# Install Seaborn package in current environment (if you don't have it)
# import sys
# !conda install --yes --prefix {sys.prefix} seaborn

import seaborn as sns

# Helper function for plotting feature importance
def plot_features(columns, importances, n=20):
    df = (pd.DataFrame({"features": columns,
                        "feature_importance": importances})
          .sort_values("feature_importance", ascending=False)
          .reset_index(drop=True))

    sns.barplot(x="feature_importance",
                y="features",
                data=df[:n],
                orient="h")

plot_features(X_train.columns, ideal_model.feature_importances_);
Enter fullscreen mode Exit fullscreen mode

Features

Top comments (0)