Skrub - Machine learning with dataframes - Book

Author

Riccardo Cappuzzo

0.1 A world without skrub

Let’s consider a world where skrub does not exist, and all we can do is use pandas and scikit-learn to prepare data for a machine learning model.

You may already be living in such a world.

0.1.1 Load and explore the data

import pandas as pd
import numpy as np

X = pd.read_csv("../data/employee_salaries/data.csv")
y = pd.read_csv("../data/employee_salaries/target.csv")["current_annual_salary"]
X.head(5)
gender department department_name division assignment_category employee_position_title date_first_hired year_first_hired
0 F POL Department of Police MSB Information Mgmt and Tech Division Records... Fulltime-Regular Office Services Coordinator 09/22/1986 1986
1 M POL Department of Police ISB Major Crimes Division Fugitive Section Fulltime-Regular Master Police Officer 09/12/1988 1988
2 F HHS Department of Health and Human Services Adult Protective and Case Management Services Fulltime-Regular Social Worker IV 11/19/1989 1989
3 M COR Correction and Rehabilitation PRRS Facility and Security Fulltime-Regular Resident Supervisor II 05/05/2014 2014
4 M HCA Department of Housing and Community Affairs Affordable Housing Programs Fulltime-Regular Planning Specialist III 03/05/2007 2007

0.1.2 Explore the target

Let’s take a look at the target:

y.head(5)
0     69222.18
1     97392.47
2    104717.28
3     52734.57
4     93396.00
Name: current_annual_salary, dtype: float64

This is a regression task: we want to predict the value of current_annual_salary.

0.1.3 Strategizing

We can begin by exploring the dataframe with .describe, and then think of a plan for pre-processing our data.

X.describe(include="all")
gender department department_name division assignment_category employee_position_title date_first_hired year_first_hired
count 9211 9228 9228 9228 9228 9228 9228 9228.000000
unique 2 37 37 694 2 443 2264 NaN
top M POL Department of Police School Health Services Fulltime-Regular Bus Operator 12/12/2016 NaN
freq 5481 1844 1844 300 8394 638 87 NaN
mean NaN NaN NaN NaN NaN NaN NaN 2003.597529
std NaN NaN NaN NaN NaN NaN NaN 9.327078
min NaN NaN NaN NaN NaN NaN NaN 1965.000000
25% NaN NaN NaN NaN NaN NaN NaN 1998.000000
50% NaN NaN NaN NaN NaN NaN NaN 2005.000000
75% NaN NaN NaN NaN NaN NaN NaN 2012.000000
max NaN NaN NaN NaN NaN NaN NaN 2016.000000

0.1.4 Our plan

We need to:

  • Impute some missing values in the gender column.
  • Encode convert categorical features into numerical features.
  • Convert the column date_first_hired into numerical features (year, month, day etc.).
  • Scale numerical features (we’ll be using a linear model).
  • Evaluate the performance of the model.

0.2 Feature engineering

0.2.1 Step 1: Convert date features to numerical

We extract numerical features from the date_first_hired column.

# Create a copy to work with
X_processed = X.copy()

# Parse the date column
X_processed['date_first_hired'] = pd.to_datetime(X_processed['date_first_hired'])

# Extract numerical features from date
X_processed['hired_month'] = X_processed['date_first_hired'].dt.month
X_processed['hired_year'] = X_processed['date_first_hired'].dt.year

# Drop original date column
X_processed = X_processed.drop('date_first_hired', axis=1)

print("Features after date transformation:")
print("\nShape:", X_processed.shape)
Features after date transformation:

Shape: (9228, 9)

0.2.2 Step 2: Encode categorical features

We encode all categorical features using one-hot encoding.

# Find the categorical columns
categorical_cols = X_processed.select_dtypes(include=['object']).columns.tolist()
print("Categorical columns to encode:", categorical_cols)

# Apply one-hot encoding only to categorical columns
X_encoded = pd.get_dummies(X_processed, columns=categorical_cols)
print("\nShape after encoding:", X_encoded.shape)
Categorical columns to encode: ['gender', 'department', 'department_name', 'division', 'assignment_category', 'employee_position_title']

Shape after encoding: (9228, 1218)

Notice how the number of features has increased massively due to the large number of unique values in some of the columns. The large number of features can cause various issues, ranging from overfitting, to performance and memory issues. This is also known as the Curse of dimensionality.

Alternative strategies for encoding high-cardinality data (such as text) should be employed.

0.2.3 Step 3: Impute missing values

We impute the missing values in the gender column to fill any gaps. In this case, this dataframe does not include many missing values, and they’re concentrated in a single categorical column, so we fill missing values with the “most frequent” strategy.

from sklearn.impute import SimpleImputer

# Impute missing values with most frequent value
imputer = SimpleImputer(strategy='most_frequent')
X_encoded_imputed = pd.DataFrame(
    imputer.fit_transform(X_encoded),
    columns=X_encoded.columns
)

Some models – like the Ridge model we will be using – cannot handle missing values, and require imputation in order to work. Other models, such as the HistGradientBoostingClassifier, do not suffer from this limitation.

Important

Null values are important! The fact that a value is missing is by itself useful information, and the pattern of missingness is also important.

For this reason, skrub transformers keep missing values as they are to retain the information content.

Example:

Consider a dataset about employment, which among others includes the column current position. A missing value in this column likely indicates unemployment, and imputing it with a strategy like “most frequent” would introduce wrong information about the sample and lead the model to wrong conclusions.

0.2.4 Step 4: Scale numerical features

Linear models are strongly affected by numeric features that haven’t been scaled, so this step should always be done. The StandardScaler scales values by removing the mean and scaling to unit variance.

