Decision Trees

Decision tree ensembles, as the name suggests, rely on decision trees. So let’s start there! A decision tree asks a series of binary (that is, yes or no) questions about the data. After each question the data at that part of the tree is split between a “yes” and a “no” branch, as shown in <>. After one or more questions, either a prediction can be made on the basis of all previous answers or another question is required.

An example of decision tree

This sequence of questions is now a procedure for taking any data item, whether an item from the training set or a new one, and assigning that item to a group. Namely, after asking and answering the questions, we can say the item belongs to the same group as all the other training data items that yielded the same set of answers to the questions. But what good is this? The goal of our model is to predict values for items, not to assign them into groups from the training dataset. The value is that we can now assign a prediction value for each of these groups—for regression, we take the target mean of the items in the group.

Let’s consider how we find the right questions to ask. Of course, we wouldn’t want to have to create all these questions ourselves—that’s what computers are for! The basic steps to train a decision tree can be written down very easily:

  1. Loop through each column of the dataset in turn.
  2. For each column, loop through each possible level of that column in turn.
  3. Try splitting the data into two groups, based on whether they are greater than or less than that value (or if it is a categorical variable, based on whether they are equal to or not equal to that level of that categorical variable).
  4. Find the average sale price for each of those two groups, and see how close that is to the actual sale price of each of the items of equipment in that group. That is, treat this as a very simple “model” where our predictions are simply the average sale price of the item’s group.
  5. After looping through all of the columns and all the possible levels for each, pick the split point that gave the best predictions using that simple model.
  6. We now have two different groups for our data, based on this selected split. Treat each of these as separate datasets, and find the best split for each by going back to step 1 for each group.
  7. Continue this process recursively, until you have reached some stopping criterion for each group—for instance, stop splitting a group further when it has only 20 items in it.

Although this is an easy enough algorithm to implement yourself (and it is a good exercise to do so), we can save some time by using the implementation built into sklearn.

First, however, we need to do a little data preparation.

A: Here’s a productive question to ponder. If you consider that the procedure for defining a decision tree essentially chooses one sequence of splitting questions about variables, you might ask yourself, how do we know this procedure chooses the correct sequence? The rule is to choose the splitting question that produces the best split (i.e., that most accurately separates the items into two distinct categories), and then to apply the same rule to the groups that split produces, and so on. This is known in computer science as a “greedy” approach. Can you imagine a scenario in which asking a “less powerful” splitting question would enable a better split down the road (or should I say down the trunk!) and lead to a better result overall?

Handling Dates

The first piece of data preparation we need to do is to enrich our representation of dates. The fundamental basis of the decision tree that we just described is bisection— dividing a group into two. We look at the ordinal variables and divide up the dataset based on whether the variable’s value is greater (or lower) than a threshold, and we look at the categorical variables and divide up the dataset based on whether the variable’s level is a particular level. So this algorithm has a way of dividing up the dataset based on both ordinal and categorical data.

But how does this apply to a common data type, the date? You might want to treat a date as an ordinal value, because it is meaningful to say that one date is greater than another. However, dates are a bit different from most ordinal values in that some dates are qualitatively different from others in a way that that is often relevant to the systems we are modeling.

In order to help our algorithm handle dates intelligently, we’d like our model to know more than whether a date is more recent or less recent than another. We might want our model to make decisions based on that date’s day of the week, on whether a day is a holiday, on what month it is in, and so forth. To do this, we replace every date column with a set of date metadata columns, such as holiday, day of week, and month. These columns provide categorical data that we suspect will be useful.

fastai comes with a function that will do this for us—we just have to pass a column name that contains dates:

In [15]:

  1. df = add_datepart(df, 'saledate')

Let’s do the same for the test set while we’re there:

In [16]:

  1. df_test = pd.read_csv(path/'Test.csv', low_memory=False)
  2. df_test = add_datepart(df_test, 'saledate')

We can see that there are now lots of new columns in our DataFrame:

In [17]:

  1. ' '.join(o for o in df.columns if o.startswith('sale'))

Out[17]:

  1. 'saleWeek saleYear saleMonth saleDay saleDayofweek saleDayofyear saleIs_month_end saleIs_month_start saleIs_quarter_end saleIs_quarter_start saleIs_year_end saleIs_year_start saleElapsed'

