How a Decision Tree Decides
Impurity, greedy splitting, and the only model you can read end to end
Every model so far draws a single boundary through the whole space. A decision tree does something different: it asks a question, splits the data, and asks a different question of each half.
How a split gets chosen
The tree considers every feature and every possible cut point, and keeps the one that most reduces impurity.
def gini(labels):
labels = np.asarray(labels)
if len(labels) == 0:
return 0.0
p1 = labels.mean()
return 1 - (p1 ** 2 + (1 - p1) ** 2)
print('all one class %.4f' % gini([1, 1, 1, 1]))
print('three to one %.4f' % gini([1, 1, 1, 0]))
print('even mix %.4f' % gini([1, 1, 0, 0]))
three to one 0.3750
even mix 0.5000
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
import numpy as np
def gini(labels):
labels = np.asarray(labels)
if len(labels) == 0:
return 0.0
p1 = labels.mean()
return 1 - (p1 ** 2 + (1 - p1) ** 2)
y = df[TARGET].to_numpy()
x = df['tenure_months'].to_numpy()
parent = gini(y)
print('impurity before any split: %.4f' % parent)
print('\n%12s %10s %10s %12s' % ('split at', 'left n', 'right n', 'gain'))
best = None
for cut in [6, 12, 18, 24, 36, 48]:
left, right = y[x <= cut], y[x > cut]
weighted = (len(left) * gini(left) + len(right) * gini(right)) / len(y)
gain = parent - weighted
if best is None or gain > best[1]:
best = (cut, gain)
print('%12d %10d %10d %12.5f' % (cut, len(left), len(right), gain))
print('\nbest of these: tenure <= %d, gain %.5f' % best)
split at left n right n gain
6 345 2655 0.00723
12 949 2051 0.02653
18 1545 1455 0.03244
24 1918 1082 0.03664
36 2420 580 0.02537
48 2686 314 0.01562
best of these: tenure <= 24, gain 0.03664
That is the entire algorithm, applied recursively. Split, then repeat on each side, until a stopping rule fires.
Let scikit-learn do it and read the result
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(scale=True):
num_steps = [('i', SimpleImputer(strategy='median'))]
if scale:
num_steps.append(('s', StandardScaler()))
return ColumnTransformer([
('num', Pipeline(num_steps), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
from sklearn.model_selection import train_test_split
X, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
from sklearn.tree import DecisionTreeClassifier, export_text
tree = Pipeline([('prep', preprocessor(scale=False)),
('clf', DecisionTreeClassifier(max_depth=3,
random_state=42))]).fit(X_tr, y_tr)
names = list(tree.named_steps['prep'].get_feature_names_out())
print(export_text(tree.named_steps['clf'], feature_names=names))
| |--- num__tenure_months <= 17.50
| | |--- cat__contract_One Year <= 0.50
| | | |--- class: 0
| | |--- cat__contract_One Year > 0.50
| | | |--- class: 0
| |--- num__tenure_months > 17.50
| | |--- cat__contract_Two Year <= 0.50
| | | |--- class: 0
| | |--- cat__contract_Two Year > 0.50
| | | |--- class: 0
|--- cat__contract_Month-To-Month > 0.50
| |--- num__monthly_charges <= 51.02
| | |--- num__tenure_months <= 9.50
| | | |--- class: 0
| | |--- num__tenure_months > 9.50
| | | |--- class: 0
| |--- num__monthly_charges > 51.02
| | |--- num__tenure_months <= 32.50
| | | |--- class: 1
| | |--- num__tenure_months > 32.50
| | | |--- class: 0
This is why people like trees
You can read the whole model. Every prediction is a path from the root to a leaf, and you can print that path for any customer and hand it to someone who has never heard of machine learning. No other model in this course offers that.
Trees do not need scaling, and barely care about outliers
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(scale=True):
num_steps = [('i', SimpleImputer(strategy='median'))]
if scale:
num_steps.append(('s', StandardScaler()))
return ColumnTransformer([
('num', Pipeline(num_steps), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
from sklearn.model_selection import train_test_split
X, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import roc_auc_score
for scale in [True, False]:
m = Pipeline([('prep', preprocessor(scale=scale)),
('clf', DecisionTreeClassifier(max_depth=5,
random_state=42))]).fit(X_tr, y_tr)
print('scaled=%-6s AUC %.6f'
% (scale, roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])))
scaled=False AUC 0.774524
Identical to six decimal places. A split asks “is this value above the threshold”, and scaling preserves order, so it changes nothing. The same reasoning is why the 97-call outlier in week 2 moved a tree not at all.
Gini or entropy
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(scale=True):
num_steps = [('i', SimpleImputer(strategy='median'))]
if scale:
num_steps.append(('s', StandardScaler()))
return ColumnTransformer([
('num', Pipeline(num_steps), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
from sklearn.model_selection import train_test_split
X, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import roc_auc_score
for criterion in ['gini', 'entropy', 'log_loss']:
m = Pipeline([('prep', preprocessor(scale=False)),
('clf', DecisionTreeClassifier(criterion=criterion,
max_depth=5,
random_state=42))]).fit(X_tr, y_tr)
print('%-9s AUC %.4f'
% (criterion, roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])))
entropy AUC 0.7684
log_loss AUC 0.7684
Practically indistinguishable, which is the usual result. Do not spend time tuning the criterion; spend it on depth, which is tomorrow.