San Francisco Crime Classification with Gators#
This notebook demonstrates how to use the gators library for feature engineering in a multiclass classification problem. We’ll predict crime categories from the San Francisco Crime dataset using various gators transformers.
Key Features Demonstrated:#
Data preprocessing and cleaning
DateTime feature engineering (cyclic, ordinal, business time, holidays)
String feature engineering (pattern detection)
Spatial feature engineering (coordinate rotation)
Group-based imputation
Feature encoding and interaction
1. Import Libraries#
Import the necessary libraries including gators transformers for feature engineering.
[1]:
import polars as pl
import pandas as pd
from IPython.display import display
from gators.pipeline import Pipeline
from gators.encoders import OneHotEncoder, CountEncoder
from gators.data_cleaning import DropColumns, CastColumns
from gators.feature_generation import PlanRotationFeatures
from gators.imputers import GroupByImputer
from gators.feature_generation_dt import (
CyclicFeatures,
OrdinalFeatures,
BusinessTimeFeatures,
TimeBinFeatures,
HolidayFeatures,
)
from gators.feature_generation_str import (
Contains,
InteractionFeatures,
)
from xgboost import XGBClassifier
2. Load and Preprocess Data#
Load the San Francisco Crime dataset and perform initial preprocessing:
Remove redundant columns
Handle outlier coordinate values (replace invalid coordinates with null)
Create a distance-to-center feature using San Francisco’s geographic center
[2]:
# Load data
X_train = pl.read_parquet('../../../../../Documents/kaggle/sf/train.parquet')
X_test = pl.read_parquet('../../../../../Documents/kaggle/sf/test.parquet')
# Drop unnecessary columns
X_train = X_train.drop(["Descript", "Resolution", "DayOfWeek"])
X_test = X_test.drop(["DayOfWeek"])
# Extract target variable
target = 'Category'
y_train = X_train[target]
X_train = X_train.drop(target)
# Replace invalid coordinate values with null
X_train = X_train.with_columns([
pl.when(pl.col('X') == -120.5).then(None).otherwise(pl.col('X')).alias('X'),
pl.when(pl.col('Y') == 90.0).then(None).otherwise(pl.col('Y')).alias('Y')
])
X_test = X_test.with_columns([
pl.when(pl.col('X') == -120.5).then(None).otherwise(pl.col('X')).alias('X'),
pl.when(pl.col('Y') == 90.0).then(None).otherwise(pl.col('Y')).alias('Y')
])
# Create distance to San Francisco center feature
sf_center_x, sf_center_y = -122.4194, 37.7749
X_train = X_train.with_columns([
(((pl.col('X') - sf_center_x)**2 + (pl.col('Y') - sf_center_y)**2)**0.5).alias('distance_to_center')
])
X_test = X_test.with_columns([
(((pl.col('X') - sf_center_x)**2 + (pl.col('Y') - sf_center_y)**2)**0.5).alias('distance_to_center')
])
# Parse Dates into a native Datetime column up front (in Python, not inside the
# exported pipeline) - ONNX has no generic string-to-datetime parse operator, so
# this must happen before pipeline.fit()/transform() rather than via CastColumns.
X_train = X_train.with_columns(pl.col('Dates').str.to_datetime())
X_test = X_test.with_columns(pl.col('Dates').str.to_datetime())
print(f"Training data shape: {X_train.shape}")
print(f"Test data shape: {X_test.shape}")
Training data shape: (878049, 6)
Test data shape: (884262, 7)
3. Prepare Target Variable#
Convert crime category labels to numeric format for model training.
[3]:
# Define all crime categories
crime_categories = [
"ARSON", "ASSAULT", "BAD CHECKS", "BRIBERY", "BURGLARY",
"DISORDERLY CONDUCT", "DRIVING UNDER THE INFLUENCE", "DRUG/NARCOTIC",
"DRUNKENNESS", "EMBEZZLEMENT", "EXTORTION", "FAMILY OFFENSES",
"FORGERY/COUNTERFEITING", "FRAUD", "GAMBLING", "KIDNAPPING",
"LARCENY/THEFT", "LIQUOR LAWS", "LOITERING", "MISSING PERSON",
"NON-CRIMINAL", "OTHER OFFENSES", "PORNOGRAPHY/OBSCENE MAT",
"PROSTITUTION", "RECOVERED VEHICLE", "ROBBERY", "RUNAWAY",
"SECONDARY CODES", "SEX OFFENSES FORCIBLE", "SEX OFFENSES NON FORCIBLE",
"STOLEN PROPERTY", "SUICIDE", "SUSPICIOUS OCC", "TREA", "TRESPASS",
"VANDALISM", "VEHICLE THEFT", "WARRANTS", "WEAPON LAWS"
]
# Convert target to numeric labels
mapping = {cat: str(i) for i, cat in enumerate(crime_categories)}
y_train = y_train.replace(mapping).cast(pl.Int64)
print(f"Number of classes: {len(crime_categories)}")
print(f"Target shape: {y_train.shape}")
Number of classes: 39
Target shape: (878049,)
4. Build Feature Engineering Pipeline#
The gators Pipeline orchestrates multiple feature transformers:
Data Cleaning & Casting:#
GroupByImputer: Fill missing X/Y coordinates using mean values grouped by police district
CastColumns: Convert date strings to datetime type
Datetime Features:#
TimeBinFeatures: Create time-of-day bins (morning, afternoon, evening, night)
DatetimeCyclicFeatures: Generate cyclic features for temporal patterns (sin/cos transformations)
BusinessTimeFeatures: Extract business-related time features (weekday vs weekend)
HolidayFeatures: Identify whether crimes occurred on holidays
DatetimeOrdinalFeatures: Create ordinal features (month, day, hour, etc.)
String Features:#
Contains: Detect patterns in address strings (e.g., contains ‘Block’, ‘AV’, ‘ST’)
InteractionFeatures: Create interactions between categorical features (e.g., day_of_week × part_of_day)
Spatial Features:#
PlanRotationFeatures: Rotate X/Y coordinates at multiple angles to capture spatial patterns
Encoding:#
OneHotEncoder: Encode categorical variables as binary features
CountEncoder: Encode categories by their frequency
[4]:
# Define datetime components to extract
ordinal_components = [
"month", "week", "day_of_week", "day_of_month",
"day_of_year", "hour", "minute", "weekend"
]
cyclic_components = [
"month", "week", "day_of_week", "day_of_month",
"day_of_year", "hour", "minute"
]
# Build the pipeline with gators transformers
steps = [
# Impute missing coordinates using group averages
("group_imputer", GroupByImputer(
group_by_column='PdDistrict',
strategy='mean',
subset=['X', 'Y']
)),
# Extract time-of-day bins
("dt_timebin", TimeBinFeatures(
subset=['Dates'],
bin_types=['part_of_day', 'season', 'time_of_month', 'time_of_year', 'rush_hour', 'day']
)),
# Create cyclic datetime features (captures cyclical nature of time)
("dt_cyclic", CyclicFeatures(
subset=['Dates'],
angles=[180 * i / 4 for i in range(8)],
components=cyclic_components
)),
# Extract business time features
("dt_business", BusinessTimeFeatures(subset=['Dates'])),
# Add holiday indicator
("dt_holiday", HolidayFeatures(subset=['Dates'], features=['is_holiday'])),
# Extract ordinal datetime components
("dt_ordinal", OrdinalFeatures(subset=['Dates'], components=ordinal_components)),
# Extract patterns from address strings
("contains", Contains(contains_dict={'Address': ['/', 'Block', 'AV', 'ST']})),
# Drop original columns after feature extraction
("drop", DropColumns(subset=["Address", "Dates"])),
# Create interaction features
# Dates__day (string weekday name, from TimeBinFeatures) rather than the numeric
# Dates__day_of_week (OrdinalFeatures) - ONNX can't string-concatenate a numeric
# column and exactly match Polars' float-to-string formatting.
("interaction_time", InteractionFeatures(subset=['Dates__day', 'Dates__part_of_day'])),
("interaction_district", InteractionFeatures(subset=['PdDistrict', 'Dates__part_of_day'])),
# Rotate coordinates to capture spatial patterns
("plan_rotation", PlanRotationFeatures(
columns=[['X', 'Y']],
angles=[180 * i / 4 for i in range(8)]
)),
# Encode categorical variables
("onehot_encoder", OneHotEncoder()),
("count_encoder", CountEncoder()),
]
# Create and fit the pipeline
pipeline = Pipeline(steps=steps, verbose=True)
X_train_transformed = pipeline.fit_transform(X_train, y_train)
X_test_transformed = pipeline.transform(X_test)
print(f"\nOriginal features: {X_train.shape[1]}")
print(f"Engineered features: {X_train_transformed.shape[1]}")
[Pipeline] fit+transform 1/13 · group_imputer | in: rows=878049 cols=6 nulls=201 → out: rows=878049 cols=6 nulls=67 (0.017s)
[Pipeline] fit+transform 2/13 · dt_timebin | in: rows=878049 cols=6 nulls=67 → out: rows=878049 cols=12 nulls=67 (0.050s)
[Pipeline] fit+transform 3/13 · dt_cyclic | in: rows=878049 cols=12 nulls=67 → out: rows=878049 cols=68 nulls=67 (0.088s)
[Pipeline] fit+transform 4/13 · dt_business | in: rows=878049 cols=68 nulls=67 → out: rows=878049 cols=71 nulls=67 (0.037s)
[Pipeline] fit+transform 5/13 · dt_holiday | in: rows=878049 cols=71 nulls=67 → out: rows=878049 cols=72 nulls=67 (0.104s)
[Pipeline] fit+transform 6/13 · dt_ordinal | in: rows=878049 cols=72 nulls=67 → out: rows=878049 cols=80 nulls=67 (0.014s)
[Pipeline] fit+transform 7/13 · contains | in: rows=878049 cols=80 nulls=67 → out: rows=878049 cols=84 nulls=67 (0.022s)
[Pipeline] fit+transform 8/13 · drop | in: rows=878049 cols=84 nulls=67 → out: rows=878049 cols=82 nulls=67 (0.001s)
[Pipeline] fit+transform 9/13 · interaction_time | in: rows=878049 cols=82 nulls=67 → out: rows=878049 cols=83 nulls=67 (0.025s)
[Pipeline] fit+transform 10/13 · interaction_district | in: rows=878049 cols=83 nulls=67 → out: rows=878049 cols=84 nulls=67 (0.030s)
[Pipeline] fit+transform 11/13 · plan_rotation | in: rows=878049 cols=84 nulls=67 → out: rows=878049 cols=100 nulls=67 (0.005s)
[Pipeline] fit+transform 12/13 · onehot_encoder | in: rows=878049 cols=100 nulls=67 → out: rows=878049 cols=196 nulls=67 (0.139s)
[Pipeline] fit+transform 13/13 · count_encoder | in: rows=878049 cols=196 nulls=67 → out: rows=878049 cols=196 nulls=67 (0.000s)
[Pipeline] transform 1/13 · group_imputer | in: rows=884262 cols=7 nulls=228 → out: rows=884262 cols=7 nulls=76 (0.013s)
[Pipeline] transform 2/13 · dt_timebin | in: rows=884262 cols=7 nulls=76 → out: rows=884262 cols=13 nulls=76 (0.047s)
[Pipeline] transform 3/13 · dt_cyclic | in: rows=884262 cols=13 nulls=76 → out: rows=884262 cols=69 nulls=76 (0.086s)
[Pipeline] transform 4/13 · dt_business | in: rows=884262 cols=69 nulls=76 → out: rows=884262 cols=72 nulls=76 (0.034s)
[Pipeline] transform 5/13 · dt_holiday | in: rows=884262 cols=72 nulls=76 → out: rows=884262 cols=73 nulls=76 (0.005s)
[Pipeline] transform 6/13 · dt_ordinal | in: rows=884262 cols=73 nulls=76 → out: rows=884262 cols=81 nulls=76 (0.014s)
[Pipeline] transform 7/13 · contains | in: rows=884262 cols=81 nulls=76 → out: rows=884262 cols=85 nulls=76 (0.013s)
[Pipeline] transform 8/13 · drop | in: rows=884262 cols=85 nulls=76 → out: rows=884262 cols=83 nulls=76 (0.001s)
[Pipeline] transform 9/13 · interaction_time | in: rows=884262 cols=83 nulls=76 → out: rows=884262 cols=84 nulls=76 (0.024s)
[Pipeline] transform 10/13 · interaction_district | in: rows=884262 cols=84 nulls=76 → out: rows=884262 cols=85 nulls=76 (0.030s)
[Pipeline] transform 11/13 · plan_rotation | in: rows=884262 cols=85 nulls=76 → out: rows=884262 cols=101 nulls=76 (0.005s)
[Pipeline] transform 12/13 · onehot_encoder | in: rows=884262 cols=101 nulls=76 → out: rows=884262 cols=197 nulls=76 (0.047s)
[Pipeline] transform 13/13 · count_encoder | in: rows=884262 cols=197 nulls=76 → out: rows=884262 cols=197 nulls=76 (0.000s)
Original features: 6
Engineered features: 196
5. Train Model and Generate Predictions#
Train an XGBoost classifier with the engineered features and generate predictions.
[5]:
[c for c in X_test_transformed.columns if c not in X_train_transformed.columns]
[5]:
['Id']
[6]:
# Define model parameters
xgb_params = {
'max_depth': 6,
'learning_rate': 0.1,
'n_estimators': 100,
'min_child_weight': 5,
'subsample': 0.85,
'colsample_bytree': 0.85,
'max_delta_step': 2,
'tree_method': 'hist',
'eval_metric': 'mlogloss',
'random_state': 0,
'n_jobs': -1
}
# Train the model
estimator = XGBClassifier(**xgb_params)
estimator.fit(X_train_transformed, y_train)
print("Model training completed!")
# Generate predictions
submission = pd.DataFrame(
estimator.predict_proba(X_test_transformed.drop("Id")),
index=X_test["Id"].to_pandas(),
columns=crime_categories
)
submission.to_csv("submission.csv.zip", compression="zip")
print("Submission file created successfully!")
Model training completed!
Submission file created successfully!
6. Feature Importance Analysis#
Analyze which features contribute most to the model’s predictions.
[7]:
# Extract feature importances
feat_imp = pl.DataFrame({
"feature": estimator.feature_names_in_,
"importance": estimator.feature_importances_
})
feat_imp = feat_imp.sort("importance", descending=True)
# Display top 20 most important features
print("Top 20 Most Important Features:")
display(feat_imp.head(20))
Top 20 Most Important Features:
| feature | importance |
|---|---|
| str | f32 |
| "Address__contains_Block" | 0.055322 |
| "Address__contains_/" | 0.043314 |
| "Dates__minute__sin270" | 0.041261 |
| "Dates__minute" | 0.037772 |
| "Dates__minute__sin90" | 0.034877 |
| … | … |
| "PdDistrict__Dates__part_of_day… | 0.010086 |
| "PdDistrict__Dates__part_of_day… | 0.009924 |
| "XY_y45" | 0.009858 |
| "XY_x90" | 0.00976 |
| "PdDistrict__SOUTHERN" | 0.009686 |
7. ONNX Export#
Convert the fitted pipeline and XGBoost classifier to ONNX for portable, runtime-independent inference.
Function |
Purpose |
|---|---|
|
Audit which steps have native ONNX converters |
|
Export preprocessing pipeline to a self-contained ONNX graph |
|
Chain preprocessing graph with an ML model ONNX graph |
|
Create an |
|
Run session on a Polars DataFrame (supports batching) |
[8]:
from gators.onnx_converters import (
check_pipeline_onnx_compatibility,
pipeline_to_onnx,
pipeline_to_scoring_onnx,
create_session,
run_session,
)
import onnx
import os
ONNX_DIR = "onnx_models"
os.makedirs(ONNX_DIR, exist_ok=True)
# ── 7.1 Audit ONNX compatibility ───────────────────────────────────
compat = check_pipeline_onnx_compatibility(pipeline)
supported = sorted(name for name, ok in compat.items() if ok)
not_supported = sorted(name for name, ok in compat.items() if not ok)
print(f"Native ONNX converters ({len(supported)}): {supported}")
print(f"Identity pass-throughs ({len(not_supported)}): {not_supported}")
# ── 7.2 Export preprocessing pipeline ───────────────────────────────────
preprocessing_onnx = pipeline_to_onnx(pipeline, errors="coerce")
onnx.save(preprocessing_onnx, f"{ONNX_DIR}/sf_crime_preprocessing.onnx")
print(f"\nPreprocessing graph saved.")
print(f" Nodes : {len(preprocessing_onnx.graph.node)}")
print(f" Inputs : {len(preprocessing_onnx.graph.input)}")
print(f" Outputs: {len(preprocessing_onnx.graph.output)}")
Native ONNX converters (13): ['contains', 'count_encoder', 'drop', 'dt_business', 'dt_cyclic', 'dt_holiday', 'dt_ordinal', 'dt_timebin', 'group_imputer', 'interaction_district', 'interaction_time', 'onehot_encoder', 'plan_rotation']
Identity pass-throughs (0): []
Preprocessing graph saved.
Nodes : 1520
Inputs : 6
Outputs: 196
[9]:
from onnxmltools import convert_xgboost
from onnxmltools.convert.common.data_types import FloatTensorType
feature_columns = X_train_transformed.columns
n_features = len(feature_columns)
# ── 7.3 Export XGBoost classifier to ONNX ───────────────────────────────────
# estimator was fit() on a named Polars DataFrame, so its booster stores real column
# names instead of the "f0","f1",... pattern onnxmltools expects when converting a
# raw Booster - clear them so the dump uses generic feature indices.
booster = estimator.get_booster()
booster.feature_names = None
xgb_onnx = convert_xgboost(
booster,
initial_types=[("input", FloatTensorType([None, n_features]))],
)
onnx.save(xgb_onnx, f"{ONNX_DIR}/sf_crime_xgb.onnx")
print(f"XGBoost model exported ({n_features} input features)")
# ── 7.4 Build end-to-end scoring model: raw data → crime category probabilities ──
scoring_onnx = pipeline_to_scoring_onnx(
pipeline,
xgb_onnx,
feature_columns=feature_columns,
errors="coerce",
)
onnx.save(scoring_onnx, f"{ONNX_DIR}/sf_crime_scoring.onnx")
print(f"\nEnd-to-end scoring model saved.")
print(f" Inputs : {len(scoring_onnx.graph.input)}")
print(f" Outputs: {[o.name for o in scoring_onnx.graph.output]}")
XGBoost model exported (196 input features)
End-to-end scoring model saved.
Inputs : 6
Outputs: ['label', 'probabilities']
[10]:
import numpy as np
# ── 7.5 Create ORT sessions and run inference ────────────────────────────────
pre_session = create_session(preprocessing_onnx)
scoring_session = create_session(scoring_onnx, optimized_model_path=f"{ONNX_DIR}/sf_crime_scoring_opt.onnx")
# Raw test features (no Id) → engineered features
X_test_raw = X_test.select(pipeline._input_columns)
X_test_onnx = run_session(pre_session, X_test_raw)
print(f"ONNX preprocessing output: {X_test_onnx.shape}")
# End-to-end: raw data → crime category probabilities
onnx_preds = run_session(scoring_session, X_test_raw)
onnx_probs = onnx_preds["probabilities"].to_numpy()
# ── 7.6 Compare xgb_onnx vs scoring_onnx ────────────────────────────────────
# xgb_onnx expects a [N, F] float32 matrix — feed the same features used to fit()
xgb_session = create_session(xgb_onnx)
xgb_input_name = xgb_session.get_inputs()[0].name
X_xgb = X_train_transformed.to_pandas().values.astype("float32")
xgb_train_probs = np.asarray(xgb_session.run(None, {xgb_input_name: X_xgb})[1])
native_probs = estimator.predict_proba(X_train_transformed)
max_diff = float(abs(xgb_train_probs - native_probs).max())
print(f"\nMax |xgb_onnx - native|: {max_diff:.2e}")
assert max_diff < 1e-4, f"Models diverge: {max_diff:.2e}"
print("ONNX predictions match native predictions ✓")
2026-08-24 10:27:54.745 Python[55744:332825] 2026-08-24 10:27:54.745931 [W:onnxruntime:, graph.cc:5337 CleanUnusedInitializersAndNodeArgs] Removing initializer '__hf_big'. It is not used by any node and should be removed from the model.
2026-08-24 10:27:54.746 Python[55744:332825] 2026-08-24 10:27:54.745993 [W:onnxruntime:, graph.cc:5337 CleanUnusedInitializersAndNodeArgs] Removing initializer '__hf_neg1'. It is not used by any node and should be removed from the model.
2026-08-24 10:27:54.823 Python[55744:332825] 2026-08-24 10:27:54.823361 [W:onnxruntime:, graph.cc:5337 CleanUnusedInitializersAndNodeArgs] Removing initializer '__hf_big'. It is not used by any node and should be removed from the model.
2026-08-24 10:27:54.823 Python[55744:332825] 2026-08-24 10:27:54.823434 [W:onnxruntime:, graph.cc:5337 CleanUnusedInitializersAndNodeArgs] Removing initializer '__hf_neg1'. It is not used by any node and should be removed from the model.
ONNX preprocessing output: (884262, 196)
Max |xgb_onnx - native|: 1.28e-06
ONNX predictions match native predictions ✓
Summary#
This notebook demonstrates the power of the gators library for feature engineering in machine learning pipelines:
Key Takeaways:#
Modular Pipeline: Easily compose complex feature engineering workflows using gators transformers
Datetime Engineering: Rich set of temporal features (cyclic, ordinal, business time, holidays)
Spatial Features: Coordinate rotation and distance-based features for geographic data
String Processing: Pattern extraction from text fields
Smart Imputation: Group-based imputation leveraging categorical relationships
Seamless Integration: Works with Polars DataFrames and scikit-learn/XGBoost models
The gators library significantly reduced feature engineering complexity while creating a comprehensive feature set for multiclass classification.