The Big Picture — What Decision Trees Do & Why
Decision trees are one of the most interpretable machine learning algorithms. They recursively partition the feature space into regions and assign predictions to each region. Unlike black-box models, you can trace every prediction back to a sequence of human-readable rules.
Why Decision Trees Matter in AdTech
- Interpretability: Regulators and stakeholders can understand why an ad was rejected or approved.
- Feature Importance: Automatically ranks which signals matter most (CTR predictors, quality signals, etc.)
- Non-linear Relationships: Captures complex interactions without explicit feature engineering.
- Categorical & Numeric: Handles both types natively without preprocessing.
- Foundation for Ensembles: Individual trees form the basis of Random Forests and XGBoost, often state-of-the-art in AdTech.
Classification Trees
Predict discrete classes (Ad Approved, Rejected, Review). Minimize Gini impurity or entropy.
Regression Trees
Predict continuous values (Ad CTR, CPC, quality score). Minimize variance reduction.
AdTech Example: Ad Quality Scoring for Bing Ads
Imagine you're building a quality classifier for Bing Ads. When an advertiser submits a new ad, you need to decide: Approved, Needs Review, or Rejected. Here's the typical dataset:
| Feature | Type | Example | Why It Matters |
|---|---|---|---|
| CTR (Click-Through Rate) | Numeric | 0.05 | Higher CTR → better ad quality |
| Quality Score | Numeric [1-10] | 8 | Google/Bing quality assessment |
| Landing Page Relevance | Numeric [0-1] | 0.85 | Ad text matches landing page |
| Domain Age | Numeric (days) | 1825 | Established domains are safer |
| Has HTTPS | Binary | 1 (Yes) | Security signal |
| Prior Violations | Integer | 0 | History of policy compliance |
| Ad Category | Categorical | "Technology" | Different rules for different categories |
Training Data Example
With 1,000 labeled ads (Approved=1, Rejected=0), a decision tree learns the decision boundaries:
Ad_ID CTR Quality Relevance Domain_Age HTTPS Violations Category Target
1 0.08 9 0.92 2000 1 0 Tech 1 (Approved)
2 0.02 3 0.45 100 0 2 Pharmaceut. 0 (Rejected)
3 0.04 7 0.78 1500 1 0 Finance 1 (Approved)
4 0.01 2 0.30 200 0 1 Gambling 0 (Rejected)
... ... ... ... ... ... ... ... ...How the First Tree Gets Created — Root Node Selection
The root node is the single most important decision in the entire tree. It must be chosen such that it maximizes the information gain (or minimizes impurity) across the entire dataset.
The Root Node Selection Algorithm
Step 1: For each feature, evaluate all possible split points.
Step 2: For each split, calculate the resulting purity (Gini impurity or entropy).
Step 3: Pick the feature and split that yields maximum information gain.
Higher information gain = better split. This is evaluated for EVERY feature and EVERY possible split point.
Concrete Example: Selecting Root for Ad Quality
Starting dataset: 1,000 ads, 700 Approved (class=1), 300 Rejected (class=0).
Impurity of root: Gini = 1 - (0.7² + 0.3²) = 1 - (0.49 + 0.09) = 0.42
The Exhaustive Search
In practice, the algorithm:
- Numeric features: Sort the data, try every unique value as a split point. O(n log n) per feature.
- Categorical features: Try all possible groupings (for binary trees, try all subsets). May use approximations for high cardinality.
- Compute IG for each: In parallel or sequentially, compute information gain for every candidate.
- Pick the best: The split with maximum IG becomes the root.
Node Splitting Mechanics — How Each Subsequent Node Splits
Once the root is created, the tree recursively builds downward. Each new node applies the same greedy algorithm: find the split that maximizes information gain among samples in that node.
Recursive Binary Splitting
The algorithm proceeds as a depth-first traversal:
function BuildTree(samples, depth):
if stopping_criteria_met(samples, depth):
return Leaf(majority_class(samples))
best_split = None
best_gain = -∞
for feature in all_features:
for threshold in candidate_thresholds(feature, samples):
left, right = split(samples, feature, threshold)
gain = information_gain(samples, left, right)
if gain > best_gain:
best_gain = gain
best_split = (feature, threshold)
left, right = split(samples, best_split)
node.left = BuildTree(left, depth+1)
node.right = BuildTree(right, depth+1)
return nodeContinuous vs. Categorical Features
Continuous (e.g., CTR)
Split type: Threshold-based binary split.
Example: CTR ≤ 0.04 (Yes) | CTR > 0.04 (No)
Candidate thresholds: Sort unique values, try split between each consecutive pair. O(n log n) for one feature.
Categorical (e.g., Category)
Split type: Partition into groups.
Example: Category in {"Tech", "Finance"} (Yes) | {"Pharma", "Gambling"} (No)
Candidate splits: In binary trees, try all possible binary partitions of categories. For k categories, 2^(k-1)/2 unique partitions.
Stopping Criteria (Pre-pruning)
The tree stops splitting at a node if any of these are met:
| Stopping Criterion | Explanation | Effect on Bias/Variance |
|---|---|---|
max_depth reached |
Node is at maximum depth (e.g., depth ≥ 10) | ↑ Bias, ↓ Variance |
| Node is pure | All samples in node are same class (Gini=0 or H=0) | Natural stopping |
min_samples_split |
Node has fewer samples (e.g., < 10 samples) | ↑ Bias, ↓ Variance |
min_samples_leaf |
Split would create leaf with fewer samples (e.g., < 5) | ↑ Bias, ↓ Variance |
| No information gain | Best split has gain ≤ 0 (no improvement) | Natural stopping |
Visual Example: Tree Growth Over Levels
Splitting Metrics & Purity Measures
Decision trees evaluate splits based on measures of purity (or homogeneity) in child nodes. The two most common for classification are Gini Impurity and Entropy/Information Gain.
Gini Impurity
Gini measures the probability of misclassifying a randomly chosen element if it were randomly classified according to the distribution of classes in the node.
Gini(node) = 1 - Σ(pᵢ²)
Where pᵢ is the fraction of class i in the node.
Entropy & Information Gain
Entropy measures the amount of uncertainty or randomness in a node. Information Gain is the reduction in entropy after a split.
H(node) = -Σ pᵢ log₂(pᵢ)
Information Gain = H(parent) - Σ (|child|/|parent| × H(child))
Variance Reduction (for Regression Trees)
When predicting continuous values (e.g., ad CTR), we use variance reduction:
Variance Reduction = Var(parent) - Σ (|child|/|parent| × Var(child))
Where variance is computed as: Var = Σ(yᵢ - ȳ)² / n
Visual Comparison: Gini vs. Entropy
Worked Example: Computing Information Gain
Dataset: 10 ads at a node: 7 Approved, 3 Rejected.
Current entropy: H = -(7/10 × log₂(7/10)) - (3/10 × log₂(3/10))
= -(0.7 × -0.515) - (0.3 × -1.737) = 0.361 + 0.521 = 0.882
Proposed split: CTR > 0.03
- Left (CTR ≤ 0.03): 4 ads (2 App, 2 Rej) → H = 1.0
- Right (CTR > 0.03): 6 ads (5 App, 1 Rej) → H = -(5/6 × log₂(5/6)) - (1/6 × log₂(1/6)) = 0.650
Information Gain: IG = 0.882 - (4/10 × 1.0 + 6/10 × 0.650) = 0.882 - 0.79 = 0.092
Key Parameters to Tune
Decision trees have many hyperparameters that control how the tree grows. Each impacts the bias-variance tradeoff.
Comprehensive Tuning Parameters
| Parameter | Type | Default / Typical Range | Effect |
|---|---|---|---|
criterion |
String | "gini" or "entropy" | Impurity measure. Rarely matters; both similar results. |
max_depth |
Integer | None (unlimited) / [5, 20] | Critical. Shallow trees = high bias, low variance. Deep trees = low bias, high variance. Primary control for overfitting. |
min_samples_split |
Integer | 2 / [2, 20] | Minimum samples needed to split a node. Higher = less splitting = simpler tree. |
min_samples_leaf |
Integer | 1 / [1, 10] | Minimum samples in leaf node. Prevents tiny leaves (overfitting). Often set = min_samples_split / 2. |
max_features |
Integer/Float/String | None (all) / [0.5, "sqrt"] | How many features to consider per split. Lower = more regularization, faster. Important for high-dimensional data. |
min_impurity_decrease |
Float | 0.0 / [0.0, 0.1] | Minimum improvement in impurity required to split. Higher = fewer splits. |
splitter |
String | "best" or "random" | "best" = exhaustive search (slower, better). "random" = sample thresholds (faster, good for large data). |
random_state |
Integer | None / any seed | Reproducibility. When splitter="random", controls which splits are sampled. |
Common Tuning Strategies
Scenario 1: Small Dataset (< 10k samples)
Action: Aggressive regularization.
max_depth=5, min_samples_split=20, min_samples_leaf=10
Scenario 2: Large Dataset (> 100k samples)
Action: Allow deeper trees.
max_depth=15, min_samples_split=5, min_samples_leaf=2
Scenario 3: High-Dimensional Data (many features)
Action: Limit features per split.
max_features="sqrt" or max_features=0.5
Effect of max_depth on Model Complexity
Best Practices
Do Use Cross-Validation
Tune parameters on validation set, not test set. Use 5-fold or 10-fold CV to find optimal max_depth, min_samples_split, etc.
Do Monitor Feature Importance
Check which features the tree uses most. If unimportant features dominate, it's overfitting.
Don't Tune All Parameters
max_depth is king. Start by tuning that first. Other parameters matter less.
Do Use Ensemble Methods
Individual trees overfit. Random Forests (ensemble of trees) almost always better. XGBoost for high-stakes predictions.
Bias-Variance Tradeoff in Decision Trees
Decision trees exhibit an extreme bias-variance relationship that makes understanding this tradeoff critical.
Deep Trees: Low Bias, High Variance
An unpruned tree will perfectly fit the training data. It has:
- Low bias: Can represent arbitrarily complex decision boundaries.
- High variance: Small changes in training data cause large changes in tree structure.
- Result: Excellent on training data, poor on test data (overfitting).
Shallow Trees: High Bias, Low Variance
A shallow tree (e.g., max_depth=2) makes strong assumptions about the data. It has:
- High bias: Cannot represent complex boundaries (underfitting).
- Low variance: Stable across different training sets.
- Result: Mediocre on both training and test data.
Why Decision Trees Have High Variance
Decision trees are notorious for high variance because:
Why Ensembles (Random Forests, XGBoost) Win
Ensemble methods address high variance by:
Random Forests
How: Train many deep trees on bootstrap samples of data and random feature subsets. Average predictions.
Effect: Variance cancels out due to averaging. Keeps low bias from deep trees.
XGBoost/Gradient Boosting
How: Train shallow trees sequentially, each correcting previous errors. Combine via weighted sum.
Effect: More sophisticated than RF. Controls variance via regularization. State-of-the-art for AdTech.
Strengths, Weaknesses & When to Use Decision Trees
Strengths
Interpretability
Trace every prediction to a human-readable rule. Perfect for regulatory compliance and stakeholder trust.
No Feature Scaling
Threshold-based splits are invariant to monotonic transformations. No need to normalize features.
Automatic Feature Selection
Tree structure reveals which features matter. Unused features have 0 importance.
Mixed Data Types
Handle numeric and categorical features natively. No one-hot encoding needed.
Captures Non-linearity
Recursive partitioning models complex interactions without explicit feature engineering.
Fast Prediction
Prediction time is O(depth), typically O(log n). Much faster than KNN or SVM.
Weaknesses
High Variance
Small data changes cause large tree changes. Individual trees overfit unless heavily pruned.
Greedy Algorithm
Greedy splits at each node. May miss globally optimal partitions. Cannot backtrack.
Biased to Balanced Features
When features have different scales or cardinalities, trees favor high-cardinality features.
Axis-Aligned Splits
Each split is perpendicular to one axis. Diagonal boundaries require many splits and overfit.
Prone to Overfitting
Without regularization, trees memorize training data, especially with small datasets.
Imbalanced Class Bias
In imbalanced datasets, trees may favor the majority class. Need class weights or sampling.
When to Use Decision Trees
- Interpretability is critical (e.g., credit decisions, medical diagnosis).
- Stakeholders need to audit the model ("Why was this ad rejected?").
- You need a baseline model to benchmark against.
- The dataset is small and regularization (max_depth, etc.) can prevent overfitting.
- Accuracy is the primary goal. Ensembles beat single trees almost always.
- The dataset is large (> 10k samples). Variance reduction via averaging is powerful.
- Non-linear interactions are important. Multiple trees capture these better.
- You have computational resources. Ensembles are slower but worth it.
- You must have low latency and high throughput. Neural networks or SVMs may be faster in inference.
- Features are purely linear and separable. Logistic regression is simpler and faster.
- You're dealing with unstructured data (images, text). Use specialized models (CNNs, RNNs).
Decision Trees in AdTech: Real-World Context
Python Implementation from Scratch
Here's a complete, class-based DecisionTreeClassifier implemented from scratch using only NumPy and Pandas. This builds your intuition for how trees work under the hood.
Core Classes
import numpy as np
import pandas as pd
from collections import Counter
class Node:
"""Represents a node in the decision tree."""
def __init__(self, feature=None, threshold=None, left=None,
right=None, value=None):
self.feature = feature # Index of feature to split on
self.threshold = threshold # Threshold for the split
self.left = left # Left subtree (≤ threshold)
self.right = right # Right subtree (> threshold)
self.value = value # Class prediction if leaf
self.samples = 0 # Number of samples at this node
self.impurity = 0 # Gini/Entropy at this node
def is_leaf(self):
return self.value is not Noneclass DecisionTreeClassifier:
"""Decision Tree Classifier using Gini Impurity or Entropy."""
def __init__(self, max_depth=None, min_samples_split=2,
min_samples_leaf=1, criterion="gini", random_state=None):
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.min_samples_leaf = min_samples_leaf
self.criterion = criterion
self.random_state = random_state
self.root = None
self.feature_importances_ = None
def fit(self, X, y):
"""Build decision tree classifier."""
if isinstance(X, pd.DataFrame):
self.feature_names_ = X.columns.tolist()
X = X.values
else:
self.feature_names_ = [f"f{i}" for i in range(X.shape[1])]
self.root = self._build_tree(X, y)
self._compute_feature_importance(X.shape[1])
return self
def _build_tree(self, X, y, depth=0):
"""Recursively build the tree."""
n_samples, n_features = X.shape
n_classes = len(np.unique(y))
# Create leaf node
node = Node()
node.samples = n_samples
node.value = Counter(y).most_common(1)[0][0]
# Stopping criteria
if (depth >= self.max_depth if self.max_depth else False):
return node
if n_samples < self.min_samples_split:
return node
if n_classes == 1: # Pure node
return node
# Find best split
best_split = self._best_split(X, y)
if best_split is None:
return node
feature, threshold, left_idx, right_idx = best_split
# Convert to internal node
node.feature = feature
node.threshold = threshold
node.value = None
node.impurity = self._impurity(y)
# Recursively build subtrees
node.left = self._build_tree(X[left_idx], y[left_idx], depth+1)
node.right = self._build_tree(X[right_idx], y[right_idx], depth+1)
return nodeSplitting Logic
def _best_split(self, X, y):
"""Find the best split for a node."""
best_gain = -np.inf
best_split = None
for feature_idx in range(X.shape[1]):
feature_values = X[:, feature_idx]
# Try each unique value as threshold
for threshold in np.unique(feature_values):
left_idx = feature_values <= threshold
right_idx = ~left_idx
# Check min_samples_leaf constraint
if (left_idx.sum() < self.min_samples_leaf or
right_idx.sum() < self.min_samples_leaf):
continue
# Calculate information gain
gain = self._information_gain(y, y[left_idx], y[right_idx])
if gain > best_gain:
best_gain = gain
best_split = (feature_idx, threshold, left_idx, right_idx)
return best_split
def _impurity(self, y):
"""Calculate Gini or Entropy."""
counts = np.bincount(y)
probabilities = counts / len(y)
if self.criterion == "gini":
return 1 - np.sum(probabilities ** 2)
else: # entropy
return -np.sum(probabilities * np.log2(probabilities + 1e-10))
def _information_gain(self, parent, left, right):
"""Calculate information gain from a split."""
n_left, n_right = len(left), len(right)
n_total = n_left + n_right
if n_left == 0 or n_right == 0:
return 0
parent_impurity = self._impurity(parent)
left_impurity = self._impurity(left)
right_impurity = self._impurity(right)
child_impurity = (n_left / n_total) * left_impurity + \
(n_right / n_total) * right_impurity
return parent_impurity - child_impurityPrediction and Feature Importance
def predict(self, X):
"""Predict class for X."""
if isinstance(X, pd.DataFrame):
X = X.values
return np.array([self._traverse_tree(self.root, x)
for x in X])
def _traverse_tree(self, node, x):
"""Traverse tree to get prediction for single sample."""
if node.is_leaf():
return node.value
if x[node.feature] <= node.threshold:
return self._traverse_tree(node.left, x)
else:
return self._traverse_tree(node.right, x)
def _compute_feature_importance(self, n_features):
"""Compute feature importance from tree structure."""
importances = np.zeros(n_features)
def _add_importance(self, node):
if node.is_leaf():
return
gain = node.samples * node.impurity
importances[node.feature] += gain
_add_importance(self, node.left)
_add_importance(self, node.right)
_add_importance(self, self.root)
self.feature_importances_ = importances / importances.sum()
def score(self, X, y):
"""Accuracy score."""
return np.mean(self.predict(X) == y)Complete Example: Ad Quality Prediction
# Create synthetic ad quality dataset
np.random.seed(42)
n_samples = 500
X = pd.DataFrame({
'CTR': np.random.uniform(0.01, 0.15, n_samples),
'Quality_Score': np.random.randint(1, 11, n_samples),
'Domain_Age_Years': np.random.randint(1, 20, n_samples),
'Has_HTTPS': np.random.randint(0, 2, n_samples),
'Prior_Violations': np.random.randint(0, 5, n_samples)
})
# Create target: Approved (1) if conditions met
y = ((X['CTR'] > 0.04) & (X['Quality_Score'] > 6) |
(X['Domain_Age_Years'] > 10)).astype(int)
# Split data
split_idx = int(0.8 * n_samples)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]
# Train tree
tree = DecisionTreeClassifier(max_depth=5, min_samples_split=10,
criterion="gini")
tree.fit(X_train, y_train)
# Evaluate
print(f"Train Accuracy: {tree.score(X_train, y_train):.3f}")
print(f"Test Accuracy: {tree.score(X_test, y_test):.3f}")
# Feature importance
for feature, importance in zip(X.columns, tree.feature_importances_):
print(f"{feature}: {importance:.3f}")
# Make predictions
new_ad = pd.DataFrame({
'CTR': [0.06],
'Quality_Score': [8],
'Domain_Age_Years': [12],
'Has_HTTPS': [1],
'Prior_Violations': [0]
})
prediction = tree.predict(new_ad)
print(f"Ad Status: {'Approved' if prediction[0] == 1 else 'Rejected'}")Expected Output
Train Accuracy: 0.896
Test Accuracy: 0.876
CTR: 0.341
Quality_Score: 0.298
Domain_Age_Years: 0.205
Has_HTTPS: 0.089
Prior_Violations: 0.067
Ad Status: ApprovedKey Insights from the Implementation
- Pruning: Remove splits that don't improve validation accuracy (post-pruning).
- Handle Categorical: Encode categories and try all partitions, not just thresholds.
- Regression: Replace Gini/Entropy with variance reduction for continuous targets.
- Class Weights: In imbalanced datasets, weight minority class higher during splits.
- Visualization: Export tree to .dot format and render with Graphviz for interpretability.
Summary
Decision trees are intuitive, interpretable, and the foundation for modern ensemble methods. Understanding how they build recursively, evaluate splits, and balance bias-variance is crucial for applied ML.
For AdTech Specifically
Single trees explain decisions (why ads pass/fail). Ensembles (XGBoost, RF) make the actual predictions. Both are critical in production ad systems.
Next Steps
Study Random Forests and XGBoost. Experiment with tuning parameters on real datasets. Understand why ensembles beat individual trees.