This is a good first step, but we will need to do a bit more cleaning. For this, we will use fastai objects called TabularPandas and TabularProc.

Using TabularPandas and TabularProc

A second piece of preparatory processing is to be sure we can handle strings and missing data. Out of the box, sklearn cannot do either. Instead we will use fastai’s class TabularPandas, which wraps a Pandas DataFrame and provides a few conveniences. To populate a TabularPandas, we will use two TabularProcs, Categorify and FillMissing. A TabularProc is like a regular Transform, except that:

  • It returns the exact same object that’s passed to it, after modifying the object in place.
  • It runs the transform once, when data is first passed in, rather than lazily as the data is accessed.

Categorify is a TabularProc that replaces a column with a numeric categorical column. FillMissing is a TabularProc that replaces missing values with the median of the column, and creates a new Boolean column that is set to True for any row where the value was missing. These two transforms are needed for nearly every tabular dataset you will use, so this is a good starting point for your data processing:

In [18]:

  1. procs = [Categorify, FillMissing]

TabularPandas will also handle splitting the dataset into training and validation sets for us. However we need to be very careful about our validation set. We want to design it so that it is like the test set Kaggle will use to judge the contest.

Recall the distinction between a validation set and a test set, as discussed in <>. A validation set is data we hold back from training in order to ensure that the training process does not overfit on the training data. A test set is data that is held back even more deeply, from us ourselves, in order to ensure that we don’t overfit on the validation data, as we explore various model architectures and hyperparameters.

We don’t get to see the test set. But we do want to define our validation data so that it has the same sort of relationship to the training data as the test set will have.

In some cases, just randomly choosing a subset of your data points will do that. This is not one of those cases, because it is a time series.

If you look at the date range represented in the test set, you will discover that it covers a six-month period from May 2012, which is later in time than any date in the training set. This is a good design, because the competition sponsor will want to ensure that a model is able to predict the future. But it means that if we are going to have a useful validation set, we also want the validation set to be later in time than the training set. The Kaggle training data ends in April 2012, so we will define a narrower training dataset which consists only of the Kaggle training data from before November 2011, and we’ll define a validation set consisting of data from after November 2011.

To do this we use np.where, a useful function that returns (as the first element of a tuple) the indices of all True values:

In [19]:

  1. cond = (df.saleYear<2011) | (df.saleMonth<10)
  2. train_idx = np.where( cond)[0]
  3. valid_idx = np.where(~cond)[0]
  4. splits = (list(train_idx),list(valid_idx))

TabularPandas needs to be told which columns are continuous and which are categorical. We can handle that automatically using the helper function cont_cat_split:

In [20]:

  1. cont,cat = cont_cat_split(df, 1, dep_var=dep_var)

In [21]:

  1. to = TabularPandas(df, procs, cat, cont, y_names=dep_var, splits=splits)

A TabularPandas behaves a lot like a fastai Datasets object, including providing train and valid attributes:

In [22]:

  1. len(to.train),len(to.valid)

Out[22]:

  1. (404710, 7988)

We can see that the data is still displayed as strings for categories (we only show a few columns here because the full table is too big to fit on a page):

In [23]:

  1. #hide_output
  2. to.show(3)