So, here we scale numerical features for the RidgeCV regression model:

from sklearn.preprocessing import StandardScaler

# Initialize the scaler
scaler = StandardScaler()

# Fit and transform the data
X_scaled = scaler.fit_transform(X_encoded_imputed)
X_scaled = pd.DataFrame(X_scaled, columns=X_encoded_imputed.columns)
Warning

Outliers (values very far from the mean) make scaling hard to do. Robust scaling is needed.

0.2.5 Step 5: Train Ridge model with cross-validation

For this example we want to train a RidgeCV regression model and evaluate the model’s performance with cross-validation.

from sklearn.linear_model import RidgeCV
from sklearn.model_selection import cross_val_score, cross_validate
import numpy as np

# Initialize Ridge model
ridge = RidgeCV()

# Perform cross-validation (5-fold)
cv_results = cross_validate(ridge, X_scaled, y, cv=5, scoring=["r2", "neg_mean_squared_error"])

# Convert MSE to RMSE
test_rmse = np.sqrt(-cv_results["test_neg_mean_squared_error"])

# Display results
print("Cross-Validation Results:")
print(
    f"Mean test R²: {cv_results['test_r2'].mean():.4f} (+/- {cv_results['test_r2'].std():.4f})"
)
print(f"Mean test RMSE: {test_rmse.mean():.4f} (+/- {test_rmse.std():.4f})")
Cross-Validation Results:
Mean test R²: 0.8731 (+/- 0.0276)
Mean test RMSE: 10329.8687 (+/- 1421.8752)

0.3 Looking back

0.3.1 “Just ask an agent to write the code”

That’s what I did: I asked an AI agent to turn the “strategizing” section into code, and the result was not good.

In fact, the strategy I suggested is not laid out in the correct order: the agent did not spot the problem, followed each item step by step (in the wrong order) and as a result:

  • Imputation was performed before converting categorical features to numeric values, so it did not impute properly.

  • Datetimes were not parsed as datetimes before applying OneHotEncoder, so they were treated as categorical and encoded as such, rather than being converted to numeric features.

  • Finally, pd.get_dummies was executed on the full dataframe, rather than looking for categories only on the training split, and then using those categories on the test split: this causes data leakage by creating an encoding for categorical features that would appear only in the test set.

The order of our operations is just as important as the operations themselves, and relying blindly on agents can have bad consequences.

Disclaimer: advanced models probably would not fall in the same pitfalls, or would be able to self-correct to an extent. The main point still stands.

0.4 Small intermission: data leakage and fit/transform

For simplicity, let’s try to impute values in a series as our example. One-hot leakage works in similar ways.

This example dataframe is split into training and test split with a 50/50 split.

from sklearn.impute import SimpleImputer
import pandas as pd
df = pd.DataFrame({
    "value": [1, 3, None, 5, 10, None, 1000, 5]
})

df_train = df[:5]
df_test = df[5:]

A common strategy is to impute numeric missing values by finding the mean of the features that are still there. We can do this with stragegy="mean".

What happens if you try to find the mean over all data?

imputer = SimpleImputer(strategy="mean").set_output(transform="pandas")
df_imputed = imputer.fit_transform(df)

The result is that the missing values have been filled with the mean over both the train and test set. The test set happened to contain an outlier, which skewed the mean in the training.

To impute properly and avoid leakage, we want to fit the imputer on the training data, and then transform the test split using the mean that was learned in while fitting.

print(imputer.fit(df_train).transform(df_test))
     value
5     4.75
6  1000.00
7     5.00

Important

This fit-transform paradigm is what scikit-learn (and skrub) uses to avoid data leakage.

0.5 Waking up from a nightmare

Thankfully, we can import skrub:

from skrub import tabular_pipeline

# Perform cross-validation (5-fold)
cv_results = cross_validate(tabular_pipeline("regression"), X, y, cv=5, 
                            scoring=['r2', 'neg_mean_squared_error'],
                            return_train_score=True)

# Convert MSE to RMSE
train_rmse = np.sqrt(-cv_results['train_neg_mean_squared_error'])
test_rmse = np.sqrt(-cv_results['test_neg_mean_squared_error'])

# Display results
print("Cross-Validation Results:")
print(f"Mean test R²: {cv_results['test_r2'].mean():.4f} (+/- {cv_results['test_r2'].std():.4f})")
print(f"Mean test RMSE: {test_rmse.mean():.4f} (+/- {test_rmse.std():.4f})")
Cross-Validation Results:
Mean test R²: 0.9080 (+/- 0.0163)
Mean test RMSE: 8814.0154 (+/- 1057.4677)

0.5.1 What is happening?

The tabular_pipeline is implementing all the steps we have just covered, except in a more robust and battle-tested fashion. Before encoding, the input table is sanitized so that features have the proper dtype, null values are marked as such, and then the appropriate encoder is applied to each column.

The functioning of the tabular_pipeline is explored in more detail in a later chapter.

0.6 What we saw in this chapter

  • We built a predictive pipeline using traditional tools
  • We saw some possible shortcomings
  • We tested skrub’s tabular_pipeline

0.7 Roadmap for the course

  1. Data exploration with skrub’s TableReport
  2. Data cleaning and sanitization with the Cleaner
  3. Columnwise operations with ApplyToCols
  4. Advanced column selectors and how to use them
  5. Automatic feature engineering with the TableVectorizer
  6. A robust baseline for machine learning tasks with the tabular_pipeline
  7. Feature engineering with the skrub encoders
  8. Building dynamic pipelines with the skrub Data Ops (short intro)