saleWeekUsageBandfiModelDescfiBaseModelfiSecondaryDescfiModelSeriesfiModelDescriptorProductSizefiProductClassDescstateProductGroupProductGroupDescDrive_SystemEnclosureForksPad_TypeRide_ControlStickTransmissionTurbochargedBlade_ExtensionBlade_WidthEnclosure_TypeEngine_HorsepowerHydraulicsPushblockRipperScarifierTip_ControlTire_SizeCouplerCoupler_SystemGrouser_TracksHydraulics_FlowTrack_TypeUndercarriage_Pad_WidthStick_LengthThumbPattern_ChangerGrouser_TypeBackhoe_MountingBlade_TypeTravel_ControlsDifferential_TypeSteering_ControlssaleIs_month_endsaleIs_month_startsaleIs_quarter_endsaleIs_quarter_startsaleIs_year_endsaleIs_year_startsaleElapsedauctioneerID_naMachineHoursCurrentMeter_naSalesIDMachineIDModelIDdatasourceauctioneerIDYearMadeMachineHoursCurrentMetersaleYearsaleMonthsaleDaysaleDayofweeksaleDayofyearSalePrice
046Low521D521D#na##na##na#Wheel Loader - 110.0 to 120.0 HorsepowerAlabamaWLWheel Loader#na#EROPS w ACNone or Unspecified#na#None or Unspecified#na##na##na##na##na##na##na#2 Valve#na##na##na##na#None or UnspecifiedNone or Unspecified#na##na##na##na##na##na##na##na##na##na##na##na#StandardConventionalFalseFalseFalseFalseFalseFalse1163635200FalseFalse113924699908931571213.0200468.020061116332011.097410
113Low950FII950FII#na#MediumWheel Loader - 150.0 to 175.0 HorsepowerNorth CarolinaWLWheel Loader#na#EROPS w ACNone or Unspecified#na#None or Unspecified#na##na##na##na##na##na##na#2 Valve#na##na##na##na#23.5None or Unspecified#na##na##na##na##na##na##na##na##na##na##na##na#StandardConventionalFalseFalseFalseFalseFalseFalse1080259200FalseFalse1139248117657771213.019964640.0200432648610.950807
29High226226#na##na##na##na#Skid Steer Loader - 1351.0 to 1601.0 Lb Operating CapacityNew YorkSSLSkid Steer Loaders#na#OROPSNone or Unspecified#na##na##na##na##na##na##na##na##na#Auxiliary#na##na##na##na##na#None or UnspecifiedNone or UnspecifiedNone or UnspecifiedStandard#na##na##na##na##na##na##na##na##na##na##na#FalseFalseFalseFalseFalseFalse1077753600FalseFalse113924943480870091213.020012838.020042263579.210340

In [24]:

  1. #hide_input
  2. to1 = TabularPandas(df, procs, ['state', 'ProductGroup', 'Drive_System', 'Enclosure'], [], y_names=dep_var, splits=splits)
  3. to1.show(3)
stateProductGroupDrive_SystemEnclosureSalePrice
0AlabamaWL#na#EROPS w AC11.097410
1North CarolinaWL#na#EROPS w AC10.950807
2New YorkSSL#na#OROPS9.210340

However, the underlying items are all numeric:

In [25]:

  1. #hide_output
  2. to.items.head(3)

Out[25]:

SalesIDSalePriceMachineIDsaleWeeksaleIs_year_startsaleElapsedauctioneerID_naMachineHoursCurrentMeter_na
0113924611.097410999089461264711
1113924810.950807117657131214811
211392499.21034043480891213111

3 rows × 67 columns

In [26]:

  1. #hide_input
  2. to1.items[['state', 'ProductGroup', 'Drive_System', 'Enclosure']].head(3)

Out[26]:

stateProductGroupDrive_SystemEnclosure
01603
133603
232306

The conversion of categorical columns to numbers is done by simply replacing each unique level with a number. The numbers associated with the levels are chosen consecutively as they are seen in a column, so there’s no particular meaning to the numbers in categorical columns after conversion. The exception is if you first convert a column to a Pandas ordered category (as we did for ProductSize earlier), in which case the ordering you chose is used. We can see the mapping by looking at the classes attribute:

In [27]:

  1. to.classes['ProductSize']

Out[27]:

  1. ['#na#', 'Large', 'Large / Medium', 'Medium', 'Small', 'Mini', 'Compact']

Since it takes a minute or so to process the data to get to this point, we should save it—that way in the future we can continue our work from here without rerunning the previous steps. fastai provides a save method that uses Python’s pickle system to save nearly any Python object:

In [28]:

  1. save_pickle(path/'to.pkl',to)

To read this back later, you would type:

  1. to = (path/'to.pkl').load()

Now that all this preprocessing is done, we are ready to create a decision tree.

Creating the Decision Tree

To begin, we define our independent and dependent variables:

In [29]:

  1. #hide
  2. to = load_pickle(path/'to.pkl')

In [30]:

  1. xs,y = to.train.xs,to.train.y
  2. valid_xs,valid_y = to.valid.xs,to.valid.y

Now that our data is all numeric, and there are no missing values, we can create a decision tree:

In [31]:

  1. m = DecisionTreeRegressor(max_leaf_nodes=4)
  2. m.fit(xs, y);

To keep it simple, we’ve told sklearn to just create four leaf nodes. To see what it’s learned, we can display the tree:

In [40]:

  1. draw_tree(m, xs, size=10, leaves_parallel=True, precision=2)

Out[40]:

Decision Trees - 图2

Understanding this picture is one of the best ways to understand decision trees, so we will start at the top and explain each part step by step.

The top node represents the initial model before any splits have been done, when all the data is in one group. This is the simplest possible model. It is the result of asking zero questions and will always predict the value to be the average value of the whole dataset. In this case, we can see it predicts a value of 10.10 for the logarithm of the sales price. It gives a mean squared error of 0.48. The square root of this is 0.69. (Remember that unless you see m_rmse, or a root mean squared error, then the value you are looking at is before taking the square root, so it is just the average of the square of the differences.) We can also see that there are 404,710 auction records in this group—that is the total size of our training set. The final piece of information shown here is the decision criterion for the best split that was found, which is to split based on the coupler_system column.

Moving down and to the left, this node shows us that there were 360,847 auction records for equipment where coupler_system was less than 0.5. The average value of our dependent variable in this group is 10.21. Moving down and to the right from the initial model takes us to the records where coupler_system was greater than 0.5.

The bottom row contains our leaf nodes: the nodes with no answers coming out of them, because there are no more questions to be answered. At the far right of this row is the node containing records where coupler_system was greater than 0.5. The average value here is 9.21, so we can see the decision tree algorithm did find a single binary decision that separated high-value from low-value auction results. Asking only about coupler_system predicts an average value of 9.21 versus 10.1.

Returning back to the top node after the first decision point, we can see that a second binary decision split has been made, based on asking whether YearMade is less than or equal to 1991.5. For the group where this is true (remember, this is now following two binary decisions, based on coupler_system and YearMade) the average value is 9.97, and there are 155,724 auction records in this group. For the group of auctions where this decision is false, the average value is 10.4, and there are 205,123 records. So again, we can see that the decision tree algorithm has successfully split our more expensive auction records into two more groups which differ in value significantly.

We can show the same information using Terence Parr’s powerful dtreeviz library:

In [33]:

  1. samp_idx = np.random.permutation(len(y))[:500]
  2. dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var,
  3. fontname='DejaVu Sans', scale=1.6, label_fontsize=10,
  4. orientation='LR')

Out[33]:

Decision Trees - 图3

This shows a chart of the distribution of the data for each split point. We can clearly see that there’s a problem with our YearMade data: there are bulldozers made in the year 1000, apparently! Presumably this is actually just a missing value code (a value that doesn’t otherwise appear in the data and that is used as a placeholder in cases where a value is missing). For modeling purposes, 1000 is fine, but as you can see this outlier makes visualization of the values we are interested in more difficult. So, let’s replace it with 1950:

In [34]:

  1. xs.loc[xs['YearMade']<1900, 'YearMade'] = 1950
  2. valid_xs.loc[valid_xs['YearMade']<1900, 'YearMade'] = 1950

That change makes the split much clearer in the tree visualization, even although it doesn’t actually change the result of the model in any significant way. This is a great example of how resilient decision trees are to data issues!

In [35]:

  1. m = DecisionTreeRegressor(max_leaf_nodes=4).fit(xs, y)
  2. dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var,
  3. fontname='DejaVu Sans', scale=1.6, label_fontsize=10,
  4. orientation='LR')

Out[35]:

Decision Trees - 图4

Let’s now have the decision tree algorithm build a bigger tree. Here, we are not passing in any stopping criteria such as max_leaf_nodes:

In [36]:

  1. m = DecisionTreeRegressor()
  2. m.fit(xs, y);

We’ll create a little function to check the root mean squared error of our model (m_rmse), since that’s how the competition was judged:

In [37]:

  1. def r_mse(pred,y): return round(math.sqrt(((pred-y)**2).mean()), 6)
  2. def m_rmse(m, xs, y): return r_mse(m.predict(xs), y)

In [38]:

  1. m_rmse(m, xs, y)

Out[38]:

  1. 0.0

So, our model is perfect, right? Not so fast… remember we really need to check the validation set, to ensure we’re not overfitting:

In [39]:

  1. m_rmse(m, valid_xs, valid_y)

Out[39]:

  1. 0.331466

Oops—it looks like we might be overfitting pretty badly. Here’s why:

In [40]:

  1. m.get_n_leaves(), len(xs)

Out[40]:

  1. (324544, 404710)

We’ve got nearly as many leaf nodes as data points! That seems a little over-enthusiastic. Indeed, sklearn’s default settings allow it to continue splitting nodes until there is only one item in each leaf node. Let’s change the stopping rule to tell sklearn to ensure every leaf node contains at least 25 auction records:

In [41]:

  1. m = DecisionTreeRegressor(min_samples_leaf=25)
  2. m.fit(to.train.xs, to.train.y)
  3. m_rmse(m, xs, y), m_rmse(m, valid_xs, valid_y)

Out[41]:

  1. (0.248562, 0.323396)

That looks much better. Let’s check the number of leaves again:

In [42]:

  1. m.get_n_leaves()

Out[42]:

  1. 12397

Much more reasonable!

A: Here’s my intuition for an overfitting decision tree with more leaf nodes than data items. Consider the game Twenty Questions. In that game, the chooser secretly imagines an object (like, “our television set”), and the guesser gets to pose 20 yes or no questions to try to guess what the object is (like “Is it bigger than a breadbox?”). The guesser is not trying to predict a numerical value, but just to identify a particular object out of the set of all imaginable objects. When your decision tree has more leaves than there are possible objects in your domain, then it is essentially a well-trained guesser. It has learned the sequence of questions needed to identify a particular data item in the training set, and it is “predicting” only by describing that item’s value. This is a way of memorizing the training set—i.e., of overfitting.

Building a decision tree is a good way to create a model of our data. It is very flexible, since it can clearly handle nonlinear relationships and interactions between variables. But we can see there is a fundamental compromise between how well it generalizes (which we can achieve by creating small trees) and how accurate it is on the training set (which we can achieve by using large trees).

So how do we get the best of both worlds? We’ll show you right after we handle an important missing detail: how to handle categorical variables.

Categorical Variables

In the previous chapter, when working with deep learning networks, we dealt with categorical variables by one-hot encoding them and feeding them to an embedding layer. The embedding layer helped the model to discover the meaning of the different levels of these variables (the levels of a categorical variable do not have an intrinsic meaning, unless we manually specify an ordering using Pandas). In a decision tree, we don’t have embeddings layers—so how can these untreated categorical variables do anything useful in a decision tree? For instance, how could something like a product code be used?

The short answer is: it just works! Think about a situation where there is one product code that is far more expensive at auction than any other one. In that case, any binary split will result in that one product code being in some group, and that group will be more expensive than the other group. Therefore, our simple decision tree building algorithm will choose that split. Later during training the algorithm will be able to further split the subgroup that contains the expensive product code, and over time, the tree will home in on that one expensive product.

It is also possible to use one-hot encoding to replace a single categorical variable with multiple one-hot-encoded columns, where each column represents a possible level of the variable. Pandas has a get_dummies method which does just that.

However, there is not really any evidence that such an approach improves the end result. So, we generally avoid it where possible, because it does end up making your dataset harder to work with. In 2019 this issue was explored in the paper “Splitting on Categorical Predictors in Random Forests” by Marvin Wright and Inke König, which said:

: The standard approach for nominal predictors is to consider all $2^{k-1} − 1$ 2-partitions of the k predictor categories. However, this exponential relationship produces a large number of potential splits to be evaluated, increasing computational complexity and restricting the possible number of categories in most implementations. For binary classification and regression, it was shown that ordering the predictor categories in each split leads to exactly the same splits as the standard approach. This reduces computational complexity because only k − 1 splits have to be considered for a nominal predictor with k categories.

Now that you understand how decisions tree work, it’s time for the best-of-both-worlds solution: random forests.